Compare commits
89 Commits
main
...
53c0e095cf
| Author | SHA1 | Date | |
|---|---|---|---|
| 53c0e095cf | |||
| 47af46fd09 | |||
| c1817607b4 | |||
| 05ee576d7d | |||
| 7459d49e83 | |||
| b6a6b14fad | |||
| 3ec3b37a65 | |||
| e2adfacb0b | |||
| 96e28a702f | |||
| a8401bd143 | |||
| ab077d8080 | |||
| 42dbfd0c34 | |||
| 5b536ff981 | |||
| c5e627c5fc | |||
| 7e65a83d42 | |||
| 3a49376f17 | |||
| e326bebd42 | |||
| 46ed894621 | |||
| a21d9b0735 | |||
| f6fc3c262e | |||
| 62693667a8 | |||
| 82004a30b0 | |||
| 0958d70132 | |||
| c0fd69344b | |||
| 39b67feea0 | |||
| c7e924abe7 | |||
| 13da2d9e03 | |||
| 7302936502 | |||
| 4ffa055d62 | |||
| 0fc8479e22 | |||
| 7dab6e44e2 | |||
| 91ee3567aa | |||
| f366ee29a7 | |||
| 04a2de0b17 | |||
| 9e62bdea53 | |||
| 38266d96f8 | |||
| 2295af610a | |||
| 4c80ada3de | |||
| 517045c257 | |||
| c07150cb27 | |||
| 005c052bc6 | |||
| c869c56a17 | |||
| 65bd67cc20 | |||
| 0f164300f0 | |||
| 767a18e98c | |||
| 22487ed575 | |||
| cd2a45c254 | |||
| e62a17e5a2 | |||
| 4e96659bd7 | |||
| 84bc87d13c | |||
| af58667b4f | |||
| 01d65238ce | |||
| 6ceec5e161 | |||
| 3dfd549af3 | |||
| 17543f04c3 | |||
| 25b1c033d9 | |||
| e951eaaf96 | |||
| 0911b1f9de | |||
| 02cda056b8 | |||
| 8ca79c6d16 | |||
| 8bb19b687b | |||
| eafb4403b9 | |||
| 966ba9855c | |||
| 3ad06dfaec | |||
| 82c2cb5f27 | |||
| bc3d270bd4 | |||
| a436c3bd79 | |||
| 337a1e000d | |||
| 619b94ac0e | |||
| db9eb1cf47 | |||
| aabb2b77f7 | |||
| 83604cbc16 | |||
| aacc2ec3ee | |||
| 555c670b22 | |||
| 3f47ea886f | |||
| a54c119b4d | |||
| d69fd7a5f9 | |||
| 685f6d379d | |||
| c52e581e4f | |||
| 98c796054e | |||
| 266e33556d | |||
| 073150ef61 | |||
| aec338441c | |||
| e270cc6063 | |||
| 9af128cb22 | |||
| 3ca2d10c49 | |||
| 222658a75d | |||
| 0294e3992f | |||
| 445a448031 |
@@ -0,0 +1,41 @@
|
||||
# Festipod — variables d'environnement (exemple)
|
||||
#
|
||||
# Copier en `.env` et renseigner les valeurs.
|
||||
# En dev (`bun run dev`) ET en prod (`bun run start`), l'app sert depuis src/ et
|
||||
# lit ces variables au RUNTIME (via l'endpoint /festipod-config.json de src/index.ts).
|
||||
#
|
||||
# REQUIS POUR LES TESTS. La suite Cucumber tourne sous `node` (pas sous Bun), qui
|
||||
# ne charge pas `.env` tout seul : le harness le lit explicitement et LÈVE UNE
|
||||
# ERREUR NOMMÉE si le mot de passe ou le fichier manquent. Or `.env` ET `*.ngw`
|
||||
# sont tous deux gitignorés — un clone frais n'a donc ni l'un ni l'autre et ne
|
||||
# peut pas exécuter `@data`/`@e2e` tant que ces deux valeurs ne sont pas fournies.
|
||||
|
||||
# ── Portefeuille partagé (stopgap staging) ─────────────────────────────────
|
||||
# Mot de passe du portefeuille partagé.
|
||||
# VIDE => rien n'est passé à `configure({ sharedWallet })` => le SDK refuse de
|
||||
# signer l'entrée et l'app affiche son panneau d'erreur au lieu de démarrer.
|
||||
# REQUIS en staging (onboarding d'un appareil sans wallet) ET pour les tests.
|
||||
FESTIPOD_SHARED_WALLET_PASSWORD=
|
||||
|
||||
# Chemin vers le fichier portefeuille partagé (.ngw), absolu ou relatif à la
|
||||
# racine. Servi en téléchargement à /shared-wallet.ngw — par le build de l'app,
|
||||
# et par le serveur du harness pendant les tests.
|
||||
FESTIPOD_SHARED_WALLET_FILE=/chemin/absolu/vers/festipod-wallet.ngw
|
||||
|
||||
# ── Seed automatique (opt-in) ──────────────────────────────────────────────
|
||||
# Non vide => l'app amorce des données de démo dans un wallet VIDE au 1er login.
|
||||
# OFF par défaut : laisser vide en usage normal.
|
||||
FESTIPOD_AUTO_SEED=
|
||||
|
||||
# ── Serveur ────────────────────────────────────────────────────────────────
|
||||
# Port HTTP du serveur (défaut 3000).
|
||||
PORT=3000
|
||||
|
||||
# NODE_ENV=production bascule `bun run start` (pas de HMR). En dev, laisser vide.
|
||||
NODE_ENV=
|
||||
|
||||
# ── Outillage dev (facultatif) ─────────────────────────────────────────────
|
||||
# Override du chemin local du polyfill @ng-eventually/sdk pour `pnpm run
|
||||
# link:polyfill` (lien local réactif). Défaut = ../nextgraph/ng-eventually-js/packages/sdk.
|
||||
NG_EVENTUALLY_LOCAL=
|
||||
|
||||
@@ -41,3 +41,9 @@ playwright/.auth/
|
||||
|
||||
*storybook.log
|
||||
storybook-static
|
||||
dist-staging/
|
||||
*.ngw
|
||||
.tasks/
|
||||
|
||||
# Per-developer contract access map (canonical provider → local checkout) — never committed.
|
||||
.project/contracts.local.yaml
|
||||
|
||||
@@ -1,175 +0,0 @@
|
||||
# Matrice d'autorisations et inventaire des requêtes
|
||||
|
||||
**Status:** Incubating — analyse en cours
|
||||
**Last updated:** 2026-05-18
|
||||
|
||||
## Context
|
||||
|
||||
Préalable au refactor multi-store ([brief](./multi-store-refactor.md)) et à toute évolution multi-user. La structure de stores NextGraph cible doit être *dérivée* de :
|
||||
|
||||
1. Une matrice d'autorisations (qui peut faire quoi sur quel type de donnée).
|
||||
2. Un inventaire des requêtes nécessaires (lectures, abonnements, écritures par écran).
|
||||
3. Les partitions naturelles qui en découlent (regroupements de données qui partagent autorisations *et* schéma d'accès).
|
||||
|
||||
Ce brief porte cette analyse. Il alimentera la décision finale sur la structure de stores.
|
||||
|
||||
## Cadre
|
||||
|
||||
### Acteurs (tous authentifiés)
|
||||
|
||||
- `Self` — propriétaire de la donnée (varie par type : auteur d'un message, titulaire d'un profil…)
|
||||
- `D` — Déclarant d'un événement (celui qui a inséré la référence dans Festipod ; pas l'organisateur réel)
|
||||
- `H` — Hôte d'un point de rencontre (celui qui l'a créé)
|
||||
- `I` — Inscrit à un point de rencontre
|
||||
- `C` — Connexion (« ami ») d'un autre acteur lié à la donnée
|
||||
- `U` — Utilisateur authentifié quelconque, sans relation à la donnée
|
||||
|
||||
### Verbes
|
||||
|
||||
- `créer`
|
||||
- `lire` (one-shot)
|
||||
- `s'abonner` (lecture longue / réactive)
|
||||
- `modifier`
|
||||
- `supprimer`
|
||||
|
||||
### Conventions
|
||||
|
||||
`✓` autorisé · `✗` interdit · `cond` autorisé sous condition (notée) · `—` sans objet
|
||||
|
||||
## Décisions cadre (acquises)
|
||||
|
||||
- **Tous les utilisateurs sont authentifiés.** Pas d'accès anonyme.
|
||||
- **Points de rencontre publics universels.** Tout utilisateur peut lire et s'abonner.
|
||||
- **Création de point de rencontre ouverte à tous.** Pas de prérequis (adhésion, invitation).
|
||||
- **Hôte = détenteur technique des droits d'écriture** sur un point de rencontre. À ce stade : 1 hôte par PdR, celui qui l'a créé.
|
||||
- **Adhésion à une communauté : hors périmètre actuel.** Le rôle « Membre de communauté » n'est pas analysé ici.
|
||||
- **Suivi de communauté ou d'utilisateur : hors périmètre actuel.** À reprendre quand la fonctionnalité de discovery par abonnement sera traitée.
|
||||
|
||||
## Matrice par type de donnée
|
||||
|
||||
### Point de rencontre
|
||||
|
||||
| Verbe | Self (= Hôte) | I (autre inscrit) | D (déclarant de l'événement parent) | U (utilisateur lambda) |
|
||||
|---|---|---|---|---|
|
||||
| créer | ✓ (l'acte de créer rend l'utilisateur hôte) | — | ✗ | ✓ (l'acte le rend hôte) |
|
||||
| lire | ✓ | ✓ | ✓ | ✓ |
|
||||
| s'abonner | ✓ | ✓ | ✓ | ✓ |
|
||||
| modifier | ✓ | ✗ | ✗ | ✗ |
|
||||
| supprimer | ✓ | ✗ | ✗ | ✗ |
|
||||
|
||||
**Notes :**
|
||||
- Pas de différenciation `C` (connexion de l'hôte) — les connexions sont un filtre d'affichage côté UI, pas un droit d'accès, puisque tout est public.
|
||||
- Le `D` n'a pas de droit particulier sur les PdR greffés sur son événement déclaré — il a juste déclaré la référence.
|
||||
|
||||
### Inscription à un point de rencontre
|
||||
|
||||
L'objet « Inscription » lie un utilisateur et un point de rencontre. Représente l'engagement à participer.
|
||||
|
||||
| Verbe | Self (l'inscrit) | H (hôte du PdR) | I (autre inscrit au même PdR) | U (utilisateur lambda) |
|
||||
|---|---|---|---|---|
|
||||
| créer | ✓ (s'inscrire) | ✗ | ✗ | ✓ (l'acte le rend inscrit) |
|
||||
| lire | ✓ | ✓ | ? **à trancher** | ? **à trancher** |
|
||||
| s'abonner | ✓ | ✓ | ? **à trancher** | ? **à trancher** |
|
||||
| modifier | ? **à trancher** (selon les champs modifiables) | ✗ | ✗ | ✗ |
|
||||
| supprimer | ✓ (se désinscrire) | ? **à trancher** (modération ? blacklist ?) | ✗ | ✗ |
|
||||
|
||||
**Questions ouvertes :**
|
||||
- **Visibilité de la liste des inscrits.** Cohérent avec « tout est public » : tous les utilisateurs voient qui s'est inscrit. Mais à confirmer — y a-t-il un cas où on veut cacher la liste (PdR à inscription confidentielle) ?
|
||||
- **Champs modifiables d'une inscription.** Booléen seul, ou champs additionnels (commentaire, statut "peut-être", nombre d'accompagnants) ?
|
||||
- **Modération par l'hôte.** L'hôte peut-il désinscrire un inscrit (= blacklist) ?
|
||||
|
||||
### Événement
|
||||
|
||||
| Verbe | Self (= D, déclarant) | H (hôte d'un PdR greffé) | U (utilisateur lambda) |
|
||||
|---|---|---|---|
|
||||
| créer | ✓ (l'acte rend déclarant) | — | ✓ (l'acte le rend déclarant) |
|
||||
| lire | ✓ | ✓ | ✓ |
|
||||
| s'abonner | ✓ | ✓ | ✓ |
|
||||
| modifier | ? **à trancher** | ? **à trancher** | ? **à trancher** |
|
||||
| supprimer | ? **à trancher** | ✗ | ✗ |
|
||||
|
||||
**Questions ouvertes :**
|
||||
- **Qui peut modifier un événement déclaré ?** Le déclarant seul (modèle propriétaire) ? Tout utilisateur (modèle wiki, pour compléter/corriger) ? Personne après création (modèle immuable, pour éviter les modifications mal intentionnées) ? Cette question est centrale pour le défi de déduplication évoqué dans le README — un modèle wiki facilite la convergence, un modèle propriétaire complique.
|
||||
- **Qui peut supprimer ?** Si le déclarant supprime, que deviennent les PdR greffés (orphelins ? supprimés en cascade ? l'événement reste mais marqué supprimé ?) ?
|
||||
|
||||
### Profil utilisateur
|
||||
|
||||
À déterminer : un seul objet ou split public/privé ?
|
||||
|
||||
| Verbe | Self | C (connexion) | U (utilisateur lambda) |
|
||||
|---|---|---|---|
|
||||
| créer | ✓ (à l'inscription) | — | — |
|
||||
| lire (partie publique) | ✓ | ✓ | ? **à trancher** |
|
||||
| lire (partie privée) | ✓ | ? **à trancher** | ✗ |
|
||||
| s'abonner | ✓ | ? | ? |
|
||||
| modifier | ✓ | ✗ | ✗ |
|
||||
| supprimer | ✓ (auto-destruction du compte) | ✗ | ✗ |
|
||||
|
||||
**Questions ouvertes :**
|
||||
- **Split public/privé ?** Le profil contient-il des champs réservés aux connexions ou à l'utilisateur seul (préférences, paramètres, email) ?
|
||||
- **Profil entièrement public ?** Cohérent avec « points de rencontre publics » : un visiteur peut voir le profil de l'hôte d'un PdR. Mais le détail (bio, photos, ville…) ?
|
||||
|
||||
### Connexion (lien d'amitié)
|
||||
|
||||
| Verbe | Self (A, demandeur) | Other (B, l'autre côté de la connexion) | U (utilisateur lambda) |
|
||||
|---|---|---|---|
|
||||
| créer (demande) | ✓ | — | — |
|
||||
| accepter | — | ✓ | ✗ |
|
||||
| lire (sa propre liste d'amis) | ✓ | — | — |
|
||||
| lire (la liste d'amis d'un autre) | — | — | ? **à trancher** |
|
||||
| s'abonner (à sa liste) | ✓ | — | — |
|
||||
| modifier | — | — | — |
|
||||
| supprimer (rompre la connexion) | ✓ | ✓ | ✗ |
|
||||
|
||||
**Questions ouvertes :**
|
||||
- **Bilatérale ou unilatérale ?** Le concept « connexion / ami » suggère bilatérale (les deux acceptent). À confirmer ; si oui, il y a deux objets distincts : `DemandeDeConnexion` (unilatérale) et `Connexion` (bilatérale).
|
||||
- **Visibilité de la liste d'amis.** Une connexion est-elle observable par des tiers ? « Marie est connectée à Bob » est-il public, restreint, ou privé ?
|
||||
|
||||
## Hors périmètre actuel
|
||||
|
||||
À reprendre quand ces concepts deviendront actifs :
|
||||
|
||||
- **Communauté d'intérêt** (membres, modération, création)
|
||||
- **Adhésion à une communauté**
|
||||
- **Liste curated** (création, partage, abonnement)
|
||||
- **Suivi d'utilisateur ou de communauté** pour discovery distribuée
|
||||
|
||||
## Inventaire des requêtes par écran
|
||||
|
||||
*À remplir une fois la matrice des autorisations stabilisée.*
|
||||
|
||||
Schéma prévu :
|
||||
|
||||
| Écran | Lectures one-shot | Abonnements | Écritures | Acteur déclencheur |
|
||||
|---|---|---|---|---|
|
||||
|
||||
Écrans à analyser (depuis [AGENTS.md](../../AGENTS.md#routing)) :
|
||||
|
||||
- `WelcomeScreen` `/`
|
||||
- `LoginScreen` `/login`
|
||||
- `HomeScreen` `/home`
|
||||
- `EventsScreen` `/events`
|
||||
- `CreateEventScreen` `/events/new`
|
||||
- `EventDetailScreen` `/events/:id`
|
||||
- `UpdateEventScreen` `/events/:id/edit`
|
||||
- `InviteScreen` `/events/:id/invite` (à voir si encore pertinent)
|
||||
- `ParticipantsListScreen` `/events/:id/participants`
|
||||
- `MeetingPointsScreen` `/events/:id/meeting-points`
|
||||
- `ProfileScreen` `/profile`
|
||||
- `UpdateProfileScreen` `/profile/edit`
|
||||
- `FriendsListScreen` `/profile/friends`
|
||||
- `ShareProfileScreen` `/profile/share`
|
||||
- `UserProfileScreen` `/users/:id`
|
||||
- `SettingsScreen` `/settings`
|
||||
|
||||
## Partitions naturelles dérivées
|
||||
|
||||
*À remplir une fois la matrice + l'inventaire stabilisés.*
|
||||
|
||||
Heuristique de dérivation : on regroupe dans un même store les données qui (a) partagent leur cellule d'autorisation pour les verbes d'écriture, et (b) sont accédées ensemble dans la majorité des requêtes (pour éviter de multiplier les abonnements).
|
||||
|
||||
## See Also
|
||||
|
||||
- [Brief : refactor multi-store](./multi-store-refactor.md) — consommateur principal de cette analyse
|
||||
- [README §Modèle fonctionnel](../../README.md) — source des acteurs et concepts
|
||||
- [Knowledge : data layer](../knowledge/data-layer.md) — état actuel mono-store
|
||||
@@ -1,126 +0,0 @@
|
||||
# Refactor multi-store NextGraph
|
||||
|
||||
**Status:** Incubating — aucun travail démarré
|
||||
**Last updated:** 2026-05-17
|
||||
|
||||
## Context
|
||||
|
||||
L'app Festipod est aujourd'hui *mono-store* : tout ce que l'app écrit (events, profils, participations, friendships) atterrit dans le `private_store` de l'utilisateur connecté. C'est un héritage du sample expense-tracker-rdf, formalisé dans [la décision du 2026-03-17](../decisions/2026-03-17-1600-private-store-nuri-scope.md).
|
||||
|
||||
Ce choix bloque toute évolution vers du multi-utilisateurs : par construction le `private_store` est non partageable (cf. [data-layer](../knowledge/data-layer.md) et la doc NextGraph officielle — *« It is not possible to share the documents of your private store with anybody else »*). Tant que tout est dans le private_store, Bob ne pourra jamais voir l'event d'Alice.
|
||||
|
||||
Le modèle natif NextGraph est *multi-store par utilisateur* (private, protected, public, group, dialog) — chaque type d'information a sa place. Festipod doit s'aligner sur ce modèle avant de pouvoir devenir collaboratif.
|
||||
|
||||
**Déclencheur :** discussion du 2026-05-17 sur la suite multi-user. Décision prise : *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:30` — `ensureGraphNuri()` retourne `did:ng:${session.private_store_id}` pour TOUTES les entités, peu importe leur nature.
|
||||
- `src/shared/hooks/useShapeWithDefaults.ts` — accepte un `storeNuri` mais l'appelant unique (`FestipodDataContext`) lui passe systématiquement le NURI du private_store.
|
||||
|
||||
Entités impactées (toutes mélangées dans le même store aujourd'hui) :
|
||||
- `FpEvent` — devrait vivre dans un store partagé (logique multi-user)
|
||||
- `FpUserProfile` — devrait être en partie privée, en partie publique
|
||||
- `FpParticipation` — liée à un event, devrait vivre avec lui
|
||||
- `FpMeetingPoint` — actuellement local-only côté types ([`src/shared/data/types.ts:106`](../../src/shared/data/types.ts)), pas encore branché à NextGraph
|
||||
- `FpFriendship` — actuellement local-only, naturellement privée
|
||||
|
||||
### Modèle cible proposé
|
||||
|
||||
Structure hiérarchique en **4 niveaux de Group stores** (pas de private/public pour le métier collaboratif — tout en Group) :
|
||||
|
||||
```
|
||||
┌─ Group store « index communautaire » ────────────────────┐
|
||||
│ Référence tous les events visibles dans la communauté │
|
||||
│ Lecture par tous les membres, sert d'annuaire/discovery │
|
||||
│ │
|
||||
│ ┌─ Group store « communauté » ──────────────────────┐ │
|
||||
│ │ Propriétaire de l'event │ │
|
||||
│ │ Permissions = qui peut modifier l'event │ │
|
||||
│ │ (organisateurs / membres de la communauté) │ │
|
||||
│ │ │ │
|
||||
│ │ ┌─ Group store « event » ─────────────────────┐ │ │
|
||||
│ │ │ Tout ce qui se rattache à l'event : │ │ │
|
||||
│ │ │ participations, infos pratiques, discu… │ │ │
|
||||
│ │ │ Membres = participants à l'event │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ ┌─ Group store « meeting point » ───────┐ │ │ │
|
||||
│ │ │ │ Un RDV de l'event = son propre group │ │ │ │
|
||||
│ │ │ │ Permet participations + discu │ │ │ │
|
||||
│ │ │ │ scopées au point de rencontre │ │ │ │
|
||||
│ │ │ └───────────────────────────────────────┘ │ │ │
|
||||
│ │ └─────────────────────────────────────────────┘ │ │
|
||||
│ └───────────────────────────────────────────────────┘ │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Mapping entités → store cible :
|
||||
|
||||
| Entité | Store cible | Justification |
|
||||
|---|---|---|
|
||||
| Event (métadonnées : titre, dates, description) | Group store « communauté » | C'est la communauté qui possède l'event, donc qui contrôle qui peut le modifier |
|
||||
| Référence d'event (pointeur depuis l'index) | Group store « index communautaire » | Discovery : « voici les events visibles » |
|
||||
| Participation | Group store « event » | Une participation n'a de sens que dans le contexte de son event |
|
||||
| MeetingPoint (métadonnées) | Group store « event » | Le RDV appartient à l'event |
|
||||
| Participation à un MeetingPoint | Group store « meeting point » | RSVP/présence scopés au RDV |
|
||||
| UserProfile (partie publique) | public_store de l'utilisateur | Modèle natif NextGraph |
|
||||
| Friendship | private_store de l'utilisateur | Donnée purement personnelle |
|
||||
|
||||
### Contrainte SDK bloquante
|
||||
|
||||
La création de Group stores et la gestion des invitations/permissions **ne sont pas exposées dans le SDK `@ng-org/web` actuel** (version `0.1.2-alpha.11`). Les méthodes disponibles : `doc_create`, `doc_subscribe`, `sparql_query/update`, `orm_start_*`, `file_get`, `app_request_stream`. Aucune méthode `share_doc`, `invite_user`, `create_group_store`, `accept_invite`. La doc NextGraph annonce qu'*« An API will be provided for permission manipulation »* — pas de date.
|
||||
|
||||
**Implication :** le refactor *structurel* (passer d'un store unique à un système de stores par entité) peut commencer sans attendre cette API, en utilisant des placeholders (par ex. continuer à pointer vers `private_store_id` pour les Group stores qui ne peuvent pas encore exister). Mais l'**aboutissement complet** (vrai multi-user, partage entre wallets distincts) dépend de l'arrivée de l'API SDK ou d'un contournement (fork du wallet, accès Rust direct, etc.).
|
||||
|
||||
### Implications côté code
|
||||
|
||||
Le refactor touche au moins :
|
||||
|
||||
1. **Disparition de `ensureGraphNuri()`** comme helper unique. Remplacé par des helpers par entité (`getEventStore(communityId)`, `getParticipationStore(eventId)`, `getProfileStore(scope: 'public' | 'private')`, …) ou par une couche `storeRegistry` qui résout le NURI selon `(entité, contexte)`.
|
||||
2. **`useShapeWithDefaults` reste un wrapper utile** mais l'appelant choisit explicitement le store. Aujourd'hui un seul appelant ([`FestipodDataContext`](../../src/shared/context/FestipodDataContext.tsx)), demain N appelants ou un appelant qui résout dynamiquement.
|
||||
3. **Chaque entité de domaine déclare son store cible** — soit via un mapping centralisé, soit via une convention (shape → store).
|
||||
4. **`bootstrapWallet()`** ([`src/shared/utils/ngBootstrap.ts`](../../src/shared/utils/ngBootstrap.ts)) doit être revu : on ne seed plus dans un unique store, on doit seed dans plusieurs (ou décider que seed ne crée que des données de l'utilisateur courant — ce qui colle mieux à la réalité multi-user).
|
||||
5. **`FestipodDataContext`** : structurer les hooks par entité, chacun avec son store résolu.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Quand crée-t-on un Group store de communauté ?** L'API n'existe pas en SDK aujourd'hui. Faut-il que ce soit un acte explicite de l'utilisateur (« créer une communauté ») ou bien tout user a une communauté par défaut à la création de son wallet ?
|
||||
2. **Comment Bob connaît-il l'index communautaire d'Alice ?** Discovery toujours ouverte — possiblement via le public_store d'Alice qui annonce le NURI de l'index communautaire.
|
||||
3. **Faut-il vraiment 4 niveaux d'imbrication ?** Le « meeting point comme group store » mérite d'être validé — quel besoin réel justifie une couche de permission supplémentaire vs un simple sous-graphe du group store de l'event ?
|
||||
4. **Que devient le seed de démo** quand l'app est multi-store et que les Group stores ne peuvent pas encore exister ? Mode dégradé en private_store le temps que le SDK rattrape, ou retirer le seed en mode connecté ?
|
||||
5. **Migration des wallets existants** : les wallets de test ont déjà des données dans le private_store. Comment on les fait évoluer (script de migration, wipe and reseed, ignore) ?
|
||||
6. **Bootstrap d'un user vierge** : à la première connexion, faut-il auto-créer un Group store communautaire « par défaut » pour lui ou attendre une action utilisateur ?
|
||||
|
||||
## Possible Approaches
|
||||
|
||||
Esquisses sans engagement (les arbitrages se feront dans une décision dédiée au moment de l'exécution) :
|
||||
|
||||
- **Refactor structurel d'abord, partage ensuite.** Réorganiser l'app en multi-store dès maintenant en utilisant `private_store_id` comme placeholder pour les Group stores manquants. Quand l'API arrive, on remplace les placeholders par de vrais NURIs de Group stores.
|
||||
- **Registry centralisé** vs **résolution par convention**. Soit un `storeRegistry.ts` qui mappe explicitement `(entité, contexte) → NURI`, soit chaque shape porte sa propre logique de scope.
|
||||
- **Big-bang** vs **par entité**. Tout migrer en un coup vs migrer entité par entité (commencer par Event qui est le plus stratégique).
|
||||
- **Maintenir un mode mono-store** parallèle pour le dev/demo tant que les Group stores ne sont pas fonctionnels.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
Ce brief — et le refactor qui en découlera — **ne traite pas** :
|
||||
- L'invitation effective d'utilisateurs à un Group store (capability sharing, Nuri d'invitation)
|
||||
- La gestion des permissions par rôle (organisateur / membre / lecteur)
|
||||
- La résolution du problème de discovery cross-wallet
|
||||
- Le contournement éventuel de l'UI wallet (jugée dysfonctionnelle dans cette conversation)
|
||||
- Le mode P2P direct sans broker
|
||||
|
||||
Ces sujets relèvent d'un **second chantier multi-user** dont le refactor multi-store est seulement le *prérequis structurel*.
|
||||
|
||||
## Starting Points
|
||||
|
||||
- [decision: private_store NURI scope](../decisions/2026-03-17-1600-private-store-nuri-scope.md) — la décision actuelle qu'on viendra modifier
|
||||
- [knowledge: data-layer](../knowledge/data-layer.md) — état actuel du pattern d'écriture
|
||||
- [`src/shared/utils/ngGraph.ts`](../../src/shared/utils/ngGraph.ts) — point de hardcoding principal
|
||||
- [`src/shared/hooks/useShapeWithDefaults.ts`](../../src/shared/hooks/useShapeWithDefaults.ts) — l'autre point de hardcoding
|
||||
- [`src/shared/context/FestipodDataContext.tsx`](../../src/shared/context/FestipodDataContext.tsx) — l'unique appelant aujourd'hui
|
||||
- [`src/shared/utils/ngBootstrap.ts`](../../src/shared/utils/ngBootstrap.ts) — le seed à revoir
|
||||
- NextGraph docs : [Documents et Stores](https://docs.nextgraph.org/en/documents/), [Getting started](https://docs.nextgraph.org/en/getting-started/)
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
type: _overview
|
||||
summary: Feature-based architecture of the app — modules per domain, import invariant, provider-stacked app shell, path-based routing, screens and registry
|
||||
triggers:
|
||||
keywords: [module, modules, screen, écran, routing, route, navigate, useNavigate, useParams, registry, registre, app shell, shared, import]
|
||||
paths: ["src/app/**", "src/screens/**", "src/modules/*/screens/**", "src/shared/components/**", "src/shared/context/**"]
|
||||
---
|
||||
|
||||
# App architecture
|
||||
|
||||
How the app's code is **structured** and **assembled**. *Feature-based* architecture: the code is organized by **business domain** (module), not by technical layer.
|
||||
|
||||
**Read this first:** [[rule_module-imports]] — the central invariant that keeps modules decoupled.
|
||||
|
||||
## Links
|
||||
|
||||
- [[knowledge_module-structure]] — module tree + `shared/` layer
|
||||
- [[knowledge_app-shell]] — `src/app/`, provider stack, entry points
|
||||
- [[knowledge_routing]] — path-based routing (History API), route table, hooks
|
||||
- [[knowledge_screens]] — screen inventory, registry, component library
|
||||
- [[knowledge_screen-pattern]] — canonical anatomy of a screen (no props, flex layout, showToast)
|
||||
- [[caveat_identity-ids-in-screens]] — `currentUserId` is the profile document's NURI and is **empty until the protected read lands**; empty reads like "no data"
|
||||
- [[caveat_boot-unverified-outside-broker]] — the unconditional `ensureIdentity()` await is verified inside the broker iframe; standalone/top-level boot is unverified
|
||||
- [[knowledge_styling-system]] — `src/index.css`, `app-*` classes, vars, pitfalls (Tailwind unused, `user-content` inert)
|
||||
- [[cookbook_add-screen]] — procedure for wiring up a new screen (registry + router + shell)
|
||||
- `tech-stack` — build, Bun bundler, commands
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: The standalone boot (app opened directly, not in the broker iframe) is covered by NO test, and it broke SILENTLY once — a blank page with no error, because nothing started a session and ensureIdentity() then settled neither way. Fixed by making the session start unconditionally; still untested, so break it and you will not hear about it.
|
||||
last_checked: 2026-08-10
|
||||
---
|
||||
|
||||
# Pitfall: nothing tests the app booting outside the broker iframe
|
||||
|
||||
## What happened, VERIFIED
|
||||
|
||||
`AuthGate` awaits `ensureIdentity()` and renders **nothing** until it settles. `NextGraphProvider` used to start the NextGraph session **only inside the broker iframe** — standalone, the session was started by the user pressing "Entrer" on the app's own access screen.
|
||||
|
||||
That screen was deleted the same day (the SDK shows the barrier now, see [[decision_2026-08-10_sdk-renders-the-barrier]]), and the iframe-only condition survived it. Standalone, the result was: no session ever started → the `getSession` thunk never returned → `ensureIdentity()` **neither resolved nor rejected** → `AuthGate` returned `null` forever. **A blank page with nothing in the console.**
|
||||
|
||||
Note the shape of the failure, because it is the instructive part: a rejection would have been *shown* (`AuthGate` renders a named error panel). What produced silence was a promise that never settled at all — the one outcome no error path catches. Found by a human opening the app, not by any suite.
|
||||
|
||||
The fix: the session starts unconditionally, in the iframe and standalone alike, through one `startSession()` in `NextGraphContext`. Standalone, `initNg()` redirects to the broker — that redirect **is** the sign-in flow now that nothing is left to click.
|
||||
|
||||
## What is still true
|
||||
|
||||
**No test exercises this path.** `@data` runs the harness inside the broker iframe; `@e2e` drives the real app inside the broker iframe too. The standalone top-level boot — the one a developer uses every day with `bun run dev`, and the one a first-time visitor hits — is covered by nothing.
|
||||
|
||||
So: a change to `AuthGate`, to `NextGraphProvider`, or to what `configure()` receives can break the app's entry completely while every suite stays green. If you touch any of them, **open the app standalone yourself** before believing the tests.
|
||||
|
||||
Two related pieces: [[caveat_first-time-entry-untested]] (the wallet-import journey, same blind spot seen from the user's side) and [[caveat_shared-wallet-global-before-gate-import]] (a missing wallet password now makes `ensureIdentity()` throw, which at least fails loudly).
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: currentUserId is now the profile document's NURI — the same value as currentUser.id — so the old two-id-spaces pitfall is gone; the live hazard is that it is EMPTY until the protected profile read lands, and nothing raises when a screen keys on it too early
|
||||
last_checked: 2026-08-10
|
||||
---
|
||||
|
||||
# Pitfall: the current user arrives late, and empty reads like a value
|
||||
|
||||
## What is true now — one id, not two
|
||||
|
||||
`currentUserId` **is** `currentUser?.id`: the **NURI of the profile document** the app reads back in its own protected scope. The two are no longer distinct spaces, because the app no longer derives a principal from anything it was told — it stopped naming its own identity altogether (concept `app-security`, [[decision_2026-08-10_the-barrier-names-no-identity]]). A participation written today carries that same NURI in `fp:user`.
|
||||
|
||||
> The earlier pitfall — a stable principal `urn:festipod:user:<key>` on one side and a profile NURI on the other, never equal in connected mode — **no longer applies to values written today**. The provider still resolves the older principal form on read (`resolveParticipantUser`, concept `data-layer` → [[knowledge_context-internals]]); a screen never sees it.
|
||||
|
||||
## The live hazard: `''` before the read lands
|
||||
|
||||
`currentUserId` is **empty** until the protected profile read resolves — and empty is a perfectly ordinary string. Nothing throws.
|
||||
|
||||
- A **query** keyed on it (`getUserEvents`, `isParticipating`, `getFriends` — all defaulting to `currentUserId`) returns an **empty result** rather than an error, which renders as "you have nothing" instead of "not ready yet".
|
||||
- A **mutation** that needs it refuses rather than writing a malformed entity: `joinEvent` logs `empty user principal — refusing to write a participation with no fp:user` and returns. A screen that assumed the write happened shows a success it did not get.
|
||||
|
||||
**The rule**: treat an empty `currentUserId` as *not ready*, never as *no data*. Gate on it before rendering an emptiness verdict or firing a mutation that stores it.
|
||||
|
||||
The participation→profile join itself is **not** the screen's business: it is done in the provider (`resolveParticipantUser`). Full mechanics and the write/read invariant: concept `data-layer`, [[knowledge_context-internals]].
|
||||
@@ -0,0 +1,20 @@
|
||||
---
|
||||
type: cookbook
|
||||
summary: Procedure for adding a screen — create the component in the module, register it in src/screens/index.ts, add the route in router.tsx, mount it in App.tsx, plus a screenNameMap alias if it is covered by BDD
|
||||
---
|
||||
|
||||
# Cookbook: adding a screen
|
||||
|
||||
A screen has to be wired up in **several places** — forgetting one produces silent bugs (see the `ConnectScreen` case, [[knowledge_screens]]).
|
||||
|
||||
1. **Create the component**: `src/modules/{module}/screens/MyScreen.tsx`, following [[knowledge_screen-pattern]] (function with no props, `useFestipodData`/`useNavigate`/`useParams`, flex layout, styling via [[knowledge_styling-system]]). Respect [[rule_module-imports]] (import only from `shared/`).
|
||||
|
||||
2. **Add it to the registry**: `src/screens/index.ts` — add the import plus the entry (`id`, French `name`, `path`, `component`). **The most frequently forgotten step**: a screen missing from the registry is invisible to Storybook and to the registry's consumers, even though it works as a route.
|
||||
|
||||
3. **Add the route**: `src/app/router.tsx` — extend the `Route` type, add the case in `parsePath()` (and the reverse conversion if present).
|
||||
|
||||
4. **Mount it in the shell**: `src/app/App.tsx` — add the case in the switch that maps `route.page` → component.
|
||||
|
||||
5. **(If covered by BDD)**: add an alias in `screenNameMap` (`src/shared/steps/ui/navigation.steps.ts`) if the French name used in the `.feature` does not resolve trivially to the `id`. See concept `bdd-testing`.
|
||||
|
||||
> Check consistency: the `id` must be identical across the registry, the router and `screenNameMap`. A silent mismatch = an unreachable or unrendered screen.
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: src/app/ is the app's real shell — App.tsx stacks the providers (Theme > NextGraph > FestipodData > Router), AuthGate makes the one unconditional ensureIdentity() await and renders nothing of its own until it settles, and the shell switches screens according to the route
|
||||
last_checked: 2026-08-10
|
||||
---
|
||||
|
||||
# App shell
|
||||
|
||||
`src/app/` is the **shell of the real app** (mobile web app), not a prototyping tool.
|
||||
|
||||
> Migration note: older notes described `src/app/` as a "prototyping tool" using hash routing (`#/`, `#/demo/...`). That is **obsolete** since the restructuring into a real app. The current truth: path-based routing via the History API (see [[knowledge_routing]]).
|
||||
|
||||
## Provider stack
|
||||
|
||||
`App.tsx` stacks the providers, then switches screens according to the current route:
|
||||
|
||||
```
|
||||
ThemeProvider
|
||||
└ NextGraphProvider (NextGraph connection cycle — concept data-layer)
|
||||
└ FestipodDataProvider (data, connected/demo mode — concept data-layer)
|
||||
└ RouterProvider (current route + navigate)
|
||||
└ div.app-container
|
||||
├ AuthGate (the one ensureIdentity() await; renders nothing of its own)
|
||||
│ └ AppContent (switch route.page → screen)
|
||||
└ ToastContainer
|
||||
```
|
||||
|
||||
`AppContent` reads `useRouter()` to resolve `route.page` → the screen to render. **There is no identity provider**: the app names no identity of its own (concept `app-security`, [[decision_2026-08-10_the-barrier-names-no-identity]]), so there is nothing to hold above the data provider.
|
||||
|
||||
### Ordering invariants (what breaks if you move a layer)
|
||||
|
||||
- **`AuthGate` sits INSIDE the router**: it reads `useRouter()`/`useNavigate()` to leave the logged-out landing route once identified. Moving it out of `RouterProvider` breaks it.
|
||||
- **`AuthGate` wraps EVERY routed screen**, and it holds them behind **one** condition: the single `await ensureIdentity()` (`@ng-eventually/polyfill`) it fires unconditionally on mount has resolved. Until it does, `AuthGate` renders **nothing at all** — there is no Festipod screen standing in for `AppContent` while it waits (concept `app-security`, [[decision_2026-08-10_sdk-renders-the-barrier]]). `AuthGate` does not read `useNextGraph()` — no `status`, no `connect()`, no error branch of its own. The identity await is not decoration: `ensureIdentity()` also does the connection work (restoring what others shared with us), so a screen mounted before it resolves would read as an identity that is not yet settled. Consequence: **no screen may assume it is reachable without a settled identity**, and there is no longer a way to disable the barrier — see [[caveat_boot-unverified-outside-broker]] for the one path this leaves unverified.
|
||||
- **`ToastContainer` sits OUTSIDE `AuthGate`** (but inside `.app-container`): it is mounted regardless of whether identity has settled.
|
||||
|
||||
## Entry points
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `src/index.ts` | `Bun.serve()` — HTTP server, serves `index.html` + the cucumber report |
|
||||
| `src/index.html` | HTML entry point, loads `src/app/frontend.tsx` |
|
||||
| `src/app/frontend.tsx` | React root, renders `<App />` |
|
||||
|
||||
The build and the bundler (Bun + Tailwind, alias `@/* → ./src/*`) are documented in the `tech-stack` concept.
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Feature-based tree — business modules (event, user, home, auth, workshop, meeting, notification) and a shared/ layer importable by all of them
|
||||
---
|
||||
|
||||
# Module structure
|
||||
|
||||
The code is organized by **business domain**, not by technical layer.
|
||||
|
||||
```
|
||||
src/modules/
|
||||
event/ # Events: CRUD, discovery, participants, meeting points
|
||||
user/ # Profiles, connections ("friends"), sharing
|
||||
home/ # Dashboard, settings
|
||||
auth/ # Login, welcome/onboarding
|
||||
workshop/ # Workshop specs (features only, no screens)
|
||||
meeting/ # Meeting point specs (features only)
|
||||
notification/ # Notification specs (features only)
|
||||
```
|
||||
|
||||
Each module may contain:
|
||||
- `screens/` — React screen components
|
||||
- `features/` — Gherkin `.feature` files (BDD specs, see concept `bdd-testing`)
|
||||
- `steps/{ui,data,e2e}/` — Cucumber step definitions, one folder per layer
|
||||
|
||||
## The `shared/` layer
|
||||
|
||||
`src/shared/` holds everything reusable across modules:
|
||||
|
||||
| Directory | Contents |
|
||||
|---|---|
|
||||
| `components/` | UI component library (see [[knowledge_screens]]) |
|
||||
| `context/` | `ThemeContext`, `NextGraphContext`, `FestipodDataContext` (concept `data-layer`); their **stacking order** is constrained, see [[knowledge_app-shell]]. There is no identity context — the app names no identity of its own (concept `app-security`) |
|
||||
| `data/` | User stories, `features.ts` (auto-generated), `seedData.ts`, `types.ts` |
|
||||
| `hooks/` | empty — the reactive read binding lives in `data/useShapeQuery.ts` (concept `data-layer`) |
|
||||
| `shapes/` | SHEX + ORM bindings (see concept `data-layer`) |
|
||||
| `utils/` | `ngSession.ts`, `ngBootstrap.ts`, `ngGraph.ts`, `storeRegistry.ts`, `connections.ts`, `identifier.ts` |
|
||||
| `steps/`, `support/` | Shared Cucumber step definitions and hooks (concept `bdd-testing`) |
|
||||
| `lib/` | Helpers (`cn`, etc.) |
|
||||
|
||||
The dependency rule between modules and `shared/` lives in [[rule_module-imports]].
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Path-based routing via the History API (hand-rolled router in src/app/router.tsx) — route table, useNavigate/useParams hooks, no prop drilling
|
||||
---
|
||||
|
||||
# Routing
|
||||
|
||||
**Path-based** routing via the History API — hand-rolled router in `src/app/router.tsx` (`window.history.pushState` + `popstate`, `parsePath(pathname)`). No hash routing.
|
||||
|
||||
## Route table
|
||||
|
||||
| Path | Screen |
|
||||
|---|---|
|
||||
| `/` | WelcomeScreen |
|
||||
| `/home` | HomeScreen |
|
||||
| `/events` | EventsScreen |
|
||||
| `/events/new` | CreateEventScreen |
|
||||
| `/events/:id` | EventDetailScreen |
|
||||
| `/events/:id/edit` | UpdateEventScreen |
|
||||
| `/events/:id/invite` | InviteScreen |
|
||||
| `/events/:id/participants` | ParticipantsListScreen |
|
||||
| `/events/:id/meeting-points` | MeetingPointsScreen |
|
||||
| `/profile` | ProfileScreen |
|
||||
| `/profile/edit` | UpdateProfileScreen |
|
||||
| `/profile/friends` | FriendsListScreen |
|
||||
| `/profile/share` | ShareProfileScreen |
|
||||
| `/profile/connect` | ConnectScreen (`src/modules/user/screens/ConnectScreen.tsx`) — routed and mounted in `App.tsx`, but **absent from the screen registry**, see [[knowledge_screens]] |
|
||||
| `/users/:id` | UserProfileScreen |
|
||||
| `/settings` | SettingsScreen |
|
||||
|
||||
> This table mirrors `parsePath()` in `router.tsx` — go back to it whenever it changes, that is the source of truth. There is **no** authentication route: the access barrier is not routed, it is rendered *above* the route switch by `AuthGate` (see [[knowledge_app-shell]]).
|
||||
|
||||
## Hooks
|
||||
|
||||
Screens use the router's `useNavigate()` and `useParams()` — **no prop drilling**. The shell intercepts navigation to switch the displayed screen (see [[knowledge_app-shell]]).
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Canonical anatomy of a screen — named function with no props, reads everything through useFestipodData/useNavigate/useParams, flex column layout (Header / scrollable content / BottomNav), feedback via showToast, hard-coded French labels; zero-prop rule has no exception left
|
||||
last_checked: 2026-08-10
|
||||
---
|
||||
|
||||
# Canonical screen pattern
|
||||
|
||||
Every screen follows the same shape. Knowing it avoids reinventing or diverging.
|
||||
|
||||
## Shape
|
||||
|
||||
```tsx
|
||||
export function MyScreen() { // named function, NEVER any props
|
||||
const navigate = useNavigate();
|
||||
const { eventId, userId } = useParams();
|
||||
const { getEvent, currentUser, … } = useFestipodData();
|
||||
const [local, setLocal] = useState(…); // screen-local state (steps, selections)
|
||||
|
||||
const handleAction = () => {
|
||||
// …mutate through useFestipodData
|
||||
showToast('Message', 'success'); // feedback
|
||||
navigate('/path');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display:'flex', flexDirection:'column', height:'100%' }}>
|
||||
<Header title="…" /* left/right optional */ />
|
||||
<div style={{ flex:1, overflow:'auto' }}>{/* scrollable content */}</div>
|
||||
<BottomNav active="…" /> {/* only on hub screens */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Invariants
|
||||
|
||||
- **Zero props, no exception left**: every registered screen receives nothing; everything comes from context/hooks (`useFestipodData`, `useNavigate`, `useParams`). `WelcomeScreen` does not use `useFestipodData` (intro) — but still takes no props. (`LoginScreen`/`ConnexionScreen`/`AccessGateScreen` no longer exist — Festipod renders no access screen of its own; see [[knowledge_screens]] and [[knowledge_app-shell]].)
|
||||
- **Identity: the current user may not be there yet.** `currentUserId` is the profile document the app reads back in its own protected scope, so it is **empty until that read lands** — see [[caveat_identity-ids-in-screens]] before keying anything on it.
|
||||
- **Layout**: full-height flex column; `Header` at the top, content at `flex:1; overflow:auto`, `BottomNav` at the bottom **only for hub screens** (Home, Events, Profile, Friends). Flow screens (creation, editing, detail) have no `BottomNav`.
|
||||
- **Feedback**: `showToast(message, 'success'|'info'|'error')` (`ToastContainer` mechanism exported by `sketchy/`).
|
||||
- **Labels**: **French, hard-coded** — no i18n, no translation keys anywhere in the project.
|
||||
- Styling: see [[knowledge_styling-system]]. Navigation/registry: [[knowledge_routing]], [[knowledge_screens]].
|
||||
|
||||
To **create** a screen (the 3+ places to wire up), see [[cookbook_add-screen]].
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Inventory of screens per module, central registry src/screens/index.ts, and the component library under shared/components/sketchy/ — whose NAME is kept but which renders a modern theme (not hand-drawn); the auth module now holds only WelcomeScreen, no access screen of its own
|
||||
last_checked: 2026-08-10
|
||||
---
|
||||
|
||||
# Screens and components
|
||||
|
||||
## Component library: `sketchy/` = modern theme
|
||||
|
||||
⚠️ **Naming pitfall.** The component library lives under `src/shared/components/sketchy/` (path kept, imported by ~17 screens), **but it no longer renders a "hand-drawn" style**: it was ported to a **modern** theme (DM Sans / orange, `app-*` classes). The *import path* is right, the *"sketchy" visual description* is obsolete. Do not reintroduce hand-drawn aesthetics on the strength of the folder name.
|
||||
|
||||
Typical components: `Header`, `BottomNav`, `Button`, `Card`, `Input`, `Badge`, `Avatar`/`AvatarStack`, `Text`/`Title`, `Toggle`, `ListItem`, `Divider`, `Placeholder`, `BrokerBanner`, `NgStatus`.
|
||||
|
||||
## Screen registry
|
||||
|
||||
`src/screens/index.ts` imports every screen from every module and exposes:
|
||||
|
||||
```typescript
|
||||
export const screenGroups // grouped by domain (home, events, user, general)
|
||||
export const screens // flat list
|
||||
export function getScreen(id): Screen | undefined
|
||||
```
|
||||
|
||||
Used notably by Storybook (see concept `tech-stack`) to browse the screens.
|
||||
|
||||
## Inventory
|
||||
|
||||
Screens per module (IDs = registry keys):
|
||||
|
||||
- **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/**: `WelcomeScreen` (intro, routed at `/`) is the only screen left in this module. Festipod renders **no access screen of its own** any more: `AccessGateScreen`, its route and its registration are deleted, along with the `LoginScreen`/`ConnexionScreen` that preceded it. Signing in is `src/app/AuthGate.tsx`'s single `await ensureIdentity()`; whatever a user sees or does while that resolves is drawn entirely by the SDK, outside the registry, outside routing, and outside this app's component tree (concept `app-security`, [[decision_2026-08-10_sdk-renders-the-barrier]]).
|
||||
|
||||
> The path → screen mapping lives in [[knowledge_routing]]. Most screens consume `useFestipodData()` (concept `data-layer`); the exception is `WelcomeScreen`.
|
||||
|
||||
## Pitfall: incomplete registry
|
||||
|
||||
The registry must list **every** screen. Observed case, **still true (verified 2026-07-28)**: `ConnectScreen` (`src/modules/user/screens/ConnectScreen.tsx`) exists, is routed at `/profile/connect` (`router.tsx`) and is mounted in `App.tsx` (`case 'connect'`), yet it is **missing from `src/screens/index.ts`** → invisible to Storybook and to the registry's consumers, even though it works as a route. Always check that the screen is registered (see [[cookbook_add-screen]]).
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: src/index.css is the styling source of truth — --app-* variables (colors, radii, DM Sans font) and app-* classes rendered by the components; screens combine those classes with inline styles; Tailwind is in the build but screens use no Tailwind utilities; the user-content class is inert
|
||||
last_checked: 2026-06-15
|
||||
---
|
||||
|
||||
# Styling system
|
||||
|
||||
**Source of truth: `src/index.css`** ("Modern clean — DM Sans" theme). That is where the CSS variables and the `app-*` classes live. No per-module CSS files.
|
||||
|
||||
## Variables (`:root`)
|
||||
|
||||
- Colors: `--app-black #1a1a1a`, `--app-gray #888`, `--app-bg/--app-white #fff`, orange accent `--app-accent #E8590C` (+ `-light #FFF7ED`, `-border`, `-dark #C05621`), green `--app-green #22543D` (+ `-light`, `-border`, `-text`).
|
||||
- Radii: `--app-radius 16px`, `--app-radius-sm 12px`, `--app-radius-xs 8px`.
|
||||
- Font: `--font-app: 'DM Sans', …`.
|
||||
|
||||
## `app-*` classes
|
||||
|
||||
Defined in `index.css`, rendered by the components in `shared/components/sketchy/`: `app-btn` (+ `-primary`/`-green`), `app-input`, `app-card`, `app-title`/`app-subtitle`/`app-text`, `app-badge`, `app-toggle`, `app-checkbox`, `app-header`, `app-navbar`, `app-list-item`, `app-avatar`, `app-placeholder`, `app-divider`, `app-tab`.
|
||||
|
||||
## Conventions for writing a screen
|
||||
|
||||
- Use the **`sketchy/` components** (which carry the `app-*` classes) for buttons/inputs/cards/typography.
|
||||
- For **layout** (flex, gaps, paddings, one-off colors), screens use **inline styles** (`style={{…}}`) — that is the normal pattern, not a deviation.
|
||||
- Icons: **emojis**/Unicode symbols (📅 📍 📝 🎪…), generally no icon imports.
|
||||
- Width: `.app-container` caps at **`max-width: 768px`, `height: 100dvh`** (mobile-first/portrait tablet). No media queries — no desktop responsiveness.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Tailwind is in the build** (`bun-plugin-tailwind` plugin, `tailwindcss` dependency), but **screens use no Tailwind utility classes** — the actual styling goes through `app-*` + inline. Do not "tailwindify" a screen thinking you are following the convention.
|
||||
- **`user-content` is an INERT class**: used on many titles/names across the screens, **with no CSS definition whatsoever**. It is a legacy marker with no effect — do not rely on it for styling, do not assume it does anything.
|
||||
- No **dark mode**: the "darkMode" toggle in `SettingsScreen` is not wired to anything.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
type: rule
|
||||
summary: A module imports ONLY from shared/ (and the screen registry) — never from another module; this is the invariant that keeps the feature-based architecture real
|
||||
---
|
||||
|
||||
# Rule: a module never imports from another module
|
||||
|
||||
**Modules import only from `shared/` — never from each other.**
|
||||
|
||||
```
|
||||
src/modules/event/screens/EventDetailScreen.tsx
|
||||
✅ import from 'shared/components/...'
|
||||
✅ import from 'shared/context/FestipodDataContext'
|
||||
✅ import from 'src/screens' (registry types)
|
||||
❌ import from 'modules/user/screens/...'
|
||||
```
|
||||
|
||||
## Why
|
||||
|
||||
This is what makes the *feature-based* architecture real rather than cosmetic: each domain stays a self-contained block, movable or removable without breaking the others. Any shared need **moves up into `shared/`**; any cross-domain dependency goes through a `shared/` contract (usually `FestipodDataContext` or the screen registry), never through a direct import.
|
||||
|
||||
## How to check
|
||||
|
||||
`grep -rE "from '\.\./\.\./(event|user|home|auth|workshop|meeting|notification)/" src/modules/` must not surface anything pointing from one module to *another* module. An import that crosses two different module names is a violation.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
type: _overview
|
||||
summary: Festipod's security & privacy — isolation between scopes is enforced by the data SDK, the app trusts it and carries no authorization logic in the screens; wallet-based authentication; target authorization matrix still incubating
|
||||
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, anonymat, pseudonyme, traçage, corrélation, identité, login, scope, isolation]
|
||||
paths: ["src/modules/auth/**", "src/shared/context/NextGraphContext.tsx"]
|
||||
---
|
||||
|
||||
# App security
|
||||
|
||||
Festipod's **security, privacy and authorization** model.
|
||||
|
||||
- **Enforced model** — **isolation between scopes** (public / protected / private) is **enforced by the data SDK** (`@ng-eventually/polyfill`), which exposes to each user only what they are entitled to. The app **trusts** the SDK: no screen carries authorization logic. See [[knowledge_trust-model]].
|
||||
- **Target authorization matrix** — the detail of *who may do what* per actor × verb (personal data = network, anonymity through the notification inbox): [[brief_2026-05-18_authorization-matrix]]. **Incubating.** It will graduate into `rule_`/`behavior_` as the product settles.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- [[caveat_shared-wallet-global-before-gate-import]] — a wallet-password global set **after** `sharedWallet.ts` has been imported makes `ensureIdentity()` throw and the app render nothing, silently
|
||||
|
||||
> **Before designing anything "anonymous"**, read the closing section of [[knowledge_trust-model]]: the contract guarantees isolation, never anonymity, so a Festipod action that circulates a reference to someone's document is pseudonymous at best.
|
||||
|
||||
## Links
|
||||
|
||||
- [[knowledge_trust-model]] — the app delegates isolation to the SDK, no access control in the screens
|
||||
- [[knowledge_authentication]] — wallet-based auth, everyone authenticated, no anonymous access, no screen of Festipod's own
|
||||
- [[decision_2026-08-10_the-barrier-names-no-identity]] — the app names no identity: the barrier takes nothing, signing in is one `ensureIdentity()`
|
||||
- [[decision_2026-08-10_sdk-renders-the-barrier]] — Festipod renders no access screen of its own; the SDK draws whatever a first-time device needs to see
|
||||
- [[brief_2026-05-18_authorization-matrix]] — target authorization matrix (incubating)
|
||||
- Concept `functional-domain` → [[knowledge_data-scopes-and-discovery]] — which scope for which entity (product fact)
|
||||
@@ -0,0 +1,126 @@
|
||||
---
|
||||
type: brief
|
||||
summary: Target authorization matrix per data type (meeting point, registration, event, profile, connection) expressed as public/protected/private + dialog scopes; settled framing decisions (everyone authenticated, public meeting points, personal data = network, notification through an identified-or-anonymous inbox); open questions on the event write model and on host identity
|
||||
last_updated: 2026-05-18
|
||||
---
|
||||
|
||||
# Authorization matrix and query inventory
|
||||
|
||||
**Status:** Incubating — target model, not frozen into rules.
|
||||
|
||||
## Context
|
||||
|
||||
The **target** model of who-may-do-what. Festipod's privacy derives from: (1) an authorization matrix per actor × verb; (2) the inventory of queries per screen; (3) the **scopes** that follow from them — data sharing both an authorization *and* an access pattern. The concrete entity → scope placement is a product fact: concept `functional-domain` → [[knowledge_data-scopes-and-discovery]]. Isolation is **enforced by the data SDK** ([[knowledge_trust-model]]).
|
||||
|
||||
## Framing
|
||||
|
||||
### Actors (all authenticated)
|
||||
|
||||
`Alice` (viewpoint, owner of the data in focus) · `Bob` (second protagonist, bilateral relationships) · `D` (event declarer) · `H` (host of a meeting point) · `I` (registrant) · `C` (connection) · `U` (plain user with no relationship).
|
||||
|
||||
### Verbs
|
||||
|
||||
`create` · `read` (one-shot) · `subscribe` (reactive read) · `update` · `delete`. Conventions: `✓` allowed · `✗` forbidden · `cond` conditional · `—` not applicable.
|
||||
|
||||
## Framing decisions (settled)
|
||||
|
||||
- **Everyone is authenticated.** No anonymous access.
|
||||
- **Meeting points are universally public.** Any user may read and subscribe.
|
||||
- **Creating a meeting point is open to all.** No prerequisite.
|
||||
- **Host = holder of the write rights** on a meeting point (1 host, the creator; being the host is public).
|
||||
- **Personal information = reserved to the network.** Visible only to the holder and their connections: participations, the profile in full, the connection list, and any declarative state whose disclosure would be a leak. "Public" status (meeting point, event) and "personal" status (profile, participations, connections) coexist within the same user.
|
||||
- **Bilateral connection.** It exists once both sides have accepted. Two objects: `DemandeDeConnexion` (one-sided, transient) and `Connexion` (bilateral, persistent).
|
||||
- **Registration notification through the meeting point's inbox.** The act of registering is composite: (a) writing an `Inscription` object into the registrant's *protected* scope, (b) dropping a link into the **inbox** of the meeting point document. The sender is **identified if they are one of the host's connections, anonymous otherwise** — a property of the data model.
|
||||
- **Joining a community / following: out of current scope.**
|
||||
|
||||
## Matrix per data type
|
||||
|
||||
### Meeting point
|
||||
|
||||
| Verb | Alice (= Host) | I (other registrant) | D (parent declarer) | U (plain user) |
|
||||
|---|---|---|---|---|
|
||||
| create | ✓ (becomes host) | — | ✗ | ✓ (becomes host) |
|
||||
| read | ✓ | ✓ | ✓ | ✓ |
|
||||
| subscribe | ✓ | ✓ | ✓ | ✓ |
|
||||
| update | ✓ | ✗ | ✗ | ✗ |
|
||||
| delete | ✓ | ✗ | ✗ | ✗ |
|
||||
|
||||
Notes: no `C` differentiation (connections are a UI display filter, not a right, since everything is public). `D` has no special right over the meeting points grafted onto their event.
|
||||
|
||||
### Registration to a meeting point
|
||||
|
||||
`Inscription` links a user and a meeting point. **Personal data** (registrant + their connections). Composite act (a)+(b) above.
|
||||
|
||||
| Verb | Alice (registrant) | C (connection) | H (host) | I (other registrant) | U |
|
||||
|---|---|---|---|---|---|
|
||||
| create (composite act) | ✓ | — | ✗ | ✗ | ✓ (becomes registrant) |
|
||||
| read the content | ✓ | ✓ | cond: ✓ if H ∈ connections(Alice); opaque link otherwise | cond: ✓ if I ∈ connections(Alice) | ✗ |
|
||||
| subscribe | ✓ | ✓ | cond (same) | cond (same) | ✗ |
|
||||
| read the meeting point's inbox (raw entries) | — | — | ✓ | ✗ | ✗ |
|
||||
| update | ? **to be decided** (depends on the fields) | ✗ | ✗ | ✗ | ✗ |
|
||||
| delete | ✓ (unregister; remove the link from the inbox if possible) | ✗ | cond: inbox moderation only (does not delete the object) | ✗ | ✗ |
|
||||
|
||||
**Host visibility: resolved** (identified if a connection, anonymous otherwise). **Open questions:** which fields of a registration are editable (a bare boolean, or + comment/status/companions?); **inbox-side deletion** — can a depositor withdraw their link from a document they do not control?
|
||||
|
||||
### Event
|
||||
|
||||
| Verb | Alice (= D) | H (host of a grafted meeting point) | U |
|
||||
|---|---|---|---|
|
||||
| create | ✓ (becomes declarer) | — | ✓ (becomes declarer) |
|
||||
| read / subscribe | ✓ | ✓ | ✓ |
|
||||
| update | ? **to be decided** | ? **to be decided** | ? **to be decided** |
|
||||
| delete | ? **to be decided** | ✗ | ✗ |
|
||||
|
||||
**Open questions:** who may **update** a declared event — the declarer alone (owner)? every user (wiki)? nobody (immutable)? Central to deduplication (concept `functional-domain`, [[brief_2026-06-15_event-deduplication]]). Who may **delete** it, and what becomes of the grafted meeting points (orphaned/cascade/marked deleted)?
|
||||
|
||||
### User profile
|
||||
|
||||
**Nothing in the profile is public.** Two scopes: the **network profile** (Alice + connections: name, avatar, bio, city, interests); the **private profile** (Alice alone: settings, email, preferences).
|
||||
|
||||
| Verb | Alice | C | U |
|
||||
|---|---|---|---|
|
||||
| create | ✓ (at sign-up) | — | — |
|
||||
| read — network | ✓ | ✓ | ✗ |
|
||||
| read — private | ✓ | ✗ | ✗ |
|
||||
| subscribe | ✓ | ✓ (network) | ✗ |
|
||||
| update | ✓ | ✗ | ✗ |
|
||||
| delete (account) | ✓ | ✗ | ✗ |
|
||||
|
||||
**Tension to resolve:** a meeting point is readable by everyone, yet its host should not be identifiable by a plain user. Three positions: (i) **pseudonym through identity alone** (name/avatar resolved only for connections); (ii) **identity denormalized into the offer** (the host picks a "business card" per meeting point, living inside the meeting point object, profile closed); (iii) **host anonymity** (identity revealed only to connections). To be decided. Others: field-by-field composition of each scope; status of the `username` (public/network/dropped?).
|
||||
|
||||
### Connection (friendship link)
|
||||
|
||||
Bilateral. `DemandeDeConnexion` (one-sided, pending) → `Connexion` (bilateral, upon acceptance; opens access to personal data). Alice's connection list is **personal** (Alice + her connections).
|
||||
|
||||
| Verb | Alice (initiator) | Bob (other side) | C | U |
|
||||
|---|---|---|---|---|
|
||||
| create the request | ✓ | — | — | — |
|
||||
| accept | — | ✓ | — | ✗ |
|
||||
| read Alice's list | ✓ | ✓ | ✓ | ✗ |
|
||||
| subscribe | ✓ | ✓ | ✓ | ✗ |
|
||||
| delete (break A↔B) | ✓ | ✓ | ✗ | ✗ |
|
||||
|
||||
**Open questions:** granularity on Bob's side (does he see Alice's whole list, or only A↔B? — the principle implies: the whole list); "friends of friends" discoverability (does Alice see Bob↔Carole? — no, unless Carole ∈ connections(Alice)).
|
||||
|
||||
## Derived scopes
|
||||
|
||||
Heuristic: same scope if (a) same write-authorization cell *and* (b) accessed together. Three **scopes** emerge, plus the bilateral case:
|
||||
|
||||
| Scope | Write | Read | Data |
|
||||
|---|---|---|---|
|
||||
| **public** | Alice alone | Everyone | Meeting points hosted by Alice; declared events *(subject to the write model)* |
|
||||
| **protected** (network) | Alice alone | Alice + connections | Network profile; participations; connection index |
|
||||
| **private** | Alice alone | Alice alone | Private profile (settings, email, preferences) |
|
||||
| **dialog** (A↔B) | Alice and Bob | Alice and Bob | The bilateral `Connexion` (+ groundwork for future messaging) |
|
||||
|
||||
The bilateral **`Connexion`** has *two* writers → a **dialog** scope dedicated to the pair; the **"all of Alice's connections" index** lives in *protected* (it lists the connection references). The **meeting point's inbox** is an attribute of the public document, not a separate scope.
|
||||
|
||||
## Query inventory per screen
|
||||
|
||||
*To be filled in once the matrix has stabilized.* Planned schema: `| Screen | One-shot reads | Subscriptions | Writes | Triggering actor |`. Screens to analyze: see the route table (concept `app-architecture`).
|
||||
|
||||
## See Also
|
||||
|
||||
- Concept `functional-domain` → [[knowledge_data-scopes-and-discovery]] — entity → scope placement + discovery
|
||||
- [[knowledge_trust-model]] — isolation is enforced by the SDK
|
||||
- `README.md §Modèle fonctionnel` — source of the actors
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: The wallet password is captured at the EVALUATION of src/shared/utils/sharedWallet.ts; a value set after that first import is never re-read — a missing one used to yield AccessGateScreen's error block, now it makes ensureIdentity() throw and the app render nothing at all, silently
|
||||
last_checked: 2026-08-10
|
||||
---
|
||||
|
||||
# Pitfall: set the wallet-password global BEFORE the module is first imported
|
||||
|
||||
The contract requires a deployment to **serve a wallet file and pass its URL and password to `configure`** ([[contract_polyfill-surface]]). Festipod does that from one module, and *when* that module is evaluated decides whether the value arrives at all.
|
||||
|
||||
**The invariant.** `src/shared/utils/sharedWallet.ts` reads `globalThis.__FESTIPOD_SHARED_WALLET_PASSWORD__` **exactly once, at module evaluation** (the `SHARED_WALLET_PASSWORD` constant, surfaced by `hasSharedWallet()`). A value set *after* that first import is never re-read. This module used to be `src/modules/auth/sharedWallet.ts`; that file, and `AccessGateScreen` which was its only reason to sit in the `auth` module, are both deleted — the surviving copy lives in `shared/utils/` and is imported by `src/shared/utils/ngSession.ts`, which reads `hasSharedWallet()` to decide whether to pass a `sharedWallet` config into the SDK's `configure()`.
|
||||
|
||||
**Why it still matters, and how the consequence changed.** `hasSharedWallet() === false` is a **misconfiguration**, not a degraded mode: the contract makes serving a wallet file and passing its URL and password a deployment requirement, so an app without them cannot sign anyone in. With no `sharedWallet` passed, `ensureIdentity()` **throws**, and `AuthGate` shows its named error panel — loud, which is the point. What must never come back is a silent fallback that renders screens anyway: a session that failed looks exactly like an account that owns nothing.
|
||||
|
||||
## Impact — if I touch X, Y breaks
|
||||
|
||||
- **Static import = trap.** A static `import` reaching `ngSession.ts` (hence `sharedWallet.ts`) from an entry point that sets the global itself is **hoisted above the assignment** → empty password → the failure mode above, with no JS error at the import site to signal it. The remedy is a **dynamic import** (`await import(...)`) executed after setting the global.
|
||||
- **The real entry point that must get this right**: the frontend served from `src/` (`src/app/frontend.tsx` fetches `/festipod-config.json`, sets the global, then imports `App` dynamically — mechanics in `tech-stack` → [[knowledge_build-pipeline]]). A bundle produced by `build.ts` is **not** concerned: there the value is inlined by `define`.
|
||||
- **`@ui` reaches the module too, but harmlessly today.** `screens/index.ts` eagerly imports every screen including `SettingsScreen`, which imports `ngSession.ts` — so any `@ui` test already evaluates `sharedWallet.ts` with the global unset. This does not currently break anything because no `@ui` path calls `ensureIdentity()` (`renderScreen()` bypasses `AuthGate`/`NextGraphProvider` entirely); see `bdd-testing` → [[knowledge_ui-layer]] for the detail and for what would make it stop being harmless.
|
||||
- **Operations**: a server without `FESTIPOD_SHARED_WALLET_PASSWORD` now fails **silently** (blank page, console-only) rather than with a screen saying so — worth knowing when diagnosing "the app shows nothing."
|
||||
|
||||
**Verified (2026-08-10)**: capture at evaluation time in `src/shared/utils/sharedWallet.ts`; the `sharedWallet: hasSharedWallet() ? {...} : undefined` branch in `ngSession.ts`'s `configure()` call; the `throw` in `ensureIdentity()` when no `sharedWallet` config is present; `AuthGate`'s `.catch(err => console.error(...))` with no fallback UI.
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
type: decision
|
||||
summary: Festipod deleted its own access-gate screen (AccessGateScreen, its route, its wallet module) and relies entirely on the SDK's ensureIdentity() to show whatever a first-time device needs to see; cost accepted: the app can no longer test that path itself, from any layer
|
||||
---
|
||||
|
||||
# Decision (2026-08-10): the SDK renders the barrier, Festipod renders none
|
||||
|
||||
## Context
|
||||
|
||||
[[decision_2026-08-10_the-barrier-names-no-identity]] settled *what* the barrier asks (nothing — no identifier). It left open a separate question: *who draws the screen* a device sees while `ensureIdentity()` resolves — a Festipod component still fed by SDK state, or nothing on Festipod's side at all.
|
||||
|
||||
## Decision
|
||||
|
||||
**Festipod renders no access screen of its own.** `AccessGateScreen`, its route, its registration, and `src/modules/auth/sharedWallet.ts` (the wallet re-export whose only consumer it was) are deleted. `src/app/AuthGate.tsx` makes a single unconditional `await ensureIdentity()` and renders nothing until it settles — it no longer couples to `useNextGraph()`'s status, `connect()`, or error state. Whatever a user has to see or do while the wallet loads onto a first-time device belongs to the SDK, which shows it: the library owns that flow end to end and absorbed it precisely so consumer applications can delete theirs (see [[contract_polyfill-surface]] on `ensureIdentity`). `src/shared/utils/sharedWallet.ts` keeps the one surviving copy of the wallet material (file URL, password, import URL) and hands it to the SDK through `configure({ sharedWallet })` — Festipod's only remaining involvement is supplying those three values, never displaying them.
|
||||
|
||||
## Cost accepted
|
||||
|
||||
Festipod now has **no test at all** proving a first-time device can get in. The contract publishes no testid, no DOM contract and no call for a test to interact with the SDK's barrier, so the scenario that used to drive `AccessGateScreen`'s own DOM ("Parcours humain — le testeur importe le portefeuille fourni par Festipod et se connecte", `workshop/multibrowser-harness.feature`) had nothing left to assert and was deleted rather than rewritten. See [[caveat_first-time-entry-untested]] (concept `bdd-testing`). Raised with the provider.
|
||||
|
||||
## Rejected alternative
|
||||
|
||||
**Keep a thin Festipod wrapper around the SDK's state** (a `status`/`error`/`onEnter`-driven screen, still Festipod-rendered). Rejected: it would recreate the exact code the library moved out of consumer applications, for a flow already declared owned by the SDK — a wrapper an application must still write, test and delete at migration is not an absorption, it is the old cost with new labels.
|
||||
|
||||
## Scope
|
||||
|
||||
Distinct from [[decision_2026-08-10_the-barrier-names-no-identity]] (that one settles *what* the barrier asks; this one settles *who draws it*). Current state of the flow: [[knowledge_authentication]].
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
type: decision
|
||||
summary: The access barrier no longer takes an identifier — the SDK surface stopped letting an application name its own identity, so signing in is one ensureIdentity() call that takes nothing; supersedes the identifier half of the 2026-07-06 and 2026-07-20 arbitrations
|
||||
---
|
||||
|
||||
# Decision (2026-08-10): the barrier names no identity
|
||||
|
||||
## Context
|
||||
|
||||
Earlier arbitrations put an **identifier** at the access barrier: the user typed it in the same act that opened the wallet, and the application handed it to the data layer. They rested on a premise the provider has since withdrawn — that an application **names its own identity**. (Those leaves were deleted on 2026-08-16, with everything else that described the data layer's internals; `git log` has them.)
|
||||
|
||||
The pulled [[contract_polyfill-surface]] removes that premise explicitly. `ensureIdentity()` takes **no identifier**, and the contract states why: naming your own identity is *"the gesture that inverts the model"*, so a "set my identity" call was removed rather than renamed. There is no successor call — the capability is gone, not relocated.
|
||||
|
||||
## Decision
|
||||
|
||||
**Festipod does not name, persist or switch its own identity.** Concretely:
|
||||
|
||||
- The barrier asks for nothing but the wallet: « Entrer » triggers the broker redirect and nothing else.
|
||||
- Signing in is **one await on `ensureIdentity()`**, in `src/app/AuthGate.tsx`, before any screen renders.
|
||||
- All app-side identity machinery is deleted: the identity context, the `?id=` URL param that carried it across the broker round-trip, the localStorage key, the app-level (faux) logout. The only logout left is the **wallet session** one.
|
||||
- **Who the current user is** is no longer derived from an input; it is **the profile document read back in the app's own protected scope**.
|
||||
|
||||
## Consequences accepted with it
|
||||
|
||||
- **Multi-identity on one page is no longer expressible**, and that is correct rather than missing: it was a property of *one wallet hosting several identities*, i.e. emulation scaffolding. Multi-user is exercised as it is lived — several browser contexts, each signing in as itself ([[rule_tests-validate-festipod-not-the-sdk]] in bdd-testing).
|
||||
- **The `@data` layer lost its per-scenario determinism**, which the app used to provide by planting a fresh identity per scenario. The app cannot restore it — choosing which identity comes up is exactly what the surface no longer allows. Open, with the provider: [[caveat_data-scenarios-share-one-wallet]].
|
||||
|
||||
## Rejected alternative
|
||||
|
||||
**Keeping an app-side identifier and mapping it onto the SDK behind the scenes.** Rejected: it would teach the application a model it must unlearn, and it would convert a deliberate provider decision into an app-side workaround nobody revisits ([[rule_app-uses-sdk-surface-only]]).
|
||||
|
||||
## Scope
|
||||
|
||||
Supersedes every earlier arbitration that put an identifier at the barrier. Current state of the flow: [[knowledge_authentication]].
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: A user's identity = their NextGraph wallet; every user is authenticated (no anonymous access); the app never names, persists or switches its own identity, and renders no access screen of its own — AuthGate awaits ONE unconditional ensureIdentity() before anything renders
|
||||
last_checked: 2026-08-10
|
||||
---
|
||||
|
||||
# Authentication
|
||||
|
||||
**A user's identity = their NextGraph wallet.** There is **no anonymous access** to the app: every user is authenticated (see concept `functional-domain`). There is **no application-level account/password system** — authentication is **delegated to the data SDK** (`@ng-eventually/polyfill`): opening your session means opening your wallet.
|
||||
|
||||
## The flow — one act, no screen of Festipod's own
|
||||
|
||||
**Signing in is `src/app/AuthGate.tsx`'s single, unconditional `await ensureIdentity()`.** It fires on mount, with no dependency on `NextGraphContext`'s connection status. **Nothing of the app renders before it resolves**: `ensureIdentity()` settles who we are *and* does the connection work (restoring what others shared with us). A screen mounted earlier would read as an identity that is not yet settled.
|
||||
|
||||
**Festipod renders no access screen of its own.** `AccessGateScreen`, its route and its registration are deleted; whatever a user has to see or do while the SDK resolves — opening the shared wallet, loading it onto a first-time device — is drawn entirely by the SDK. The library owns that flow and absorbed it precisely so consumer applications can delete theirs. See [[decision_2026-08-10_sdk-renders-the-barrier]].
|
||||
|
||||
**The application never names, persists or switches its own identity.** `ensureIdentity()` takes **no identifier**, deliberately, and the contract states that **no other call takes one either** ([[contract_polyfill-surface]]). There is consequently **no** app-side identity state at all: no identity context, no `?id=` URL param, no localStorage identity key, no "set my identity" call. See [[decision_2026-08-10_the-barrier-names-no-identity]].
|
||||
|
||||
**Festipod's only remaining involvement is supplying the wallet material, never displaying it.** `src/shared/utils/sharedWallet.ts` holds the one copy of the file URL, password and import URL this deployment hands out, and passes them to the SDK through `configure({ sharedWallet })` in `src/shared/utils/ngSession.ts` — the contract makes that a deployment requirement. The one hazard left around that module is an evaluation-order trap, [[caveat_shared-wallet-global-before-gate-import]]. Misconfiguration (no password set) makes `ensureIdentity()` throw, and `AuthGate` shows its named error panel instead of any screen.
|
||||
|
||||
**Signing out.** The only logout left is the **wallet session** one (`logoutNg`, offered as « Quitter l'environnement de test » in the settings screen): it stops the shared-wallet session so the next access goes back through the broker. There is no app-level sign-out, because there is no app-level identity to sign out of.
|
||||
|
||||
## Who the current user IS, seen from the app
|
||||
|
||||
The app does not derive an identity from anything it was told; **what it is, is the profile document it reads back in its own protected scope**. That value is therefore empty until the protected read lands — the mechanics and the hazard that follows live in concept `data-layer`, [[knowledge_context-internals]] and `app-architecture` → [[caveat_identity-ids-in-screens]].
|
||||
|
||||
**Vocabulary.** `username` designates the profile handle `UserProfile.username` and nothing else. `normalizeIdentifier` (`src/shared/utils/identifier.ts`) is a **pure string normalization** of that handle, applied only to `UserProfile.username` — the join between a profile and the person it belongs to, and the name given when sharing a document with a neighbour. It is never applied to the identity: normalising an identity belongs to the data layer, which the contract states outright, and no configuration hook takes it from us. It names no space, account or session.
|
||||
|
||||
## The test wallet
|
||||
|
||||
The `@data`/`@e2e` tests open a real wallet (`festipod-tests`, persistent profile) — see concept `bdd-testing`. These are **plaintext test credentials**, with no security stake, dedicated to staging. Since no call takes an identifier, a scenario cannot choose which identity it comes up as: every scenario in a run shares that one wallet, which keeps growing — [[caveat_data-scenarios-share-one-wallet]] (bdd-testing).
|
||||
|
||||
> The authorization model that will build on this identity (bilateral connections, personal data = network, host anonymity) is incubating: [[brief_2026-05-18_authorization-matrix]].
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Isolation between scopes (public/protected/private) is enforced by the data SDK; the app trusts it and only displays what it returns — no access control in the screens, and the only thing it declares is which of its own documents it shares with whom
|
||||
last_checked: 2026-08-10
|
||||
---
|
||||
|
||||
# Trust model
|
||||
|
||||
**Stance:** the app reads data through the ORM subscriptions of the `@ng-eventually/polyfill` SDK and displays it **with no app-side authorization logic** (`src/shared/context/FestipodDataContext.tsx`, `useNgData`).
|
||||
|
||||
Principles:
|
||||
|
||||
1. **Isolation is delegated to the SDK.** Every entity lives in the store of its **scope** (public / protected / private, see concept `functional-domain` → [[knowledge_data-scopes-and-discovery]]); the SDK **exposes to the current user only what they are entitled to**. The app assumes that whatever it receives is already authorized — privacy rests on the SDK, not on Festipod code.
|
||||
2. **Screens carry no access rules.** No "is this user allowed to see this data" check in the components, nor in the data context. The public / network / private separation is a property of **placement by scope**, not of an application-level filter.
|
||||
3. **The relationship between users ("connections") is an application-level notion.** The contract publishes no connection or friendship primitive: it models reading as **key possession**, and giving someone that key is **one act** — `inbox.share(doc, toUser)`, naming the document and the person. The app therefore **owns** its relationship graph (`src/shared/utils/connections.ts`) and, once a link is two-sided, **shares its own protected documents** with that neighbour. It does not delegate the notion of a relationship, only the **enforcement** of the isolation that follows from it.
|
||||
|
||||
What the app declares to the SDK is now **only those shares**: it declares **no identity** ([[decision_2026-08-10_the-barrier-names-no-identity]]), and it **never handles a key or an inbox address** — neither exists in app code. Sharing is also **irreversible**: the contract publishes no revocation, so an act of sharing is permanent ([[contract_polyfill-surface]]).
|
||||
|
||||
## The point to watch
|
||||
|
||||
Because the app **displays everything it receives**, privacy rests entirely on the SDK exposing only what is legitimate. It is a deliberate choice (the app stays thin), but it means **never reintroducing on the screen side a piece of data that the scope should not have let through**.
|
||||
|
||||
**And never promise anonymity.** The contract guarantees isolation per document; it guarantees **no anonymity** — nothing per reader on a public document, no revocation, and a reference that names a person's document remains comparable wherever it travels. So a Festipod action that circulates such a reference (a sign-up, an invitation, a mention, an index entry) is **pseudonymous at best**: do not label it "anonymous" in the interface, and count the contexts in which you expose the same reference.
|
||||
|
||||
> To check when in doubt: `useNgData` in `FestipodDataContext.tsx` contains no identity-filtering branch — that is intentional, isolation comes from below.
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
type: _overview
|
||||
summary: BDD Cucumber/Gherkin in French across 3 layers (@ui, @data, @e2e) — setup, layer contract (what to test where), real broker harness, and the source-grep leftovers pitfall
|
||||
triggers:
|
||||
keywords: [cucumber, gherkin, bdd, feature, scenario, scénario, step, steps, "@ui", "@data", "@e2e", playwright, broker, harness, wallet, world, hooks, renderHelper, multibrowser, multi-navigateur, "@multibrowser", "@shared-wallet", storageState, "@wip", "@humain"]
|
||||
paths: ["src/modules/*/features/**", "src/modules/*/steps/**", "src/shared/steps/**", "src/shared/support/**", "src/shared/test-harness/**", "cucumber.json"]
|
||||
---
|
||||
|
||||
# BDD testing
|
||||
|
||||
BDD tests written in **Cucumber/Gherkin in French** (`Etant donné`, `Quand`, `Alors`) across **3 layers** of increasing cost.
|
||||
|
||||
**Read before writing a test:** [[rule_test-layer-contracts]] — each layer answers a distinct question; mixing them produces brittle tests. That is the rule which decides *where* an assertion belongs. And [[rule_tests-validate-festipod-not-the-sdk]] — which decides *whether the assertion belongs here at all*.
|
||||
|
||||
## The 3 layers
|
||||
|
||||
```
|
||||
/\ @e2e real app inside the broker iframe — critical journeys
|
||||
/ \
|
||||
/----\ @data mutations & persistence through the real NextGraph broker
|
||||
/------\
|
||||
/ @ui \ in-process screen rendering (happy-dom + seed) — the bulk of the volume
|
||||
/__________\
|
||||
```
|
||||
|
||||
## Links
|
||||
|
||||
- [[rule_test-layer-contracts]] — what to test at each layer (the contract)
|
||||
- [[rule_tests-validate-festipod-not-the-sdk]] — the subject under test is Festipod's behaviour, never the SDK's; no shortcut past the published surface
|
||||
- [[knowledge_cucumber-setup]] — config, layout, scripts, auto-generated files
|
||||
- [[knowledge_ui-layer]] — the `@ui` layer: render helper, fixtures, good and bad patterns
|
||||
- [[knowledge_data-layer-broker]] — the `@data` layer: broker harness, wallet lifecycle, bridge
|
||||
- [[knowledge_e2e-layer]] — the `@e2e` layer: the real app inside the iframe
|
||||
- [[knowledge_multibrowser-harness]] — several isolated browsers on the shared wallet (storageState injection); the only way multi-user is exercised
|
||||
- [[caveat_data-scenarios-share-one-wallet]] — a scenario cannot choose its identity, so all of them share one wallet that nothing empties: no per-scenario isolation
|
||||
- [[caveat_reconnexion-froide-local-vs-broker]] — a "fresh page" is not a cold start: which setup proves broker durability, and which one just re-reads local
|
||||
- [[caveat_first-time-entry-untested]] — **open**: no test proves a first-time device can get into Festipod any more; the SDK's replacement barrier publishes nothing to test against
|
||||
- [[decision_2026-03-12_headless-wallet-creation]] — why the test wallet is created through a headless UI
|
||||
- [[caveat_source-grep-vestiges]] — leftovers from the "source analysis" era in `world.ts`
|
||||
- [[cookbook_add-scenario]] — adding a scenario/step (layers, `evaluate` serialization pitfall, `@wip`)
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: A @data scenario cannot choose which identity it comes up as — no published call takes an identifier — so every scenario in a run shares one identity and one physical wallet, which nothing empties. Per-scenario isolation is GONE, and the wallet grows for the whole run.
|
||||
last_checked: 2026-08-10
|
||||
---
|
||||
|
||||
# Pitfall: one wallet, one identity, nothing that empties it
|
||||
|
||||
## What is verified
|
||||
|
||||
**No scenario can name the identity it opens as.** [[contract_polyfill-surface]] is explicit: `ensureIdentity()` takes no identifier, *"and no other call takes one"*. So a scenario gets whatever identity the wallet in `.playwright-profile` resolves to — the same one, every time.
|
||||
|
||||
Machinery from when this was not true is still in the tree and is now **inert**: the `Before` hook mints a unique identifier (`freshScenarioIdentifier`, `src/shared/support/hooks.ts`) and injects it via `addInitScript` into `localStorage['festipod.account.identifier']`, and several steps re-inject it. Nothing consumes that key. **Do not build new setup on it, and do not "repair" it** by making the app read it again — naming your own identity is exactly what the surface refuses.
|
||||
|
||||
## What follows, and gets worse
|
||||
|
||||
**Everything a run writes lands in ONE wallet, and nothing removes it.** There is no per-scenario reset: the old one (`resetDataState()`, a SPARQL DELETE on the anchor graph) was dropped for cost and its helper is gone. So each scenario leaves its documents behind for every later scenario to carry — within a run, and across runs. That is the source of [[caveat_wallet-bloat-hang]].
|
||||
|
||||
The practical signature: hook timeouts on `__testData.ready` that appear **partway through a run** and get worse the longer the profile has lived, **with no console error at all**. Silence is the tell — a wallet that has stopped answering just stops answering.
|
||||
|
||||
So: a scenario failing on **stale data from an earlier scenario** is expected, not a surprise — scenarios are not isolated. A scenario **timing out in `Before`**, especially the fifth one onward, is the wallet, not the assertion. Move the profile aside and re-measure before diagnosing anything else ([[caveat_wallet-bloat-hang]]).
|
||||
|
||||
## What is missing
|
||||
|
||||
A way to start a scenario from a clean slate. The surface publishes no teardown and no throwaway-wallet call, and there is nothing to fake here: it is a **gap to raise with the provider**, stated as the need — *a scenario must be able to begin on an empty space*. Until then, per-scenario determinism is not available at the `@data` layer, and scenarios must be written so they do not depend on it.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: No test proves a first-time device can get into Festipod — the scenario that drove AccessGateScreen's own DOM was deleted with the screen, and the SDK's replacement barrier publishes no testid or contract to write a new one against
|
||||
last_checked: 2026-08-10
|
||||
---
|
||||
|
||||
# Caveat: first-time entry has no test, and none can be written from here
|
||||
|
||||
## What is gone
|
||||
|
||||
`workshop/multibrowser-harness.feature` used to carry « Parcours humain — le testeur importe le portefeuille fourni par Festipod et se connecte »: a fresh browser opened the staging app, `AccessGateScreen` offered the wallet file and password, the file was downloaded **from the screen** (`[data-testid=shared-wallet-download]`), the password checked against the wallet's own (`[data-testid=shared-wallet-password]`), imported on `nextgraph.eu`, then an identifier typed (`[data-testid=identifier-input]`) and « Entrer » clicked — landing on the connected home screen. Every step drove **Festipod's own DOM**.
|
||||
|
||||
`AccessGateScreen` is deleted (concept `app-security`, [[decision_2026-08-10_sdk-renders-the-barrier]]), and with it every testid the scenario asserted on, the steps that drove them (`src/modules/workshop/steps/data/multibrowser.steps.ts`), and the helpers built only for this scenario (`pool.ensureStagingApp`, `pool.importWalletViaFile`, `findE2eWalletFile`, the `dist-staging` build in `hooks.ts`).
|
||||
|
||||
## Why it cannot be rewritten, not just why it was deleted
|
||||
|
||||
The scenario was not migrated to assert against something else, because there is nothing to migrate it to: `ensureIdentity()` (`@ng-eventually/polyfill`) is a plain async function with no published testid, no documented DOM contract, and no call a test could make to drive or observe what it shows a first-time device. [[contract_polyfill-surface]] (concept `data-layer`) states only the call's signature and behaviour, not a UI shape — by design, since that UI is exactly the part the SDK owns and Festipod must not couple to.
|
||||
|
||||
## What is true today
|
||||
|
||||
**No test at all — `@ui`, `@data`, `@e2e`, or `@humain` — proves that a first-time device can sign into Festipod.** The `@shared-wallet` multi-browser scenario ([[knowledge_multibrowser-harness]]) injects the wallet via `storageState`, bypassing the import entirely; every `@data`/`@e2e` scenario runs on a persistent profile that is already signed in before `ensureIdentity()` ever runs ([[caveat_data-scenarios-share-one-wallet]]), so none of them exercises the path a genuinely new user takes either.
|
||||
|
||||
## What would close it
|
||||
|
||||
A test contract published by the SDK for its own barrier (a testid, an event, a promise a test can await) — this is a gap in what Festipod consumes, not in what Festipod tests. Raised with the provider. Until one exists, this path is verified only by hand.
|
||||
|
||||
## Links
|
||||
|
||||
[[knowledge_multibrowser-harness]] — where the deleted scenario lived. Concept `app-architecture` → [[caveat_boot-unverified-outside-broker]] — the related, narrower question of whether the boot even completes outside the broker iframe.
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: A "fresh page" on the PERSISTENT context never proves broker durability — it re-reads the same profile's IndexedDB; only a non-persistent context spawned from freshBrowser, seeded solely by the BeforeAll storageState, settles broker-vs-local
|
||||
last_checked: 2026-08-10
|
||||
---
|
||||
|
||||
# Pitfall: a "fresh page" is not a cold start (local vs broker)
|
||||
|
||||
**Reconnection** scenarios all ask the same question — *does the user get their own data back after closing and reopening?* — but **depending on the browser context chosen, they do not answer the same question**. That is the pitfall: the most natural setup (`ctx.newPage()`) yields a green that proves nothing about the broker.
|
||||
|
||||
## The two setups, and what each one proves
|
||||
|
||||
| Setup | Where | What it proves | What it does NOT prove |
|
||||
|---|---|---|---|
|
||||
| `this.page!.context().newPage()` — fresh page on the **persistent** context (`.playwright-profile`) | `reconnexion.steps.ts` (@data), `reconnexion-persistance.steps.ts` (@e2e) | a new broker login and a full remount of the providers, with nothing carried over in memory | nothing about **broker durability**: the profile **still holds local data** in IndexedDB, so a "fresh" reader may well read **from local** |
|
||||
| `spawnContext('shared')` — **non-persistent** context spawned from `freshBrowser` | `reconnexion-froide-sans-local.steps.ts` (@data) | that the data **reached the broker** (or did not) | nothing about the real UI journey (this is the harness, not the app) |
|
||||
|
||||
**Invariant.** Any assertion of the form "the write is durable broker-side" **requires** the second setup. Making that assertion on a fresh page of the persistent context produces a false green (or a red blamed on the broker when it is really local/timing).
|
||||
|
||||
## What makes the "no-local" verdict valid (do not break it)
|
||||
|
||||
Three conditions, all met in `reconnexion-froide-sans-local.steps.ts`:
|
||||
|
||||
1. **Separate process** — `freshBrowser` is a non-persistent `chromium.launch`, distinct from the profile carrying the wallet (see [[knowledge_multibrowser-harness]] for the isolation proven all the way to the broker origin).
|
||||
2. **Hermetic partition** — every Playwright `newContext()` has its own storage; no IndexedDB shared with the writing page.
|
||||
3. **The only pre-injected state is `pool.sharedWalletState`**, captured **once at `BeforeAll`**, hence **before** the scenario writes anything → the snapshot **cannot** contain the data under test.
|
||||
|
||||
> **Impact if you touch the storageState capture** (`hooks.ts` `BeforeAll` → `pool.sharedWalletState`): moving it later, re-capturing it per scenario, or adding a warm-up that writes data **silently invalidates** the verdict of every "cold, no local" scenario — they would turn green by re-reading the snapshot. The step **fails outright** when `sharedWalletState` is missing (by design: no verdict beats a false verdict).
|
||||
|
||||
## Reconnection vs isolation — the identifier no longer decides anything
|
||||
|
||||
`isolation.steps.ts` and `reconnexion.steps.ts` set up **the same machinery** (fresh page plus an identifier written into `localStorage['festipod.account.identifier']` via `addInitScript`). That identifier used to be the **only** thing telling them apart — same value re-injected = reconnection, new value = a distinct identity B.
|
||||
|
||||
**It decides nothing now**: nothing reads that key, so both setups yield the **same** identity. The reconnection sense still holds (a fresh page on the same wallet is genuinely a reconnection); the **isolation** sense is gone — the setup can no longer produce a second identity at all, which is why `event/isolation-deux-identites.feature` is `@wip`. Proving isolation now needs **two genuinely separate browser contexts**, each signing in for itself ([[rule_tests-validate-festipod-not-the-sdk]]). Background: [[caveat_data-scenarios-share-one-wallet]].
|
||||
|
||||
## Reads stay reactive, even when "waiting a long time"
|
||||
|
||||
The reconnection `Then` steps read the **reactive** state (`homeEventTitles` on the bridge, via `waitForFunction`) — never a broker re-read loop ([[rule_no-broker-polling]]). The long diagnostic step (« … en laissant jusqu'à 60 secondes à la barrière avec rechargements ») does loop, but over the **reactive state already pushed** plus **full page reloads** (each reload = a new mount, hence a fresh attempt at reaching a synced state): that is the pragmatic fallback the rule explicitly allows, not broker polling. The distinction to keep in mind — *observing the reactive state* versus *re-issuing a broker read*.
|
||||
|
||||
## Current state of the scenarios
|
||||
|
||||
`reconnexion-froide-sans-local.feature`, the `@reconnexion-pause` scenario of `reconnexion-meme-identite.feature` and `reconnexion-persistance-e2e.feature` are **`@wip`**: they are **diagnostic instruments** (they print a verdict to stdout / as a Cucumber attachment), not regression guards. `@wip` is excluded from the default run (`cucumber.json`) — run them explicitly with `--tags`. The **non-`@wip`** scenario of `reconnexion-meme-identite.feature`, on the other hand, is a genuine guard and must stay green.
|
||||
|
||||
> This leaf describes **the test setup that produces a readable verdict**, and nothing else. What a write has to clear to be durable is not this repo's to explain — if a verdict comes back negative, that is a finding to raise with the provider, not a mechanism to write up here.
|
||||
|
||||
## Links
|
||||
|
||||
- [[knowledge_data-layer-broker]] — the single-browser `@data` layer (persistent profile).
|
||||
- [[knowledge_multibrowser-harness]] — `freshBrowser`, `spawnContext`, `pool.sharedWalletState`.
|
||||
- [[knowledge_e2e-layer]] — the `@e2e` counterpart (real app) of the "close and reopen" setup.
|
||||
- [[rule_no-broker-polling]] — the red line waiting steps must not cross.
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: world.ts still carries leftovers from the "source analysis" era (screenFileMap, screenFieldDetectors, screenExpectedContent, screenRequiredFields; hasText/hasField/hasElement with a source fallback) — to be deleted once the @ui migration to the rendered DOM is complete
|
||||
last_checked: 2026-06-15
|
||||
---
|
||||
|
||||
# Caveat: source-analysis leftovers in `world.ts`
|
||||
|
||||
The `@ui` suite **predates** the layer contract ([[rule_test-layer-contracts]]). Remnants of the "grep the source code" era are still around and **must not be extended**:
|
||||
|
||||
- `world.ts:screenFileMap`, `screenFieldDetectors`, `screenExpectedContent`, `screenRequiredFields` — mappings from the source-analysis approach.
|
||||
- `hasText` / `hasField` / `hasElement` — they now **prefer the rendered DOM** but **fall back to the source** so that unmigrated steps keep working during the transition.
|
||||
|
||||
## Migration plan (in progress)
|
||||
|
||||
1. Rewrite grep-the-source assertions as DOM queries through the render helper.
|
||||
2. Delete tests that assert implementation details (`/showDuplicateWarning/`, `/importableEvents/`, regexes over JSX).
|
||||
3. Move behavioural assertions to `@e2e` where they are not already covered.
|
||||
4. Drop the `@e2e` content checks that duplicate `@ui`.
|
||||
|
||||
Once the migration is done, the 4 leftover maps can go away in favour of assertions on the rendered DOM plus seed data. **As long as they exist, do not rely on them for new tests.**
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: The @data suite degrades within a run and across runs, silently — later scenarios time out in Before with nothing in the console. Moving .playwright-profile aside does NOT reset the data (the served wallet file is what holds it), so two "fresh profile" runs measure the same accumulated state; the only real lever is serving a new wallet file, which nothing here does yet.
|
||||
last_checked: 2026-08-16
|
||||
---
|
||||
|
||||
# Pitfall: the test wallet saturates — within a run, and across runs
|
||||
|
||||
The persistent Chromium profile `.playwright-profile` (at the root of the working tree) carries the **wallet** the whole `@data`/`@e2e` suite opens. Every scenario reads and writes through it, so anything that slows that wallet down slows everything.
|
||||
|
||||
Two distinct phenomena, and the first is the one that bites today.
|
||||
|
||||
## Within a single run — the binding constraint
|
||||
|
||||
**Symptom, VERIFIED.** On a **fresh** profile, on an idle machine, per-scenario duration climbs monotonically (observed 7 s → 53 s across the six that pass), then every later scenario dies in the `Before` hook on `frame.waitForFunction` at its 30 s cap. **Silently** — no error, no rejection, nothing in the console. Reproduced twice with identical results (6 of 14 passing, 8 min 34 s and 8 min 37 s).
|
||||
|
||||
**What it is NOT.** Runs that never exit leave a Chromium and two servers resident (see below), and it was reasonable to suspect that pressure. **Ruled out by measurement**: one of the two runs above happened with four leaked browsers and two leaked servers alive, the other on a cleaned machine — same pass count, same duration. Leaked processes are a real defect and not this cause.
|
||||
|
||||
**The likely mechanism, INFERRED.** Every scenario in a run writes into the **same wallet**, and nothing removes what it wrote ([[caveat_data-scenarios-share-one-wallet]]) — so each one leaves behind documents that every later scenario carries. That is not something tidying the test code can fix. What would settle it is a reset the surface does not publish (a teardown call, or a throwaway wallet per run): raise it with the provider rather than faking one here.
|
||||
|
||||
**Practical reading.** A `Before` timing out, especially from roughly the sixth scenario onward, is the wallet — not the assertion below it, and not the step definition. Diagnose the run's shape before diagnosing the scenario.
|
||||
|
||||
## Moving the profile aside does NOT reset the data — corrected 2026-08-16
|
||||
|
||||
The reset this leaf used to prescribe (`mv .playwright-profile …`) gives a fresh **browser profile**, not fresh **data**. The suite's data lives in the wallet file the deployment serves (`FESTIPOD_SHARED_WALLET_FILE`, a fixed `.ngw` at the working-copy root), which is the same file on every run and whose state persists outside the profile entirely. Recreating the profile makes the harness build a new broker-side wallet to get *into* the broker; the app then opens the same served wallet as always.
|
||||
|
||||
This matters beyond the inconvenience: two measurements taken "on a fresh profile" are **not** two measurements on fresh data. A pair of identical numbers from them proves reproducibility and nothing about accumulation — a conclusion drawn from exactly that mistake had to be withdrawn.
|
||||
|
||||
**The lever we actually have** is the served wallet file: it is the application's own deployment parameter, not something the provider controls. Serving a new one gives genuinely empty data. Nothing in this repo does that yet.
|
||||
|
||||
Until it does, treat any `@data` number as **relative to whatever that wallet already holds**, and do not compare two runs taken days apart as if they measured the same thing.
|
||||
|
||||
## The leak that makes it worse
|
||||
|
||||
A Cucumber run prints its summary and then **does not exit**, leaving a Chromium and two servers alive (runs observed still resident 2-3 hours after reporting). It does not cause the degradation above, but it fills the machine and forces manual cleanup. Kill the process after reading the summary until the teardown releases what it opens.
|
||||
|
||||
> This caveat describes only what is observable on the test side. Why a saturated wallet stops answering is not this repo's to explain.
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
type: cookbook
|
||||
summary: How to add a BDD scenario/step — a tagged French .feature, steps per layer, the appFrame.evaluate serialization pitfall (pass arguments, no closures), add helpers to BOTH harnesses, @wip tag for what is not implemented yet
|
||||
---
|
||||
|
||||
# Cookbook: adding a scenario / a step
|
||||
|
||||
1. **Write the `.feature`**: `src/modules/{module}/features/us-N-slug.feature`, `# language: fr`, a header tag `@CATEGORIE @priority-N`, and one layer tag per scenario (`@ui` / `@data` / `@e2e`). French keywords: `Fonctionnalité`, `Contexte` (Background), `Scénario`, `Étant donné`/`Quand`/`Alors`. Tag `@wip` any scenario whose steps are not written yet.
|
||||
|
||||
2. **Pick the layer** (see [[rule_test-layer-contracts]]): rendering assertion → `@ui`; mutation/persistence → `@data`; full journey → `@e2e`.
|
||||
|
||||
3. **Write the steps** in `src/modules/{module}/steps/{ui,data,e2e}/*.steps.ts` (or `src/shared/steps/ui/` if cross-domain). Signature: `async function (this: FestipodWorld, …)`. Import `FestipodWorld` from `../../../../shared/support/world` (adjust the relative path).
|
||||
|
||||
4. **Data access, per layer**:
|
||||
- `@ui`: `this.renderedDoc` / `this.getDomText()` / `this.hasText(...)` after `navigateTo(...)` (see [[knowledge_ui-layer]]).
|
||||
- `@data`/`@e2e`: `await this.appFrame!.evaluate(fn, ...args)` on the `window.__testData` bridge (see [[knowledge_data-layer-broker]]).
|
||||
|
||||
5. **⚠️ `appFrame.evaluate` serialization pitfall**: the function you pass runs **inside the iframe**, and the step's variables **are not captured** (closures are lost). **Pass every value as an argument**:
|
||||
```ts
|
||||
// ❌ const title = eventTitle; await appFrame.evaluate(() => td.getEventByTitle(title)) // title is undefined
|
||||
// ✅ await appFrame.evaluate((t) => td.getEventByTitle(t), eventTitle)
|
||||
```
|
||||
Always `await` (forgetting it means asserting before the promise resolves).
|
||||
|
||||
6. **If you add a data operation**: expose the helper on `window.__testData` in **both** harnesses (`src/shared/test-harness/harness.tsx` AND `harness-ng.tsx`) — otherwise the mock fallback drifts away from the real broker.
|
||||
|
||||
7. **Wire up a screen under test**: if the French screen name does not resolve to its `id`, add an alias in `screenNameMap` (`src/shared/steps/ui/navigation.steps.ts`).
|
||||
|
||||
8. **Run**: `bun run test:cucumber` (everything) or `bun run test:data` (@data). Report: `reports/cucumber-report.html`. `@data`/`@e2e` require the test wallet (`bun run test:auth-setup` on the first go if needed, otherwise it is created automatically — see [[decision_2026-03-12_headless-wallet-creation]]).
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
type: decision
|
||||
summary: Decision 2026-03-12 — create the test wallet by automating the broker UI headlessly (Playwright) rather than through the NG API, because it exercises the real auth flow and avoids reverse-engineering the sign-up API
|
||||
---
|
||||
|
||||
# Automated Headless Wallet Creation for CI
|
||||
|
||||
**Date:** 2026-03-12 15:00
|
||||
**Status:** Accepted
|
||||
|
||||
## Context
|
||||
|
||||
`@data` tests require a NextGraph wallet inside a persistent Chromium profile. Previously, the first run required manual interaction (visible browser, wallet created by hand) → CI was blocked.
|
||||
|
||||
## Options Considered
|
||||
|
||||
### Option A: create the wallet programmatically through the NG SDK
|
||||
Call `ng.wallet_create()` from Node/Bun, without any UI.
|
||||
- **Pros**: faster, no browser needed.
|
||||
- **Cons**: `@ng-org/web` is browser-only (WASM + postMessage); we would have to reverse-engineer the sign-up API of `account.nextgraph.eu`; it does not exercise the real auth flow.
|
||||
|
||||
### Option B: automate the UI flow headlessly
|
||||
Drive the very same wallet-creation UI with Playwright, in headless mode.
|
||||
- **Pros**: exercises the real auth/login flow end to end; no reverse-engineering; the same persistent profile is reused; CI-ready with no manual step.
|
||||
- **Cons**: depends on `nextgraph.eu`/`account.nextgraph.eu` being reachable; brittle to NextGraph UI changes; adds ~27s to the first run.
|
||||
|
||||
## Decision
|
||||
|
||||
**Option B** — automate the broker UI. The creation flow (navigate → Create Wallet → ToS → username/password → submit) is itself a legitimate test of the auth feature. Depending on external services is acceptable since the tests already depend on the broker being reachable.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:** fully CI-ready tests (zero interaction); the auth flow gets tested along the way; `bun run test:data` starts from a clean state.
|
||||
**Negative:** requires internet access (nextgraph.eu, account.nextgraph.eu); brittle to NextGraph UI changes (button texts, form IDs).
|
||||
**Risk:** rate-limiting from `account.nextgraph.eu` if CI recreates wallets often.
|
||||
|
||||
> Detailed lifecycle mechanics: [[knowledge_data-layer-broker]].
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Cucumber config (cucumber.json, fr language, tsx loader), layout of features/steps colocated per module, shared steps under shared/steps/, and the scripts that generate features.ts/testResults.ts/stepDefinitions.ts
|
||||
---
|
||||
|
||||
# Cucumber setup
|
||||
|
||||
26 `.feature` files (US-1 to US-26), all in **French**, tagged `@CATEGORIE @priority-N` (categories EVENT, WORKSHOP, USER, MEETING, NOTIF).
|
||||
|
||||
## Layout
|
||||
|
||||
Features and steps are **colocated with their module**:
|
||||
|
||||
```
|
||||
src/modules/event/features/us-13-creer-evenement.feature
|
||||
src/modules/event/steps/{ui,data,e2e}/
|
||||
```
|
||||
|
||||
**Shared** (cross-domain) steps live in `src/shared/steps/ui/`:
|
||||
- `navigation.steps.ts` — navigation, auth, clicks/selections, section/button/field assertions
|
||||
- `form.steps.ts` — field validation, required fields, import/duplicate
|
||||
- `screen.steps.ts` — screen content (participants, events, profiles, QR)
|
||||
|
||||
The French screen names (`"accueil"`, `"détail événement"`, `"mon profil"`…) map to screen IDs through `screenNameMap`.
|
||||
|
||||
Scenario tags: `@ui` / `@data` / `@e2e` (layer) plus **`@wip`** for a scenario whose steps are not implemented yet **or whose application behaviour is not reliable yet** (intended use: flagging a genuine expectation that fails because of a product bug, not marking an obsolete test — historical example: sign-up cancellation not being reflected in the UI, `@wip` **lifted** once it was fixed in T02.c, see [[caveat_participation-deletion]]). **`@wip` is EXCLUDED from the default run** (`cucumber.json: "tags": "not @wip"`): those scenarios document an expectation without breaking the suite; drop the `@wip` once they are reliable. A common `Contexte` (Background) — « Étant donné que je suis connecté » — merely sets an `isAuthenticated` flag, there is no real auth in `@ui`.
|
||||
|
||||
## Config
|
||||
|
||||
`cucumber.json`: `import` of `src/shared/support/**`, `src/shared/steps/**`, `src/modules/*/steps/**`; `paths` = `src/modules/*/features/**`; `tags: "not @wip"` (excludes WIP scenarios); `language: fr`. **Runner = Node + tsx**, not Bun — the plugins (Playwright, happy-dom) do not load under a native Bun import. Do not "bunify" `cucumber:run`/`test:data`. ⚠️ The runner must point at the package's **actual JS entry** (`node_modules/@cucumber/cucumber/bin/cucumber.js`), **never** at `node_modules/.bin/cucumber-js`: depending on the installer, `.bin/` holds a **shell shim** (not JS) that `node --import tsx/esm` cannot execute.
|
||||
|
||||
## The test harness is built on demand
|
||||
|
||||
The `@data`/`@e2e` harnesses (`src/shared/test-harness/harness.tsx`, `harness-ng.tsx`) are **not** built by `build.ts`. The `BeforeAll` hook in `hooks.ts` compiles them **on demand** (`bun build` → `dist/test-harness*.js`). The test wallet can be created ahead of time with `bun run test:auth-setup` (`scripts/setup-test-auth.ts`); otherwise it is created automatically on the first run (see [[decision_2026-03-12_headless-wallet-creation]]).
|
||||
|
||||
## Auto-generated files
|
||||
|
||||
Scripts under `scripts/` parse features and steps into TS data consumed by the browsing tool:
|
||||
|
||||
| Script | Input | Output |
|
||||
|---|---|---|
|
||||
| `parse-features.ts` | `*/features/*.feature` | `src/shared/data/features.ts` |
|
||||
| `parse-test-results.ts` | `reports/cucumber-report.json` | `src/shared/data/testResults.ts` |
|
||||
| `extract-step-definitions.ts` | `shared/steps/ui/*.ts` | `src/shared/data/stepDefinitions.ts` |
|
||||
|
||||
Run: `bun run test:cucumber` (everything), `bun run test:data` (@data). After adding steps: `bun run steps:extract`.
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: The @data layer — Playwright drives Chromium (persistent profile) into the real broker, which loads harness-ng.tsx in an iframe; automated wallet lifecycle, window.__testData bridge, mock fallback; the harness signs in exactly as the app does, and per-scenario isolation is currently ABSENT
|
||||
last_checked: 2026-08-10
|
||||
---
|
||||
|
||||
# The `@data` layer (real broker)
|
||||
|
||||
`@data` exercises the **real NextGraph pipeline** through a broker, not mocked data.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Cucumber → Playwright (Chromium, persistent profile)
|
||||
→ broker wallet login (automated)
|
||||
→ broker loads the harness in an iframe (http://127.0.0.1:{port})
|
||||
→ harness-ng.tsx (init → useShape → ORM → broker)
|
||||
→ window.__testData bridge
|
||||
```
|
||||
|
||||
**Dual mode**: real broker (`harness-ng.tsx`, the default) or mock fallback (`harness.tsx`, standalone DeepSignalSets if the NG build fails).
|
||||
|
||||
## Wallet lifecycle (automated, CI-ready)
|
||||
|
||||
- **First run**: no `.wallet-ready` marker → headless Chromium creates the wallet (`nextgraph.eu` → Create Wallet → ToS on `account.nextgraph.eu` → username/password → submit), **then logs in** — that initial login is required to bootstrap the session (saved in localStorage); without it, writes would not go through. Marker written.
|
||||
- **Subsequent runs**: marker found → automated login (click Login → wallet → password → submit) → harness in the iframe → `window.__testData.ready`.
|
||||
- Wallet credentials: `festipod-tests` / `festipod-tests`.
|
||||
|
||||
> The choice of "automating the headless UI rather than creating the wallet through an API" is settled in [[decision_2026-03-12_headless-wallet-creation]].
|
||||
|
||||
## Technical details
|
||||
|
||||
- **Chromium flags** (`--disable-web-security`, `--allow-insecure-localhost`, Private Network Access turned off): necessary because the public broker loads a `http://127.0.0.1` harness in an iframe.
|
||||
- **Persistent profile** `.playwright-profile/` (gitignored, wallet in localStorage) — requires the real Chrome binary, not `chrome-headless-shell`.
|
||||
- **HTTP server** started in `BeforeAll` (auto-assigned port), serving the HTML plus `/harness.js` (separate files — an inline script breaks because of special characters in the bundle).
|
||||
- **The bridge is the real app path (per entity).** Since the move to *one document per entity* (concept `data-layer`, [[rule_document-per-entity]]), the `window.__testData` bridge (`events`/`users`/`participations`, `joinEvent`/`leaveEvent`/`isParticipating`/`getEventParticipants`, `loadTestData`) **delegates to the app's data context** (`appData` through `FestipodDataProvider`) — this is the real per-entity path the screens use, not a read at root-store level. It reads `appData` through a **live ref** (a captured snapshot goes stale after a seed re-render).
|
||||
- **The harness signs in exactly as the app does.** It mounts `NextGraphProvider > FestipodDataProvider` — **no identity provider, no default login** — and awaits the single `ensureIdentity()` before exposing the bridge, mirroring the order `AuthGate` imposes (concept `app-security`, [[decision_2026-08-10_the-barrier-names-no-identity]]). Nothing may read before it resolves. The low-level probes that used to reach past the app path are **gone**, along with the scenarios whose subject was the SDK rather than Festipod ([[rule_tests-validate-festipod-not-the-sdk]]).
|
||||
- **Identity before writing.** A `Participation` has a mandatory `fp:user`; the current user is **the profile document read back in the protected scope**, so it lags behind the public events. Steps wait for `ensureCurrentUser()` before `joinEvent` (otherwise the mutation refuses, or writes a participation with no user → dropped on read) and then wait (`waitForFunction`) for the participation to be read back.
|
||||
- **Per-scenario isolation is currently ABSENT — read [[caveat_data-scenarios-share-one-wallet]] before trusting a green run.** The `Before` hook still mints `this.freshIdentifier` and injects it into `localStorage['festipod.account.identifier']`, and several steps re-inject it, but **nothing reads that key any more**: no published call takes an identifier. Every scenario therefore runs as the same identity on one accumulating wallet. That machinery is inert, not load-bearing — do not build new setup on it, and do not "repair" it by making the app honour the key again.
|
||||
- The old per-scenario reset (`resetDataState()`, a SPARQL DELETE on the anchor graph) was dropped for cost (up to 10 s of the `Before` hook's 60 s budget, already eaten by the broker login) and its helper is gone too.
|
||||
- The **physical** growth of the shared wallet was never bounded by any of this — see [[caveat_wallet-bloat-hang]] (profile to be moved aside when reads start to hang).
|
||||
- The connected seed stays **lightweight** (few docs): creating a document is a serial round trip, so the seed's cost is linear in the number of documents it writes.
|
||||
@@ -0,0 +1,76 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: The @e2e layer — Playwright boots the REAL app inside the broker iframe, driven through appFrame.evaluate()/locator(); no mock fallback; there is no more access-gate-disable flag, and no scenario has had to drive the SDK's own barrier because the persistent profile comes up already signed in
|
||||
last_checked: 2026-08-10
|
||||
---
|
||||
|
||||
# The `@e2e` layer (real app)
|
||||
|
||||
`@e2e` tests the **UI of the real app** running inside the broker iframe — unlike `@data`, which loads a test harness.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Cucumber → Playwright (Chromium, persistent profile)
|
||||
→ https://nextgraph.net/redir/#/?o=http://127.0.0.1:{appPort}
|
||||
→ broker login (automated, same machinery as @data)
|
||||
→ broker loads the REAL APP in an iframe
|
||||
→ app renders with a self-connecting NextGraphProvider
|
||||
→ steps through appFrame.evaluate() + Playwright locators
|
||||
```
|
||||
|
||||
**App server**: started in `BeforeAll` (`spawn('bun', ['src/index.ts'], { env: { PORT } })`, polled until it answers over HTTP, killed in `AfterAll`). It reuses the `setupBrokerPage()` helper from `@data` (redirect, login, iframe discovery).
|
||||
|
||||
## Step definitions
|
||||
|
||||
Inside the modules (e.g. `src/modules/auth/steps/e2e/connexion.steps.ts`):
|
||||
- `this.appFrame!.evaluate()` — JS inside the app iframe (hash/path navigation, content checks)
|
||||
- `this.appFrame!.locator()` — DOM elements
|
||||
- `this.appFrame!.waitForFunction()` — poll for an expected state
|
||||
- `SCREEN_MARKERS` — map from screen ID to a unique verification text
|
||||
|
||||
Navigation: `window.history.pushState` plus a `popstate` dispatch (path-based routing, see `app-architecture`).
|
||||
|
||||
## Differences from `@data`
|
||||
|
||||
| Aspect | `@data` | `@e2e` |
|
||||
|---|---|---|
|
||||
| Loaded in the iframe | harness (`harness-ng.tsx`) | real app (`src/index.ts`) |
|
||||
| Ready signal | `window.__testData.ready` | `root.innerHTML.length > 100` |
|
||||
| Interaction | `evaluate()` bridge | `evaluate()` + locators |
|
||||
| Mock fallback | yes | **no** (real broker required) |
|
||||
| Tests | data operations | UI behaviour (nav, redirects, clicks) |
|
||||
|
||||
> **Do not re-check in `@e2e` what `@ui` already covers** — `@e2e` must break when the *collaboration* between layers breaks, not when an icon changes (see [[rule_test-layer-contracts]]).
|
||||
|
||||
## Scenario identity, and why no scenario drives the SDK's barrier
|
||||
|
||||
The `Before` hook still plants `this.freshIdentifier` — a unique identifier minted per scenario (`freshScenarioIdentifier`) and injected via `addInitScript` into `localStorage['festipod.account.identifier']` on the **persistent** context. **Nothing consumes it**: no published call takes an identifier, so a scenario cannot choose who it opens as. Treat it as inert machinery, not as a determinism lever — [[caveat_data-scenarios-share-one-wallet]].
|
||||
|
||||
**There is no more access-gate-disable flag.** `AccessGateScreen` and the `__FESTIPOD_ACCESS_GATE_DISABLED__` global it used to check are both gone. What keeps every `@e2e` scenario from having to drive the SDK's barrier is simply that the **persistent profile already carries an open wallet session** — the automated broker login in the shared `@data`/`@e2e` setup put it there. **Fresh** contexts (multi-browser, see [[knowledge_multibrowser-harness]]) carry no such session, but no scenario left loads the real app through a fresh context — and none could assert against that barrier anyway ([[caveat_first-time-entry-untested]]).
|
||||
|
||||
> **Impact:** any page opened by hand inside a step (`ctx.newPage()`) does **not** inherit page-level init scripts — `addInitScript` applies only to the pages of the context it was called on.
|
||||
|
||||
## The "close and reopen" idiom (reconnection scenarios)
|
||||
|
||||
`reconnexion-persistance-e2e.feature` / `src/modules/event/steps/e2e/reconnexion-persistance.steps.ts` reproduce the "I create, I close, I come back" journey inside the REAL app:
|
||||
|
||||
1. **Creation through the real form** — the step drives the actual creation wizard at DOM level (3-step wizard, selection by *placeholder*: event name, venue; submit button by its label). ⚠️ **These steps are coupled to the French labels of the creation screen**: renaming a placeholder or the submit button breaks the scenario, not the app.
|
||||
2. **Reopening** — a second page on the **same** persistent context, replanting `this.freshIdentifier` on it (page-level `addInitScript` only covers the page it is called on), then `pool.setupBrokerPage(page, pool.appUrl!)` → new broker login, same identity.
|
||||
3. **Proof** — the step captures the console of **both** pages and publishes a summary through `this.attach` (Cucumber attachment) plus stdout; a raw dump of the connection/sync lines is **opt-in** through the `RECO_RAW_DUMP=1` environment variable (noisy, off by default).
|
||||
|
||||
> **Limitation to know about**: this setup proves the reconnection *of the journey*, **not** the broker durability of the write — the second page shares the IndexedDB of the persistent profile. See [[caveat_reconnexion-froide-local-vs-broker]] for the setup that does settle broker-vs-local.
|
||||
|
||||
The scenario is **`@wip`** (diagnostic instrument, excluded from the default run).
|
||||
|
||||
## `@smoke` — guarding the "blank page once connected" class
|
||||
|
||||
`@e2e @smoke` (`src/modules/home/features/accueil-connecte-rend.feature`) guards a CLASS of regression: a rendering crash that happens ONLY once the app is connected and mounted on real data (symptom: only the broker iframe banner shows, `#root` stays empty). The smoke test reuses the boot from the @e2e `Before` hook, navigates to the connected home screen and asserts TWO things:
|
||||
1. **HomeScreen really mounted** — presence of strong markers (`.app-navbar` plus the `[aria-label="Relayer un événement"]` button), absent from a spinner or from the broker banner. A `throw` inside a component/provider mounted after connection unmounts the tree (there is no `ErrorBoundary`) → those markers disappear → red.
|
||||
2. **Zero runtime errors** — `this.pageErrors` (see below) must be empty.
|
||||
|
||||
The @e2e `Before` hook now **collects** the app page's `pageerror` and `console.error` events into the World (field `pageErrors`, reset per scenario) — that is what makes the "no error" assertion possible. The default run of `bun run validate` executes `@smoke and not @wip` (not all of `@e2e`, to stay fast). **Detection proof**: a `throw` at the top of `HomeScreen` turns the smoke test red; without it, green.
|
||||
|
||||
## Key files
|
||||
|
||||
`src/shared/support/hooks.ts` (Playwright lifecycle + `pageErrors` collection), `world.ts` (fields `page`/`appFrame`/`pageErrors`), `scripts/debug-browser.ts` (headed debugging), `.playwright-profile{,-debug}/` (gitignored).
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Multi-browser harness — isolated contexts spawned from a non-persistent freshBrowser, all carrying the shared wallet by storageState injection (test-only); the scenario that once drove the real access screen end to end is gone with the screen, and nothing replaces it
|
||||
last_checked: 2026-08-10
|
||||
---
|
||||
|
||||
# Multi-browser harness (shared wallet)
|
||||
|
||||
The ability of the `@data`/`@e2e` harness to drive **several isolated browsers** within a single scenario. This is also the **only** way multi-user is exercised now: each browser context signs in **as itself**, since nothing lets a single page hold two identities ([[rule_tests-validate-festipod-not-the-sdk]]). That capability is not yet fully used: `isolation-deux-identites.feature` needs exactly this — two real contexts, each connecting for itself — and is currently `@wip` because it still assumes the old single-page identity switch (product-level statement of the gap: concept `functional-domain` → [[knowledge_roadmap]]).
|
||||
|
||||
| Concern | What it decides | Expressed by |
|
||||
|---|---|---|
|
||||
| **Number of browsers** (machinery) | 1..N isolated named contexts | `openBrowser(name, …)` + steps `… dans le navigateur "X"` |
|
||||
| **Wallet model** | which wallet a context carries | the `WalletModel` argument (`'own'` \| `'shared'`) |
|
||||
|
||||
## Wallet model — one is exercised, one is dormant
|
||||
|
||||
- `Étant donné un navigateur "A" avec le wallet partagé` → **shared** model, tag `@shared-wallet`. This is what every scenario uses.
|
||||
- The **own-wallet** model (`'own'`, an empty partition with no wallet) still exists in `spawnContext`, but **no scenario exercises it**: the two `@private-wallet` scenarios were **deleted** because what they proved — Playwright's storage partitioning — is a property of the tooling, not a Festipod behaviour. Keep the machinery, do not re-add scenarios whose subject is the isolation of the tooling.
|
||||
- Umbrella tag `@multibrowser` (whole feature).
|
||||
|
||||
## Architecture (where things live)
|
||||
|
||||
- **`src/shared/support/browserPool.ts`** — shared state + factory. Outside the **persistent** Chromium context that carries the shared wallet (legacy single-browser `@data`/`@e2e`, **unchanged**, see [[knowledge_data-layer-broker]]), the harness launches a **non-persistent** browser `freshBrowser` (`chromium.launch`) which mints fresh isolated contexts on demand (`spawnContext(wallet)`). The module is imported by `hooks.ts` (lifecycle) and `world.ts` (per-scenario use) — no import cycle.
|
||||
- **`world.ts`** — API: `openBrowser(name, wallet)`, `browser(name)`, `loadAppInBrowser(name, 'app'|'harness')`, `closeBrowsers()`; registry `browsers: Map<name, NamedBrowser>`. Named browsers are closed in `After`, `freshBrowser` in `AfterAll`.
|
||||
- **`hooks.ts`** — a scenario tagged `@multibrowser` **does not get** the legacy single page; the steps open the browsers themselves. Requires the real broker mode (`freshBrowser` is unavailable in the mock fallback).
|
||||
|
||||
## Wallet provisioning
|
||||
|
||||
- **own**: empty `newContext()` → distinct NG identity / no wallet.
|
||||
- **shared**: `newContext({ storageState })`, where `storageState` is **captured once** at `BeforeAll` from the persistent profile (warm-up through `setupBrokerPage`, then `browserContext.storageState()`), exposed as `pool.sharedWalletState`. **Empirically verified (2026-06-16)**: the `nextgraph.eu` and `nextgraph.net` origins round-trip into the fresh contexts, and two **shared** browsers both reach the app **connected** to NextGraph (`window.__testData.ready`) **without any manual login**.
|
||||
|
||||
> This provisioning is **test-only** — distinct from the **product** mechanism (FILE-assisted import). The shared-wallet scenario using storageState **bypasses the import**, and nothing left validates that import end to end: see [[caveat_first-time-entry-untested]] (concept `bdd-testing`).
|
||||
|
||||
## No scenario left drives the real app through a fresh context
|
||||
|
||||
There used to be a `@humain` scenario here that drove `AccessGateScreen` end to end on a fresh context: download the wallet file from the screen, import it on `nextgraph.eu`, come back, type an identifier, land connected. `AccessGateScreen`, its testids (`shared-wallet-download`, `shared-wallet-password`, `identifier-input`), and every helper built only for that scenario (`pool.ensureStagingApp`, `pool.importWalletViaFile`, `findE2eWalletFile`, the `dist-staging` build) are **deleted** along with the screen itself (concept `app-security`, [[decision_2026-08-10_sdk-renders-the-barrier]]) — nothing of Festipod's own is left to assert against. What this leaves unproven: [[caveat_first-time-entry-untested]].
|
||||
|
||||
The `@shared-wallet` scenario above is unaffected — it never drove the import, and it loads the **harness** (`loadAppInBrowser(name, 'harness')`), not the real app, so it never touched `AccessGateScreen` or `ensureIdentity()` either.
|
||||
|
||||
## Isolation of the contexts (a property of the harness, not a tested behaviour)
|
||||
|
||||
1. `freshBrowser` runs in a **separate process** from the persistent profile carrying the wallet → an **own** browser starts **with no wallet**.
|
||||
2. Every `newContext()` is a **hermetic storage partition** (Playwright guarantee).
|
||||
3. That holds on the **local** origin (`127.0.0.1`) and on the **broker origin** `nextgraph.net` **where the wallet actually lives**.
|
||||
|
||||
These three are what makes a cold-start verdict meaningful ([[caveat_reconnexion-froide-local-vs-broker]]). They are **no longer asserted by scenarios** — they were, and those scenarios were deleted: their subject was the tooling.
|
||||
|
||||
## Files
|
||||
|
||||
- Feature: `src/modules/workshop/features/multibrowser-harness.feature`.
|
||||
- Steps: `src/modules/workshop/steps/data/multibrowser.steps.ts`.
|
||||
- A `/blank` route added to the harness server (`hooks.ts`): a minimal page **with no NG stack**, for the localStorage isolation checks.
|
||||
|
||||
## The `@wip` convention (now enforced)
|
||||
|
||||
`cucumber.json` (profile `default`) carries `"tags": "not @wip"`. `cookbook_add-scenario` prescribed `@wip` for anything unimplemented, but it was **excluded nowhere**; now `not @wip` is **AND**ed with the CLI filters (e.g. `--tags @data` → `(not @wip) and @data`, verified).
|
||||
|
||||
## Links
|
||||
|
||||
- [[knowledge_data-layer-broker]] — the single-browser `@data` layer (persistent profile) that this capability extends.
|
||||
- [[cookbook_add-scenario]] — the `@wip` convention, step pitfalls.
|
||||
- [[caveat_first-time-entry-untested]] — the hole left by the deleted `@humain` scenario.
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: The @ui layer — renderHelper.tsx renders a screen inside LocalDataProvider + happy-dom, assertions run against the rendered DOM; there is no access screen left to render, and a dormant module-evaluation-order trap around sharedWallet.ts survives, currently harmless
|
||||
last_checked: 2026-08-10
|
||||
---
|
||||
|
||||
# The `@ui` layer
|
||||
|
||||
`@ui` renders a screen with `LocalDataProvider` (seed) plus `RouterProvider` through happy-dom, then asserts on the **rendered DOM**.
|
||||
|
||||
- Helper: `src/shared/test-harness/renderHelper.tsx` (installs the happy-dom globals, wraps the screen). Invoked from `world.ts:renderCurrentScreen()` on every `navigateTo(...)`.
|
||||
- Deterministic fixtures (`src/shared/data/seedData.ts`, see concept `data-layer`): `Marie Dupont`/`@mariedupont` = currentUser, `Jean Durand`/`@jeandurand` exists, 5 events, and so on.
|
||||
|
||||
## Good assertion patterns
|
||||
|
||||
```ts
|
||||
// Visible text
|
||||
expect(this.getDomText()).to.include('Marie Dupont');
|
||||
// Element present, by class/role
|
||||
expect(this.renderedDoc!.querySelector('.app-avatar')).to.not.be.null;
|
||||
// Conditional rendering (filled vs empty)
|
||||
expect(this.renderedDoc!.querySelectorAll('.app-card').length).to.be.greaterThan(0);
|
||||
// Required field rendered with a label and an asterisk
|
||||
const labels = Array.from(this.renderedDoc!.querySelectorAll('p')).map(p => p.textContent ?? '');
|
||||
expect(labels.some(t => t.includes("Nom de l'événement *"))).to.be.true;
|
||||
```
|
||||
|
||||
## `FestipodWorld` fields & helpers (`src/shared/support/world.ts`)
|
||||
|
||||
- `renderedDoc: Document | null` — the rendered happy-dom DOM (populated by `renderCurrentScreen()`, called on every `navigateTo(...)`).
|
||||
- `currentScreenId: string | null` — the current screen.
|
||||
- Assertion helpers: `getDomText()` (DOM text), `hasText(t)`, `hasField(name)`, `hasElement(selector)` — they **prefer the rendered DOM** but **fall back to the screens' source** for unmigrated steps (a leftover, see [[caveat_source-grep-vestiges]]).
|
||||
|
||||
## ⚠️ No `@ui` module renders an access screen — there is none left to render
|
||||
|
||||
Festipod deleted its own access screen (`AccessGateScreen`) entirely; signing in is now one `ensureIdentity()` call, entirely SDK-owned (concept `app-security`, [[decision_2026-08-10_sdk-renders-the-barrier]]). The two features that used to cover the identifier field and its resolution were **deleted** with the screen — there is nothing left for a `@ui` scenario to render or assert here, and `renderElement()` (the helper `renderHelper.tsx` used to expose for prop-driven components like that screen) is gone too.
|
||||
|
||||
**A dormant trap survives, unrelated to the screen's deletion.** `src/shared/utils/sharedWallet.ts` (the module used to be `src/modules/auth/sharedWallet.ts`, now deleted — the surviving copy moved) still **captures, at module evaluation time**, a global set by `build.ts` (`__FESTIPOD_SHARED_WALLET_PASSWORD__`). The `@ui` harness runs under Node **without going through the build**, and it reaches this module regardless of which screen a scenario renders: `screens/index.ts` eagerly imports every screen including `SettingsScreen`, which imports `src/shared/utils/ngSession.ts`, which imports `sharedWallet.ts` — so `hasSharedWallet()` is always `false` under `@ui`. This is currently **harmless**: `configure()` just runs with `sharedWallet: undefined`, and no `@ui` path ever calls `ensureIdentity()` (`renderScreen()` bypasses `AuthGate`/`NextGraphProvider` entirely). It stops being harmless the day a `@ui` scenario does call `ensureIdentity()` — full mechanics: `app-security` → [[caveat_shared-wallet-global-before-gate-import]].
|
||||
|
||||
> The `app-*` classes confirm the modern theme (see `app-architecture`). Anti-patterns (regexes over the source, implementation details) are banned by [[rule_test-layer-contracts]]. To write a new scenario, see [[cookbook_add-scenario]].
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
type: rule
|
||||
summary: NEVER poll the broker (re-reading in a loop "is it there yet?"). The read surface is push-based and says itself when a scope has finished syncing — `isPending` differs from `isSuccess` with empty `data`. App and tests wait for the push, never a broker re-read loop.
|
||||
last_checked: 2026-07-09
|
||||
---
|
||||
|
||||
# Never poll the broker — wait for the subscription
|
||||
|
||||
The published read surface is **push-based**: `watchShape` resolves a scope, pushes on every change, and carries its own readiness — `isPending` (still syncing) is distinct from `isSuccess` with empty `data` (synced and genuinely empty). A read is therefore never "query in a loop until it shows up"; the surface already answers *"has it finished?"*, and a loop that re-asks the question is asking something the answer is already available for.
|
||||
|
||||
## The anti-pattern to ban
|
||||
|
||||
```
|
||||
for (i = 0; i < N; i++) { if (await authParticipationCount(...) === X) break; sleep(500); }
|
||||
```
|
||||
|
||||
Any loop that **re-queries the broker** (repeated `authParticipationCount`, `listMyEntityDocs`, `sparqlQuery`) in order to "wait" for data is forbidden: it hides the real mechanism, makes the test brittle (guessed timeout), and contradicts the surface the app is built on. That remark is what caused the deletion of the old caveat which wrongly held polling up as a practice.
|
||||
|
||||
## What to do instead
|
||||
|
||||
Wait for the **reactive push**. In practice (app AND test): the reactive state updates **on push**. We wait for THAT state to reflect the expectation — we **observe the settled reactive state**, we do NOT re-issue a broker read.
|
||||
|
||||
- App: the screen is already reactive (re-render on push) — no application-level polling, no spinner driven by a guessed timeout (if a waiting state is wanted, it comes from the surface's own readiness flags, not from an added signal).
|
||||
- Test: **a helper that reliably waits for the push/readiness is welcome** (it makes things reliable without making them brittle). What is banned is the **re-read loop**, not waiting for a signal.
|
||||
- **Pragmatic fallback**: if strictly waiting for the push/signal turns out to be brittle one way or another, a **short interval** (`setInterval` / closely spaced re-checks) that **observes the ALREADY updated reactive state** (the local state fed by the subscription — NOT a broker re-read) is acceptable: it is as close as it gets to what the user experiences, simply **waiting** for the (reactive) screen to update. The red line is invariant: **never re-query the broker in a loop**; observing the settled reactive state, yes.
|
||||
|
||||
See also [[caveat_wallet-bloat-hang]] (another source of `@data` flakiness, orthogonal to this one).
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
type: rule
|
||||
summary: Each BDD layer answers a distinct question — @ui = rendering (DOM + seed), @data = broker mutations/persistence, @e2e = collaboration of the layers over a journey; push every assertion down to the lowest layer that can answer it
|
||||
---
|
||||
|
||||
# Rule: the test layer contract
|
||||
|
||||
Each layer answers **one distinct question**. Mixing concerns produces brittle tests that break on refactors without catching any real regression. **Push every assertion down to the lowest layer that can answer it.**
|
||||
|
||||
- **`@ui` — display layer.** Renders a screen with `LocalDataProvider` (seed) plus happy-dom and asserts on the DOM. Checks that *given known data, the screen shows the expected text and elements*. **Does not test** navigation, mutations or persistence.
|
||||
|
||||
- **`@data` — data layer.** Drives ORM mutations through the **real NextGraph broker** (headless harness, no app UI). Checks that *shape operations are persisted and observable in the wallet*. No DOM here — use the `window.__testData` bridge.
|
||||
|
||||
- **`@e2e` — integration layer.** Boots the real app inside the broker iframe (Playwright/Chromium). Checks that *the layers collaborate to deliver a journey* (create → list → edit → reload → still there). **Rare**: 1 scenario per critical path; **never duplicate** a `@ui` content check.
|
||||
|
||||
## Why cost dictates the pyramid
|
||||
|
||||
`@ui` runs in-process (instant); `@data` boots a broker (~50s); `@e2e` boots broker + app + browser (~2min). A rendering claim belongs to `@ui`, not to `@e2e`.
|
||||
|
||||
## `@ui` anti-patterns to ban
|
||||
|
||||
```ts
|
||||
// ❌ regex over the source: couples the test to the structure of the code
|
||||
expect(/<Title[^>]*>Marie Dupont<\/Title>/.test(source)).to.be.true;
|
||||
// ❌ implementation details
|
||||
expect(/showDuplicateWarning/.test(source)).to.be.true;
|
||||
```
|
||||
|
||||
Prefer assertions on the **rendered DOM** plus seed data (see [[knowledge_ui-layer]]). The source-analysis helpers and maps are leftovers on their way out: [[caveat_source-grep-vestiges]].
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
type: rule
|
||||
summary: Festipod's tests validate FESTIPOD's behaviour — multi-user included — never the SDK's, and they take NO shortcut past the published surface. Multi-user is exercised the way it is lived, several browser contexts each signing in as itself, since no published call lets one page hold two identities.
|
||||
---
|
||||
|
||||
# The tests validate Festipod, not the SDK — and they take no shortcut
|
||||
|
||||
## The rule
|
||||
|
||||
Stated by the project owner on 2026-08-10, when the app moved onto the pulled [[contract_polyfill-surface]]:
|
||||
|
||||
1. **Festipod is a consumer entirely ignorant of how the SDK is implemented, and its tests may take no shortcut.** No deep import into the package, no reaching for a symbol the contract does not publish, no fixture that reaches past the published surface to get to a state faster.
|
||||
2. **The subject under test is Festipod's behaviour — multi-user included — never the SDK's.** An assertion whose subject is "the capability was learned", "the store served the key", "the inbox holds two deposits" is testing the provider. It does not belong here; if it is worth having, it belongs in the provider's own suite.
|
||||
3. **Multi-user is tested the way it is lived**: several browser contexts, each signing in as itself through `ensureIdentity()`. Each actor obtains what it consumes through the application, under its own session.
|
||||
|
||||
## Why
|
||||
|
||||
The contract publishes no way to name or switch identity: signing in is one call that takes **no identifier**, and *"no other call takes one"*. A session is one user's. So "play two identities on one page" is not a capability that went missing — it is something no published call offers, and a test that manufactured it would be exercising something below the surface and would keep passing while the real behaviour rotted; worse, it would hand one actor's values to another through a shared variable, which is exactly the shape that once hid a real bug behind a green test (see [[multi-actor-tests-obtain-not-receive]]).
|
||||
|
||||
The rule also protects the thing the contract exists for. Every shortcut past the surface is a place the app learns something it must unlearn, and it silently converts a **provider gap** — which should be written down and raised — into an app-side workaround nobody revisits.
|
||||
|
||||
## How to apply
|
||||
|
||||
The tell is mechanical: a test import that is not `@ng-eventually/polyfill`, or an assertion naming an SDK concept rather than something a Festipod user would observe.
|
||||
|
||||
When a scenario cannot be written without a shortcut, that is a finding, not an obstacle to route around: the missing thing is either a **product behaviour Festipod does not expose yet** (build it) or a **gap in the provider's contract** (raise it with the provider and leave the scenario unwritten or `@wip` meanwhile — [[rule_app-uses-sdk-surface-only]]). Deleting a scenario whose subject turns out to be the SDK is the correct outcome, not a loss of coverage.
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
type: _overview
|
||||
summary: How Festipod persists its data through the @ng-eventually/polyfill SDK — entities stored as documents placed by scope, direct SPARQL writes + reactive shape reads, SHEX stack, connected/demo modes, seed
|
||||
triggers:
|
||||
keywords: [nextgraph, "@ng-eventually", polyfill, watchShape, useShape, useShapeQuery, SHEX, shape, scope, "@graph", NURI, inbox, share, sparql, seed, wallet, FestipodData, ngSession, ngGraph, storeRegistry, bootstrap, document, entité, déconnexion, reconnexion]
|
||||
paths: ["src/shared/shapes/**", "src/shared/data/**", "src/shared/context/NextGraphContext.tsx", "src/shared/context/FestipodDataContext.tsx", "src/shared/utils/*", "src/shared/data/seedData.ts"]
|
||||
---
|
||||
|
||||
# Data layer
|
||||
|
||||
How Festipod **persists its data** through NextGraph (P2P, local-first, end-to-end encrypted). The data SDK is **`@ng-eventually/polyfill`**: every entity is a **document** placed in its **scope** (public / protected / private). A **write** is direct SPARQL into the entity's own document; a **read** is the SDK's **reactive shape surface** (`watchShape(shape, scope)` → the app's `useShapeQuery` binding), which resolves the scope itself and pushes on change — the app resolves, lists and re-queries nothing. See [[rule_document-per-entity]]. The mapping *which entity → which scope* is a **product** fact (concept `functional-domain`, [[knowledge_data-scopes-and-discovery]]); this concept describes **how Festipod uses the surface**.
|
||||
|
||||
> **SDK boundary.** `@ng-eventually/polyfill` is injected **exactly once** through `ngSession.configure(...)`. The pulled contract is the whole of what this repo knows about it: never describe here how the data layer is implemented underneath. See [[rule_app-uses-sdk-surface-only]].
|
||||
|
||||
## Model & data
|
||||
|
||||
- [[contract_polyfill-surface]] — **the data contract, PULLED from the provider and version-pinned**: the `@ng-eventually/polyfill` surface the app codes against, what it guarantees and what it refuses to promise. The ONLY reference — never open the provider's own sources.
|
||||
- [[knowledge_nextgraph-stack]] — the SHEX shapes, the reactive ORM bindings, `build:orm`, injection through `ngSession`
|
||||
- [[knowledge_data-modes]] — connected (SDK) vs disconnected/demo (seeded local state), how the provider is chosen
|
||||
- [[knowledge_entities]] — the `Fp*` types and their SHEX shapes
|
||||
- [[knowledge_seed-data]] — seed data, `CURRENT_USER_ID`
|
||||
- [[knowledge_context-internals]] — pitfalls of `FestipodDataContext` (who the current user is and when it arrives, the legacy principal space, dev auto-seed, `participantCount`, local no-op)
|
||||
|
||||
## Write rules
|
||||
|
||||
- [[rule_document-per-entity]] — every entity gets **its own document** (per scope), never one at store level; access is granted per document, so this is what makes isolation possible
|
||||
- [[rule_app-uses-sdk-surface-only]] — the pulled contract is the only reference; a gap in it is raised with the provider, never worked around here
|
||||
|
||||
## Pitfalls (read before touching deletions / event fields)
|
||||
|
||||
- [[caveat_participation-deletion]] — withdrawal must be **authoritative** and must not come back
|
||||
- [[caveat_event-fields-not-persisted]] — `startTime`/`themes`… not covered by the Event shape → lost when connected
|
||||
|
||||
> Confidentiality (scope isolation, trusting the SDK): concept `app-security`. Product scopes per entity + discovery: concept `functional-domain`.
|
||||
@@ -0,0 +1,97 @@
|
||||
---
|
||||
type: brief
|
||||
summary: Target model for sign-ups — a Participation READABLE by everyone (event ref + `active` boolean + a key-less reference to the participant's profile), deposited into the event's inbox; the creator processes the inbox, dedups WITHOUT knowing who, files the reference into a Set on the event and PURGES the cancelled ones; count = Set.size with no filtering (accepted upper bound); only connections can read the profile and recognize the person. Supersedes the mutated counter + plaintext userId.
|
||||
---
|
||||
|
||||
# Brief (2026-07-20, revised 2026-07-27) — Set-based sign-ups
|
||||
|
||||
## The model
|
||||
|
||||
Laid down and refined by the PO on 2026-07-27. Everything is **keys and URLs** — no roles, no membership, no allow-list.
|
||||
|
||||
1. The participant creates a **Participation** object, **readable by everyone**, holding: the **reference to the event**, an **`active` boolean**, and a **reference to their *protected* profile that carries no key**. **Nothing else** — no description for now.
|
||||
2. They deposit the **Participation's reference** into the **event's inbox**.
|
||||
3. The **creator** processes their inbox **automatically**, as soon as they are online.
|
||||
4. They **dedup** (see below) — **without knowing who the participant is**: they hold a name for the profile, not the key to read it.
|
||||
5. They file a **reference** to the Participation into a **Set** carried by the event's document.
|
||||
6. Anyone reads **`Set.size`** → the number of participants.
|
||||
7. Someone **connected** to the participant holds their profile's cap, reads it, and **recognizes** the person.
|
||||
|
||||
**Withdrawal**: the participant sets `active` to false **on their own object**. The creator sees it when reading, and **purges** — removing the reference from the Set.
|
||||
|
||||
Three properties follow: **anonymous attendance by default** (even the creator cannot see who); **nobody modifies anyone else's sign-up** (only the participant holds the write key to their own object); **no plaintext `userId`** ever travels.
|
||||
|
||||
### The principle that holds it all together: the truth is in the object, messages are only hints
|
||||
|
||||
The object **controlled by the participant** is what counts. Any message — an inbox deposit, a purge notification — is only a **hint** that triggers a check, never an authority.
|
||||
|
||||
Consequence: **forgery becomes structurally harmless**. A fake « purge X » leads the creator to read X, find it still active, and do nothing. That is why inbox deposits **need not be signed** — which is just as well, since the contract promises no authenticated sender.
|
||||
|
||||
### Why a flag rather than a deletion
|
||||
|
||||
Without the read key, a **deletion** cannot be told apart from "nothing was ever there". A **readable** object carrying a **flag** transforms the problem: the cancellation no longer has to be *detected*, it is simply *read*. The blocker disappears instead of being worked around with a forgeable message.
|
||||
|
||||
### Why the identity pointer targets the existing profile
|
||||
|
||||
No need for a second document per participation: the participant's **protected profile** already plays that role, and their connections **already** hold the key to read it — that is the very definition of being connected. A third party sees an opaque reference.
|
||||
|
||||
The advantage over an encrypted field inside the Participation: **adding a connection rewrites nothing**. The profile is shared with a new connection once, durably (and irreversibly — the contract publishes no revocation). An encrypted field would require re-encrypting to N recipients and rewriting the Participation on every new connection.
|
||||
|
||||
## What this rests on
|
||||
|
||||
Two guarantees the contract publishes, and one thing it does not.
|
||||
|
||||
| What the model needs | Where it stands |
|
||||
|---|---|
|
||||
| A reference can **name without granting read access** | Published: *"A returned reference carries no key… A reference found inside a document yields a name, not a key."* |
|
||||
| Sharing is **per document, durable and one-way** | Published: `inbox.share(doc, toUser)` — one act, no revocation, nothing per reader on a public document |
|
||||
| **Anyone may deposit, only the owner reads** the inbox | Published: `inbox.postToDocument` / `inbox.read` |
|
||||
| A **dedup key** letting the creator count distinct people without reading them | **NOT published.** See below — this is the open dependency. |
|
||||
|
||||
## The dedup: the requirement, and the gap
|
||||
|
||||
**The requirement, validated by the PO (2026-07-27)**: the creator must be able to tell two references from the *same* person apart from two references from *different* people, **without ever knowing who** — otherwise the count is not a count of people, and a participant could inflate it by creating several Participations.
|
||||
|
||||
**The contract publishes nothing that does this.** A reference "yields a name, not a key", and no call answers "do these two references belong to one person?". So the mechanism is **not Festipod's to specify**: it is a **gap to raise with the provider**, stated as a need — *a stable, per-person discriminator that can be compared without reading the referenced document*.
|
||||
|
||||
Design consequence, whatever the mechanism turns out to be: the Set is **keyed by that discriminator** — at most one reference per person. `Set.size` = the number of distinct people.
|
||||
|
||||
### The reservation that must outlive this brief
|
||||
|
||||
Any such discriminator is by construction a **pseudonym**: it does not say *who*, but it is comparable across contexts, so whoever collects references can link them. **Never present a Festipod action as "anonymous"** when it circulates one — the contract guarantees no anonymity, and making the Participation public widens the surface on which it is collected. Whether the pseudonym can be rotated, or scoped, is part of the gap above.
|
||||
|
||||
## Trade-offs deliberately accepted (PO, 2026-07-27)
|
||||
|
||||
- **No filtering on read.** The count is `Set.size`, **without** checking the `active` flags. We accept the **staleness risk**: a cancelled participation still counts until the creator has purged. `Set.size` is therefore an **upper bound**, exact once purged. *Rationale: keep reads simple and O(1).*
|
||||
- **Purging is the creator's job.** No curator service, no catch-up by readers.
|
||||
- **No description** in the Participation for now. *(To be reopened when the need arises: whatever we put there would become public.)*
|
||||
- **Creator offline**: the Set does not move until they have processed their inbox. Accepted.
|
||||
|
||||
## What changes vs the current implementation
|
||||
|
||||
What exists today ([[knowledge_context-internals]] §participantCount) derives a `participantCount` **written by the owner** from inbox markers carrying the **plaintext `userId`**.
|
||||
|
||||
- **Drop the `userId`** from inbox deposits → only the **Participation's reference** remains.
|
||||
- **Count distinct people** through the discriminator above, no longer `userId`s.
|
||||
- **`event.participantCount` goes away**, replaced by `Set.size`.
|
||||
- **Identity resolution** now goes through **reading the profile** (hence through being connected), no longer through the marker.
|
||||
- **Withdrawal stops being a deletion** → `active` set to false + a purge by the creator. See [[caveat_participation-deletion]], whose requirement (« authoritative, must not come back ») still holds but changes mechanism.
|
||||
|
||||
Still valid as-is: **reactive reads**, **re-arming on reconnection**, and the **id-space fix** already shipped.
|
||||
|
||||
## Open points
|
||||
|
||||
- **Participation scope** — it becomes **public**, whereas current product doctrine places it in *protected* ([[knowledge_data-scopes-and-discovery]], concept `functional-domain`). That leaf describes **what is implemented**: do not change it until this brief has graduated, but **do update it at that point**.
|
||||
- **Recognition by connections** (step 7) — and what happens to a broken connection: the contract publishes **no revocation**, so sharing a profile is permanent. Explicitly deferred to a second stage.
|
||||
- **Public reads are not recursive** — this is the principle the whole model rests on, and it deserves to be stated on its own: *an item in the **public** store is public — whoever has the URL reads the content.* But **not recursively**: public content may **reference** private content, and **that is exactly our case**. So the creator reads the Participation (public) and **cannot** follow the reference to the profile (protected). That is what yields both readability by the creator and anonymity towards them — with no additional mechanism.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Blocking — a contract gap**: no published way to **dedup without reading** (see above). Until the contract answers it, coding this model would produce a count that **claims** to be a count of people without being one. Raise it with the provider; do not emulate it here.
|
||||
- **Parked**: **identity terminology** (wallet / user / profile) — see `.project/to-discuss.md`.
|
||||
|
||||
## Status: model settled, implementation gated
|
||||
|
||||
The model is **settled** (PO, 2026-07-27). What remains gated is the **implementation**, waiting on the dependency above. **Do not remove the current owner-derived counter** in the meantime ([[knowledge_context-internals]]).
|
||||
|
||||
Links: [[caveat_participation-deletion]], [[rule_document-per-entity]], [[rule_app-uses-sdk-surface-only]], app-security ([[brief_2026-05-18_authorization-matrix]], [[knowledge_trust-model]]).
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: The FpEventData type and the seed carry startDate/endDate/startTime/endTime/themes, but the Event SHEX does not define them — these fields are silently lost in connected mode (NextGraph)
|
||||
last_checked: 2026-06-15
|
||||
---
|
||||
|
||||
# Caveat: event fields not persisted in connected mode
|
||||
|
||||
The app type `FpEventData` (`src/shared/data/types.ts`) and the seed (`seedData.ts`) carry the fields **`startDate`, `endDate`, `startTime`, `endTime`, `themes`** — but the **SHEX `Event` shape** (`src/shared/shapes/shex/festipodShapes.shex`) does **not** define them. The shape covers exactly (verified 2026-08-10 in the `.shex`): `title, description, date, location, distance, participantCount, coverImage, hostName, hostInitials`, plus an optional `inbox`.
|
||||
|
||||
> That `inbox` field is a **vestige, and it must stay unused**: it was there to publish an event's inbox address so others could deposit into it. The app no longer handles an inbox address anywhere — a deposit **names the document** (`inbox.postToDocument(doc, …)`) and the owner opens its own with `openDocumentInbox(doc)`. Writing an address into the entity would put back exactly what the surface removed ([[rule_document-per-entity]]).
|
||||
|
||||
## Consequence
|
||||
|
||||
In **connected mode** (SDK), the mapping (`mapEvent` in `FestipodDataContext.tsx`) only reads/writes the shape's fields. Fields outside the shape are **silently lost**: filled with defaults, or empty. Yet screens **do display them** (e.g. `startTime`/`endTime` in `EventDetailScreen`) — so in demo mode (the local seed) they show up, but when connected they vanish. The discrepancy is only observable in actual use.
|
||||
|
||||
## To fix it (if we want them persisted)
|
||||
|
||||
Add the fields to `festipodShapes.shex`, then `bun run build:orm`, and extend `mapEvent`. Until that is done, **do not rely on the date/time/theme fields in connected mode**.
|
||||
@@ -0,0 +1,15 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: Withdrawing from a meeting point must be AUTHORITATIVE — once the Participation is deleted, it must not come back; after a real refresh, check that the sign-up is really gone on the data side
|
||||
last_checked: 2026-07-03
|
||||
---
|
||||
|
||||
# Caveat: withdrawal must be authoritative
|
||||
|
||||
Business contract: when a user **withdraws** from a meeting point (`leaveEvent` in `src/shared/context/FestipodDataContext.tsx`), the `Participation` must be **durably deleted**. It must **not come back to life** after a resynchronization.
|
||||
|
||||
## The pitfall
|
||||
|
||||
Reflecting the deletion only in the UI's reactive state is not enough: the sign-up can reappear if the deletion is not **persisted** on the data side. Withdrawal must therefore be **authoritative at the document level**, not merely at the display level.
|
||||
|
||||
**To check after any change to `leaveEvent`**: sign up, then withdraw, do a **real refresh**, and confirm that the participation is indeed gone (the button must not stay on « ✓ Je participe »). Covered by the `@e2e` scenario « Se désinscrire d'un événement » (`src/modules/event/features/cycle-de-vie-evenement.feature`) and a `@data` one, « désinscription persistante » (`inscription-inbox.feature`).
|
||||
@@ -0,0 +1,150 @@
|
||||
---
|
||||
type: contract
|
||||
summary: The API @ng-eventually/polyfill exposes to an application — signatures, guaranteed behaviour, and what it does not offer
|
||||
pulled_from: https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git/.project/concepts/app-contract/contract_polyfill-surface.md
|
||||
pulled_version: 1ecf511e9d8de8e0feb007f3a88f2c0d56ce455a
|
||||
pulled_at: 2026-08-16
|
||||
---
|
||||
|
||||
# contract_polyfill-surface — `@ng-eventually/polyfill`
|
||||
|
||||
## Scope
|
||||
|
||||
This package is a polyfill of NextGraph's SDK.
|
||||
|
||||
This package covers placement (creating and listing an application's documents by scope), reading (a document's subjects, one-shot or reactive), sharing a document with a named user, and depositing into inboxes. It does not cover user management, display names, transport, or the operation of a deployment.
|
||||
|
||||
### Deployment requirements
|
||||
|
||||
An application using this package must:
|
||||
|
||||
- serve a wallet file (`.ngw`) from its own bundle, and pass its URL and password to `configure` as `sharedWallet: { fileUrl, password }`;
|
||||
- call `init(…)` — this package's, not the one it passed to `configure` — and then await `ensureIdentity()`, in a browser context, before rendering its interface. `ensureIdentity()` resolves once a session is open, and a session arrives only through `init`: awaited before `init` has been called, it throws and names the call to make first.
|
||||
|
||||
## Surface
|
||||
|
||||
Full typed shape: the package's `types` entry, `@ng-eventually/polyfill`. A type is published only when a published signature uses it. The load-bearing signatures:
|
||||
|
||||
```ts
|
||||
// ── bootstrap ────────────────────────────────────────────────────────────
|
||||
export function configure(c: EventuallyConfig): void;
|
||||
export interface EventuallyConfig {
|
||||
ng: NgLike; // the `ng` object from @ng-org/web
|
||||
useShape: UseShapeLike; // `useShape` from @ng-org/orm
|
||||
sharedWallet?: SharedWalletConfig; // { fileUrl, password, importUrl? }
|
||||
debugAccessLog?: boolean;
|
||||
init?: (...args: any[]) => any;
|
||||
initNg?: (...args: any[]) => any;
|
||||
}
|
||||
|
||||
// ── identity — one await before the application renders ──────────────────
|
||||
export async function ensureIdentity(): Promise<PrincipalId>; // returns who you are
|
||||
|
||||
// ── addressing ───────────────────────────────────────────────────────────
|
||||
export type Nuri = `did:ng:${string}`;
|
||||
export type NuriLike = Nuri | string;
|
||||
export type Scope = "public" | "protected" | "private";
|
||||
|
||||
// ── placement: where an application's documents live ─────────────────────
|
||||
export const storeRegistry: { // no identity parameter — a session is one user's
|
||||
createEntityDoc(scope: Scope): Promise<Nuri>;
|
||||
listMyEntityDocs(scope: Scope): Promise<Nuri[]>;
|
||||
resolveScopeGraph(scope: Scope): Promise<Nuri>;
|
||||
resolveWriteGraph(scope: Scope): Promise<Nuri>;
|
||||
openDocumentInbox(doc: NuriLike): Promise<Nuri>;
|
||||
};
|
||||
|
||||
// ── reading ──────────────────────────────────────────────────────────────
|
||||
export async function readUnion(docs: NuriLike[]): Promise<UnionSubject[]>;
|
||||
export interface UnionSubject { subject: string; graph: Nuri; props: Record<string, string[]> }
|
||||
export function useShape(shapeType: unknown, scope: unknown): unknown; // read-filtered view
|
||||
export function watchShape(query: ShapeQuery): ShapeObservable;
|
||||
export function subscribeDoc(nuri: NuriLike, onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe;
|
||||
export function subscribeDocs(nuris: NuriLike[], onChange: (r: DocChange, t: DocChangeType) => void): Unsubscribe;
|
||||
|
||||
// ── low-level document / SPARQL primitives ───────────────────────────────
|
||||
export const docs: {
|
||||
// `sessionId` is `string | number` — upstream's own declared type (`Session.session_id`).
|
||||
// It is RELAYED, never converted: the wasm side deserializes a `u64`, and stringifying it
|
||||
// fails for real (`Deserialization error of session_id JsValue("1")`).
|
||||
docCreate(sessionId: string | number, crdt: string, cls: string, dest: string, store?: unknown): Promise<Nuri>;
|
||||
sparqlQuery(sessionId: string | number, query: string, base?: string, anchor?: NuriLike, label?: string): Promise<unknown>;
|
||||
// Returns the commits the update produced, as upstream does (it typed this `void` until
|
||||
// 2026-08-14 while already relaying the value). A caller that ignores it is unaffected.
|
||||
sparqlUpdate(sessionId: string | number, query: string, anchor?: NuriLike, label?: string): Promise<unknown>;
|
||||
};
|
||||
|
||||
// ── inbox: giving to read, and depositing ────────────────────────────────
|
||||
export const inbox: {
|
||||
share(doc: NuriLike, toUser: string): Promise<void>; // give a reader the key
|
||||
post(targetInbox: NuriLike, opts: PostOptions): Promise<void>;
|
||||
postToDocument(doc: NuriLike, opts: PostOptions): Promise<void>;
|
||||
read(targetInbox: NuriLike): Promise<Deposit[]>; // only your own
|
||||
readForDocument(doc: NuriLike): Promise<Deposit[]>;
|
||||
readSynced(targetInbox: NuriLike): Promise<Deposit[]>;
|
||||
processInbox(targetInbox: NuriLike): Promise<Deposit[]>;
|
||||
watch(targetInbox: NuriLike, onDeposits: (d: Deposit[]) => void): () => void;
|
||||
// `materialize` (a second published name for `read`) was REMOVED on 2026-08-14 —
|
||||
// an alias with no call site, and no counterpart upstream. Use `read`.
|
||||
};
|
||||
export interface Deposit { from: PrincipalId | null; payload: unknown; ts: number }
|
||||
|
||||
// ── the wrapped SDK objects ──────────────────────────────────────────────
|
||||
export const ng: NG; // call this instead of the `ng` passed to `configure`
|
||||
// `NG` is upstream's own type (`@ng-org/web`), 88 typed
|
||||
// members; it was `Record<string, any>` until 2026-08-14
|
||||
export function init(...args: any[]): any; // likewise — not the `init` passed to `configure`
|
||||
export function initNg(...args: any[]): any;
|
||||
```
|
||||
|
||||
## Guarantees
|
||||
|
||||
Every entry accepts `NuriLike` and validates at the door; what it returns is a precise `Nuri`. No type guard is published.
|
||||
|
||||
A returned reference carries no key — not `createEntityDoc`, not `listMyEntityDocs`, not `UnionSubject.subject` / `.graph`. A reference found inside a document yields a name, not a key.
|
||||
|
||||
You read a document whose key you hold: you created it, it was shared with you, or it sits in a public store, which serves its read key to whoever asks. No call answers "may I read this?".
|
||||
|
||||
What was shared with you becomes readable after `ensureIdentity()`.
|
||||
|
||||
`readUnion` returns one entry per distinct subject present in a document. `subject` is that subject's IRI exactly as written, and is a `string`, because a subject may be any IRI; `graph` is the document reference you passed in, and is the `Nuri` to hand back to this surface. Properties of different subjects are never merged, and the same subject IRI found in two documents stays two entries, told apart by `graph`. Several objects in one document are allowed. Recommended placement is one document per business entity: access is granted per document.
|
||||
|
||||
`urn:ng-eventually:` is reserved. Triples whose **subject** falls under that prefix are dropped on read and never returned by `readUnion`; every other IRI is returned.
|
||||
|
||||
Only a document's owner writes to it. Holding its read key never grants a write.
|
||||
|
||||
`inbox.share(doc, toUser)` names the document and the person; the recipient calls nothing. It refuses a recipient nobody has signed in as, rather than creating them.
|
||||
|
||||
`inbox.post` refuses a target that is not an inbox; to reach a document's owner, use `inbox.postToDocument(doc, …)`. Anyone may deposit into an inbox; only its owner reads it.
|
||||
|
||||
`ensureIdentity()` settles the identity, completes the connection work it starts, and returns the identity. It takes no identifier, and no other call takes one.
|
||||
|
||||
It resolves **only once that work has actually completed**: if what was shared with you could not be restored, or a queue could not be drained, it throws instead of returning. So a resolved call means everything shared with you is readable — and a rejected one must not be rendered past, since the interface would show an empty account rather than an empty screen.
|
||||
|
||||
`ensureIdentity()` mounts a full-screen barrier on every top-level load, and takes it down itself — past the broker round-trip it never appears. A person who comes back to the page from that round-trip finds the barrier live again, prefilled, and confirming it hands the page over a second time. The application's own page is never reloaded and nothing outside the barrier is touched.
|
||||
|
||||
**The session is the package's, not yours.** You never build one, and no call takes one. Call this package's `init` (not the one you passed to `configure`): it captures the session the SDK delivers to `init`'s callback and keeps it, then calls your callback with that same event untouched — so an application that wants the `session_id` for the `docs` primitives reads it there, and one that does not may pass no callback at all. Identity normalisation is the package's too: `@Alice`, `alice ` and `ALICE` are one person.
|
||||
|
||||
Where a call must first find out whether something already exists — a document's record in its store, a user's inbox — it throws when it could not find out, instead of proceeding as though the answer were "nothing". So `createEntityDoc` throws if the document cannot be recorded in its store, and resolving an inbox throws rather than handing back a second one. **A rejection means "unknown", never "absent"** — retry it or surface it, but do not read it as an empty result.
|
||||
|
||||
## Non-guarantees
|
||||
|
||||
**No display name.** `ensureIdentity()` returns an opaque identifier: do not parse it, split it, or render it as a readable name.
|
||||
|
||||
**No revocation.** `inbox.share` cannot be undone.
|
||||
|
||||
**Nothing per reader on a document in a public store.** No grant, no revocation, no audience list.
|
||||
|
||||
**No delegated writing.** A received key never grants a write, and no call adds a writer to a document.
|
||||
|
||||
**No mailbox model.** Do not build on the raw deposit list.
|
||||
|
||||
**No cross-broker reference.** A returned reference resolves for users of the same broker.
|
||||
|
||||
**No unfiltered read through `useShape`.** Members that yield items are filtered and mutations pass through; anything else throws. A document reached through that view alone, read nowhere else first, does not appear.
|
||||
|
||||
## Change policy
|
||||
|
||||
This surface changes, and shrinks. The package does not offer semantic-version stability.
|
||||
|
||||
Re-pull this contract at every upgrade.
|
||||
@@ -0,0 +1,94 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Internal pitfalls of FestipodDataContext — currentUserId is the profile document read back in the protected scope (empty until it lands), the legacy principal space resolveParticipantUser still resolves on read, OPT-IN auto-seed, owner-derived participantCount, local-mode no-op mutations
|
||||
last_checked: 2026-08-10
|
||||
---
|
||||
|
||||
# Internals & pitfalls of `FestipodDataContext`
|
||||
|
||||
Non-obvious behaviours of `src/shared/context/FestipodDataContext.tsx` to know about before touching the data context.
|
||||
|
||||
## Who am I — `currentUserId` is a document you read back, not a value you were given
|
||||
|
||||
**The app names no identity of its own** (concept `app-security`, [[decision_2026-08-10_the-barrier-names-no-identity]]): `ensureIdentity()` takes nothing, and nothing switches identity afterwards. So the provider cannot *derive* a principal from an input. What the current user **is**, is the **profile document it reads back in its own protected scope**: `currentUserId` = that profile's `@id`, a doc NURI — the very same value as `currentUser?.id`.
|
||||
|
||||
**The pitfall that follows**: it is **empty until the protected read lands**, and empty is an ordinary string that raises nothing. Mutations needing it **refuse** (`joinEvent` logs `empty user principal — refusing to write a participation with no fp:user` rather than writing an entity that would be dropped on read); queries keyed on it return **empty results** that render as "you have nothing". Treat `''` as *not ready*, never as *no data* — see `app-architecture`, [[caveat_identity-ids-in-screens]].
|
||||
|
||||
> The `currentUser` object is picked with a **fallback** (`@mariedupont`, then `users[0]`) — a leftover of the demo seed, and a silent one: on a wallet holding several profiles it can settle on the wrong person. Worth a look whenever "the app thinks I am someone else".
|
||||
|
||||
## The legacy principal space — resolved on READ only
|
||||
|
||||
A `Participation` written **today** carries `currentUserId` in `fp:user`, i.e. a profile doc NURI, so a direct `participation.userId === profile.id` join matches. Participations written under the **earlier** scheme carry a principal of the form `urn:festipod:user:<normalized-handle>`, which matches nothing directly.
|
||||
|
||||
**`resolveParticipantUser`** (`FestipodDataContext`) is the single join point, and it tries, in order: (1) a **direct match** `u.id === userId` — today's writes, and the demo seed's bare `user-1` space; (2) failing that, strip `USER_PRINCIPAL_PREFIX` and match the remainder against `normalizeIdentifier(profile.username)` — the legacy space. Never join by direct comparison at a call site: the symptom of getting it wrong is every participant rendering as « participant inconnu », which shipped once already.
|
||||
|
||||
`USER_PRINCIPAL_PREFIX` is now **read-side only** — nothing mints it any more. It is kept so old data still resolves; it is not a shape to write against.
|
||||
|
||||
A further id space takes **no** part in this join: the inbox deposit `uid` (`mint…`) — it identifies a **deposit** for the counter, never a user.
|
||||
|
||||
> **Horizon.** This paragraph describes **what is implemented**. The target model drops the plaintext `userId` and routes identity resolution through **reading the profile** — see [[brief_2026-07-20_attendance-set-model]], whose implementation is gated. The id-space fix is explicitly noted there as **still valid**: do not undo it in anticipation of the target.
|
||||
|
||||
### Which space each query expects (the `buildQueries` contract)
|
||||
|
||||
| Query | What it expects / returns |
|
||||
|---|---|
|
||||
| `getUserEvents(userId)`, `isParticipating(eventId, userId?)`, `getFriends(userId?)` | filter on `participation.userId` / `friendship.userId`; their default is `currentUserId`, which is correct |
|
||||
| `getEventParticipants(eventId)` | **returns profiles** (`FpUserData` → `id` = NURI), the join being done internally |
|
||||
|
||||
**Screen-side impact**: `currentUserId` and `currentUser?.id` are now the same value, so filtering yourself out of a participant list works either way. What still bites is passing an id **before it resolves** — see `app-architecture`, [[caveat_identity-ids-in-screens]].
|
||||
|
||||
## Reads = `watchShape` (the SDK surface), no more bespoke machinery
|
||||
|
||||
**Since 2026-07-10**: `useNgData` reads through `useShapeQuery(shape, scope)` (a `useSyncExternalStore` binding over `watchShape`) — THREE useQuery-shaped reads (events/public, users/protected, participations/protected) + Fp adapters (`shapeAdapters.ts`). Removed: `readEntities`, `subscribeDocs`+`bumpRead`+`readTick`, the manual listing (`publicDocs`/`protectedDocs`/`registerDoc` for reads), and `relist`. `ready` = the combination of the `isSuccess` flags. See [[rule_app-uses-sdk-surface-only]].
|
||||
|
||||
**Immediate visibility of mutations = an OPTIMISTIC overlay** (no `registerDoc`): `createEvent`/`joinEvent`/`leaveEvent` feed `pendingAddEvents`/`pendingAddParticipations`/`pendingRemoveIds`; the exposed state = merge(reactive, adds) minus removes, deduped by id (id = the doc's NURI). Reconciliation happens automatically on push (an add that shows up in the reactive state, or a remove that disappears from it, is dropped) — never a poll ([[rule_no-broker-polling]]).
|
||||
|
||||
## Dev auto-seed
|
||||
|
||||
**Since 2026-07-13 the auto-seed is OPT-IN and OFF by default**: it only fires if the `FESTIPOD_AUTO_SEED` env var is set (`=1`), no longer off `NODE_ENV`. Var absent → **no automatic seed at all**, even in dev (`autoSeedEnabled()`/`shouldAutoSeed()`, `src/shared/utils/autoSeed.ts`; delivered in dev through the `/festipod-config.json` runtime route + a compile-time `define` in `build.ts`, the same mechanism as the shared wallet — see `tech-stack/knowledge_build-pipeline`). The **explicit** seed (`loadTestData()`, @data tests) is unchanged. Rationale: the repeated auto-seed was bloating the wallet (slow reads, see [[caveat_wallet-bloat-hang]]).
|
||||
|
||||
When it is enabled, the auto-seed fires if events AND users are both empty — **gated on `isSuccess`** (`watchShape`'s readiness), NO LONGER on a 3s `setTimeout`: we only decide "the wallet is empty" once the sync is **confirmed** (`isSuccess`), otherwise a not-yet-finished read was taken for an empty wallet → a re-seed on every reconnection (bug fixed). Remaining pitfalls:
|
||||
- **One seed at a time**: `loadTestData()` sets `hasTriedAutoSeed`, and the auto-seed re-checks it → an explicit load cancels the pending auto-seed (otherwise two concurrent `bootstrapWallet` calls write everything twice).
|
||||
- The seed writes under the **connected session**, so the session that seeds **holds** what it seeded and its protected fixtures round-trip. Seeded users are fixtures, not accounts — nobody has signed in as them, which matters because `inbox.share` refuses a recipient nobody has ever been. Only **events** get an inbox opened at seed time (`openDocumentInbox`), because events are what people deposit into.
|
||||
- **No retry**: if the seed fails, you get an empty screen + a `console.error`.
|
||||
|
||||
## `participantCount` — derived and owned by the owner
|
||||
|
||||
> ✅ **CORRECTED (2026-07-13).** The requirement is **"reliable at the owner's NEXT CONNECTION"** (the creator processes their inbox when they connect), NOT a live real-time cross-user notification. The bug was: the owner-materializer materialized **too early** (before the participant's deposit had synced) → read `active=0` → wrote 0 → **memoized that 0** → never re-processed. Fix: (1) read through `inbox.readSynced` instead of `inbox.read` — the two differ by contract, and only the former is the synced view ([[contract_polyfill-surface]]); (2) the materializer fires **directly on connection** (`[ready, ownedKey]`), no longer only on a push; (3) `materializedCountRef` no longer locks in a premature 0 (its sole role = loop guard: only write when the derived value changes); (4) **the single source of the NUMBER = `event.participantCount`** (the `participantCount: 1` literal in `CreateEventScreen` is removed → it starts at 0; the display no longer computes a local number). Kept GREEN (on a fresh profile) by `event/e2e-multibrowser.feature` « Le compteur converge chez le propriétaire à sa prochaine connexion » (un-`@wip`'d). No polling ([[rule_no-broker-polling]]).
|
||||
|
||||
**Since 2026-07-07**: `participantCount` is no longer mutated in place by the participant. The flow is inbox-deposit → owner-materialization:
|
||||
- `joinEvent`/`leaveEvent` **no longer** write `participantCount` on the event's doc — **only a document's owner writes to it**, so a participant cannot touch someone else's. The participant only writes their **own** participation doc (protected), then **deposits** a marker into the event's inbox (`depositRegistration` on join, `depositLeave` on leave, `src/shared/data/registration.ts`).
|
||||
- The event **owner's** session does the materializing: it watches (`inbox.watch`, no polling) the inbox of the events it owns (`ownedEventIds` = `listMyEntityDocs('public')` + freshly created events), and on every deposit it **recomputes** `participantCount` on **its own** event doc (`updateEntityField` on its own doc). It is the counter's only writer.
|
||||
- **The counter is DERIVED, not incremented**: `materializeAttendance` (registration.ts) reads the inbox and computes the **set** of distinct active sign-ups (`new-participant` deposits deduped by `uid`, MINUS those cancelled by a `leave-participant` — by exact `regUid` or by the `(eventId, userId)` fallback). `participantCount = |active set|` — **no host baseline**: the creator does not attend automatically (there is no notion of host, see concept `functional-domain`), so the counter starts at **0** on creation and only moves on real sign-ups. `createEvent` **no longer writes** a participation at creation time (it used to write a host participation and set the counter to 1); the creator sees « J'y serai » and can join/leave their own event like anyone else. Because it is a **pure function of the inbox**, a replay converges — never double-counting nor a phantom decrement (idempotence). The write is guarded (it only writes when the value changes), a loop guard. Covered by the `@data` scenario « Le créateur ne participe pas automatiquement à son événement » (us-13): counter 0 + `isParticipating(E)===false` at creation, then join→true / leave→false.
|
||||
- **Owner offline = eventual**: only the owner's session materializes; while they are disconnected, the counter does not move for anyone else (the participations and deposits stay persisted — nothing is lost).
|
||||
- The counter nevertheless remains an **aggregate**, not the list of named participants: `getEventParticipants` (named identity) is still governed by what the protected scope hands back ([[caveat_participation-deletion]] for the authoritative deletion, unchanged).
|
||||
|
||||
### Id-form invariant: match on the CANONICAL form of the event id
|
||||
|
||||
An event's `@id` **is** its document NURI (`did:ng:o:<repo>[:v:<overlay>]`). The owner's materializer matches the inbox **deposits** to the owned events **by event id**: `ownedEventIds` (what the materializer iterates over), the **deposit key** (`payload.eventId`, what the participant deposits under) and the counter's **write target** must all designate the same event.
|
||||
|
||||
**Measured finding (2026-07-07)**: on the current tree these three paths carry the **same** NURI (the `:v:<overlay>` suffix included) — create-time, `listMyEntityDocs` and the `@id` read back all coincide, because `readUnion` **pins the subject to the input NURI** (lib `read-model.ts`, `63ecfee`). So matching already works, **including** for an owned event reached through `listMyEntityDocs` (validated by the @data scenario « …fait converger le compteur dérivé »). The canonicalization below is **defensive**, not the fix for an active bug. (The mismatch one investigation thought it had seen was the **seeded-but-not-owned** artifact: on a persistent wallet, the seed belonged to a `test-*` identity from an earlier run → the current session reaches it through discovery, not through `ownedEventIds` — correct behaviour.)
|
||||
|
||||
**Rule**: match the event id on its **canonical form** — the base repo id, with any `:v:<overlay>` suffix stripped (`canonicalEventId`, `src/shared/data/registration.ts`). This canonical form is used for **matching** in `materializeAttendance` / `readRegistrationNotifications`, and for **deduplicating** `ownedEventIds` (`ownedKey`, FestipodDataContext) so that one and the same event reached through two paths is not materialized twice. **Careful**: only the **matching** uses the stripped form; the counter is always **written** to the real owned NURI (a live, openable doc) — a stripped id must never serve as a write target or an anchor. This is an **app-side** invariant (not a NextGraph detail): however the lib makes the overlay vary, the app matches on the common base.
|
||||
|
||||
## There is no identity switch any more
|
||||
|
||||
The app settles its identity **once**, before anything renders (`ensureIdentity()` in `AuthGate`), and offers no way to change it — the surface stopped publishing one (concept `app-security`, [[decision_2026-08-10_the-barrier-names-no-identity]]). So the provider carries **no identity-change reset**: no `useEffect([identifier])`, no cap reset, no registry-cache reset. Those symbols are gone; do not reintroduce a reset for a transition that cannot happen.
|
||||
|
||||
> **Why there is nothing left to reset.** An identity-change reset only made sense while a single React tree could outlive a change of identity. It cannot: one page hosts exactly one identity for its whole life, so session state (the read set, the optimistic overlay, the owner-materializer's doc set) has no second identity to leak into.
|
||||
>
|
||||
> The **cross-identity isolation** behaviour is still a real Festipod requirement, but proving it needs **two genuinely separate browser contexts**, each signing in for itself. `event/isolation-deux-identites.feature` is `@wip` for exactly that reason (concept `bdd-testing`, [[rule_tests-validate-festipod-not-the-sdk]]).
|
||||
|
||||
## `useShapeQuery` instrumentation — global spinner + timing
|
||||
|
||||
`useShapeQuery` (a `useSyncExternalStore` binding over `watchShape`) instruments **every query cycle**: at the start of a cycle it registers itself in a module-level store `src/shared/data/pendingQueries.ts` (`beginQuery`/`resolveQuery`, a Set of ids — idempotent, safe under StrictMode), and on the first `isPending → isSuccess|isError` transition (the "first result", the readPromise equivalent) it resolves AND logs the delay: `[FestipodData] <shape>/<scope> premier résultat en <N>ms (n=<len>)` (so the delay for Event/public events is visible by name). The `cycleId` is memoized on `[shapeKey, scope]` → an identity/scope switch recreates the observable AND starts a new cycle (a fresh `beginQuery`), and the cleanup resolves on unmount (never stuck). The `usePendingQueries()` hook exposes the number of pending queries; `HomeScreen` renders a `Spinner` (sketchy, `.app-spinner` + `@keyframes app-spin` in `index.css`) next to the « Festipod » title as long as the count is > 0 → it only stops once **all** in-flight queries have received their first result. Any future `useShapeQuery` contributes to it automatically. The measurement lives on the app side (React-perceived delay), **not** in the polyfill.
|
||||
|
||||
## Logging convention — identity-first prefix, and counter before→after
|
||||
|
||||
Every DATA log from the provider goes through **`logPrefix`**: `[<currentUserId>][app][data]` when the principal is resolved, `[app][data]` otherwise (a transient connection state). Reason: a run often drives **several sessions at once** (two tabs, a multi-browser scenario) and their lines end up read side by side — an unprefixed line does not say *whose* it is and becomes useless for diagnosing a leak or a stuck counter. **Adding a DATA log = reusing `logPrefix`**, not a bare `console.log`.
|
||||
|
||||
Two measurement points are laid down **as a pair** and serve together: the owner's materializer logs `participantCount` **before → after** its write, and the display read logs the value **as exposed to the render**. Comparing them tells a stuck counter apart between a **DATA** problem (never incremented) and a **DISPLAY** problem (incremented but not re-read until the next session). Do not remove one without the other — on their own they diagnose nothing.
|
||||
|
||||
## Mutations are no-ops in local mode
|
||||
|
||||
In local/demo mode (`useLocalData`), `createEvent`/`joinEvent`/`leaveEvent`/`updateEvent` are **no-ops** (a `console.log`, the state does not change) — yet the screens still show a **success toast** (« Tu participes »). Potentially misleading UX: the user believes they signed up when nothing has changed. See [[knowledge_data-modes]] for how the provider is chosen based on status.
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Two modes (connected = the @ng-eventually/polyfill SDK, disconnected/demo = seeded local state); FestipodDataContext picks the provider based on connection status, and every screen goes through useFestipodData()
|
||||
---
|
||||
|
||||
# Data modes & contexts
|
||||
|
||||
The app has **two modes**, both consumed through the `useFestipodData()` hook:
|
||||
|
||||
1. **Connected** — ORM shapes from the `@ng-eventually/polyfill` SDK (P2P, encrypted, local-first)
|
||||
2. **Disconnected / Demo** — local React state seeded from `seedData.ts` (see [[knowledge_seed-data]])
|
||||
|
||||
## NextGraphContext (`src/shared/context/NextGraphContext.tsx`)
|
||||
|
||||
- Connection cycle: `disconnected` → `connecting` → `connected` | `error`.
|
||||
- That status is what the data provider below keys on; the app holds no session of its own.
|
||||
|
||||
## FestipodDataContext (`src/shared/context/FestipodDataContext.tsx`)
|
||||
|
||||
- Reads the shapes reactively through **`useShapeQuery()`** (`src/shared/data/useShapeQuery.ts`), the app's React binding over the SDK's `watchShape(shape, scope)` observable — three scoped reads: events (`public`), profiles and participations (`protected`), mapped to the `Fp*` domain types by `shapeAdapters.ts` (see [[rule_document-per-entity]] §Reads). There is no `useShapeWithDefaults` — that name is obsolete (verified 2026-07-28).
|
||||
- Exposes `useFestipodData()` (consumed by every screen) + CRUD (`createEvent`, `updateEvent`, `joinEvent`, `leaveEvent`, etc.).
|
||||
- **Provider chosen by connection status**:
|
||||
- `disconnected` → `LocalDataProvider` with the seed (demo)
|
||||
- `connecting` → an **empty** `LocalDataProvider` (avoids flashing the seed before the wallet loads)
|
||||
- `connected` → `NgDataProvider` (real wallet data)
|
||||
- `error` → `LocalDataProvider` with the seed (graceful fallback)
|
||||
|
||||
> Mutations are **genuinely persisted** in connected mode (`joinEvent` writes a Participation and notifies the meeting point's host, `leaveEvent` deletes authoritatively — see [[caveat_participation-deletion]]). In local/demo mode they are no-ops (see [[knowledge_context-internals]]).
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: The Fp* data types — Event, UserProfile, Participation, MeetingPoint and Notification are persisted in NextGraph (SHEX shapes + ORM); only Friendship stays local-only (app-TS)
|
||||
last_checked: 2026-07-03
|
||||
---
|
||||
|
||||
# Data entities
|
||||
|
||||
`src/shared/data/types.ts`:
|
||||
|
||||
| Type | Persistence | Key fields |
|
||||
|---|---|---|
|
||||
| `FpEventData` | SDK (Event shape) | id, title, date, location, distance, themes |
|
||||
| `FpUserData` | SDK (UserProfile shape) | id, name, username, bio, city, counts |
|
||||
| `FpParticipationData` | SDK (Participation shape) | eventId + userId + confirmed |
|
||||
| `FpMeetingPointData` | SDK (MeetingPoint shape) | eventId, location, time, host |
|
||||
| `FpNotificationData` | SDK (Notification shape) | kind, target, source |
|
||||
| `FpFriendshipData` | **local-only** | userId + friendId |
|
||||
|
||||
`MeetingPoint` and `Notification` do have real **SHEX shapes** (`src/shared/shapes/shex/festipodShapes.shex`) with generated ORM bindings (`festipodShapes.shapeTypes.ts`: `FpMeetingPointShapeType`, `FpNotificationShapeType`) and **are persisted**. A `Notification` is created in particular when signing up to a meeting point (`joinEvent`).
|
||||
|
||||
`Friendship` has **no** SHEX shape and no persistence — it stays app-TS-only (see [[knowledge_nextgraph-stack]]).
|
||||
|
||||
> Pitfall: even for `FpEvent` (which is persisted), several fields of the app type are **not** in the shape and are lost when connected — see [[caveat_event-fields-not-persisted]].
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: The data SDK is @ng-eventually/polyfill, injected exactly once through ngSession.configure; reads go through the reactive useShape/watchShape surface over the festipodShapes SHEX shapes, whose ORM bindings are regenerated with build:orm
|
||||
---
|
||||
|
||||
# Data stack (SHEX shapes over the `@ng-eventually/polyfill` surface)
|
||||
|
||||
Festipod persists through **`@ng-eventually/polyfill`**. What that surface offers, and what it refuses to promise, is written down in one place: [[contract_polyfill-surface]], pulled into this repo and version-pinned. See [[rule_app-uses-sdk-surface-only]].
|
||||
|
||||
## SDK boundary (the golden rule)
|
||||
|
||||
- The app **depends on `@ng-eventually/polyfill` only** for data.
|
||||
- It is **initialized/injected exactly once** through `ngSession.configure(...)` (`src/shared/utils/ngSession.ts`) — a single injection point. Everything else in the app (data plane, lifecycle, login, types) goes through it.
|
||||
- **Never describe here how the data layer is implemented underneath.** This concept covers only **how Festipod uses the surface**.
|
||||
|
||||
## ORM & SHEX shapes
|
||||
|
||||
The reactive ORM (`useShape`) is built on **SHEX shapes**: `src/shared/shapes/shex/festipodShapes.shex` defines:
|
||||
|
||||
- **Event** — title, description, dates, location, themes, participants
|
||||
- **UserProfile** — name, username, bio, city, visibility
|
||||
- **Participation** — links an event and a user, confirmation status
|
||||
- **MeetingPoint** — a meeting point (location, time, host)
|
||||
- **Notification** — a notification (created in particular when signing up to a meeting point)
|
||||
|
||||
The ORM bindings are generated in `src/shared/shapes/orm/` (`*.schema.ts`, `*.shapeTypes.ts`, `*.typings.ts`). **Regenerate** with `bun run build:orm` after any `.shex` change.
|
||||
|
||||
> **The canonical way to read is the reactive hook.** `useShape`/`watchShape`: you subscribe to a shape on a scope, you get the current value, and the component re-renders on every change — subscription/push, never polling; one-shot reads are the exception. The read/reactivity contract is [[contract_polyfill-surface]] and nothing else.
|
||||
|
||||
> `Friendship` has **no** SHEX shape and no persistence — it stays app-TS-only (see [[knowledge_entities]]).
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: seedData.ts provides deterministic fixtures (10 users, events, participations) with CURRENT_USER_ID = 'user-1' (Marie Dupont); used in demo mode and by the @ui tests
|
||||
---
|
||||
|
||||
# Seed data
|
||||
|
||||
`src/shared/data/seedData.ts` provides **deterministic** fixtures:
|
||||
|
||||
- 10 users — **Marie Dupont = the current user**, `user-1`
|
||||
- Several events (dates, locations, themes)
|
||||
- Participations, meeting points, friendships
|
||||
- `CURRENT_USER_ID = 'user-1'`
|
||||
|
||||
These fixtures serve (a) **demo mode** (`LocalDataProvider`, see [[knowledge_data-modes]]) and (b) the **`@ui`** tests, which render the screens against this predictable data (`Marie Dupont`/`@mariedupont` = currentUser, `Jean Durand`/`@jeandurand` exists, etc. — see concept `bdd-testing`).
|
||||
|
||||
> `bootstrapWallet()` (`src/shared/utils/ngBootstrap.ts`) seeds this data into the wallet in connected mode — triggered only by an explicit user action (« Charger données de test »).
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
type: rule
|
||||
summary: The app codes against the engagement the provider publishes — [[contract_polyfill-surface]], pulled into this repo and version-pinned — and that copy is the ONLY reference. Never open the provider's sources or its node_modules copy; never describe or reason about how the data layer is implemented; what the contract does not answer is a GAP, raised with the provider and never worked around here.
|
||||
---
|
||||
|
||||
# The app uses the published surface only
|
||||
|
||||
## The rule
|
||||
|
||||
Festipod is a consumer of **one published contract** and is entirely ignorant of how it is honoured.
|
||||
|
||||
1. **The pulled contract is the only reference.** [[contract_polyfill-surface]] is the provider's engagement, version-pinned in this repo. An agent working here reads that file and **never opens the provider's repo or its `node_modules` copy** — not to check a signature, not to settle a doubt.
|
||||
2. **What the contract does not answer is a gap.** Raise it with the provider and leave the app's call site as it is. An app-side workaround is a doctrine violation *even when it works*, because it hard-codes a passing state into code meant to outlive it.
|
||||
3. **No description of how the data layer works underneath**, in code, in comments or in this repo's doctrine. Nothing of the form "we do X because a read behaves like Y". From the app's point of view there is only the contract and what it promises.
|
||||
4. **No shortcut, in the app or in its tests.** Deep imports into the package are refused by its `exports` map, and that refusal is correct — see [[rule_tests-validate-festipod-not-the-sdk]].
|
||||
|
||||
## The surface shrinks, and that is normal
|
||||
|
||||
The contract's own change policy states that this surface **changes, and shrinks**, and that it must be re-pulled at every upgrade. A removal is therefore never a regression to absorb defensively — it is work the app deletes.
|
||||
|
||||
## What the app reads through
|
||||
|
||||
Reactive reads go through `useShapeQuery` (a `useSyncExternalStore` binding over `watchShape`) plus the Fp adapters in `src/shared/data/`. The app mounts no reactivity of its own and keeps no bespoke read model.
|
||||
|
||||
What the app **does** rely on is the distinction the observable carries: `isPending` (sync in progress) is not the same as `isSuccess` with empty `data` (synced and genuinely empty). Code that needs "is it really empty?" — the auto-seed gate, the `ready` flag — uses that distinction and nothing finer.
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
type: rule
|
||||
summary: Festipod persists EVERY entity as ITS OWN document (through the SDK), placed in its scope — never several entities in a store-level document. The document is the unit of sharing and of rights: access is granted PER DOCUMENT, so one document per entity is what makes it possible.
|
||||
---
|
||||
|
||||
# Rule: one document per entity (never at store level)
|
||||
|
||||
When Festipod creates an entity (event, meeting point, profile, participation, notification), it writes it as **its own document**, through the surface's "create a document" call ([[knowledge_nextgraph-stack]]), stating its **scope** (`public` / `protected` / `private`). The entity is then read from and written to **that** document.
|
||||
|
||||
**Never** write several entities into a shared "store-level" document (e.g. putting everything into a single root document). That is an anti-pattern that breaks isolation.
|
||||
|
||||
## Why
|
||||
|
||||
The **document is the unit of sharing and of rights**: the contract states that **access is granted per document**. `private` → the owner; `protected` → the owner + their connections; `public` → everyone. That discrimination is possible **only if each entity has its own document**: putting several entities (or worse, several owners) into a single document makes sharing all-or-nothing and defeats scope-based isolation.
|
||||
|
||||
Isolation itself is **entirely the surface's business** ([[knowledge_trust-model]] in the `app-security` concept) — the app carries no access logic; it declares **no identity at all**, only which of its own documents it shares with whom, then trusts whatever it gets back. The "one document per entity" granularity is the write-side counterpart of that trust.
|
||||
|
||||
## How to apply it
|
||||
|
||||
- At creation time: ask the SDK for **a document for the entity, in its scope** — `createEntityDoc(scope)`. Placement is named by **scope alone** — the session belongs to one user, so there is no identity to pass, and a creation that cannot be recorded **throws** rather than handing back a reference that would read empty forever. Write the entity into it. Do not reuse a document from another scope, nor a store-level document.
|
||||
- **A document only HAS an inbox if its owner opened one** (`openDocumentInbox(doc)`). Festipod opens one on the documents meant to **receive** deposits — its **events** — not on every entity. A deposit then **names the document**: `inbox.postToDocument(doc, …)`, never an address the app resolved itself.
|
||||
- For reads: go through the **reactive shape surface** (see below) — the app names a SHEX shape and a **logical scope**, and the surface resolves that scope to the documents to read, synchronizes them and pushes changes. No NURI resolution, no document listing and no query written on the app side.
|
||||
- The *entity → scope* mapping (event/meeting point → public, network profile/participation → protected, settings → private) is a product fact (concept `functional-domain`, [[knowledge_data-scopes-and-discovery]]).
|
||||
|
||||
## Reads: the SDK's reactive shape surface (`watchShape` / `useShapeQuery`)
|
||||
|
||||
**Reads go through the SDK surface only** ([[rule_app-uses-sdk-surface-only]]). The app names a shape and a scope, and gets a live, `useQuery`-shaped result back:
|
||||
|
||||
1. `watchShape(shapeType, scope)` returns an **observable** — `{ data, isPending, isSuccess, isError }` — which resolves the scope itself and **pushes** on every change. `data` is always an array; a synced-but-empty scope reads `{ data: [], isPending: false, isSuccess: true }`, which is what distinguishes "still syncing" from "genuinely empty".
|
||||
2. `useShapeQuery(shapeType, scope)` (`src/shared/data/useShapeQuery.ts`) is the app's **only** React binding over it (`useSyncExternalStore`), memoized per (shape, scope) so the underlying document subscriptions are not churned on every render.
|
||||
3. `FestipodDataContext` mounts exactly three of them — events (`public`), profiles (`protected`), participations (`protected`) — and maps the returned `UnionSubject` property bags onto the app's `Fp*` domain types through `src/shared/data/shapeAdapters.ts`.
|
||||
|
||||
**The app resolves, lists, registers and re-queries nothing.** There is no app-side document set, no manual re-read signal and no per-document subscription: reactivity is the surface's own push. The single app-side layer laid over the read is a **pure optimistic overlay** (`pendingAdd*` / `pendingRemoveIds` in `FestipodDataContext`), auto-reconciled the moment the reactive set catches up — it hides push latency, it is not a read path.
|
||||
|
||||
> **Removed (verified 2026-07-28).** An earlier version of this rule described reads as a bespoke union model: an on-demand document set (`publicDocs`/`protectedDocs` fed by `listMyEntityDocs` + `registerDoc`), a one-shot `readEntities` → `readModel.readUnion`, and a manual re-query signal (`bumpRead`/`readTick`). **None of those read symbols exist in `src/` any more** — `src/shared/data/readEntities.ts` is gone, and the surviving mentions are historical code comments. Do not code against them ([[rule_app-uses-sdk-surface-only]]).
|
||||
|
||||
## Direct writes (the round-trip pitfall)
|
||||
|
||||
**Writing** an entity happens **directly into its own document** (through the surface's SPARQL call — `src/shared/data/entityWrites.ts`, `writeEntity`), **not** by adding to a reactive set. Reason: a reactive set is only *writable* if the target document is **already** within its subscription scope; but registering the freshly created document is React state that only takes effect on the **next** render → you cannot create-then-add in a single synchronous pass (seed loop, first creation). The observable symptom when you try: an `add` on an empty scope raises "Set is readonly because scope is empty".
|
||||
|
||||
So: **write = direct SPARQL into the entity's document** (immediate, per-document); **read = the SDK's reactive shape surface** (above).
|
||||
|
||||
**Graph convention (write into the anchored default graph).** A write passes the document's NURI as the **anchor** of `docs.sparqlUpdate` and writes the SPARQL body **without** an explicit `GRAPH <…>` clause; the shape read queries that same anchored default graph. This is the **canonical** form — to be kept for `writeEntity`, `updateEntityField` and `registration.ts`. It is a choice of **simplicity and uniformity**, not a round-trip necessity: an explicit `GRAPH` wrapper anchored to the same document does round-trip, so a "0 entities" symptom is never evidence of a graph mismatch — look at the test wallet first (`bdd-testing/caveat_wallet-bloat-hang`).
|
||||
|
||||
The same goes for **mutating an existing field** (e.g. `participantCount`): mutating a value in memory does not hold — the reactive read re-reads the **persisted** value from the broker (reverting to the old value) → persist through SPARQL (`updateEntityField`: DELETE then INSERT of the triple) so that the change sticks and the re-read agrees. Each field is written with the **right RDF term** according to the SHEX shape (xsd:integer / float / boolean, or an IRI for the `Participation.event`/`.user` references) — a missing or mistyped mandatory field makes the read **discard the entity** (it never round-trips). The entity's **subject** = its document's **NURI** (one entity = one document), which yields an `@id` of the form `did:ng:…`.
|
||||
|
||||
Identity corollary: a `Participation` carries a **mandatory** `fp:user` — never write it with an empty value (the entity would be discarded on read). What goes in it is `currentUserId`, i.e. the **NURI of the profile document the app read back in its own protected scope**; the app derives it from nothing, because it names no identity ([[decision_2026-08-10_the-barrier-names-no-identity]] in `app-security`). It therefore **arrives late**: a mutation fired before the protected read lands must refuse rather than write, which is what `joinEvent` does. See [[knowledge_context-internals]].
|
||||
|
||||
Sharing keys off a different space: `inbox.share(doc, toUser)` names a **person**, so bilateral connections (`declareConnections`) are declared with **normalized profile handles**, not document NURIs — the data context maps each peer IRI to that key before declaring, and skips peers whose profile it cannot read (they cannot be named).
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
type: _overview
|
||||
summary: Festipod's product model — the meeting point grafted onto a public event as the unit of value, its actors, its business concepts, and the confidentiality scopes (public/protected/private) per entity
|
||||
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é]
|
||||
paths: ["src/modules/*/features/**"]
|
||||
---
|
||||
|
||||
# Functional domain
|
||||
|
||||
Festipod's **functional domain**: what the product promises and the business vocabulary that describes it. Original source: `README.md §Modèle fonctionnel`.
|
||||
|
||||
**Read this first:** [[knowledge_business-model]] — without it, you conflate the event (the anchor) with the meeting point (the value), and you model things backwards.
|
||||
|
||||
## Pivotal idea
|
||||
|
||||
Festipod lets users create **meeting points** that *graft* themselves onto existing **public events**. The event (festival, conference…) is only a *pretext* and a space-time anchor; the value produced is the meeting point. **You sign up to a meeting point, never to an event.**
|
||||
|
||||
## Scope & confidentiality
|
||||
|
||||
The product model of **who sees what** — personal data reserved to one's network, public events/meeting points, identified-or-anonymous sign-up notification — is a business fact: see [[knowledge_data-scopes-and-discovery]]. The detailed authorization matrix (actor × verb) and its incubation live in the `app-security` concept ([[brief_2026-05-18_authorization-matrix]]).
|
||||
|
||||
## Links
|
||||
|
||||
- [[knowledge_business-model]] — the event / meeting point inversion
|
||||
- [[knowledge_actors-and-concepts]] — reference for actors and business concepts
|
||||
- [[knowledge_data-scopes-and-discovery]] — public/protected/private scopes per entity + discovery
|
||||
- [[knowledge_roadmap]] — current features vs upcoming evolutions
|
||||
- [[brief_2026-06-15_event-deduplication]] — open challenge of deduplicating events in P2P
|
||||
@@ -0,0 +1,23 @@
|
||||
---
|
||||
type: brief
|
||||
summary: Open challenge — on a P2P infrastructure, two users can declare the same public event and fragment the meeting points grafted onto it; leads not yet settled
|
||||
---
|
||||
|
||||
# Deduplicating events on a decentralized infrastructure
|
||||
|
||||
**Status:** Open challenge — not settled
|
||||
**Captured:** 2026-06-15 (from `README.md §Défis ouverts`)
|
||||
|
||||
## Problem
|
||||
|
||||
Since NextGraph is P2P, nothing prevents two users from **independently declaring the same public event** (e.g. "Eurockéennes 2027") and producing two distinct entries. The resulting dispersion **fragments the meeting points grafted onto it** and reduces their visibility — which runs against the app's primary purpose (see [[knowledge_business-model]]).
|
||||
|
||||
## Leads considered (not settled)
|
||||
|
||||
- **Search before create** — when declaring, offer the user the events already declared within their network / their communities that match what they are typing.
|
||||
- **Canonical external identifier** — use an official event URL, Wikidata, or `schema.org/Event` to recognize duplicates and present them as a single event on display.
|
||||
- **Curation** — let curators (human or community-run) merge / vet the canonical entries.
|
||||
|
||||
## Link with the write model
|
||||
|
||||
This challenge is coupled to an open question in [[brief_2026-05-18_authorization-matrix]]: **who may modify a declared event** (owner / wiki / immutable). A *wiki* model would make convergence easier; an *owner* model makes it harder. To be decided together.
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Reference for the actors (user, connection, declarer, host, participant, member) and the business concepts (meeting point, event, community, curated list, connection)
|
||||
---
|
||||
|
||||
# Actors and business concepts
|
||||
|
||||
Vocabulary reference. Every actor is a specialization of an authenticated **user** in a given context — not a separate account role.
|
||||
|
||||
## Actors
|
||||
|
||||
| Actor | Definition |
|
||||
|---|---|
|
||||
| **User** | Anyone with an account (a NextGraph wallet). The root of all the others. |
|
||||
| **Connection ("friend")** | Another user I am connected to. Used to scope lists ("my friends who are attending…") and trust. Bilateral (accepted on both sides). |
|
||||
| **Declarer of an event** | The user who inserted the event into Festipod. *Not necessarily the real organizer*: just whoever references it. **There is NO notion of "event host"**: the event is public, merely flagged by its declarer, who **is NOT required to attend** — at creation no participation is written, the counter starts at 0, and the declarer can join/leave like anyone else (a product decision; on the data side see data-layer/[[knowledge_context-internals]] §participantCount). The "host" remains an actor at the **meeting point** level (next row), not at the event level. |
|
||||
| **Host of a meeting point** | The user who created a meeting point attached to an event. |
|
||||
| **Participant in a meeting point** | A user signed up to a meeting point; in effect they become an attendee of the parent event. |
|
||||
| **Member of an interest community** | A user subscribed to a community in order to discover the events it references. |
|
||||
|
||||
## Business concepts
|
||||
|
||||
| Concept | Definition |
|
||||
|---|---|
|
||||
| **Meeting point** | *The app's unit of value.* A moment of meeting proposed by a host at a given place and time, grafted onto a public event. It is what you sign up to. |
|
||||
| **Event** | The anchor. A real public event referenced in Festipod to serve as a support for meeting points. A mere pretext (title, dates, location, themes). |
|
||||
| **Interest community** | A thematic grouping of users. Used mainly to discover events (through subscription) and to delimit referencing scopes. |
|
||||
| **Curated list** | An editorialized list of events (by a user or a community), distinct from "the events I declared". Allows organizing/recommending. |
|
||||
| **Connection** | A bilateral trust link between two users (the equivalent of "friend"). |
|
||||
|
||||
> Communities, curated lists and subscriptions are largely **prospective** (see [[knowledge_roadmap]]). The detailed authorization matrix by data type lives in [[brief_2026-05-18_authorization-matrix]].
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: The meeting point is the unit of value, grafted onto an event that is only a pretext; you sign up to the meeting point, not to the event
|
||||
---
|
||||
|
||||
# Business model: the grafted meeting point
|
||||
|
||||
> Festipod lets users create **meeting points** that come to be "grafted" onto **existing public events**. The goal: encourage people to meet around those events.
|
||||
|
||||
## The inversion to grasp
|
||||
|
||||
The **public event** (festival, conference, trade show, exhibition…) is **only a pretext** and a *temporal and geographical anchor point*. The value the app produces is the **meeting point** that users come and graft onto it.
|
||||
|
||||
Direct consequences for modelling:
|
||||
|
||||
- **You sign up to a meeting point, not to an event.** Without meeting points, a Festipod event is of no interest whatsoever.
|
||||
- The **declarer** of an event is *not* (necessarily) its real organizer — just someone who inserted the reference into Festipod so that others can attach meeting points to it.
|
||||
- The **host** of a meeting point is whoever created it; the act of creating makes you the host. Likewise the act of declaring an event makes you its declarer.
|
||||
|
||||
## Authentication
|
||||
|
||||
**Every user is authenticated** (each one owns a NextGraph wallet) — there is no anonymous access to the app. The various "actors" (declarer, host, participant, connection…) are *specializations of a user in a given context*, not separate accounts. See [[knowledge_actors-and-concepts]].
|
||||
|
||||
## Underlying stack
|
||||
|
||||
A mobile-first web app, Bun + React + **NextGraph** (P2P, local-first, end-to-end encrypted). The P2P choice has a strong business consequence: see the challenge in [[brief_2026-06-15_event-deduplication]].
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: The product model of confidentiality and discovery — every entity lives in a SCOPE (public / protected / private) depending on who must see it; events & meeting points = public, network profile & participations = protected (network), settings = private; bilateral connections = the dialog scope; discovery = reading the public scope
|
||||
---
|
||||
|
||||
# Data scopes and discovery
|
||||
|
||||
The **product** model of who sees what, and of how events are found. This is **domain**: the technical *how* is the `@ng-eventually/polyfill` data SDK's business — the app only states **the business intent**.
|
||||
|
||||
## Three scopes per piece of data
|
||||
|
||||
Every entity is stored in the **scope** matching who must be able to read it:
|
||||
|
||||
| Entity | Scope | Who reads it |
|
||||
|---|---|---|
|
||||
| Event (the anchor) | **public** | everyone |
|
||||
| Meeting point | **public** | everyone |
|
||||
| Network profile (name, avatar, bio, city, interests) | **protected** | the holder + their connections |
|
||||
| Participation / sign-up to a meeting point | **protected** | the participant + their connections |
|
||||
| Connection index | **protected** | the holder + their connections |
|
||||
| Private profile (settings, email, preferences) | **private** | the holder alone |
|
||||
| Connection A↔B (bilateral link, + future messaging) | **dialog** | both users |
|
||||
|
||||
Guiding principle: **the "public" side (meeting point, event) and the "personal" side (profile, participations, connections) coexist within a single user.** Personal information is reserved to the **network** (bilateral connections), never visible to an ordinary user.
|
||||
|
||||
- **Meeting points / events are universally public.** Any user can read them and subscribe; creating a meeting point makes you its host, declaring an event makes you its declarer (no prerequisite).
|
||||
- **The host is the sole holder of write rights** on their meeting point; the declarer has no particular right over the meeting points grafted onto their event.
|
||||
- **Bilateral connection**: `DemandeDeConnexion` (unilateral, transient) → `Connexion` (bilateral, persistent) — the latter opens access to the other person's *protected* data.
|
||||
|
||||
Festipod **places each entity in its scope**; isolation between scopes is **handled by the data SDK**, not by application code (see concept `app-security`).
|
||||
|
||||
## Event discovery
|
||||
|
||||
A user discovers the events they did not create simply by **reading the `public` scope**: the app names the shape and the scope, and gets back everyone's public events, not just its own. That is the **primary** discovery axis; a **secondary**, relational one is layered on top (the connections' *protected* participations: "my friends are attending…").
|
||||
|
||||
> **Sign-up notification (product intent).** Signing up to a meeting point notifies its host: identified if the participant is one of the host's connections, **unnamed otherwise**. This "identified if known, unnamed otherwise" falls out of scope placement — the host can read the sign-up, but not the *protected* profile it points at unless they are connected. The app states the intent; it implements no filter of its own.
|
||||
|
||||
## Open questions (business)
|
||||
|
||||
- **Event write model**: owner (the declarer alone) / wiki (everyone) / immutable? Central to deduplication ([[brief_2026-06-15_event-deduplication]]).
|
||||
- **The host's identity towards an ordinary user**: a meeting point is readable by all, but should its host be identifiable? (pseudonym by default, a business card per meeting point, or anonymity lifted only for connections.)
|
||||
- **Which fields of a sign-up can be edited**; **"friends of friends" discoverability**.
|
||||
|
||||
> The detailed authorization matrix by actor × verb lives in the `app-security` concept ([[brief_2026-05-18_authorization-matrix]]).
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: What is implemented today (event + meeting point lifecycle, profiles, connections) vs the evolutions identified but not built (communities, subscriptions, curated lists, multi-user)
|
||||
---
|
||||
|
||||
# Current features vs upcoming evolutions
|
||||
|
||||
## Implemented (screens reachable through the router)
|
||||
|
||||
- Authentication through a NextGraph wallet
|
||||
- Event lifecycle (declaration, viewing, update)
|
||||
- Meeting point lifecycle (attached to an event)
|
||||
- Signing up to / withdrawing from a meeting point
|
||||
- List of an event's participants
|
||||
- User profile, profile update, profile sharing
|
||||
- Friends list (connections), another user's profile
|
||||
|
||||
> Signing up to / withdrawing from a meeting point is **genuinely wired** on the data side: `joinEvent` persists a Participation, notifies the meeting point's host and creates a Notification; `leaveEvent` deletes the Participation authoritatively (see concept `data-layer`, [[caveat_participation-deletion]] on the data-layer side). Public discovery — a user seeing another user's public event — works too.
|
||||
|
||||
> **The reconnection promise is guarded, not assumed.** "I come back later and my events and sign-ups are still there" is a product promise like any other, and it is the one whose failure would be least visible — nothing on screen distinguishes "you have nothing" from "it did not come back". The `src/modules/event/features/reconnexion-*.feature` scenarios of the `event` module are its non-regression guard; keep them meaningful, and read [[caveat_reconnexion-froide-local-vs-broker]] (concept `bdd-testing`) before trusting one of them green, because the natural setup proves less than it looks.
|
||||
|
||||
> **Product reservation — a user cannot be shown two identities on one device.** Signing in is one act with no choice attached: the user does not name, pick or switch an identity, and there is no in-app sign-out from one identity into another (concept `app-security`, [[decision_2026-08-10_the-barrier-names-no-identity]]). One session = one person, for the life of the page. Consequence for the domain: **do not design a flow that asks "who are you signing in as"**, nor an account-switcher, nor a demo that plays two people side by side on one device — none of them is expressible. Two people means two devices (or two browser contexts). The **isolation between two identities** is still a real requirement, but it is currently unproven at the `@data` layer for the same reason (concept `bdd-testing`).
|
||||
|
||||
## Identified evolutions (not implemented)
|
||||
|
||||
- **Subscribing to an interest community** in order to discover its events (distributed discovery).
|
||||
- **Subscribing to a user** in order to follow their declarations without being a friend.
|
||||
- **Curated lists** — creating/sharing editorialized selections.
|
||||
- **Collaborative multi-user**: actually sharing a meeting point seen by several users, resting on the public/protected/private scopes (see [[knowledge_data-scopes-and-discovery]]).
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
type: _overview
|
||||
summary: Stack and tooling — Bun-first (runtime, bundler, native APIs), build pipeline, and the project's commands
|
||||
triggers:
|
||||
keywords: [bun, bunx, build, bundler, vite, webpack, jest, npm, storybook, "bun.serve", hmr, tailwind, package.json]
|
||||
paths: ["build.ts", "package.json", "bunfig.toml", "tsconfig.json", "src/index.ts", "src/index.html", ".storybook/**", "scripts/**"]
|
||||
---
|
||||
|
||||
# Tech stack
|
||||
|
||||
The project's stack and tooling. Guiding principle: **Bun-first** — Bun replaces Node/npm/vite/webpack/jest and provides the native server APIs.
|
||||
|
||||
**Read this first:** [[rule_bun-first]] — the convention that decides which tool to use.
|
||||
|
||||
## Links
|
||||
|
||||
- [[rule_bun-first]] — use Bun, not Node/npm/vite/jest/express/ws/pg…
|
||||
- [[knowledge_bun-apis]] — native Bun APIs (serve, sqlite, redis, sql, file, shell)
|
||||
- [[knowledge_build-pipeline]] — build.ts, bundler, server, harness built separately, Storybook
|
||||
- [[knowledge_stack-and-commands]] — stack components + the real scripts (plus quirks)
|
||||
- [[knowledge_deployment]] — Dockerfile, prod served from src/, no CI, `portless` in dev
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: Firefox 151+ blocks (Local Network Access) the hosted broker embedding the local dev app in its iframe → blank iframe, zero app logs, no error at all. This is NOT a code bug. Browser-side fix — about:config network.lna.enabled=false.
|
||||
last_checked: 2026-08-10
|
||||
---
|
||||
|
||||
# Firefox LNA blocks the broker's app iframe in local dev
|
||||
|
||||
## Symptom
|
||||
|
||||
In local dev, the app runs INSIDE the hosted broker's iframe (`nextgraph.eu`/`nextgraph.net` over HTTPS embeds `festipod.localhost` → `127.0.0.1`). On **Firefox 151+**, the iframe stays **blank**: **no `[FestipodData]`/`[NG]` log at all** (the app's JS never runs), and **no red error** either (the block is a network policy decision, not a throw). Easy to mistake for a Festipod rendering crash — it is NOT one.
|
||||
|
||||
## Cause
|
||||
|
||||
**Local Network Access (LNA)**: Firefox 151+ (on by default, see the 149→151 rollout) forbids a **public site** (the HTTPS broker) from reaching a **local network resource** (`127.0.0.1`) — including embedding it in an iframe. The telltale console log:
|
||||
`Local Network Access detected: ... accessing target "…festipod.localhost…" (127.0.0.1) … prompt action: auto_deny`.
|
||||
|
||||
Two corollaries that mislead:
|
||||
- **The top level loads just fine**: navigating directly to `https://festipod.localhost:1355` is NOT subject to LNA. Only **iframe embedding** by the broker is. So "the cert is already accepted / the app starts up" before the iframe does not mean the iframe will go through.
|
||||
- **HTTPS changes nothing**: LNA targets the **local destination address**, not the protocol. Switching to `portless proxy start --https` (app on `https://festipod.localhost`) does not unblock it.
|
||||
|
||||
## Fix (browser, not code)
|
||||
|
||||
`about:config` → **`network.lna.enabled` = `false`** (master flag: disables every LNA check). Targeted alternative: **`network.lna.skip-domains`** with `nextgraph.eu`, `nextgraph.net` (keeps the protection everywhere else). Other LNA prefs: `network.lna.blocking`, `network.lna.block_trackers`.
|
||||
|
||||
Do NOT go hunting for a Festipod rendering bug as long as there is **no `[FestipodData]` log** in the console: with no app logs, the app never ran → it is the environment (LNA, untrusted cert, dev server down), not the code. The `@e2e` smoke test CANNOT catch this: Playwright does not enforce LNA the way a real Firefox does.
|
||||
@@ -0,0 +1,40 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Dev runs on bun --hot, prod builds through build.ts (Bun bundler + Tailwind plugin) into dist/, path alias @/* → ./src/*
|
||||
last_checked: 2026-08-10
|
||||
---
|
||||
|
||||
# Build pipeline
|
||||
|
||||
- **Dev**: `bun --hot src/index.ts` (through `bun run dev`) — HMR, port 3000.
|
||||
- **Prod**: `bun run build` → `build.ts` (Bun bundler + Tailwind plugin) → `dist/`.
|
||||
- **Path alias**: `@/* → ./src/*` (declared in `tsconfig.json`, resolved relative to that file — `paths` has needed no `baseUrl` since TS 4.4).
|
||||
|
||||
> ⚠️ **Never put `baseUrl` back in `tsconfig.json`.** TypeScript 6 reports it as an **error that aborts the whole compilation**, and the failure is silent where it hurts: `tsc --noEmit` then exits **0 having checked nothing**, so the typecheck gate goes green over any amount of broken code. A green typecheck is only meaningful if `tsc` actually ran — treat an instant, output-free `tsc` as a red flag, not a fast pass.
|
||||
|
||||
The server serves `src/index.html`, which loads `src/app/frontend.tsx` (see `app-architecture` §app-shell). The bundler transpiles the TSX and bundles the CSS without any external tool — no Vite/webpack/esbuild (see [[rule_bun-first]]).
|
||||
|
||||
## Details of `build.ts` and of the server
|
||||
|
||||
- `build.ts` scans `src/**/*.html` for entrypoints (today just one: `src/index.html`), `target: 'browser'`, minify plus linked sourcemap, `bun-plugin-tailwind` plugin. Adding a second `.html` would create a second bundle.
|
||||
- `src/index.ts` (`Bun.serve`) serves: `/reports/cucumber` (HTML report), `/api/hello*` stubs, `/festipod-config.json` plus `/shared-wallet.ngw` (runtime config, see below), and a **catch-all `/*` → `src/index.html`** (SPA routing, which must stay last). HMR when `NODE_ENV !== 'production'`, port from `PORT`.
|
||||
|
||||
## Build-time globals vs runtime config (the shared wallet pitfall)
|
||||
|
||||
`build.ts` injects **compile-time globals** through `define`: `__FESTIPOD_SHARED_WALLET_PASSWORD__` from `FESTIPOD_SHARED_WALLET_PASSWORD`, and `__FESTIPOD_AUTO_SEED__` from `FESTIPOD_AUTO_SEED` — the dev auto-seed, OFF when absent. **Pitfall**: the `src/index.ts` server (used by `bun run dev` AND `bun run start`) bundles `index.html` through Bun's HTML import, which **applies no `define`** — neither `bun --define` nor `process.env` propagates there (verified). So an environment variable passed to `bun run dev` never reaches the frontend bundle along that path.
|
||||
|
||||
For those paths served from `src/`, the configuration therefore goes through the **runtime**: `src/index.ts` exposes `/festipod-config.json` (read from the environment), and the `src/app/frontend.tsx` entry **fetches it first**, sets the global, **then imports the app dynamically** (`await import('./App')`) — so that `sharedWallet.ts` reads the value when it is evaluated. In a `build.ts` bundle the value is already inlined by `define`, so the fetch is skipped (`NODE_ENV === 'production'`). Practical consequence: to exercise the "shared wallet" flow in dev **end to end** (download plus a working import), pass the REAL password of the e2e wallet **and** the file — the password shown on screen must match the imported `.ngw`, otherwise the import fails (a dummy value such as `1` merely makes the screen appear):
|
||||
|
||||
```
|
||||
FESTIPOD_SHARED_WALLET_PASSWORD=festipod-e2e-tests \
|
||||
FESTIPOD_SHARED_WALLET_FILE=./festipod-e2e-tests.ngw \
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## The test harness is built separately
|
||||
|
||||
⚠️ `build.ts` does **not** build the test harnesses. The Cucumber hooks (`src/shared/support/hooks.ts`) run a `bun build` **on demand** for `src/shared/test-harness/harness.tsx` (and `harness-ng.tsx`) → `dist/test-harness*.js`. It is an entrypoint separate from the app build — see concept `bdd-testing`.
|
||||
|
||||
## Storybook
|
||||
|
||||
`storybook dev -p 6006` — **webpack5 + SWC** (not Vite). The decorators (`.storybook/`) inject the full provider stack (Theme > NextGraph > FestipodData > Router) and import `src/index.css`; mobile viewport by default. Tightly coupled to the project context (not reusable outside Festipod).
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Native Bun APIs used by the project — Bun.serve (HTTP/WS/routes), bundled HTML imports, bun:sqlite, Bun.redis, Bun.sql, Bun.file, Bun.$
|
||||
---
|
||||
|
||||
# Native Bun APIs
|
||||
|
||||
Reference for the Bun APIs to favour (see [[rule_bun-first]]). Full documentation: `node_modules/bun-types/docs/**.mdx`.
|
||||
|
||||
## Server — `Bun.serve()`
|
||||
|
||||
Supports WebSockets, HTTPS and routes. No need for `express`/`ws`.
|
||||
|
||||
```ts
|
||||
import index from "./index.html"
|
||||
Bun.serve({
|
||||
routes: {
|
||||
"/": index,
|
||||
"/api/users/:id": { GET: (req) => new Response(JSON.stringify({ id: req.params.id })) },
|
||||
},
|
||||
websocket: { open: (ws) => ws.send("hello"), message: (ws, m) => ws.send(m), close: (ws) => {} },
|
||||
development: { hmr: true, console: true },
|
||||
})
|
||||
```
|
||||
|
||||
This is the mechanism behind `src/index.ts` (see concept `app-architecture` §app-shell).
|
||||
|
||||
## HTML imports (frontend)
|
||||
|
||||
`Bun.serve()` serves HTML imports; the Bun bundler automatically transpiles and bundles `.tsx`/`.jsx`/`.js` and the CSS (Tailwind included). A `<script type="module" src="./frontend.tsx">` in the HTML is enough — no Vite.
|
||||
|
||||
## Storage & shell
|
||||
|
||||
- **`bun:sqlite`** for SQLite (not `better-sqlite3`)
|
||||
- **`Bun.redis`** for Redis (not `ioredis`)
|
||||
- **`Bun.sql`** for Postgres (not `pg`/`postgres.js`)
|
||||
- **`WebSocket`** built in (not `ws`)
|
||||
- **`Bun.file`** rather than `node:fs` readFile/writeFile
|
||||
- **`Bun.$\`ls\`** rather than `execa`
|
||||
|
||||
Bun loads `.env` automatically → do not use `dotenv`.
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Deployment — multi-stage Bun Alpine Dockerfile; install through pnpm (git+node inside the image) but bun at runtime; runs `bun run start` from src/ (not dist/), EXPOSE 3000, env PORT/NODE_ENV; no CI/CD committed; dev goes through the portless wrapper
|
||||
last_checked: 2026-07-14
|
||||
---
|
||||
|
||||
# Deployment & infra
|
||||
|
||||
## Dockerfile
|
||||
|
||||
A `Dockerfile` exists (multi-stage Bun Alpine). **Installation goes through pnpm, but runtime/build/test stay on bun** (see [[knowledge_stack-and-commands]]):
|
||||
- `FROM oven/bun:1-alpine`, `install` stage: `apk add --no-cache git nodejs npm` then `npm install -g pnpm@10.26.0` (the bun image has neither Node nor pnpm; Alpine's `apk nodejs` does not ship corepack), `COPY package.json pnpm-lock.yaml`, then `pnpm install --frozen-lockfile`. `git` is required because `@ng-eventually/polyfill` is a public **git+https** dependency (Gitea, no auth). `release` stage: copies `node_modules` plus the source.
|
||||
- `ENV NODE_ENV=production`, `USER bun`, `EXPOSE 3000/tcp`, `ENTRYPOINT ["bun","run","start"]`.
|
||||
|
||||
**`bun` peer pitfall**: `bun-plugin-tailwind` declares `bun` as a peerDependency → pnpm materializes the npm `bun` package and **creates a `node_modules/.bin/bun` shim** that shadows the `bun` from the PATH under `bun run`/`pnpm run`. Its postinstall is ignored by default → broken shim → `bun run start` fails. Fixed by approving the build: `pnpm.onlyBuiltDependencies: ["bun"]` in `package.json` (the postinstall then downloads the real binary). Without that, the whole pnpm migration breaks startup.
|
||||
|
||||
**Quirk**: `start` = `NODE_ENV=production bun src/index.ts` → the container **runs the TypeScript source directly** (Bun transpiles on the fly), it **does not use `dist/`**. `bun run build` (→ `dist/`) is therefore **not** on the default production path. Serving the build would require changing the entrypoint.
|
||||
|
||||
## CI/CD
|
||||
|
||||
**No** pipeline is committed (`.github/workflows/` absent, no Coolify config in the repo). A knowingly accepted blind spot. To host the Bun app, the `coolify-hosting` skill applies.
|
||||
|
||||
## Environment variables
|
||||
|
||||
- `PORT` (default 3000), `NODE_ENV` (enables/disables HMR and the dev auto-seed — see concept `data-layer`).
|
||||
- No `.env*` is committed (`.env` is gitignored). No secret management in the repo.
|
||||
|
||||
## Dev
|
||||
|
||||
`bun run dev` = **`portless festipod bun --hot src/index.ts`** — it goes through the **`portless`** wrapper (an external port-management tool), not a bare `bun --hot`. HMR is active outside production.
|
||||
|
||||
**Reactive local link to the SDK**: in production the `@ng-eventually/polyfill` dependency comes from Gitea (git+https, pinned by `pnpm-lock.yaml`). When the provider's package has to be exercised from a local checkout, `pnpm run link:polyfill` (script `scripts/link-polyfill.ts`) replaces `node_modules/@ng-eventually/polyfill` with a **real copy** of that checkout (location overridable with `NG_EVENTUALLY_LOCAL`) — **without** its own `node_modules/@ng-org` — and resyncs on every edit. Copying rather than symlinking is what keeps **a single `@ng-org/*` instance** installed: a symlink would drag in a second one and the SDK would stop working. To go back to the committed state: `pnpm install`.
|
||||
@@ -0,0 +1,43 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Stack components (Bun runtime/build/test, install through pnpm, React, NextGraph, Storybook, Cucumber, Tailwind-inside-the-build) and the real list of package.json scripts, quirks included (cucumber through node+tsx, link:polyfill for the reactive local link)
|
||||
---
|
||||
|
||||
# Stack & commands
|
||||
|
||||
## Components
|
||||
|
||||
| Layer | Technology |
|
||||
|---|---|
|
||||
| Runtime / bundler / test | **Bun** (see [[rule_bun-first]]) |
|
||||
| **Dependency installation** | **pnpm** (`pnpm install`, `pnpm-lock.yaml`) — **only** installation moves to pnpm; runtime/build/test stay on bun. Reason: `@ng-eventually/polyfill` is resolved from Gitea over **git+https** (pnpm handles `git+…#main&path:/packages/polyfill` cleanly, along with deduplication of the `@ng-org` peers). Do not switch installation back to bun/npm. |
|
||||
| UI | **React** (mobile-first, max width 768px — styling covered by concept `app-architecture`) |
|
||||
| Data | **NextGraph** P2P local-first (concept `data-layer`) |
|
||||
| CSS build | **Tailwind** (`tailwindcss` + `bun-plugin-tailwind`) — present in the build, but the screens style themselves with `app-*`/inline, no Tailwind utilities (see concept `app-architecture`) |
|
||||
| UI exploration | **Storybook** (webpack5 + SWC, port 6006) |
|
||||
| Tests | **Cucumber/Gherkin** in French, multi-layer, plus Playwright, happy-dom and chai (concept `bdd-testing`) |
|
||||
|
||||
## `package.json` scripts (the real ones)
|
||||
|
||||
| Script | Command / role |
|
||||
|---|---|
|
||||
| `dev` | `portless festipod bun --hot src/index.ts` — dev with HMR through the `portless` wrapper (see [[knowledge_deployment]]) |
|
||||
| `start` | `NODE_ENV=production bun src/index.ts` — production, served from `src/` (not `dist/`) |
|
||||
| `build` | `bun run build.ts` — Bun bundler + Tailwind → `dist/` ([[knowledge_build-pipeline]]) |
|
||||
| `test:cucumber` | chains `cucumber:run` → `cucumber:report` → `features:parse` → `steps:extract` |
|
||||
| `cucumber:run` | `node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js` — **through Node+tsx, not Bun** (Playwright/happy-dom plugin compatibility), and through the package's **actual JS entry**, not the `.bin/` shim (see Pitfalls) |
|
||||
| `test:data` | same, with `--tags @data` |
|
||||
| `test:auth-setup` | `bun scripts/setup-test-auth.ts` — bootstraps the persistent test wallet |
|
||||
| `cucumber:report` | `bun scripts/parse-test-results.ts` — `cucumber-report.json` → HTML |
|
||||
| `features:parse` | `bun scripts/parse-features.ts` → `features.ts` |
|
||||
| `steps:extract` | `bun scripts/extract-step-definitions.ts` |
|
||||
| `build:orm` | `rdf-orm build --input ./src/shared/shapes/shex --output ./src/shared/shapes/orm` |
|
||||
| `build:ng` | `bash scripts/build-ng-packages.sh` — (re)builds the NextGraph packages from a local source (optional tool) |
|
||||
| `link:polyfill` | `bun scripts/link-polyfill.ts` — **reactive** local link to `@ng-eventually/polyfill` (copy-overlay + watcher). Details in [[knowledge_deployment]]. |
|
||||
| `storybook` / `build-storybook` | Storybook dev (6006) / static build |
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **`cucumber:run`/`test:data` run under Node+tsx**, not Bun — the test plugins do not load under a native Bun import. Do not "bunify" these scripts.
|
||||
- **Never point a script at `node_modules/.bin/*`.** Installation goes through pnpm ([[rule_bun-first]] §exception), which puts **shell shims** there rather than JS entries: `node --import tsx/esm node_modules/.bin/cucumber-js` fails. Invoke the package's **actual JS entry** (`node_modules/@cucumber/cucumber/bin/cucumber.js`). This holds for any npm script that would launch a dependency's binary under `node`.
|
||||
- **`build:orm` was broken until 2026-07-28**: it targeted `./src/shapes/`, which does not exist (the shapes live under `src/shared/shapes/`), so the command exited with an error. **Fixed in `package.json`** — it now runs. Beware of a side effect: the generator has moved on since the committed bindings were produced, so a run reformats them and drops the `: Schema` annotation. That regeneration is a **tool-version bump, not a content fix** — treat it as its own validated change, do not let it ride along.
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
type: rule
|
||||
summary: By default use Bun and its native APIs, never the Node equivalents — bun instead of node/ts-node, bun test/build, bunx, and no express/ws/pg/dotenv. EXCEPTION — package installation goes through pnpm, not bun install
|
||||
---
|
||||
|
||||
# Rule: Bun-first
|
||||
|
||||
By default, use **Bun** and its native APIs rather than the Node.js equivalents.
|
||||
|
||||
| Instead of… | Use |
|
||||
|---|---|
|
||||
| `node <file>`, `ts-node` | `bun <file>` |
|
||||
| `jest`, `vitest` | `bun test` |
|
||||
| `npm/yarn install`, `bun install` | **`pnpm install`** (see the exception below) |
|
||||
| `npm run <script>` | `bun run <script>` |
|
||||
| `npx <pkg>` | `bunx <pkg>` |
|
||||
| `webpack`, `esbuild`, `vite` | `bun build` / the Bun bundler (HTML imports) |
|
||||
| `express` | `Bun.serve()` |
|
||||
| `better-sqlite3` | `bun:sqlite` |
|
||||
| `ioredis` | `Bun.redis` |
|
||||
| `pg`, `postgres.js` | `Bun.sql` |
|
||||
| `ws` | `WebSocket` (built in) |
|
||||
| `node:fs` readFile/writeFile | `Bun.file` |
|
||||
| `execa` | `Bun.$\`...\`` |
|
||||
| `dotenv` | (pointless — Bun loads `.env` automatically) |
|
||||
|
||||
API details: [[knowledge_bun-apis]].
|
||||
|
||||
## Exception: package installation goes through pnpm
|
||||
|
||||
**Dependencies are installed with `pnpm install`, not `bun install`.** Everything else stays on Bun: **runtime, build, test, scripts** (`bun run dev`, `bun build`, `bun test`, `bunx`). Only the installation step changes package manager.
|
||||
|
||||
**Why.** The data SDK is installed from a Gitea repository as a **subdirectory** git dependency: `git+https://…/ng-eventually.git#main&path:/packages/polyfill`. pnpm (≥ 10.26) resolves that `#<ref>&path:/…` format and guarantees a **single** instance of `@ng-org/*`; `bun install` does not handle this workflow cleanly. The reference lockfile is therefore `pnpm-lock.yaml`, and the reactive local link goes through `pnpm run link:polyfill` (see [[knowledge_deployment]]).
|
||||
|
||||
**Practical consequence.** npm scripts that relied on `node_modules/.bin/*` may break (pnpm puts shell shims there, not JS entries) — call the package's actual JS entry (e.g. `node_modules/@cucumber/cucumber/bin/cucumber.js`) rather than the `.bin/` shim.
|
||||
|
||||
## Why (Bun for everything else)
|
||||
|
||||
The project is all-Bun (runtime, bundler, test, server). Reintroducing a redundant Node tool adds a dependency, diverges from the repo's conventions, and breaks the native integration (HMR, automatic TS transpilation, `.env` loading). This is a consistency choice, not a cosmetic preference. The installation exception above is the only deviation, and it is motivated by the subdirectory git dependency.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Inter-repo contracts. Festipod is a CONSUMER only: it publishes no interface of its own,
|
||||
# and it consumes exactly one — the SDK surface `@ng-eventually/polyfill` engages toward the
|
||||
# applications built on it.
|
||||
#
|
||||
# The pulled copy under `into:` IS the specification Festipod codes against. An agent
|
||||
# working here reads that copy and never opens the provider's own source: a gap is raised
|
||||
# upstream (see `data-layer/rule_app-uses-sdk-surface-only`), never peeked around.
|
||||
#
|
||||
# `pullFrom:` names the canonical identity of the provider (its git remote URL + the
|
||||
# repo-relative path of the leaf), so the manifest travels with the branch. Per-developer
|
||||
# access to a local checkout lives in `.project/contracts.local.yaml`, which is never
|
||||
# committed.
|
||||
|
||||
consume:
|
||||
- contract: polyfill-surface
|
||||
into: concepts/data-layer
|
||||
type: git
|
||||
pullFrom: https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git/.project/concepts/app-contract/contract_polyfill-surface.md
|
||||
# The contract is published from the branch that carries it while that branch is still
|
||||
# in flight; it moves to `main` once the provider lands it there. Flip this line then,
|
||||
# and re-pull — the stamp records which commit the local copy actually came from.
|
||||
ref: caps-p1a-and-virtual-user-boundary
|
||||
@@ -1,54 +0,0 @@
|
||||
# Automated Headless Wallet Creation for CI
|
||||
|
||||
**Date:** 2026-03-12 15:00
|
||||
**Status:** Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Data-layer BDD tests (`@data` scenarios) require a NextGraph wallet in a persistent Chromium profile. Previously, the first run required manual interaction: a visible browser opened and the user had to create a wallet and close the browser. This blocked CI execution.
|
||||
|
||||
## Options Considered
|
||||
|
||||
### Option A: Programmatic wallet creation via NG SDK
|
||||
Call `ng.wallet_create()` directly from Node/Bun, bypassing the UI entirely.
|
||||
|
||||
**Arguments for:**
|
||||
- Fastest execution
|
||||
- No browser needed for wallet creation
|
||||
|
||||
**Arguments against:**
|
||||
- `@ng-org/web` is browser-only (WASM + postMessage)
|
||||
- Would need to reverse-engineer the registration API at `account.nextgraph.eu`
|
||||
- Doesn't test the real auth flow
|
||||
|
||||
### Option B: Automate the browser UI flow headlessly
|
||||
Use Playwright to drive the same wallet creation UI a real user would use, but in headless mode.
|
||||
|
||||
**Arguments for:**
|
||||
- Tests the real auth/login feature end-to-end
|
||||
- No API reverse-engineering needed
|
||||
- Same persistent profile used for subsequent test runs
|
||||
- CI-ready with no manual steps
|
||||
|
||||
**Arguments against:**
|
||||
- Depends on `nextgraph.eu` and `account.nextgraph.eu` being reachable
|
||||
- UI changes in NextGraph could break the automation
|
||||
- Adds ~27s to first run
|
||||
|
||||
## Decision
|
||||
|
||||
Option B — automate the browser UI. The wallet creation flow (navigate to `nextgraph.eu` → "Create Wallet" → accept ToS at `account.nextgraph.eu` → fill username/password → submit) is itself a legitimate test of the app's auth feature. The dependency on external services is acceptable since the tests already depend on the broker being reachable.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- Tests are fully CI-ready (no human interaction)
|
||||
- Auth/login flow is tested as a side effect
|
||||
- Single command `bun run test:data` works from a clean state
|
||||
|
||||
**Negative:**
|
||||
- Requires internet access (nextgraph.eu, account.nextgraph.eu)
|
||||
- Fragile to NextGraph UI changes (button text, form IDs)
|
||||
|
||||
**Risks:**
|
||||
- `account.nextgraph.eu` rate limiting could block CI runs that frequently recreate wallets
|
||||
@@ -1,48 +0,0 @@
|
||||
# Conditional NextGraph Init Based on Broker Iframe Detection
|
||||
|
||||
**Date:** 2026-03-13 14:00
|
||||
**Status:** Accepted
|
||||
|
||||
## Context
|
||||
|
||||
`@ng-org/web`'s `initNgWeb()` checks `window.self === window.top`. When the app runs standalone (not in an iframe), it redirects the entire page to `nextgraph.net/redir/` to trigger broker authentication. This caused the app to redirect on every load — even during development or when the user hadn't clicked "Se connecter".
|
||||
|
||||
## Options Considered
|
||||
|
||||
### Option A: Always auto-init NG on mount
|
||||
**Arguments for:**
|
||||
- Simpler code — no branching logic
|
||||
|
||||
**Arguments against:**
|
||||
- Causes immediate redirect to broker when loaded standalone
|
||||
- Breaks development workflow
|
||||
- User sees broker login page instead of the app
|
||||
|
||||
### Option B: Conditional auto-init based on iframe detection
|
||||
**Arguments for:**
|
||||
- When in iframe, the broker has already authenticated — safe to auto-init
|
||||
- When standalone, user must explicitly click "Se connecter" to trigger the redirect
|
||||
- Preserves standalone demo/development experience
|
||||
- Matches `@ng-org/web`'s own detection logic
|
||||
|
||||
**Arguments against:**
|
||||
- Relies on `window.self !== window.top` heuristic (could theoretically be wrong if embedded in non-broker iframe)
|
||||
|
||||
## Decision
|
||||
|
||||
Option B. `NextGraphContext` checks `const isInsideBroker = typeof window !== 'undefined' && window.self !== window.top` at module level. `useEffect` only auto-calls `initNg()` when `isInsideBroker` is true. The `connect()` callback remains available for explicit user-initiated connection.
|
||||
|
||||
Additionally, `FestipodDataContext` now renders empty data (not seed data) during the `connecting` phase to avoid flashing demo content before the wallet loads.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- App loads without redirecting — works standalone for development and demo
|
||||
- In broker iframe, connection is seamless and automatic
|
||||
- No seed data flash during wallet connection
|
||||
|
||||
**Negative:**
|
||||
- None significant
|
||||
|
||||
**Risks:**
|
||||
- If `@ng-org/web` changes its detection logic, our guard may diverge — keep them aligned
|
||||
@@ -1,67 +0,0 @@
|
||||
# Use private_store_id as useShape scope and @graph
|
||||
|
||||
**Date:** 2026-03-17 16:00
|
||||
**Status:** Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Clicking "Charger données de test" loaded data in-memory (via ORM signals) but produced `RepoNotFound` errors from `doc_create` and `orm_frontend_update`. Data disappeared after page reload because SPARQL writes never reached the broker. The NextGraph verifier's `self.repos` HashMap didn't contain the private store repo, so `resolve_target()` failed.
|
||||
|
||||
## Options Considered
|
||||
|
||||
### Option A: `did:ng:i` scope + `doc_create` for @graph
|
||||
Use "entire user site" scope for reads, create a new document for writes.
|
||||
|
||||
**Arguments for:**
|
||||
- `did:ng:i` is well-documented as a valid subscription scope
|
||||
- `doc_create` returns a real document NURI
|
||||
|
||||
**Arguments against:**
|
||||
- `did:ng:i` uses a special code path (`NuriTargetV0::UserSite`) that doesn't open individual repos
|
||||
- `doc_create` calls `resolve_target(NuriTargetV0::PrivateStore)` which needs the repo in `self.repos` — fails if repo wasn't opened
|
||||
- Requires complex retry logic / timing workarounds
|
||||
|
||||
### Option B: `private_store_id` as both scope AND @graph
|
||||
Mirror the expense-tracker-rdf example: `useShape(type, `did:ng:${session.private_store_id}`)` and `@graph: `did:ng:${session.private_store_id}``.
|
||||
|
||||
**Arguments for:**
|
||||
- Proven pattern: expense-tracker-rdf uses exactly this and works
|
||||
- `orm_start_graph` with private store NURI opens the repo in the verifier's `self.repos` HashMap
|
||||
- Subsequent writes via `orm_frontend_update` find the repo because it's now in the cache
|
||||
- Simple, no retry logic needed
|
||||
|
||||
**Arguments against:**
|
||||
- Slightly less flexible than `did:ng:i` (scoped to one store)
|
||||
- Requires passing session to `useShapeWithDefaults`
|
||||
|
||||
### Option C: `did:ng:i` scope + reuse existing entity @graph
|
||||
Subscribe with `did:ng:i`, then reuse `@graph` from any existing entity for writes.
|
||||
|
||||
**Arguments for:**
|
||||
- Works for returning users who already have data
|
||||
|
||||
**Arguments against:**
|
||||
- Fails for empty wallets (no existing entities to reuse)
|
||||
- Still needs `doc_create` fallback which hits the same `RepoNotFound` issue
|
||||
|
||||
## Decision
|
||||
|
||||
**Option B**: Use `did:ng:${session.private_store_id}` as both `useShape` scope and `@graph` for writes. This matches the official expense-tracker-rdf example exactly.
|
||||
|
||||
The `useShapeWithDefaults` hook accepts a `storeNuri` parameter. `FestipodDataContext.useNgData()` gets the session from `useNextGraph()` and passes `did:ng:${session.private_store_id}`.
|
||||
|
||||
`ensureGraphNuri()` simplified: checks existing entities first (optimization), then falls back to `did:ng:${session.private_store_id}`.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- Writes work immediately after connection (no retries needed)
|
||||
- Data persists across page reloads
|
||||
- Pattern matches official NextGraph examples
|
||||
- All 7 e2e scenarios pass including data persistence
|
||||
|
||||
**Negative:**
|
||||
- `useShapeWithDefaults` signature changed (added `storeNuri` parameter)
|
||||
|
||||
**Risks:**
|
||||
- If NextGraph changes the private store behavior, this would break
|
||||
@@ -1,81 +0,0 @@
|
||||
# Use SPARQL DELETE instead of ORM ngSet.delete() for object removal
|
||||
|
||||
**Date:** 2026-03-17 18:00
|
||||
**Status:** Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Leaving an event requires deleting the user's `Participation` object from the NextGraph store. The ORM's `DeepSignalSet.delete()` method updates the local reactive state (UI reflects the change immediately) but the deletion does not persist to the broker — after page refresh, the participation reappears.
|
||||
|
||||
## Options Considered
|
||||
|
||||
### Option A: ORM `ngSet.delete(item)`
|
||||
|
||||
The ORM README shows `dogs.delete(aDog)` as the intended API. Internally, `.delete()` generates a `{ op: "remove", path: "/<syntheticId>" }` patch, delivered via microtask to `OrmSubscription.onSignalObjectUpdate`, which calls `ng.graph_orm_update()`.
|
||||
|
||||
**Arguments for:**
|
||||
- Official ORM API, shown in README examples
|
||||
- Immediate local reactive update (instant UI feedback)
|
||||
|
||||
**Arguments against:**
|
||||
- Does not persist in practice: `delete()` returns `true`, local set updates, but after refresh the object is back
|
||||
- The `graph_orm_update` WASM call may not correctly handle "remove" patches for top-level set objects (possible engine bug)
|
||||
- No error is thrown — fails silently
|
||||
|
||||
### Option B: `ng.sparql_update()` with SPARQL DELETE
|
||||
|
||||
Bypass the ORM patch mechanism entirely. Use `DELETE WHERE { GRAPH <graph> { <subject> ?p ?o } }` to remove all RDF triples for the object.
|
||||
|
||||
**Arguments for:**
|
||||
- Works: deletion persists across page refresh
|
||||
- The broker confirms via `TORMO became invalid` + `GraphOrmUpdate` remove, which reactively removes the item from the ORM set
|
||||
- Direct control over RDF triple removal
|
||||
|
||||
**Arguments against:**
|
||||
- Not instant: UI update waits for the SPARQL round-trip + broker `GraphOrmUpdate` callback (near-instant in practice, ~50ms)
|
||||
- Must not combine with `ngSet.delete()` — running both causes CRDT conflicts where the item reappears
|
||||
|
||||
### Option C: `ngSet.delete()` + `ng.sparql_update()` together
|
||||
|
||||
Use `.delete()` for instant UI and SPARQL for persistence.
|
||||
|
||||
**Arguments for:**
|
||||
- Instant UI feedback + guaranteed persistence
|
||||
|
||||
**Arguments against:**
|
||||
- **Does not work**: the ORM `.delete()` patch and the SPARQL DELETE backend update conflict in the CRDT, resulting in neither UI change nor persistence
|
||||
|
||||
## Decision
|
||||
|
||||
**Option B: SPARQL DELETE only.** The broker sends back a `GraphOrmUpdate` with `op: "remove"` that reactively removes the item from the ORM set, so the UI still updates — just not synchronously.
|
||||
|
||||
Do NOT call `ngSet.delete()` alongside `sparql_update()` — they conflict.
|
||||
|
||||
## Implementation
|
||||
|
||||
```typescript
|
||||
// In FestipodDataContext.tsx leaveEvent():
|
||||
const session = await sessionPromise;
|
||||
await ng.sparql_update(
|
||||
session.session_id,
|
||||
`DELETE WHERE { GRAPH <${partGraph}> { <${partId}> ?p ?o } }`,
|
||||
partGraph,
|
||||
);
|
||||
```
|
||||
|
||||
Imports: `ng` from `@ng-org/web`, `sessionPromise` from `../utils/ngSession`.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- Deletion actually persists
|
||||
- Single source of truth (broker → ORM → UI)
|
||||
|
||||
**Negative:**
|
||||
- Slight UI delay (~50ms) vs instant for property mutations
|
||||
- Pattern diverges from ORM README examples
|
||||
|
||||
**Risks:**
|
||||
- If `ng.sparql_update` API changes, this breaks
|
||||
- Other delete operations (if added) must follow the same pattern
|
||||
- The ORM `ngSet.delete()` bug may be fixed in a future version — revisit when upgrading `@ng-org/orm`
|
||||
@@ -1,84 +0,0 @@
|
||||
# Architecture
|
||||
|
||||
Feature-based architecture where code is organized by business domain (module), not by technical layer.
|
||||
|
||||
## Module Structure
|
||||
|
||||
```
|
||||
src/modules/
|
||||
event/ # 7 screens, 5 features — events CRUD, discovery, participants, meeting points
|
||||
user/ # 5 screens, 11 features — profiles, friends, sharing
|
||||
home/ # 2 screens — dashboard, settings
|
||||
auth/ # 2 screens — login, welcome/onboarding
|
||||
workshop/ # 0 screens, 6 features — workshop/atelier specs (future)
|
||||
meeting/ # 0 screens, 1 feature — meeting point specs
|
||||
notification/ # 0 screens, 3 features — notification specs
|
||||
```
|
||||
|
||||
Each module can contain:
|
||||
- `screens/` — React screen components
|
||||
- `features/` — Gherkin `.feature` files (BDD specs)
|
||||
- `steps/{ui,data,e2e}/` — Cucumber step definitions by layer
|
||||
|
||||
## Import Rules
|
||||
|
||||
**Modules only import from `shared/` — never from each other.**
|
||||
|
||||
```
|
||||
src/modules/event/screens/EventDetailScreen.tsx
|
||||
✅ import from '../../../shared/components/sketchy'
|
||||
✅ import from '../../../shared/context/FestipodDataContext'
|
||||
✅ import from '../../../screens' (registry types)
|
||||
❌ import from '../../user/screens/...'
|
||||
```
|
||||
|
||||
## Shared Layer
|
||||
|
||||
`src/shared/` contains everything reusable across modules:
|
||||
|
||||
| Directory | Contents |
|
||||
|-----------|----------|
|
||||
| `components/sketchy/` | Hand-drawn UI library (Button, Card, Avatar, Header, NavBar, etc.) |
|
||||
| `components/ui/` | Shadcn/Radix components (used only in prototyping tool) |
|
||||
| `context/` | ThemeContext, NextGraphContext, FestipodDataContext |
|
||||
| `data/` | User stories (`index.ts`), auto-generated `features.ts`, `testResults.ts`, `seedData.ts`, `types.ts` |
|
||||
| `hooks/` | `useShapeWithDefaults` (NextGraph) |
|
||||
| `shapes/` | SHEX definitions + ORM TypeScript bindings |
|
||||
| `utils/` | `ngSession.ts`, `ngBootstrap.ts` |
|
||||
| `steps/ui/` | Shared BDD step definitions (navigation, screen, form) |
|
||||
| `support/` | Cucumber `world.ts`, `hooks.ts` |
|
||||
| `types/` | `gherkin.ts` (ParsedFeature, ParsedScenario types) |
|
||||
| `lib/` | `utils.ts` (cn helper for Tailwind) |
|
||||
|
||||
## App Shell
|
||||
|
||||
`src/app/` is the prototyping tool — not part of the Festipod app itself:
|
||||
|
||||
- `App.tsx` — Root: ThemeProvider > NextGraphProvider > FestipodDataProvider > RouterProvider
|
||||
- `router.tsx` — Hash-based routing: `#/` (gallery), `#/demo/{screenId}`, `#/specs/{featureId}`
|
||||
- `frontend.tsx` — React entry point (referenced from `src/index.html`)
|
||||
- `components/Gallery.tsx` — Screen preview grid
|
||||
- `components/DemoMode.tsx` — Interactive mockup viewer with sidebar navigation
|
||||
- `components/specs/` — BDD specs browser (SpecsPage, FeatureView, GherkinHighlighter)
|
||||
|
||||
## Screen Registry
|
||||
|
||||
`src/screens/index.ts` is the central registry that imports all screens from all modules and exports:
|
||||
- `screenGroups` — Grouped by domain (Accueil, Evenements, Utilisateur, General)
|
||||
- `screens` — Flat list
|
||||
- `getScreen(id)` — Lookup by ID
|
||||
- `ScreenProps` interface — `{ navigate: (screenId: string) => void }`
|
||||
|
||||
## Entry Points
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/index.ts` | Bun.serve() — HTTP server, serves index.html + cucumber report |
|
||||
| `src/index.html` | HTML entry, loads `src/app/frontend.tsx` |
|
||||
| `src/app/frontend.tsx` | React root, renders `<App />` |
|
||||
|
||||
## Build
|
||||
|
||||
- Dev: `bun --hot src/index.ts` (via `bun run dev`)
|
||||
- Prod: `bun run build.ts` — Bun bundler + Tailwind plugin → `dist/`
|
||||
- Path alias: `@/*` → `./src/*` (tsconfig)
|
||||
@@ -1,113 +0,0 @@
|
||||
# BDD Testing
|
||||
|
||||
Cucumber/Gherkin BDD specs in French with multi-layer step definitions.
|
||||
|
||||
## Overview
|
||||
|
||||
- 26 feature files (US-1 to US-26), all in French
|
||||
- Categories: EVENT, WORKSHOP, USER, MEETING, NOTIF
|
||||
- Priorities: 0 (Impossible), 1 (Haute), 2 (Moyenne), 3 (Basse)
|
||||
- Current results: 51 passed, 7 failed, 75 skipped (133 scenarios total)
|
||||
|
||||
## Multi-Layer BDD
|
||||
|
||||
Each module has step directories for three test layers:
|
||||
|
||||
```
|
||||
src/modules/event/steps/
|
||||
ui/ # UI/screen assertions (source analysis)
|
||||
data/ # Data layer assertions (Playwright + broker)
|
||||
e2e/ # E2E assertions (Playwright + broker + real app UI)
|
||||
```
|
||||
|
||||
Shared steps (cross-domain) live in `src/shared/steps/ui/`.
|
||||
|
||||
## Feature Files
|
||||
|
||||
Collocated with their module:
|
||||
|
||||
```
|
||||
src/modules/event/features/us-13-creer-evenement.feature
|
||||
src/modules/user/features/us-23-connexion-utilisateurs.feature
|
||||
src/modules/workshop/features/us-1-visualiser-atelier-termine.feature
|
||||
...
|
||||
```
|
||||
|
||||
Tagged with `@CATEGORY @priority-N` for filtering.
|
||||
|
||||
## Step Definitions
|
||||
|
||||
### Shared Steps (`src/shared/steps/ui/`)
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `navigation.steps.ts` | Screen navigation, authentication, click/select actions, section/button/field assertions |
|
||||
| `form.steps.ts` | Form field validation, required fields, import/duplicate detection |
|
||||
| `screen.steps.ts` | Screen content assertions (participants, events, profiles, QR codes) |
|
||||
|
||||
### How UI Steps Work
|
||||
|
||||
`@ui` steps render the screen with `LocalDataProvider` (seed data) and `RouterProvider` via happy-dom, then assert on the rendered DOM. The render helper lives in `src/shared/test-harness/renderHelper.tsx` and is invoked from `world.ts:renderCurrentScreen()` on every `navigateTo(...)`.
|
||||
|
||||
See [test-layer-contracts](./test-layer-contracts.md) for what `@ui` is allowed to test and the patterns to follow (and avoid).
|
||||
|
||||
Legacy: `screenFileMap`, `screenFieldDetectors`, `screenExpectedContent`, `screenRequiredFields` in `world.ts` are vestiges of an earlier source-code-grep approach. `hasText`/`hasField`/`hasElement` now prefer the rendered DOM and fall back to source so unmigrated steps keep working during the transition.
|
||||
|
||||
### Screen Name Resolution
|
||||
|
||||
French names in `.feature` files map to screen IDs via `screenNameMap`:
|
||||
- `"accueil"` → `home`
|
||||
- `"détail événement"` → `event-detail`
|
||||
- `"mon profil"` → `profile`
|
||||
- `"relayer un événement"` → `create-event`
|
||||
|
||||
## Cucumber Configuration
|
||||
|
||||
`cucumber.json`:
|
||||
```json
|
||||
{
|
||||
"default": {
|
||||
"import": [
|
||||
"src/shared/support/**/*.ts",
|
||||
"src/shared/steps/**/*.ts",
|
||||
"src/modules/*/steps/**/*.ts"
|
||||
],
|
||||
"paths": ["src/modules/*/features/**/*.feature"],
|
||||
"language": "fr"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Requires `tsx` loader: `node --import tsx/esm node_modules/.bin/cucumber-js`
|
||||
|
||||
## Auto-Generated Files
|
||||
|
||||
Scripts in `scripts/` parse features and steps into TypeScript data files consumed by the prototyping tool:
|
||||
|
||||
| Script | Input | Output |
|
||||
|--------|-------|--------|
|
||||
| `parse-features.ts` | `src/modules/*/features/*.feature` | `src/shared/data/features.ts` |
|
||||
| `parse-test-results.ts` | `reports/cucumber-report.json` | `src/shared/data/testResults.ts` |
|
||||
| `extract-step-definitions.ts` | `src/shared/steps/ui/*.ts` | `src/shared/data/stepDefinitions.ts` |
|
||||
|
||||
Run all: `bun run test:cucumber`
|
||||
|
||||
## Data-Layer Testing
|
||||
|
||||
`@data` scenarios test through the real NextGraph broker. See [data-layer-testing](./data-layer-testing.md) for full architecture.
|
||||
|
||||
## E2E Testing
|
||||
|
||||
`@e2e` scenarios test the real app running in the broker iframe. See [data-layer-testing](./data-layer-testing.md#e2e-layer) for architecture. Key differences from `@data`:
|
||||
|
||||
- Uses the **real app** (not a test harness) served on a local HTTP port
|
||||
- Interacts via Playwright locators and `evaluate()` on the app iframe
|
||||
- Tests actual UI behavior: navigation, redirects, button clicks, screen content
|
||||
- Requires real broker mode (fails with `Error` if broker unavailable)
|
||||
|
||||
## Adding New Steps
|
||||
|
||||
1. **Module-specific**: Create in `src/modules/{module}/steps/ui/`
|
||||
2. **Cross-domain**: Add to `src/shared/steps/ui/`
|
||||
3. Import `FestipodWorld` type from `../../support/world` (shared) or adjust relative path
|
||||
4. Run `bun run steps:extract` to regenerate tooltip data
|
||||
@@ -1,177 +0,0 @@
|
||||
# Data-Layer Testing
|
||||
|
||||
BDD scenarios tagged `@data` test the real NextGraph data pipeline through a broker, not mocked data.
|
||||
|
||||
## Overview
|
||||
|
||||
`@data` scenarios run Cucumber steps against a real NextGraph broker. Playwright drives a Chromium instance that authenticates with the broker, which loads our test harness in an iframe. The harness uses real `useShape`/ORM subscriptions and exposes a `window.__testData` bridge for step definitions.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Cucumber steps → Playwright (Chromium, persistent profile)
|
||||
↓
|
||||
https://nextgraph.eu/auth/#/?o=http://127.0.0.1:{port}
|
||||
↓
|
||||
Broker wallet login (automated)
|
||||
↓
|
||||
Broker loads app in iframe → http://127.0.0.1:{port}
|
||||
↓
|
||||
harness-ng.tsx (init → useShape → ORM → broker)
|
||||
↓
|
||||
window.__testData bridge
|
||||
```
|
||||
|
||||
## Dual Mode
|
||||
|
||||
- **Real broker** (default): `harness-ng.tsx` with NextGraph ORM through broker iframe
|
||||
- **Mock fallback**: `harness.tsx` with standalone DeepSignalSets (if NG harness build fails)
|
||||
|
||||
## Wallet Lifecycle
|
||||
|
||||
Fully automated — no manual interaction required. CI-ready.
|
||||
|
||||
### First Run (wallet creation + bootstrap)
|
||||
1. `BeforeAll` detects no `.wallet-ready` marker in `.playwright-profile/`
|
||||
2. Launches headless Chromium with persistent profile
|
||||
3. Navigates to `https://nextgraph.eu/` → clicks "Create Wallet"
|
||||
4. Redirected to `account.nextgraph.eu` → clicks "I accept" (ToS)
|
||||
5. Redirected back → fills username/password form → submits
|
||||
6. Wallet created in localStorage
|
||||
7. **Logs in to the wallet** — this triggers the verifier bootstrap from the remote broker, populating localStorage with repo data
|
||||
8. Waits 10s for bootstrap to complete, then closes context
|
||||
9. Marker written
|
||||
|
||||
Step 7 is critical: the NextGraph verifier starts with an empty `repos` HashMap. On first login, `verifier.sync()` bootstraps from the remote broker, downloading repo data (including store repos). This data is saved to localStorage via `session_save`. Without this initial login, subsequent sessions would have empty repos and all writes would fail with `RepoNotFound`.
|
||||
|
||||
### Subsequent Runs (automated login)
|
||||
1. Marker found → skip wallet creation
|
||||
2. Headless Chromium with persistent profile
|
||||
3. Automated login: click "Login" → click wallet link → fill password → submit
|
||||
4. Broker authenticates, loads app harness in iframe
|
||||
5. Harness initializes NG, creates ORM subscriptions, seeds data if needed
|
||||
6. `window.__testData.ready` → steps execute via `appFrame.evaluate()`
|
||||
|
||||
### Wallet Credentials
|
||||
- Name: `festipod-tests`
|
||||
- Password: `festipod-tests`
|
||||
|
||||
## Key Technical Details
|
||||
|
||||
### Chromium Flags
|
||||
```
|
||||
--disable-features=PrivateNetworkAccessRespectPreflightResults,BlockInsecurePrivateNetworkRequests,...
|
||||
--allow-insecure-localhost
|
||||
--disable-web-security
|
||||
```
|
||||
Required because broker at `nextgraph.eu` (public) loads harness from `http://127.0.0.1:{port}` (local) in an iframe — Chromium's Private Network Access blocks this by default.
|
||||
|
||||
### Persistent Profile (`.playwright-profile/`)
|
||||
- Stores NG wallet in localStorage (`ng_wallets` on `nextgraph.eu`, `ng_bootstrap` on `nextgraph.net`)
|
||||
- Gitignored
|
||||
- Must use full Chrome binary, not `chrome-headless-shell`
|
||||
|
||||
### HTTP Server
|
||||
- Started in `BeforeAll` on auto-assigned port (`127.0.0.1:0`)
|
||||
- Serves harness HTML at `/` and JS bundle at `/harness.js` (separate files — inline script breaks due to special characters in bundle)
|
||||
- Shut down in `AfterAll`
|
||||
|
||||
### ORM Subscriptions
|
||||
Harness creates subscriptions for all three shapes with scope `did:ng:${session.private_store_id}` (opens the store repo for reads AND writes):
|
||||
- `FpEventShapeType` → events
|
||||
- `FpUserProfileShapeType` → users
|
||||
- `FpParticipationShapeType` → participations
|
||||
|
||||
### Test Bridge (`window.__testData`)
|
||||
Exposed by the harness, consumed by steps via `appFrame.evaluate()`:
|
||||
- `events`, `users`, `participations` — live DeepSignalSets
|
||||
- `currentUserId` — IRI of the test user
|
||||
- `getEvent(id)`, `getEventByTitle(title)` — lookups
|
||||
- `joinEvent(eventId, userId)`, `leaveEvent(eventId, userId)` — mutations
|
||||
- `isParticipating(eventId, userId)`, `getEventParticipants(eventId)` — queries
|
||||
- `updateEvent(eventId, updates)` — field updates
|
||||
|
||||
## E2E Layer (`@e2e`)
|
||||
|
||||
`@e2e` scenarios test the real app UI running inside the broker iframe. Unlike `@data` which loads a test harness, `@e2e` loads the actual app.
|
||||
|
||||
### Architecture
|
||||
|
||||
```
|
||||
Cucumber steps → Playwright (Chromium, persistent profile)
|
||||
↓
|
||||
https://nextgraph.net/redir/#/?o=http://127.0.0.1:{appPort}
|
||||
↓
|
||||
Broker wallet login (automated, same as @data)
|
||||
↓
|
||||
Broker loads REAL APP in iframe → http://127.0.0.1:{appPort}
|
||||
↓
|
||||
App renders with NextGraphProvider auto-connecting
|
||||
↓
|
||||
Steps interact via appFrame.evaluate() and Playwright locators
|
||||
```
|
||||
|
||||
### App Server
|
||||
|
||||
Started in `BeforeAll` alongside the harness server:
|
||||
1. Find a free port
|
||||
2. `spawn('bun', ['src/index.ts'], { env: { PORT: appPort } })`
|
||||
3. Poll until the server responds to HTTP GET
|
||||
4. Killed in `AfterAll`
|
||||
|
||||
### Shared Infrastructure
|
||||
|
||||
`@e2e` reuses the same `setupBrokerPage()` helper as `@data` — handles broker redirect URL construction, wallet login automation, and iframe discovery.
|
||||
|
||||
### Step Definitions
|
||||
|
||||
E2E steps live in module directories (e.g., `src/modules/auth/steps/e2e/connexion.steps.ts`). They use:
|
||||
- `this.appFrame!.evaluate()` — run JS in the app iframe (hash navigation, content checks)
|
||||
- `this.appFrame!.locator()` — find and interact with DOM elements
|
||||
- `this.appFrame!.waitForFunction()` — poll for expected state (screen content, URL changes)
|
||||
- `SCREEN_MARKERS` — map screen IDs to unique text content for verification
|
||||
|
||||
### Before Hook (`@e2e`)
|
||||
|
||||
```
|
||||
1. Open new Playwright page
|
||||
2. setupBrokerPage(page, realAppUrl) → automated login → find app iframe
|
||||
3. Wait for React render (root.innerHTML.length > 100)
|
||||
4. Wait 3s for NG connection + provider stabilization
|
||||
```
|
||||
|
||||
### Differences from `@data`
|
||||
|
||||
| Aspect | `@data` | `@e2e` |
|
||||
|--------|---------|--------|
|
||||
| What loads in iframe | Test harness (`harness-ng.tsx`) | Real app (`src/index.ts`) |
|
||||
| Ready signal | `window.__testData.ready === true` | `root.innerHTML.length > 100` |
|
||||
| Interaction | `evaluate()` on test bridge | `evaluate()` + Playwright locators |
|
||||
| Mock fallback | Yes (standalone DeepSignalSets) | No — requires real broker |
|
||||
| Tests | Data operations (CRUD, queries) | UI behavior (navigation, redirects, clicks) |
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/shared/test-harness/harness-ng.tsx` | Real broker harness (useShape through broker iframe) |
|
||||
| `src/shared/test-harness/harness.tsx` | Mock harness (DeepSignalSets, no broker) |
|
||||
| `src/shared/support/hooks.ts` | Playwright lifecycle (wallet creation, login automation, iframe detection, app server) |
|
||||
| `src/shared/support/world.ts` | World with `page`/`appFrame` fields |
|
||||
| `src/modules/event/steps/data/inscription.steps.ts` | Inscription data steps |
|
||||
| `src/modules/auth/steps/e2e/connexion.steps.ts` | Auth/connection e2e steps |
|
||||
| `.playwright-profile/` | Persistent Chromium profile (gitignored) |
|
||||
| `scripts/debug-browser.ts` | Manual browser debug tool — launches headed Chromium to inspect broker interactions |
|
||||
| `.playwright-profile-debug/` | Chromium profile created by debug-browser.ts (gitignored) |
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
bun run test:data # Run @data scenarios (real broker if wallet exists, mock fallback)
|
||||
bun run test:cucumber # Run all scenarios (UI + data + e2e)
|
||||
```
|
||||
|
||||
## See Also
|
||||
|
||||
- [BDD Testing](./bdd-testing.md) — general Cucumber setup, UI-layer steps
|
||||
- [Data Layer](./data-layer.md) — NextGraph stack, shapes, context providers
|
||||
@@ -1,115 +0,0 @@
|
||||
# Data Layer
|
||||
|
||||
NextGraph-backed local-first data with fallback to local state for demo/disconnected mode.
|
||||
|
||||
## Overview
|
||||
|
||||
The app has two data modes:
|
||||
1. **Connected** — NextGraph ORM shapes (P2P, encrypted, local-first)
|
||||
2. **Disconnected/Demo** — Local React state seeded from `seedData.ts`
|
||||
|
||||
All screens use `useFestipodData()` hook regardless of mode.
|
||||
|
||||
## NextGraph Stack
|
||||
|
||||
```
|
||||
@ng-org/web # Browser WASM runtime
|
||||
@ng-org/orm # RDF shape-based ORM
|
||||
@ng-org/shex-orm # SHEX → TypeScript code generation
|
||||
@ng-org/alien-deepsignals # Reactive signals bridge
|
||||
```
|
||||
|
||||
Packages installed from npm (`@ng-org/*` alpha versions). For local development against an unreleased `nextgraph-rs` build, `scripts/build-ng-packages.sh` packs the monorepo into `.ng-tarballs/` and updates `package.json` to point at those paths.
|
||||
|
||||
## SHEX Shapes
|
||||
|
||||
`src/shared/shapes/shex/festipodShapes.shex` defines:
|
||||
- **Event** — title, description, dates, location, themes, participants
|
||||
- **UserProfile** — name, username, bio, city, visibility
|
||||
- **Participation** — links event + user, confirmation status
|
||||
|
||||
ORM bindings in `src/shared/shapes/orm/`:
|
||||
- `festipodShapes.schema.ts` — Schema registration
|
||||
- `festipodShapes.shapeTypes.ts` — Shape type constants
|
||||
- `festipodShapes.typings.ts` — TypeScript interfaces
|
||||
|
||||
Regenerate with `bun run build:orm`.
|
||||
|
||||
## NextGraph Read/Write Pattern
|
||||
|
||||
The app follows the same pattern as the official expense-tracker-rdf example:
|
||||
|
||||
- **Scope**: `useShape(shapeType, `did:ng:${session.private_store_id}`)` — opens the private store repo in the verifier
|
||||
- **@graph**: `did:ng:${session.private_store_id}` — writes target the same NURI
|
||||
|
||||
This is critical: `orm_start_graph` with the private store NURI explicitly opens the repo in the verifier's `self.repos` HashMap. Without this, `orm_frontend_update` fails with `RepoNotFound`.
|
||||
|
||||
**Do NOT use `did:ng:i` as scope** — it subscribes to the entire user site via a special code path that doesn't open individual repos, breaking all writes.
|
||||
|
||||
### Deleting Objects
|
||||
|
||||
`ngSet.delete(item)` updates the local reactive set but does **not** persist to the broker. Use `ng.sparql_update()` with SPARQL DELETE instead:
|
||||
|
||||
```typescript
|
||||
import { ng } from '@ng-org/web';
|
||||
import { sessionPromise } from '../utils/ngSession';
|
||||
|
||||
const session = await sessionPromise;
|
||||
await ng.sparql_update(
|
||||
session.session_id,
|
||||
`DELETE WHERE { GRAPH <${item["@graph"]}> { <${item["@id"]}> ?p ?o } }`,
|
||||
item["@graph"],
|
||||
);
|
||||
```
|
||||
|
||||
The broker sends back a `GraphOrmUpdate` with `op: "remove"` that reactively removes the item from the ORM set. **Do NOT combine with `ngSet.delete()`** — the two operations conflict in the CRDT.
|
||||
|
||||
See [decision record](../decisions/2026-03-17-1800-sparql-delete-for-orm-objects.md) for details.
|
||||
|
||||
### Key files
|
||||
|
||||
- `src/shared/hooks/useShapeWithDefaults.ts` — Accepts `storeNuri` param, passes to `useShape`
|
||||
- `src/shared/utils/ngGraph.ts` — `ensureGraphNuri()` returns `@graph` for entity creation
|
||||
- `src/shared/utils/ngBootstrap.ts` — Seeds test data using `ensureGraphNuri()` for `@graph`
|
||||
|
||||
See [decision record](.project/decisions/2026-03-17-1600-private-store-nuri-scope.md) for why.
|
||||
|
||||
## Context Providers
|
||||
|
||||
### NextGraphContext (`src/shared/context/NextGraphContext.tsx`)
|
||||
- Connection lifecycle: `disconnected` → `connecting` → `connected` | `error`
|
||||
- Provides session with store IDs (private, protected, public)
|
||||
- **Conditional auto-init**: Only auto-calls `initNg()` when running inside the broker iframe (`window.self !== window.top`). Outside the iframe, `initNgWeb()` would redirect the page to the broker — so connection waits for explicit `connect()` call.
|
||||
- `connect()`: Called by user clicking "Se connecter". When outside broker, triggers the redirect flow.
|
||||
|
||||
#### `@ng-org/web` redirect behavior
|
||||
`initNgWeb()` checks `window.self === window.top`. If the app is NOT in an iframe, it redirects to `nextgraph.net/redir/` with the current URL encoded as a return parameter. The broker then loads the app back in an iframe after auth. This means the app must NOT auto-init NG when loaded standalone.
|
||||
|
||||
### FestipodDataContext (`src/shared/context/FestipodDataContext.tsx`)
|
||||
- Wraps NextGraph shapes with `useShapeWithDefaults()` hook
|
||||
- CRUD: `createEvent()`, `updateEvent()`, `joinEvent()`, `leaveEvent()`, etc.
|
||||
- Exposes `useFestipodData()` hook consumed by all screens
|
||||
- `selectedEventId` state for cross-screen event navigation
|
||||
- `loadTestData()`: Calls `bootstrapWallet()` to seed test data into NG wallet — only triggered by explicit user action
|
||||
- **Provider states based on NG status**:
|
||||
- `disconnected` → `LocalDataProvider` with seed data (demo mode)
|
||||
- `connecting` → `LocalDataProvider` with **empty data** (avoids flashing seed data before wallet loads)
|
||||
- `connected` → `NgDataProvider` with real wallet data
|
||||
- `error` → `LocalDataProvider` with seed data (graceful fallback)
|
||||
|
||||
## Data Types
|
||||
|
||||
`src/shared/data/types.ts`:
|
||||
- `FpEventData` — id, title, date, location, distance, themes, etc.
|
||||
- `FpUserData` — id, name, username, bio, city, counts
|
||||
- `FpParticipationData` — eventId + userId + confirmed
|
||||
- `FpMeetingPointData` — eventId, location, time, host (local-only)
|
||||
- `FpFriendshipData` — userId + friendId (local-only)
|
||||
|
||||
## Seed Data
|
||||
|
||||
`src/shared/data/seedData.ts`:
|
||||
- 10 users (Marie Dupont = current user, `user-1`)
|
||||
- Multiple events with dates, locations, themes
|
||||
- Participations, meeting points, friendships
|
||||
- `CURRENT_USER_ID = 'user-1'`
|
||||
@@ -1,94 +0,0 @@
|
||||
# Screens
|
||||
|
||||
16 mobile mockup screens using the sketchy hand-drawn component library.
|
||||
|
||||
## Screen Inventory
|
||||
|
||||
### Home Module (`src/modules/home/screens/`)
|
||||
|
||||
| ID | Name | File | Description |
|
||||
|----|------|------|-------------|
|
||||
| `welcome` | Bienvenue | WelcomeScreen.tsx | Onboarding/welcome page |
|
||||
| `home` | Accueil | HomeScreen.tsx | Dashboard with upcoming events, quick actions |
|
||||
| `settings` | Parametres | SettingsScreen.tsx | Notifications, privacy, location settings |
|
||||
|
||||
### Event Module (`src/modules/event/screens/`)
|
||||
|
||||
| ID | Name | File | Description |
|
||||
|----|------|------|-------------|
|
||||
| `events` | Decouvrir | EventsScreen.tsx | Event discovery/search |
|
||||
| `event-detail` | Detail evenement | EventDetailScreen.tsx | Event info, participants, join/leave |
|
||||
| `create-event` | Relayer evenement | CreateEventScreen.tsx | Create/relay event, import from Mobilizon/Transiscope |
|
||||
| `update-event` | Modifier evenement | UpdateEventScreen.tsx | Edit existing event |
|
||||
| `invite` | Inviter des amis | InviteScreen.tsx | Invite contacts to event |
|
||||
| `participants-list` | Liste des participants | ParticipantsListScreen.tsx | Event participant list |
|
||||
| `meeting-points` | Points de rencontre | MeetingPointsScreen.tsx | Carpooling/meeting coordination |
|
||||
|
||||
### User Module (`src/modules/user/screens/`)
|
||||
|
||||
| ID | Name | File | Description |
|
||||
|----|------|------|-------------|
|
||||
| `profile` | Mon profil | ProfileScreen.tsx | Current user profile |
|
||||
| `update-profile` | Modifier mon profil | UpdateProfileScreen.tsx | Edit profile form |
|
||||
| `user-profile` | Profil d'un utilisateur | UserProfileScreen.tsx | View another user's profile |
|
||||
| `friends-list` | Mon reseau | FriendsListScreen.tsx | Network/friends list |
|
||||
| `share-profile` | Partager mon profil | ShareProfileScreen.tsx | QR code + link sharing |
|
||||
|
||||
### Auth Module (`src/modules/auth/screens/`)
|
||||
|
||||
| ID | Name | File | Description |
|
||||
|----|------|------|-------------|
|
||||
| `login` | Connexion | LoginScreen.tsx | Login (NextGraph + email fallback) |
|
||||
|
||||
## Screen Registry
|
||||
|
||||
`src/screens/index.ts` imports all screens and exports:
|
||||
|
||||
```typescript
|
||||
export interface ScreenProps {
|
||||
navigate: (screenId: string) => void;
|
||||
}
|
||||
|
||||
export const screenGroups: ScreenGroup[] // Grouped: home, events, user, general
|
||||
export const screens: Screen[] // Flat list
|
||||
export function getScreen(id: string): Screen | undefined
|
||||
```
|
||||
|
||||
## Sketchy Component Library
|
||||
|
||||
`src/shared/components/sketchy/` — hand-drawn UI with custom font:
|
||||
|
||||
| Component | Usage |
|
||||
|-----------|-------|
|
||||
| `Header` | Screen header with back button |
|
||||
| `NavBar` | Bottom tab navigation |
|
||||
| `Button` | Action buttons |
|
||||
| `Card` | Content cards |
|
||||
| `Input` | Text inputs |
|
||||
| `Title`, `Subtitle`, `Text` | Typography |
|
||||
| `Avatar` | User avatars with initials |
|
||||
| `Badge` | Status/category badges |
|
||||
| `Toggle`, `Checkbox` | Form controls |
|
||||
| `ListItem` | List row items |
|
||||
| `Divider` | Section separators |
|
||||
| `Placeholder` | Image/content placeholders |
|
||||
| `PhoneFrame` | Phone device frame wrapper |
|
||||
| `BrokerBanner` | NextGraph connection status banner |
|
||||
| `NgStatus` | Connection indicator dot |
|
||||
|
||||
## Screen Patterns
|
||||
|
||||
All screens follow the same pattern:
|
||||
|
||||
```typescript
|
||||
import { Header, Button, ... } from '../../../shared/components/sketchy';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import type { ScreenProps } from '../../../screens';
|
||||
|
||||
export function MyScreen({ navigate }: ScreenProps) {
|
||||
const { events, currentUser, ... } = useFestipodData();
|
||||
// render with sketchy components
|
||||
}
|
||||
```
|
||||
|
||||
Navigation between screens uses `navigate(screenId)` — the prototyping tool intercepts this to switch the displayed screen.
|
||||
@@ -1,90 +0,0 @@
|
||||
# Test Layer Contracts
|
||||
|
||||
Each BDD test layer (`@ui`, `@data`, `@e2e`) answers a distinct question. Mixing concerns produces brittle tests that fail on refactors without catching real regressions.
|
||||
|
||||
## Overview
|
||||
|
||||
```
|
||||
/\ @e2e ~10 scénarios, parcours utilisateur critiques
|
||||
/ \
|
||||
/----\
|
||||
/ @data\ ~10 scénarios, mutations & persistance NG
|
||||
/--------\
|
||||
/ @ui \ ~60 scénarios, 1-5 par état d'écran × 15 écrans
|
||||
/____________\
|
||||
```
|
||||
|
||||
The pyramid reflects cost: `@ui` runs in-process (instant), `@data` boots a broker (~50s for the suite), `@e2e` boots broker + real app + navigates a real browser (~2min). Move every assertion to the lowest layer that can answer the question — UI rendering claims belong in `@ui`, not `@e2e`.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
- **`@ui` — display layer.** Renders a screen with `LocalDataProvider` (seed data) + happy-dom and asserts on the resulting DOM. Verifies that *given known data, the screen shows the expected text and elements*. Does **not** test navigation outcomes, mutations, or data persistence.
|
||||
|
||||
- **`@data` — data layer.** Drives ORM mutations through the real NextGraph broker via a headless test harness. No app UI involved. Verifies that *operations on shapes are correctly persisted and observable in the wallet*. See [data-layer-testing](./data-layer-testing.md).
|
||||
|
||||
- **`@e2e` — integration layer.** Boots the real app inside the broker iframe with a Playwright-controlled Chromium. Verifies that *layers collaborate to deliver a user journey* (e.g. create → list → modify → reload → still there). Sparse: 1 scenario per critical path; never duplicate `@ui` content checks here.
|
||||
|
||||
## Implementation
|
||||
|
||||
### `@ui` — rendering helper
|
||||
|
||||
`src/shared/test-harness/renderHelper.tsx` installs happy-dom globals and renders any screen wrapped in `LocalDataProvider` + `RouterProvider`. Called from `world.ts:renderCurrentScreen()` on every `navigateTo(...)`. Seed data (`src/shared/data/seedData.ts`) provides predictable fixtures — `Marie Dupont`/`@mariedupont` is `currentUser`, `Jean Durand`/`@jeandurand` exists in `users`, 5 seed events, etc.
|
||||
|
||||
**Good `@ui` assertion patterns:**
|
||||
|
||||
```ts
|
||||
// Text visible to the user
|
||||
expect(this.getDomText()).to.include('Marie Dupont');
|
||||
|
||||
// Element presence by class/role
|
||||
expect(this.renderedDoc!.querySelector('.app-avatar')).to.not.be.null;
|
||||
|
||||
// Conditional rendering (filled state vs empty state)
|
||||
const cards = this.renderedDoc!.querySelectorAll('.app-card');
|
||||
expect(cards.length).to.be.greaterThan(0);
|
||||
|
||||
// Required form fields rendered with their label + asterisk
|
||||
const labels = Array.from(this.renderedDoc!.querySelectorAll('p'))
|
||||
.map(p => p.textContent ?? '');
|
||||
expect(labels.some(t => t.includes("Nom de l'événement *"))).to.be.true;
|
||||
```
|
||||
|
||||
**Anti-patterns to remove:**
|
||||
|
||||
```ts
|
||||
// ❌ Regex on source: couples test to code structure, fails on refactor
|
||||
expect(/<Title[^>]*>Marie Dupont<\/Title>/.test(source)).to.be.true;
|
||||
|
||||
// ❌ Testing implementation details
|
||||
expect(/showDuplicateWarning/.test(source)).to.be.true;
|
||||
expect(/importableEvents/.test(source)).to.be.true;
|
||||
|
||||
// ❌ Testing JSX structure rather than rendered output
|
||||
expect(/<Avatar[^>]*initials="MD"[^>]*size="lg"/.test(source)).to.be.true;
|
||||
```
|
||||
|
||||
### `@data` — broker-only
|
||||
|
||||
Already isolated correctly. See [data-layer-testing](./data-layer-testing.md). Don't touch the DOM here; use the test harness bridge (`window.__testData`).
|
||||
|
||||
### `@e2e` — full stack
|
||||
|
||||
Path-based routing: navigate via `window.history.pushState` + `popstate` dispatch (`src/modules/auth/steps/e2e/connexion.steps.ts`). Assert on actual DOM text after `appFrame.waitForFunction`. **Do not** re-verify here what `@ui` already covers — `@e2e` should fail when *collaboration* between layers breaks, not when an icon changes.
|
||||
|
||||
## Migration Consequences
|
||||
|
||||
The current `@ui` suite predates this contract. The migration plan:
|
||||
|
||||
1. **Rewrite source-grep assertions** → DOM queries via the helper. The `world.ts:hasText/hasField/hasElement` methods already prefer the rendered DOM and fall back to source — so unmigrated steps still work during the transition.
|
||||
2. **Delete tests on implementation details** (`/showDuplicateWarning/`, `/importableEvents/`, regex on JSX). They protect nothing the user sees.
|
||||
3. **Move behavioral assertions to `@e2e`** when not already covered ("clicking Suivant advances the wizard" — exercise it via Playwright if it's not redundant with existing journeys).
|
||||
4. **Drop redundant `@e2e` content checks** that duplicate `@ui` (e.g. "screen contains 'Découvrir'" — let `@ui` own that).
|
||||
|
||||
`world.ts:screenFileMap`, `screenFieldDetectors`, `screenExpectedContent`, `screenRequiredFields` are vestiges of the source-analysis era. Once the migration is complete, they can be removed in favor of seed-data assertions on the rendered DOM.
|
||||
|
||||
## See Also
|
||||
|
||||
- [BDD Testing setup](./bdd-testing.md) — Cucumber config, file layout, scripts
|
||||
- [Data Layer](./data-layer.md) — NextGraph shapes, seed data, contexts
|
||||
- [Data-Layer Testing](./data-layer-testing.md) — broker harness, wallet setup, Playwright
|
||||
- [Architecture](./architecture.md) — module structure
|
||||
@@ -0,0 +1,3 @@
|
||||
# To discuss
|
||||
|
||||
- [ ] clarifier le vocabulaire d'identité employé par Festipod (wallet, utilisateur, profil, username) et réconcilier decision_2026-07-06 (« identifiant = wallet ») et decision_2026-07-20 (« username dans le profil ») avec ce que le contrat publie aujourd'hui : `ensureIdentity()` ne prend aucun identifiant, et `username` ne désigne plus que `UserProfile.username`
|
||||
@@ -1,75 +1,40 @@
|
||||
# Festipod
|
||||
|
||||
Mobile-first web app for discovering and sharing festival/event recommendations through trusted networks.
|
||||
Web app mobile-first où les utilisateurs créent des **points de rencontre** qui se *greffent* sur des **événements publics** existants, pour favoriser les rencontres. L'événement n'est qu'un prétexte/ancrage ; la valeur, c'est le point de rencontre — **on s'inscrit à un point de rencontre, pas à un événement**. Stack : Bun + React + NextGraph (P2P, local-first, chiffré).
|
||||
|
||||
## Architecture
|
||||
## Invariants à toujours garder
|
||||
|
||||
Feature-based: code organized by business domain, not technical layer. See [architecture](.project/knowledge/architecture.md).
|
||||
- **Architecture feature-based** : le code est organisé par domaine métier, pas par couche technique.
|
||||
```
|
||||
src/modules/{event,user,home,auth,workshop,meeting,notification}/
|
||||
src/shared/ # Composants, context, data — importable par tous les modules
|
||||
src/app/ # App shell (router, providers, entrée)
|
||||
src/screens/index.ts # Registre d'écrans (utilisé par Storybook)
|
||||
```
|
||||
- **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).
|
||||
|
||||
```
|
||||
src/modules/{event,user,home,auth,workshop,meeting,notification}/
|
||||
src/shared/ # Components, context, data — importable by all modules
|
||||
src/app/ # App shell (router, providers, entry point)
|
||||
src/screens/index.ts # Screen registry (used by Storybook)
|
||||
```
|
||||
## Frontière SDK
|
||||
|
||||
## Routing
|
||||
Le SDK de données de Festipod est **`@ng-eventually/polyfill`**, injecté une seule fois via `ngSession.configure(...)`.
|
||||
|
||||
Path-based routing with History API (custom router in `src/app/router.tsx`).
|
||||
**L'engagement que le fournisseur publie est tiré dans ce repo et épinglé** : `data-layer`, fiche `contract_polyfill-surface`. **C'est la seule référence.** On n'ouvre jamais les sources du fournisseur ni sa copie dans `node_modules`, pas même pour vérifier une signature. Ce que le contrat ne dit pas, ce repo ne le sait pas : **un manque est remonté au fournisseur**, jamais contourné ici ni documenté ici. Vaut aussi pour les tests, qui valident **Festipod** et jamais le SDK (`bdd-testing`, `rule_tests-validate-festipod-not-the-sdk`).
|
||||
|
||||
| Path | Screen |
|
||||
|------|--------|
|
||||
| `/` | WelcomeScreen |
|
||||
| `/login` | LoginScreen |
|
||||
| `/home` | HomeScreen |
|
||||
| `/events` | EventsScreen |
|
||||
| `/events/new` | CreateEventScreen |
|
||||
| `/events/:id` | EventDetailScreen |
|
||||
| `/events/:id/edit` | UpdateEventScreen |
|
||||
| `/events/:id/invite` | InviteScreen |
|
||||
| `/events/:id/participants` | ParticipantsListScreen |
|
||||
| `/events/:id/meeting-points` | MeetingPointsScreen |
|
||||
| `/profile` | ProfileScreen |
|
||||
| `/profile/edit` | UpdateProfileScreen |
|
||||
| `/profile/friends` | FriendsListScreen |
|
||||
| `/profile/share` | ShareProfileScreen |
|
||||
| `/users/:id` | UserProfileScreen |
|
||||
| `/settings` | SettingsScreen |
|
||||
Le contrat se re-tire à chaque montée de version : `python3 ~/projects/skills/concept/contracts.py pull` (dérive : `… check`). Sa surface **rétrécit** — un symbole retiré est du code que l'app supprime, pas une régression à amortir.
|
||||
|
||||
Screens use `useNavigate()` and `useParams()` hooks from the router — no prop drilling.
|
||||
**Ne jamais décrire dans ce repo comment la couche de données est implémentée.** La doctrine Festipod décrit uniquement *le contrat* + *comment Festipod l'utilise* + le domaine + l'architecture + le contrat BDD.
|
||||
|
||||
## Data Layer
|
||||
## Doctrine du projet — concepts (livrée automatiquement)
|
||||
|
||||
NextGraph (P2P/local-first) with SHEX shapes and ORM. See [data-layer](.project/knowledge/data-layer.md).
|
||||
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 :
|
||||
|
||||
## BDD Testing
|
||||
| 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 |
|
||||
| `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/polyfill` : entités-documents par scope, shapes SHEX/ORM, modes connected/demo, pièges |
|
||||
| `bdd-testing` | Cucumber multi-couches FR, contrat `@ui`/`@data`/`@e2e`, harness broker, cookbook |
|
||||
| `app-security` | Isolation déléguée au SDK (pas de contrôle d'accès dans les écrans), auth wallet, matrice d'autorisations cible |
|
||||
|
||||
Multi-layer Cucumber/Gherkin in French. See [bdd-testing](.project/knowledge/bdd-testing.md) for the setup and [test-layer-contracts](.project/knowledge/test-layer-contracts.md) for what each layer is allowed to test.
|
||||
|
||||
`@ui` scenarios render screens in-process (happy-dom + seed data) and assert on the DOM. `@data` scenarios test data operations through the real NextGraph broker. `@e2e` scenarios test the real app UI in the broker iframe. See [data-layer-testing](.project/knowledge/data-layer-testing.md).
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
bun run dev # Dev server with HMR (port 3000)
|
||||
bun run build # Production build to dist/
|
||||
bun run storybook # Browse screens and components
|
||||
bun run test:cucumber # Run all BDD tests
|
||||
bun run features:parse # Regenerate features.ts from .feature files
|
||||
bun run steps:extract # Extract step definitions for tooltips
|
||||
bun run build:orm # Regenerate ORM from SHEX shapes
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Architecture](.project/knowledge/architecture.md) — module structure, import rules, app shell
|
||||
- [Data Layer](.project/knowledge/data-layer.md) — NextGraph, shapes, context, seed data
|
||||
- [BDD Testing](.project/knowledge/bdd-testing.md) — Cucumber setup, step layers, feature files
|
||||
- [Test Layer Contracts](.project/knowledge/test-layer-contracts.md) — what each of `@ui`/`@data`/`@e2e` is allowed to test
|
||||
- [Screens](.project/knowledge/screens.md) — screen inventory, registry, sketchy components
|
||||
- [Data-Layer Testing](.project/knowledge/data-layer-testing.md) — real broker testing, wallet setup, Playwright harness, e2e layer
|
||||
|
||||
## Briefs (work not yet started)
|
||||
|
||||
- [Multi-store refactor](.project/briefs/multi-store-refactor.md) — passer du mono-store actuel à une structure de Group stores par communauté/event/RDV (prérequis multi-user)
|
||||
- [Matrice d'autorisations et requêtes](.project/briefs/authorization-matrix.md) — analyse qui doit guider la structure de stores cible
|
||||
Pour **documenter** un fait projet : `/concept document <sujet>` (ne pas écrire en libre dans `.project/`).
|
||||
|
||||
@@ -2,195 +2,8 @@
|
||||
|
||||
# Festipod Project
|
||||
|
||||
Mobile-first web app for discovering and sharing festival/event recommendations through trusted networks. Uses a sketchy hand-drawn UI style.
|
||||
Le cœur toujours-chargé (but produit, invariants, conventions Bun-first, carte des concepts) vit dans `@AGENTS.md` ci-dessus. Toute la doctrine détaillée est dans `.project/concepts/` et **livrée automatiquement par le hook concept** quand tu touches le territoire concerné — ne la recopie pas ici.
|
||||
|
||||
## Architecture
|
||||
|
||||
Feature-based architecture: code is organized by business domain (module), not by technical layer. A module can only import from `shared/` — never from another module.
|
||||
|
||||
Multi-layer BDD: each module has `steps/ui/`, `steps/data/`, `steps/e2e/` directories. Shared step definitions live in `src/shared/steps/`.
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
src/
|
||||
modules/ # Business domain modules
|
||||
event/ # Events (create, discover, detail, update, invite, participants, meeting points)
|
||||
screens/ # EventsScreen, EventDetailScreen, CreateEventScreen, etc.
|
||||
features/ # Gherkin .feature files for this domain
|
||||
steps/ # BDD step definitions
|
||||
ui/ # UI-layer steps
|
||||
data/ # Data-layer steps
|
||||
e2e/ # E2E steps
|
||||
user/ # User profiles, friends, sharing
|
||||
screens/ # ProfileScreen, FriendsListScreen, ShareProfileScreen, etc.
|
||||
features/
|
||||
steps/
|
||||
home/ # Home dashboard, settings
|
||||
screens/ # HomeScreen, SettingsScreen
|
||||
auth/ # Authentication, onboarding
|
||||
screens/ # LoginScreen, WelcomeScreen
|
||||
workshop/ # Workshop/atelier specs (no screens yet)
|
||||
features/
|
||||
steps/
|
||||
meeting/ # Meeting point specs
|
||||
features/
|
||||
steps/
|
||||
notification/ # Notification specs
|
||||
features/
|
||||
steps/
|
||||
shared/ # Shared code (importable by all modules)
|
||||
components/
|
||||
sketchy/ # Hand-drawn UI components (Button, Card, Avatar, etc.)
|
||||
ui/ # Shadcn/Radix components
|
||||
context/ # ThemeContext, NextGraphContext, FestipodDataContext
|
||||
data/ # User stories, features.ts (auto-generated), testResults.ts
|
||||
hooks/ # Custom hooks (useShapeWithDefaults)
|
||||
shapes/ # SHEX shapes + ORM bindings (NextGraph)
|
||||
utils/ # ngSession, ngBootstrap
|
||||
steps/ # Shared BDD step definitions (cross-domain)
|
||||
ui/ # navigation.steps.ts, form.steps.ts, screen.steps.ts
|
||||
data/
|
||||
support/ # Cucumber hooks.ts, world.ts
|
||||
types/ # TypeScript type definitions
|
||||
lib/ # Utility functions (cn, etc.)
|
||||
app/ # App shell
|
||||
App.tsx # Root component with providers + route switch
|
||||
router.tsx # Path-based routing (History API)
|
||||
frontend.tsx # React entry point
|
||||
screens/
|
||||
index.ts # Screen registry (used by Storybook)
|
||||
scripts/ # Build scripts for parsing features
|
||||
docs/ # Documentation
|
||||
.storybook/ # Storybook configuration
|
||||
```
|
||||
|
||||
## Key Commands
|
||||
|
||||
```bash
|
||||
bun run dev # Start dev server with HMR
|
||||
bun run storybook # Browse screens and components in Storybook
|
||||
bun run test:cucumber # Run Cucumber tests
|
||||
bun run features:parse # Regenerate features.ts from .feature files
|
||||
bun run steps:extract # Extract step definitions for tooltips
|
||||
```
|
||||
|
||||
## Routing
|
||||
|
||||
Path-based routing via `src/app/router.tsx`. Screens use `useNavigate()` and `useParams()` hooks. See AGENTS.md for the full route table.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Gherkin specs are in French (Etant donne, Quand, Alors)
|
||||
- UI labels are in French
|
||||
- User stories are prefixed US-1 to US-26
|
||||
- Screens use the sketchy component library, not Tailwind
|
||||
- Max app width: 768px (tablet portrait)
|
||||
|
||||
---
|
||||
|
||||
Default to using Bun instead of Node.js.
|
||||
|
||||
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
|
||||
- Use `bun test` instead of `jest` or `vitest`
|
||||
- Use `bun build <file.html|file.ts|file.css>` instead of `webpack` or `esbuild`
|
||||
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
||||
- Use `bun run <script>` instead of `npm run <script>` or `yarn run <script>` or `pnpm run <script>`
|
||||
- Use `bunx <package> <command>` instead of `npx <package> <command>`
|
||||
- Bun automatically loads .env, so don't use dotenv.
|
||||
|
||||
## APIs
|
||||
|
||||
- `Bun.serve()` supports WebSockets, HTTPS, and routes. Don't use `express`.
|
||||
- `bun:sqlite` for SQLite. Don't use `better-sqlite3`.
|
||||
- `Bun.redis` for Redis. Don't use `ioredis`.
|
||||
- `Bun.sql` for Postgres. Don't use `pg` or `postgres.js`.
|
||||
- `WebSocket` is built-in. Don't use `ws`.
|
||||
- Prefer `Bun.file` over `node:fs`'s readFile/writeFile
|
||||
- Bun.$`ls` instead of execa.
|
||||
|
||||
## Testing
|
||||
|
||||
Use `bun test` to run tests.
|
||||
|
||||
```ts#index.test.ts
|
||||
import { test, expect } from "bun:test";
|
||||
|
||||
test("hello world", () => {
|
||||
expect(1).toBe(1);
|
||||
});
|
||||
```
|
||||
|
||||
## Frontend
|
||||
|
||||
Use HTML imports with `Bun.serve()`. Don't use `vite`. HTML imports fully support React, CSS, Tailwind.
|
||||
|
||||
Server:
|
||||
|
||||
```ts#index.ts
|
||||
import index from "./index.html"
|
||||
|
||||
Bun.serve({
|
||||
routes: {
|
||||
"/": index,
|
||||
"/api/users/:id": {
|
||||
GET: (req) => {
|
||||
return new Response(JSON.stringify({ id: req.params.id }));
|
||||
},
|
||||
},
|
||||
},
|
||||
// optional websocket support
|
||||
websocket: {
|
||||
open: (ws) => {
|
||||
ws.send("Hello, world!");
|
||||
},
|
||||
message: (ws, message) => {
|
||||
ws.send(message);
|
||||
},
|
||||
close: (ws) => {
|
||||
// handle close
|
||||
}
|
||||
},
|
||||
development: {
|
||||
hmr: true,
|
||||
console: true,
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
HTML files can import .tsx, .jsx or .js files directly and Bun's bundler will transpile & bundle automatically. `<link>` tags can point to stylesheets and Bun's CSS bundler will bundle.
|
||||
|
||||
```html#index.html
|
||||
<html>
|
||||
<body>
|
||||
<h1>Hello, world!</h1>
|
||||
<script type="module" src="./frontend.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
With the following `frontend.tsx`:
|
||||
|
||||
```tsx#frontend.tsx
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
// import .css files directly and it works
|
||||
import './index.css';
|
||||
|
||||
const root = createRoot(document.body);
|
||||
|
||||
export default function Frontend() {
|
||||
return <h1>Hello, world!</h1>;
|
||||
}
|
||||
|
||||
root.render(<Frontend />);
|
||||
```
|
||||
|
||||
Then, run index.ts
|
||||
|
||||
```sh
|
||||
bun --hot ./index.ts
|
||||
```
|
||||
|
||||
For more information, read the Bun API docs in `node_modules/bun-types/docs/**.mdx`.
|
||||
- Specs Gherkin et libellés UI en **français** (`Etant donné`, `Quand`, `Alors`).
|
||||
- Conventions techniques (Bun, APIs, build) : concept `tech-stack`. Architecture et écrans : concept `app-architecture`.
|
||||
- Documenter un fait projet : `/concept document <sujet>`.
|
||||
|
||||
+12
-4
@@ -1,11 +1,19 @@
|
||||
# Use the official Bun image
|
||||
# Use the official Bun image (runtime stays Bun; only install moves to pnpm)
|
||||
FROM oven/bun:1-alpine AS base
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
# Install dependencies with pnpm.
|
||||
# - git: the @ng-eventually/client polyfill is a git+https (public Gitea) dependency → no auth.
|
||||
# - nodejs + npm: pnpm is a Node CLI; we pin the exact pnpm version via `npm i -g`
|
||||
# (Alpine's nodejs package does not bundle corepack).
|
||||
# The `bun` npm peer (pulled by bun-plugin-tailwind) is approved to build in package.json
|
||||
# (pnpm.onlyBuiltDependencies) so node_modules/.bin/bun is a real binary — required because
|
||||
# `bun run start` puts node_modules/.bin ahead of PATH.
|
||||
FROM base AS install
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
RUN apk add --no-cache git nodejs npm \
|
||||
&& npm install -g pnpm@10.26.0
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source code and build assets
|
||||
FROM base AS release
|
||||
|
||||
@@ -40,7 +40,7 @@ Implémentées dans le code (écrans visibles via le router) :
|
||||
- Liste d'amis (connexions)
|
||||
- Profil d'un autre utilisateur
|
||||
|
||||
Voir le tableau des routes dans [AGENTS.md](./AGENTS.md#routing) et l'inventaire des écrans dans [.project/knowledge/screens.md](./.project/knowledge/screens.md).
|
||||
Voir l'inventaire des routes et des écrans dans le concept [app-architecture](./.project/concepts/app-architecture/).
|
||||
|
||||
### Défis ouverts
|
||||
|
||||
@@ -56,7 +56,7 @@ Identifiées comme nécessaires (notamment pour la scalabilité et la découvert
|
||||
- **Abonnement à une communauté d'intérêt** pour découvrir ses événements (mécanisme de discovery distribué).
|
||||
- **Abonnement à un utilisateur** pour suivre les événements qu'il déclare (sans nécessairement être ami).
|
||||
- **Listes curated** — créer et partager des sélections d'événements éditorialisées.
|
||||
- **Multi-utilisateurs collaboratif** : aujourd'hui chaque utilisateur a ses données isolées dans son wallet. Le passage en mode collaboratif (un point de rencontre vu par plusieurs personnes) suppose un refactor de la couche données vers les Group stores NextGraph. Voir [brief multi-store-refactor](./.project/briefs/multi-store-refactor.md).
|
||||
- **Multi-utilisateurs collaboratif** : aujourd'hui chaque utilisateur a ses données isolées dans son wallet. Le passage en mode collaboratif (un point de rencontre vu par plusieurs personnes) suppose un refactor de la couche données. Voir le concept [nextgraph-platform](./.project/concepts/nextgraph-platform/) (briefs multi-store, matrice d'autorisations, wallet partagé, fork inbox).
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -78,7 +78,5 @@ bun run build:orm # Régénérer l'ORM depuis les SHEX shapes
|
||||
|
||||
## Documentation
|
||||
|
||||
- [AGENTS.md](./AGENTS.md) — architecture, routes, points d'entrée pour contribuer
|
||||
- [.project/knowledge/](./.project/knowledge/) — comment les choses fonctionnent (data layer, BDD, écrans…)
|
||||
- [.project/decisions/](./.project/decisions/) — choix techniques figés
|
||||
- [.project/briefs/](./.project/briefs/) — chantiers à venir, recherche préparatoire
|
||||
- [AGENTS.md](./AGENTS.md) — cœur : but produit, invariants, carte des concepts
|
||||
- [.project/concepts/](./.project/concepts/) — toute la doctrine projet (savoir, règles, décisions, briefs), typée et livrée par hook au moment pertinent. 6 concepts : `functional-domain`, `app-architecture`, `tech-stack`, `data-layer`, `bdd-testing`, `nextgraph-platform`.
|
||||
|
||||
@@ -133,10 +133,30 @@ const result = await Bun.build({
|
||||
sourcemap: "linked",
|
||||
define: {
|
||||
"process.env.NODE_ENV": JSON.stringify("production"),
|
||||
// Shared wallet password, baked into the browser bundle as a global (see
|
||||
// sharedWallet.ts) and handed to the SDK by the ONE `configure` call. The
|
||||
// wallet FILE is copied into the outdir below (served at /shared-wallet.ngw).
|
||||
"globalThis.__FESTIPOD_SHARED_WALLET_PASSWORD__": JSON.stringify(
|
||||
process.env.FESTIPOD_SHARED_WALLET_PASSWORD ?? "",
|
||||
),
|
||||
// Auto-seed gate (OFF by default): only seed a genuinely-empty wallet with
|
||||
// demo data when FESTIPOD_AUTO_SEED is set (see src/shared/utils/autoSeed.ts).
|
||||
"globalThis.__FESTIPOD_AUTO_SEED__": JSON.stringify(
|
||||
process.env.FESTIPOD_AUTO_SEED ?? "",
|
||||
),
|
||||
},
|
||||
...cliConfig,
|
||||
});
|
||||
|
||||
// Staging: copy the shared wallet FILE into the bundle so it can be offered for
|
||||
// download (served at /shared-wallet.ngw — the `fileUrl` the app hands the SDK
|
||||
// through `configure`). See sharedWallet.ts.
|
||||
if (process.env.FESTIPOD_SHARED_WALLET_FILE) {
|
||||
const { copyFileSync } = await import("fs");
|
||||
copyFileSync(process.env.FESTIPOD_SHARED_WALLET_FILE, path.join(outdir, "shared-wallet.ngw"));
|
||||
console.log(`📦 Copied shared wallet → ${path.join(outdir, "shared-wallet.ngw")}`);
|
||||
}
|
||||
|
||||
const end = performance.now();
|
||||
|
||||
const outputTable = result.outputs.map(output => ({
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"src/modules/*/steps/**/*.ts"
|
||||
],
|
||||
"paths": ["src/modules/*/features/**/*.feature"],
|
||||
"tags": "not @wip and not @humain",
|
||||
"format": [
|
||||
"progress-bar",
|
||||
"json:reports/cucumber-report.json",
|
||||
|
||||
+13
-4
@@ -8,18 +8,21 @@
|
||||
"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",
|
||||
"build:orm": "rdf-orm build --input ./src/shared/shapes/shex --output ./src/shared/shapes/orm",
|
||||
"validate": "bun scripts/validate.ts",
|
||||
"build:ng": "bash scripts/build-ng-packages.sh",
|
||||
"link:polyfill": "bun scripts/link-polyfill.ts",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ng-eventually/polyfill": "git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#main&path:/packages/polyfill",
|
||||
"@ng-org/alien-deepsignals": "0.1.2-alpha.11",
|
||||
"@ng-org/orm": "0.1.2-alpha.18",
|
||||
"@ng-org/shex-orm": "0.1.2-alpha.8",
|
||||
@@ -56,5 +59,11 @@
|
||||
"@storybook/addon-a11y": "^10.3.5",
|
||||
"@storybook/addon-docs": "^10.3.5",
|
||||
"@storybook/addon-onboarding": "^10.3.5"
|
||||
}
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"bun"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.26.0+sha512.3b3f6c725ebe712506c0ab1ad4133cf86b1f4b687effce62a9b38b4d72e3954242e643190fc51fa1642949c735f403debd44f5cb0edd657abe63a8b6a7e1e402"
|
||||
}
|
||||
|
||||
Generated
+6408
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+83
-9508
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* link-polyfill.ts — Reactive local link for the @ng-eventually/polyfill polyfill.
|
||||
*
|
||||
* WHY a copy-overlay and not a symlink:
|
||||
* The committed prod dependency installs @ng-eventually/polyfill 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/polyfill 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/polyfill (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/polyfill/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/polyfill.
|
||||
*/
|
||||
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/polyfill";
|
||||
const TARGET = join(FESTIPOD, "node_modules", "@ng-eventually", "polyfill");
|
||||
const SRC_LOCAL = join(LOCAL, "src");
|
||||
const SRC_TARGET = join(TARGET, "src");
|
||||
const ONCE = process.argv.includes("--once");
|
||||
|
||||
function fail(msg: string): never {
|
||||
console.error(`✖ link:polyfill — ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!existsSync(join(LOCAL, "package.json"))) {
|
||||
fail(`local polyfill not found at ${LOCAL} (set NG_EVENTUALLY_LOCAL to override)`);
|
||||
}
|
||||
|
||||
// 1. Replace the pnpm store symlink with a real overlay dir (metadata + src, NO node_modules).
|
||||
console.log(`→ overlaying local polyfill: ${LOCAL}`);
|
||||
if (existsSync(TARGET) || lstatSync(TARGET, { throwIfNoEntry: false })) {
|
||||
rmSync(TARGET, { recursive: true, force: true });
|
||||
}
|
||||
mkdirSync(TARGET, { recursive: true });
|
||||
for (const meta of ["package.json", "tsconfig.json", "README.md"]) {
|
||||
const from = join(LOCAL, meta);
|
||||
if (existsSync(from)) copyFileSync(from, join(TARGET, meta));
|
||||
}
|
||||
// Copy src fresh (NEVER a node_modules dir — that is what guarantees single @ng-org instance).
|
||||
cpSync(SRC_LOCAL, SRC_TARGET, { recursive: true });
|
||||
|
||||
// 2. Assert the single-instance invariant.
|
||||
const fromFestipod = realpathSync(Bun.resolveSync("@ng-org/web", FESTIPOD));
|
||||
const overlayReal = realpathSync(TARGET);
|
||||
const fromPolyfill = realpathSync(Bun.resolveSync("@ng-org/web", overlayReal));
|
||||
console.log(` @ng-org/web (Festipod): ${fromFestipod}`);
|
||||
console.log(` @ng-org/web (overlay) : ${fromPolyfill}`);
|
||||
if (fromFestipod !== fromPolyfill) {
|
||||
fail(
|
||||
"single-instance invariant BROKEN — @ng-org/web resolves to two different realpaths.\n" +
|
||||
" The overlay must not contain its own node_modules/@ng-org. Aborting.",
|
||||
);
|
||||
}
|
||||
console.log("✓ single @ng-org/web instance preserved");
|
||||
|
||||
if (ONCE) {
|
||||
console.log("✓ overlay ready (--once, not watching)");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 3. Watch and copy on change so `bun --hot` sees live edits.
|
||||
console.log(`👀 watching ${SRC_LOCAL} → ${SRC_TARGET} (Ctrl-C to stop)`);
|
||||
watch(SRC_LOCAL, { recursive: true }, (_event, filename) => {
|
||||
if (!filename) return;
|
||||
const from = join(SRC_LOCAL, filename);
|
||||
const to = join(SRC_TARGET, filename);
|
||||
try {
|
||||
if (existsSync(from)) {
|
||||
mkdirSync(dirname(to), { recursive: true });
|
||||
copyFileSync(from, to);
|
||||
console.log(` ↻ ${filename}`);
|
||||
} else if (existsSync(to)) {
|
||||
rmSync(to, { force: true });
|
||||
console.log(` ✗ ${filename} (removed)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(` ! failed to sync ${filename}:`, err);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,490 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* validate.ts — Validation matrix (broker-level tests only, no @ui/@e2e).
|
||||
*
|
||||
* Default run (no flags) — key subset only, fast:
|
||||
* (a) Polyfill unit tests (@ng-eventually/client — bun test)
|
||||
* (b) Polyfill e2e real-broker (@ng-eventually/client — bun run e2e/run.ts)
|
||||
* (c) Festipod @data KEY SUBSET (cucumber --name regex covering terrain bugs)
|
||||
* (d) Festipod @multibrowser (cucumber --tags @multibrowser)
|
||||
* (e) Festipod @smoke (cucumber --tags @smoke — boot connecté rend)
|
||||
* (f) Festipod @wip [informational only, non-blocking]
|
||||
*
|
||||
* With --full flag:
|
||||
* (c) becomes full @data suite (cucumber --tags @data)
|
||||
*
|
||||
* Each step runs even if the previous one failed (--bail mode is OFF).
|
||||
* Exit code is non-zero if any non-informational step has failures.
|
||||
*
|
||||
* Profile rotation: both Playwright profiles are rotated before @data and
|
||||
* @multibrowser when their size exceeds BLOAT_THRESHOLD_MB (default 50 MB),
|
||||
* to avoid the sparql_query hang described in caveat_wallet-bloat-hang.
|
||||
*/
|
||||
|
||||
import { spawnSync } from "child_process";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
// ─── Config ────────────────────────────────────────────────────────────────
|
||||
|
||||
const FESTIPOD_DIR = "/home/sylvain/projects/festipod/festipod";
|
||||
const POLYFILL_DIR =
|
||||
"/home/sylvain/projects/nextgraph/ng-eventually-js/packages/client";
|
||||
|
||||
const FESTIPOD_PROFILE = path.join(FESTIPOD_DIR, ".playwright-profile");
|
||||
const POLYFILL_PROFILE = path.join(
|
||||
POLYFILL_DIR,
|
||||
"e2e",
|
||||
".playwright-profile-lib",
|
||||
);
|
||||
|
||||
/** Rotate profile when it exceeds this many MB (caveat_wallet-bloat-hang). */
|
||||
const BLOAT_THRESHOLD_MB = 50;
|
||||
|
||||
/**
|
||||
* Per-step timeouts:
|
||||
* - polyfill unit/e2e: short steps, keep 10 min
|
||||
* - @data key subset: generous — BeforeAll + 8 scenarios, ~15 min margin
|
||||
* - @data full: full suite, ~35 min margin
|
||||
* - @multibrowser: 7 scenarios, ~10 min margin
|
||||
* - @wip: informational, 10 min
|
||||
*/
|
||||
const TIMEOUT_POLYFILL_UNIT_MS = 10 * 60 * 1000; // 10 min
|
||||
const TIMEOUT_POLYFILL_E2E_MS = 10 * 60 * 1000; // 10 min
|
||||
const TIMEOUT_DATA_KEY_MS = 15 * 60 * 1000; // 15 min (key subset)
|
||||
const TIMEOUT_DATA_FULL_MS = 35 * 60 * 1000; // 35 min (--full)
|
||||
const TIMEOUT_MULTIBROWSER_MS = 10 * 60 * 1000; // 10 min
|
||||
const TIMEOUT_SMOKE_MS = 10 * 60 * 1000; // 10 min (1 @e2e boot scenario)
|
||||
const TIMEOUT_WIP_MS = 10 * 60 * 1000; // 10 min
|
||||
|
||||
/**
|
||||
* Key-subset --name regex: matches exactly the 8 scenarios that cover the
|
||||
* known terrain bugs (inscription, désinscription, isolation, reconnexion,
|
||||
* compteur dérivé, créateur ne participe pas, auth vide, auth distinctes).
|
||||
*
|
||||
* Uses a single cucumber invocation so BeforeAll (broker login) runs once.
|
||||
*
|
||||
* French accent chars must be URL-safe in the regex — cucumber uses JS
|
||||
* RegExp, which handles unicode natively; we pass the literal string.
|
||||
*/
|
||||
const DATA_KEY_NAME_REGEX = [
|
||||
"S'inscrire à un événement",
|
||||
"Se désinscrire d'un événement$",
|
||||
"Une identité fraîche ne voit pas la participation d'une autre",
|
||||
"Une page fraîche pour la même identité relit ses propres données",
|
||||
"Le créateur ne participe pas automatiquement à son événement",
|
||||
"L'inscription fait converger le compteur dérivé du propriétaire",
|
||||
"Un portefeuille connecté est vide par défaut",
|
||||
"Les données du portefeuille sont distinctes des données par défaut",
|
||||
].join("|");
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function dirSizeMB(dir: string): number {
|
||||
if (!fs.existsSync(dir)) return 0;
|
||||
try {
|
||||
const result = spawnSync("du", ["-sm", dir], { encoding: "utf-8" });
|
||||
const line = result.stdout.trim().split("\n")[0] ?? "";
|
||||
return parseInt(line.split("\t")[0] ?? "0", 10);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any stale Chromium singleton files from `profilePath`. Chromium refuses
|
||||
* to launch (ProcessSingleton error) if SingletonLock, SingletonCookie, or
|
||||
* SingletonSocket are left over from a previous crashed run. Idempotent — safe to
|
||||
* call even when the profile does not exist yet.
|
||||
*/
|
||||
function cleanSingletons(profilePath: string, label: string): void {
|
||||
if (!fs.existsSync(profilePath)) return;
|
||||
const singletons = ["SingletonLock", "SingletonCookie", "SingletonSocket"];
|
||||
for (const name of singletons) {
|
||||
const p = path.join(profilePath, name);
|
||||
if (fs.existsSync(p)) {
|
||||
try {
|
||||
fs.rmSync(p, { force: true });
|
||||
console.log(`[rotate] ${label}: removed stale ${name}.`);
|
||||
} catch {
|
||||
// Non-fatal: if we can't remove it, launch will fail with a clear error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rotateProfile(profilePath: string, label: string): void {
|
||||
const sizeMB = dirSizeMB(profilePath);
|
||||
if (sizeMB > BLOAT_THRESHOLD_MB) {
|
||||
console.log(
|
||||
`[rotate] ${label}: ${sizeMB}MB > ${BLOAT_THRESHOLD_MB}MB — rotating profile...`,
|
||||
);
|
||||
try {
|
||||
fs.rmSync(profilePath, { recursive: true, force: true });
|
||||
console.log(`[rotate] ${label}: profile removed. Will be recreated.`);
|
||||
} catch (e) {
|
||||
console.warn(`[rotate] ${label}: failed to remove profile: ${e}`);
|
||||
}
|
||||
} else {
|
||||
// Even if we keep the profile, remove any stale Chromium singleton files left
|
||||
// by a previous crashed run — Chromium refuses to launch if they exist.
|
||||
cleanSingletons(profilePath, label);
|
||||
console.log(
|
||||
`[rotate] ${label}: ${sizeMB}MB — below threshold, keeping profile.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface StepResult {
|
||||
label: string;
|
||||
status: "passed" | "failed" | "error";
|
||||
/** Lines to show in the summary (failed scenario names, FAIL lines, etc.) */
|
||||
failures: string[];
|
||||
/** Raw exit code */
|
||||
exitCode: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command and capture its output. Returns the result with parsed
|
||||
* pass/fail summary. Never throws — all errors are captured in StepResult.
|
||||
*/
|
||||
function runStep(
|
||||
label: string,
|
||||
cmd: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
timeoutMs: number,
|
||||
extraEnv: Record<string, string> = {},
|
||||
): StepResult {
|
||||
const t0 = Date.now();
|
||||
console.log(`\n${"═".repeat(60)}`);
|
||||
console.log(`▶ ${label}`);
|
||||
console.log(` ${cmd} ${args.join(" ")} (cwd: ${cwd})`);
|
||||
console.log(` timeout: ${Math.round(timeoutMs / 60000)}min`);
|
||||
console.log(`${"═".repeat(60)}`);
|
||||
|
||||
const env = { ...process.env, ...extraEnv };
|
||||
|
||||
const result = spawnSync(cmd, args, {
|
||||
cwd,
|
||||
env,
|
||||
encoding: "utf-8",
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 20 * 1024 * 1024, // 20MB
|
||||
});
|
||||
|
||||
const durationMs = Date.now() - t0;
|
||||
const stdout = result.stdout ?? "";
|
||||
const stderr = result.stderr ?? "";
|
||||
const combined = stdout + "\n" + stderr;
|
||||
|
||||
// Print output in real-time equivalent (post-hoc since spawnSync)
|
||||
if (stdout) process.stdout.write(stdout);
|
||||
if (stderr) process.stderr.write(stderr);
|
||||
|
||||
if (result.error) {
|
||||
console.error(`[${label}] process error:`, result.error.message);
|
||||
return {
|
||||
label,
|
||||
status: "error",
|
||||
failures: [`Process error: ${result.error.message}`],
|
||||
exitCode: result.status ?? 1,
|
||||
durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
const exitCode = result.status ?? 1;
|
||||
const failures = extractFailures(combined, label);
|
||||
const status = exitCode === 0 ? "passed" : "failed";
|
||||
|
||||
return { label, status, failures, exitCode, durationMs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract meaningful failure lines from combined stdout+stderr.
|
||||
* Heuristics per step type (cucumber scenario names, FAIL lines, etc.).
|
||||
*/
|
||||
function extractFailures(output: string, label: string): string[] {
|
||||
const lines = output.split("\n");
|
||||
const failures: string[] = [];
|
||||
|
||||
if (label.includes("polyfill:unit")) {
|
||||
// bun test output: lines starting with "✗" or "FAIL" or "fail"
|
||||
for (const line of lines) {
|
||||
const l = line.trim();
|
||||
if (/^(✗|✕|FAIL|fail)\s/.test(l) || l.includes("tests failed")) {
|
||||
failures.push(l);
|
||||
}
|
||||
}
|
||||
// Also capture summary line "N passed, M failed"
|
||||
const summary = lines.find(
|
||||
(l) => l.includes("passed") && l.includes("failed"),
|
||||
);
|
||||
if (summary) failures.push(summary.trim());
|
||||
} else if (label.includes("polyfill:e2e")) {
|
||||
// e2e/run.ts output: lines starting with " [FAIL]"
|
||||
for (const line of lines) {
|
||||
const l = line.trim();
|
||||
if (l.startsWith("[FAIL]")) failures.push(l);
|
||||
}
|
||||
// Summary: "N passed / M failed" style
|
||||
const summary = lines.find(
|
||||
(l) => l.includes("passed") || l.includes("failed"),
|
||||
);
|
||||
if (summary && !failures.includes(summary.trim()))
|
||||
failures.push(summary.trim());
|
||||
} else {
|
||||
// Cucumber steps: look for "✗" scenario lines, "FAILED" scenario names,
|
||||
// or lines beginning with "✖" / "×" / "Scenario:" after a failure tag
|
||||
for (const line of lines) {
|
||||
const l = line.trim();
|
||||
if (
|
||||
/^(✗|✕|×|✖)\s/.test(l) ||
|
||||
l.startsWith("✘") ||
|
||||
l.includes("# Scénario:") ||
|
||||
l.includes("# Scenario:") ||
|
||||
(l.startsWith("F") && l.length === 1) // progress-bar failure tick
|
||||
) {
|
||||
if (l.length > 1) failures.push(l);
|
||||
}
|
||||
}
|
||||
// Cucumber "N scenarios (M failed)" summary
|
||||
const summary = lines.find((l) =>
|
||||
/\d+ sc[eé]narios?.*(failed|undefined)/.test(l),
|
||||
);
|
||||
if (summary) failures.push(summary.trim());
|
||||
// Individual scenario fail lines: "✗ Scenario name (features/...)"
|
||||
for (const line of lines) {
|
||||
const l = line.trim();
|
||||
if (l.startsWith("✗") || l.startsWith("✕")) {
|
||||
if (!failures.includes(l)) failures.push(l);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return failures.filter(Boolean);
|
||||
}
|
||||
|
||||
function fmtDuration(ms: number): string {
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
const m = Math.floor(ms / 60_000);
|
||||
const s = ((ms % 60_000) / 1000).toFixed(0);
|
||||
return `${m}m${s}s`;
|
||||
}
|
||||
|
||||
function printMatrix(
|
||||
steps: StepResult[],
|
||||
wipResult: StepResult | null,
|
||||
fullMode: boolean,
|
||||
): void {
|
||||
console.log("\n");
|
||||
console.log("╔══════════════════════════════════════════════════════════╗");
|
||||
console.log("║ VALIDATION MATRIX ║");
|
||||
if (fullMode) {
|
||||
console.log("║ (mode: --full, @data complet) ║");
|
||||
} else {
|
||||
console.log("║ (mode: défaut, sous-ensemble clé) ║");
|
||||
}
|
||||
console.log("╚══════════════════════════════════════════════════════════╝");
|
||||
console.log("");
|
||||
|
||||
const maxLabel = Math.max(...steps.map((s) => s.label.length));
|
||||
|
||||
for (const step of steps) {
|
||||
const icon = step.status === "passed" ? "✅" : step.status === "failed" ? "❌" : "⚠️ ";
|
||||
const pad = step.label.padEnd(maxLabel + 2);
|
||||
console.log(` ${icon} ${pad} [${fmtDuration(step.durationMs)}]`);
|
||||
for (const f of step.failures) {
|
||||
console.log(` ↳ ${f}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (wipResult) {
|
||||
console.log("");
|
||||
console.log(" ── @wip (informational, non-blocking) ──────────────────");
|
||||
const icon =
|
||||
wipResult.status === "passed"
|
||||
? "✅"
|
||||
: wipResult.status === "failed"
|
||||
? "❌"
|
||||
: "⚠️ ";
|
||||
const pad = wipResult.label.padEnd(maxLabel + 2);
|
||||
console.log(` ${icon} ${pad} [${fmtDuration(wipResult.durationMs)}]`);
|
||||
for (const f of wipResult.failures) {
|
||||
console.log(` ↳ ${f}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
const allPassed = steps.every((s) => s.status === "passed");
|
||||
const totalMs = steps.reduce((sum, s) => sum + s.durationMs, 0) +
|
||||
(wipResult?.durationMs ?? 0);
|
||||
if (allPassed) {
|
||||
console.log(" 🟢 ALL STEPS PASSED");
|
||||
} else {
|
||||
const failed = steps.filter((s) => s.status !== "passed");
|
||||
console.log(` 🔴 ${failed.length} STEP(S) FAILED: ${failed.map((s) => s.label).join(", ")}`);
|
||||
}
|
||||
console.log(` ⏱ Total: ${fmtDuration(totalMs)}`);
|
||||
if (!fullMode) {
|
||||
console.log(" ℹ️ Pour @data complet : bun run validate -- --full");
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// ─── Cucumber command builder ───────────────────────────────────────────────
|
||||
|
||||
function cucumberArgsByTags(tags: string): string[] {
|
||||
return [
|
||||
"--import",
|
||||
"tsx/esm",
|
||||
"node_modules/.bin/cucumber-js",
|
||||
"--config",
|
||||
"cucumber.json",
|
||||
"--tags",
|
||||
tags,
|
||||
];
|
||||
}
|
||||
|
||||
function cucumberArgsByName(nameRegex: string): string[] {
|
||||
return [
|
||||
"--import",
|
||||
"tsx/esm",
|
||||
"node_modules/.bin/cucumber-js",
|
||||
"--config",
|
||||
"cucumber.json",
|
||||
"--tags",
|
||||
"@data",
|
||||
"--name",
|
||||
nameRegex,
|
||||
];
|
||||
}
|
||||
|
||||
// ─── Main ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const fullMode = args.includes("--full");
|
||||
|
||||
if (fullMode) {
|
||||
console.log("🔍 Festipod — Full Validation Run (--full : @data complet)");
|
||||
} else {
|
||||
console.log("🔍 Festipod — Validation Run (sous-ensemble clé)");
|
||||
console.log(" Pour @data complet : bun run validate -- --full");
|
||||
}
|
||||
console.log(` Festipod: ${FESTIPOD_DIR}`);
|
||||
console.log(` Polyfill: ${POLYFILL_DIR}`);
|
||||
console.log("");
|
||||
|
||||
// ── Profile rotation AVANT les étapes broker ──────────────────────────────
|
||||
console.log("── Profile rotation check (avant @data et @multibrowser) ────");
|
||||
rotateProfile(FESTIPOD_PROFILE, "festipod");
|
||||
rotateProfile(POLYFILL_PROFILE, "polyfill-lib");
|
||||
|
||||
const steps: StepResult[] = [];
|
||||
|
||||
// ── (a) Polyfill unit tests ───────────────────────────────────────────────
|
||||
steps.push(
|
||||
runStep(
|
||||
"polyfill:unit",
|
||||
"bun",
|
||||
["test"],
|
||||
POLYFILL_DIR,
|
||||
TIMEOUT_POLYFILL_UNIT_MS,
|
||||
),
|
||||
);
|
||||
|
||||
// ── (b) Polyfill e2e real broker ──────────────────────────────────────────
|
||||
// Clean singleton files immediately before launching Chromium — guards against
|
||||
// any file left by polyfill:unit (unlikely but defensive) or by a previous
|
||||
// interrupted run that the initial rotateProfile call ran before.
|
||||
cleanSingletons(POLYFILL_PROFILE, "polyfill-lib (pre-e2e)");
|
||||
steps.push(
|
||||
runStep(
|
||||
"polyfill:e2e",
|
||||
"bun",
|
||||
["run", "e2e/run.ts"],
|
||||
POLYFILL_DIR,
|
||||
TIMEOUT_POLYFILL_E2E_MS,
|
||||
),
|
||||
);
|
||||
|
||||
// ── (c) Festipod @data ────────────────────────────────────────────────────
|
||||
if (fullMode) {
|
||||
// --full : lance tout @data
|
||||
steps.push(
|
||||
runStep(
|
||||
"festipod:@data (complet)",
|
||||
"node",
|
||||
cucumberArgsByTags("@data"),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_DATA_FULL_MS,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// défaut : sous-ensemble clé en UNE invocation (BeforeAll partagé)
|
||||
steps.push(
|
||||
runStep(
|
||||
"festipod:@data (clé)",
|
||||
"node",
|
||||
cucumberArgsByName(DATA_KEY_NAME_REGEX),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_DATA_KEY_MS,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── (d) Festipod @multibrowser ────────────────────────────────────────────
|
||||
// Exclude @wip: a scenario tagged @wip @multibrowser (e.g. the reactive
|
||||
// cross-session scenario, blocked by the shared-wallet structural limit) must
|
||||
// not gate the baseline — it flows into the informational @wip pass below.
|
||||
steps.push(
|
||||
runStep(
|
||||
"festipod:@multibrowser",
|
||||
"node",
|
||||
cucumberArgsByTags("@multibrowser and not @wip"),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_MULTIBROWSER_MS,
|
||||
),
|
||||
);
|
||||
|
||||
// ── (e) Festipod @smoke — boot connecté rend / page blanche ───────────────
|
||||
// Un seul scénario @e2e : boote le VRAI App, se connecte au broker, et vérifie
|
||||
// que l'accueil connecté rend du contenu d'app réel SANS erreur runtime. Garde
|
||||
// la CLASSE « crash de rendu une fois connecté » (page blanche). On ne lance
|
||||
// QUE @smoke (pas tout @e2e) pour garder le run par défaut rapide.
|
||||
// Nettoie les singletons Chromium juste avant, comme les autres étapes broker.
|
||||
cleanSingletons(FESTIPOD_PROFILE, "festipod (pre-@smoke)");
|
||||
steps.push(
|
||||
runStep(
|
||||
"festipod:@smoke",
|
||||
"node",
|
||||
cucumberArgsByTags("@smoke and not @wip"),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_SMOKE_MS,
|
||||
),
|
||||
);
|
||||
|
||||
// ── (f) Festipod @wip [informational] ────────────────────────────────────
|
||||
console.log("\n── @wip informational pass (non-blocking) ──────────────────");
|
||||
const wipResult = runStep(
|
||||
"festipod:@wip",
|
||||
"node",
|
||||
cucumberArgsByTags("@wip"),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_WIP_MS,
|
||||
);
|
||||
|
||||
// ── Matrix ────────────────────────────────────────────────────────────────
|
||||
printMatrix(steps, wipResult, fullMode);
|
||||
|
||||
// ── Exit code ─────────────────────────────────────────────────────────────
|
||||
const anyFailed = steps.some((s) => s.status !== "passed");
|
||||
process.exit(anyFailed ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("validate.ts: unhandled error:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
+4
-3
@@ -2,11 +2,11 @@ import { RouterProvider, useRouter } from './router';
|
||||
import { ThemeProvider } from '../shared/context/ThemeContext';
|
||||
import { NextGraphProvider } from '../shared/context/NextGraphContext';
|
||||
import { FestipodDataProvider } from '../shared/context/FestipodDataContext';
|
||||
import { AuthGate } from './AuthGate';
|
||||
import { ToastContainer } from '../shared/components/sketchy';
|
||||
|
||||
// Auth
|
||||
import { WelcomeScreen } from '../modules/auth/screens/WelcomeScreen';
|
||||
import { LoginScreen } from '../modules/auth/screens/LoginScreen';
|
||||
|
||||
// Home
|
||||
import { HomeScreen } from '../modules/home/screens/HomeScreen';
|
||||
@@ -34,7 +34,6 @@ function AppContent() {
|
||||
|
||||
switch (route.page) {
|
||||
case 'welcome': return <WelcomeScreen />;
|
||||
case 'login': return <LoginScreen />;
|
||||
case 'home': return <HomeScreen />;
|
||||
case 'events': return <EventsScreen />;
|
||||
case 'create-event': return <CreateEventScreen />;
|
||||
@@ -60,7 +59,9 @@ export function App() {
|
||||
<FestipodDataProvider>
|
||||
<RouterProvider>
|
||||
<div className="app-container">
|
||||
<AppContent />
|
||||
<AuthGate>
|
||||
<AppContent />
|
||||
</AuthGate>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</RouterProvider>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* AuthGate — the ONE await the application makes before it renders.
|
||||
*
|
||||
* Signing in is `ensureIdentity()` and nothing else: it takes no identifier,
|
||||
* resolves who we are and does the connection work (restoring what others shared
|
||||
* with us, draining our inboxes). Whatever a user has to see or do while that
|
||||
* resolves — opening the wallet, loading it onto a first-time device — belongs to
|
||||
* the SDK: it mounts a full-screen barrier of its own on every top-level load and
|
||||
* takes it down itself, and it owns the return from the broker round-trip (the
|
||||
* barrier comes back prefilled, and confirming it hands the page over a second
|
||||
* time; our page is never reloaded and nothing outside the barrier is touched).
|
||||
* Festipod renders no access screen of its own and re-drives nothing.
|
||||
*
|
||||
* TWO CALLS, IN ORDER, AND THE ORDER IS CONTRACTUAL: start the session (`init`),
|
||||
* then await `ensureIdentity()`. A session arrives only through `init`, and
|
||||
* `ensureIdentity()` awaited before it has been called THROWS. The order is a fact
|
||||
* of the statement sequence below, not of React's effect ordering — which would
|
||||
* get it wrong: this gate's effect runs BEFORE its parent provider's.
|
||||
*
|
||||
* It hands back WHO WE ARE, and this is the app's only upstream answer to that
|
||||
* question — everything else it knows about the user it has to read first. The
|
||||
* value is published for display (`shared/utils/currentPrincipal`) and goes
|
||||
* nowhere near a data call: no call takes an identity, because the session
|
||||
* already belongs to one user.
|
||||
*
|
||||
* Nothing of the app renders before that await settles: a screen mounted earlier
|
||||
* would read as an identity that is not yet settled.
|
||||
*
|
||||
* AND NOTHING RENDERS IF IT FAILS. A rejected `ensureIdentity()` is not a mode the
|
||||
* app degrades through: an app that could not sign in but still shows its screens
|
||||
* is indistinguishable from an app whose user simply owns nothing — a total
|
||||
* failure wearing the face of an empty account. So the rejection is SHOWN, and the
|
||||
* children stay unmounted, which is also what keeps the data layer from settling
|
||||
* on its empty stand-in provider for the rest of the session.
|
||||
*/
|
||||
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { ensureIdentity } from '@ng-eventually/polyfill';
|
||||
import { startNgSession } from '../shared/utils/ngSession';
|
||||
import { setCurrentPrincipal } from '../shared/utils/currentPrincipal';
|
||||
import { useRouter, useNavigate } from './router';
|
||||
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
const { route } = useRouter();
|
||||
const navigate = useNavigate();
|
||||
// Whether the ONE identity await has resolved.
|
||||
const [identityReady, setIdentityReady] = useState(false);
|
||||
// Why it did NOT resolve. Set once, never cleared: signing in is attempted once.
|
||||
const [signInError, setSignInError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
// FIRST — a session arrives only through the SDK's `init`. Idempotent, so the
|
||||
// provider above may have started it already; what matters is that it has been
|
||||
// called before the await below, or the await throws.
|
||||
void startNgSession();
|
||||
void ensureIdentity()
|
||||
.then(principal => {
|
||||
// Publish who we are BEFORE anything renders — the identity is a fact of
|
||||
// the session, not state of this component, so it is recorded even if the
|
||||
// effect was torn down in between.
|
||||
setCurrentPrincipal(principal);
|
||||
if (!cancelled) setIdentityReady(true);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('[Auth] ensureIdentity failed:', err);
|
||||
if (!cancelled) setSignInError(err instanceof Error ? err.message : String(err));
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Once identified, leave the disconnected welcome screen for the app home.
|
||||
useEffect(() => {
|
||||
if (identityReady && route.page === 'welcome') {
|
||||
navigate('/home');
|
||||
}
|
||||
}, [identityReady, route.page, navigate]);
|
||||
|
||||
// Signing in FAILED — say so. The app has nothing legitimate to show, and
|
||||
// showing it anyway would pass a broken session off as an empty one.
|
||||
if (signInError) {
|
||||
return (
|
||||
<div id="auth-error" role="alert" className="app-card" style={{ margin: '2rem 1rem' }}>
|
||||
<h1 className="app-title">Connexion impossible</h1>
|
||||
<p className="app-text">
|
||||
Festipod n’a pas réussi à vous connecter. Rien ne peut s’afficher tant que
|
||||
la connexion n’a pas abouti — les écrans seraient vides sans le dire.
|
||||
</p>
|
||||
<p className="app-text" data-testid="auth-error-detail">{signInError}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Signing in is not settled yet. Render NOTHING — the SDK's own full-screen
|
||||
// barrier is what is on screen, it put it there and it takes it down. Anything
|
||||
// of ours here would be a second thing competing with it.
|
||||
if (!identityReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The app.
|
||||
return <>{children}</>;
|
||||
}
|
||||
+50
-15
@@ -3,24 +3,59 @@
|
||||
* element and renders the App component to the DOM.
|
||||
*
|
||||
* It is included in `src/index.html`.
|
||||
*
|
||||
* Before loading the app tree it pulls the RUNTIME shared-wallet config (dev
|
||||
* server + `bun run start`, which serve from src/ and so miss build.ts's
|
||||
* compile-time `define`), sets the global, then dynamically imports `App` so
|
||||
* `sharedWallet.ts` reads the value on evaluation. In a build.ts bundle the
|
||||
* password is already inlined via `define`, so this step is skipped (NODE_ENV).
|
||||
*/
|
||||
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
|
||||
const elem = document.getElementById("root")!;
|
||||
const app = (
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
if (import.meta.hot) {
|
||||
// With hot module reloading, `import.meta.hot.data` is persisted.
|
||||
const root = (import.meta.hot.data.root ??= createRoot(elem));
|
||||
root.render(app);
|
||||
} else {
|
||||
// The hot module reloading API is not available in production.
|
||||
createRoot(elem).render(app);
|
||||
/** Fetch the runtime shared-wallet config and set the global (dev/start only). */
|
||||
async function loadRuntimeConfig(): Promise<void> {
|
||||
if (process.env.NODE_ENV === "production") return; // build.ts define provides it
|
||||
try {
|
||||
const res = await fetch("/festipod-config.json");
|
||||
if (!res.ok) return;
|
||||
const cfg = (await res.json()) as { sharedWalletPassword?: string; autoSeed?: string };
|
||||
// Bracket access so build.ts's `define` (which matches the dotted global)
|
||||
// never rewrites this assignment. Only set when the env actually carries one.
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
if (cfg.sharedWalletPassword && g["__FESTIPOD_SHARED_WALLET_PASSWORD__"] == null) {
|
||||
g["__FESTIPOD_SHARED_WALLET_PASSWORD__"] = cfg.sharedWalletPassword;
|
||||
}
|
||||
// Auto-seed gate (see src/shared/utils/autoSeed.ts): only set the global when
|
||||
// the env var carries a truthy value; absent → stays undefined → seed OFF.
|
||||
if (cfg.autoSeed && g["__FESTIPOD_AUTO_SEED__"] == null) {
|
||||
g["__FESTIPOD_AUTO_SEED__"] = cfg.autoSeed;
|
||||
}
|
||||
} catch {
|
||||
// No runtime config endpoint (static build) → rely on the compile-time define.
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await loadRuntimeConfig();
|
||||
// Dynamic import AFTER the global is set, so sharedWallet.ts reads it on eval.
|
||||
const { App } = await import("./App");
|
||||
const elem = document.getElementById("root")!;
|
||||
const app = (
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
if (import.meta.hot) {
|
||||
// With hot module reloading, `import.meta.hot.data` is persisted.
|
||||
const root = (import.meta.hot.data.root ??= createRoot(elem));
|
||||
root.render(app);
|
||||
} else {
|
||||
// The hot module reloading API is not available in production.
|
||||
createRoot(elem).render(app);
|
||||
}
|
||||
}
|
||||
|
||||
void main();
|
||||
|
||||
@@ -6,7 +6,6 @@ import React, { createContext, useContext, useState, useEffect, useCallback } fr
|
||||
|
||||
type Route =
|
||||
| { page: 'welcome' }
|
||||
| { page: 'login' }
|
||||
| { page: 'home' }
|
||||
| { page: 'events' }
|
||||
| { page: 'create-event' }
|
||||
@@ -38,7 +37,6 @@ function parsePath(pathname: string): Route {
|
||||
const path = pathname.replace(/\/+$/, '') || '/';
|
||||
|
||||
if (path === '/' || path === '') return { page: 'welcome' };
|
||||
if (path === '/login') return { page: 'login' };
|
||||
if (path === '/home') return { page: 'home' };
|
||||
if (path === '/events') return { page: 'events' };
|
||||
if (path === '/events/new') return { page: 'create-event' };
|
||||
@@ -73,7 +71,6 @@ function parsePath(pathname: string): Route {
|
||||
export function routeToPath(route: Route): string {
|
||||
switch (route.page) {
|
||||
case 'welcome': return '/';
|
||||
case 'login': return '/login';
|
||||
case 'home': return '/home';
|
||||
case 'events': return '/events';
|
||||
case 'create-event': return '/events/new';
|
||||
|
||||
@@ -333,6 +333,17 @@ body {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Global data-query spinner (near the "Festipod" title) */
|
||||
@keyframes app-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.app-spinner {
|
||||
animation: app-spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Online indicator on avatar */
|
||||
.app-avatar .online-dot {
|
||||
position: absolute;
|
||||
|
||||
@@ -41,6 +41,28 @@ const server = serve({
|
||||
});
|
||||
},
|
||||
|
||||
// Shared-wallet config, exposed at RUNTIME for the dev server + `bun run start`
|
||||
// (both serve from src/, so they miss build.ts's compile-time `define`). The app
|
||||
// entry (frontend.tsx) fetches this before it loads the app tree, so
|
||||
// `sharedWallet.ts` sees the password. Empty env → '' → no shared wallet.
|
||||
"/festipod-config.json": () =>
|
||||
Response.json({
|
||||
sharedWalletPassword: process.env.FESTIPOD_SHARED_WALLET_PASSWORD ?? "",
|
||||
// Auto-seed gate (OFF by default): only set when the env var is present, so
|
||||
// the front seeds an empty wallet only on explicit opt-in (see autoSeed.ts).
|
||||
autoSeed: process.env.FESTIPOD_AUTO_SEED ?? "",
|
||||
}),
|
||||
|
||||
// The shared wallet file — the `fileUrl` the app hands the SDK, 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,
|
||||
},
|
||||
|
||||
@@ -6,29 +6,10 @@ Fonctionnalité: Connexion NextGraph et chargement des données
|
||||
Et charger les données de test dans mon portefeuille
|
||||
Afin d'utiliser l'application avec mes propres données
|
||||
|
||||
# --- UI layer: écran de connexion ---
|
||||
|
||||
@ui
|
||||
Scénario: L'écran de connexion affiche le bouton NextGraph
|
||||
Étant donné je suis sur la page "connexion"
|
||||
Alors l'écran contient un bouton "Se connecter avec NextGraph"
|
||||
|
||||
@ui @wip
|
||||
# Behavioral: requires simulating an NG status change. Better tested at the
|
||||
# @e2e layer where a real connected session triggers the redirect.
|
||||
Scénario: L'écran de connexion redirige automatiquement quand connecté
|
||||
Étant donné je suis sur la page "connexion"
|
||||
Alors l'écran gère la redirection automatique après connexion
|
||||
|
||||
@ui
|
||||
Scénario: L'état initial est "en cours" quand une connexion est en attente
|
||||
Étant donné je suis sur la page "connexion"
|
||||
Alors l'écran gère l'état de connexion en cours
|
||||
|
||||
@ui
|
||||
Scénario: Aucune donnée de démonstration n'est visible pendant la connexion
|
||||
Étant donné je suis sur la page "connexion"
|
||||
Alors l'écran n'importe pas de données de démonstration
|
||||
# NB : Festipod n'affiche plus d'écran d'accès à lui. Se connecter, c'est le
|
||||
# SEUL `ensureIdentity()` attendu par AuthGate ; ce qu'un utilisateur voit ou
|
||||
# fait pendant cette attente appartient au SDK, qui le montre. Aucun scénario
|
||||
# ici ne pilote donc une barrière d'accès.
|
||||
|
||||
# --- Data layer: comportement du portefeuille ---
|
||||
|
||||
@@ -59,11 +40,6 @@ Fonctionnalité: Connexion NextGraph et chargement des données
|
||||
|
||||
# --- E2E layer: comportement réel dans le navigateur ---
|
||||
|
||||
@e2e
|
||||
Scénario: L'écran de connexion redirige vers l'accueil si déjà connecté
|
||||
Quand l'utilisateur navigue vers l'écran "login"
|
||||
Alors l'application affiche l'écran "home"
|
||||
|
||||
@e2e
|
||||
Scénario: La navigation interne met à jour l'URL
|
||||
Quand l'utilisateur navigue vers l'écran "events"
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-webpack5';
|
||||
import { LoginScreen } from './LoginScreen';
|
||||
import { withProviders } from '../../../../.storybook/decorators';
|
||||
|
||||
const meta: Meta<typeof LoginScreen> = {
|
||||
title: 'Screens/Auth/LoginScreen',
|
||||
component: LoginScreen,
|
||||
decorators: [withProviders],
|
||||
};
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof LoginScreen>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@@ -1,95 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Button, Input, Title, Text, Divider } from '../../../shared/components/sketchy';
|
||||
import { useNextGraph } from '../../../shared/context/NextGraphContext';
|
||||
import { useNavigate } from '../../../app/router';
|
||||
|
||||
export function LoginScreen() {
|
||||
const navigate = useNavigate();
|
||||
const { status, connect } = useNextGraph();
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'connected') {
|
||||
navigate('/home');
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
const handleNgLogin = () => {
|
||||
if (status === 'connected') {
|
||||
navigate('/home');
|
||||
} else {
|
||||
connect();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<Title style={{ textAlign: 'center', fontSize: 32, marginBottom: 8 }}>Festipod</Title>
|
||||
<Text style={{ textAlign: 'center', marginBottom: 32, color: '#888' }}>Créez et rejoignez des événements entre amis</Text>
|
||||
|
||||
{/* NextGraph login */}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
{status === 'connected' ? (
|
||||
<div style={{ textAlign: 'center', marginBottom: 8 }}>
|
||||
<Text style={{ color: '#22543D', fontWeight: 'bold', margin: '0 0 8px 0' }}>
|
||||
✓ Connecté via NextGraph
|
||||
</Text>
|
||||
<Button variant="primary" onClick={() => navigate('/home')} style={{ width: '100%' }}>
|
||||
Continuer vers l'accueil
|
||||
</Button>
|
||||
</div>
|
||||
) : status === 'connecting' ? (
|
||||
<Button disabled style={{ width: '100%', opacity: 0.6 }}>
|
||||
Connexion NextGraph en cours...
|
||||
</Button>
|
||||
) : (
|
||||
<div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleNgLogin}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Se connecter avec NextGraph
|
||||
</Button>
|
||||
{status === 'error' && (
|
||||
<Text style={{ textAlign: 'center', fontSize: 12, color: '#888', marginTop: 8 }}>
|
||||
NextGraph non disponible — mode démonstration
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Text style={{ textAlign: 'center', fontSize: 14, color: '#888', margin: '16px 0' }}>
|
||||
ou connexion classique (démo)
|
||||
</Text>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||
<div>
|
||||
<Text style={{ marginBottom: 4, fontSize: 13, color: '#888' }}>Email</Text>
|
||||
<Input type="email" placeholder="vous@exemple.com" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Text style={{ marginBottom: 4, fontSize: 13, color: '#888' }}>Mot de passe</Text>
|
||||
<Input type="password" placeholder="••••••••" />
|
||||
</div>
|
||||
|
||||
<Button variant="primary" onClick={() => navigate('/home')}>
|
||||
Se connecter
|
||||
</Button>
|
||||
|
||||
<Text style={{ textAlign: 'center', fontSize: 14, color: '#E8590C' }}>
|
||||
Mot de passe oublié ?
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Text style={{ textAlign: 'center', fontSize: 14, color: '#888' }}>
|
||||
Pas encore de compte ? <span style={{ color: '#E8590C', cursor: 'pointer' }}>S'inscrire</span>
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,18 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Button, Title, Text } from '../../../shared/components/sketchy';
|
||||
import { useNavigate } from '../../../app/router';
|
||||
import { useNextGraph } from '../../../shared/context/NextGraphContext';
|
||||
|
||||
export function WelcomeScreen() {
|
||||
const navigate = useNavigate();
|
||||
const { status } = useNextGraph();
|
||||
|
||||
// Onboarding is for NOT-connected users. A connected user landing on '/'
|
||||
// (e.g. a returning tester past the access gate) goes straight to the app.
|
||||
useEffect(() => {
|
||||
if (status === 'connected') navigate('/home');
|
||||
}, [status]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
@@ -41,12 +51,12 @@ export function WelcomeScreen() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="primary" onClick={() => navigate('/login')} style={{ marginBottom: 12 }}>
|
||||
<Button variant="primary" onClick={() => navigate('/home')} style={{ marginBottom: 12 }}>
|
||||
Rejoindre la communauté
|
||||
</Button>
|
||||
|
||||
<Text style={{ textAlign: 'center', fontSize: 13, color: '#888' }}>
|
||||
Déjà membre ? <span onClick={() => navigate('/login')} style={{ color: '#E8590C', cursor: 'pointer', fontWeight: 600 }}>Connexion</span>
|
||||
Déjà membre ? <span onClick={() => navigate('/home')} style={{ color: '#E8590C', cursor: 'pointer', fontWeight: 600 }}>Connexion</span>
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,31 +2,18 @@ import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
// Seed data matching what bootstrapWallet uses
|
||||
import { seedEvents, seedUsers } from '../../../../shared/data/seedData';
|
||||
|
||||
// --- Setup ---
|
||||
|
||||
Given('le portefeuille est vide', async function (this: FestipodWorld) {
|
||||
// Verify starting state: the harness graph should have its own seeded data.
|
||||
// We clear events/users/participations to simulate a truly empty wallet.
|
||||
await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
// Delete all events
|
||||
for (const e of [...td.events]) td.events.delete(e);
|
||||
// Delete all users
|
||||
for (const u of [...td.users]) td.users.delete(u);
|
||||
// Delete all participations
|
||||
for (const p of [...td.participations]) td.participations.delete(p);
|
||||
});
|
||||
|
||||
// Verify empty
|
||||
// A fast INSTANT CHECK — assert the reactive read already shows nothing — NOT
|
||||
// the old `clearWallet` per-entity-doc fan-out (a full wallet enumeration that
|
||||
// was itself slow). No mutation, no polling.
|
||||
const counts = await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
return { events: td.events.size, users: td.users.size, participations: td.participations.size };
|
||||
return { events: td.events.size, users: td.users.size };
|
||||
});
|
||||
expect(counts.events, 'Events should be empty').to.equal(0);
|
||||
expect(counts.users, 'Users should be empty').to.equal(0);
|
||||
expect(counts.events, 'An empty wallet should have no events').to.equal(0);
|
||||
expect(counts.users, 'An empty wallet should have no users').to.equal(0);
|
||||
});
|
||||
|
||||
Given('le portefeuille contient déjà des événements', async function (this: FestipodWorld) {
|
||||
@@ -40,10 +27,13 @@ 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,
|
||||
{ timeout: 10000 },
|
||||
undefined,
|
||||
{ timeout: 75000 },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -58,22 +48,37 @@ When('je charge les données de test', async function (this: FestipodWorld) {
|
||||
});
|
||||
(this as any)._eventCountBefore = countBefore;
|
||||
|
||||
await this.appFrame!.evaluate(() => {
|
||||
// AWAIT the seed's own promise (loadTestData returns a BootstrapResult promise)
|
||||
// and record whether it actually seeded — so the propagation wait below can tell
|
||||
// a genuinely-populated wallet (nothing to appear) from an empty one that must
|
||||
// seed. Fire-and-forget here would let the assertions race the async seed.
|
||||
const seededResult = await this.appFrame!.evaluate(async () => {
|
||||
const td = (window as any).__testData;
|
||||
td.loadTestData();
|
||||
const r = await td.loadTestData();
|
||||
return { seeded: r?.seeded ?? false };
|
||||
});
|
||||
(this as any)._loadSeeded = seededResult.seeded;
|
||||
|
||||
// Wait for data to propagate (if wallet was empty, data should appear)
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => {
|
||||
const td = (window as any).__testData;
|
||||
// Either data was already there, or it should appear after loading
|
||||
return td.events.size > 0 || td._loadResult?.seeded === false;
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
).catch(() => {
|
||||
// Timeout is OK if wallet was already populated (idempotent case)
|
||||
});
|
||||
// Wait for data to propagate. Events reach the read via the discovery index (a
|
||||
// fast, independent path); the seeded PROTECTED user docs reach it only through
|
||||
// the by-need re-list, which can lag the public read under load. So wait for BOTH
|
||||
// events AND users to settle (not just events) — otherwise `contient des
|
||||
// utilisateurs` asserts before the protected read lands and flakes to users:0.
|
||||
// On a wallet that already had data (seeded === false) there is nothing to wait
|
||||
// for. The assertions still verify the real counts; this only synchronizes.
|
||||
if (seededResult.seeded) {
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => {
|
||||
const td = (window as any).__testData;
|
||||
return td.events.size > 0 && td.users.size > 0;
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 75000 },
|
||||
).catch(() => {
|
||||
// Timeout tolerated — the assertions below surface the real failure with a
|
||||
// clearer message than a raw waitForFunction timeout.
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// --- Assertions ---
|
||||
|
||||
@@ -13,7 +13,6 @@ import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
const SCREEN_MARKERS: Record<string, string> = {
|
||||
'home': 'Festipod',
|
||||
'events': 'Découvrir',
|
||||
'login': 'connecter',
|
||||
'profile': 'Mon profil',
|
||||
'create-event': "Relayer un événement",
|
||||
'settings': 'Paramètres',
|
||||
@@ -35,7 +34,6 @@ function pathForScreen(screenId: string): string {
|
||||
case 'home': return '/home';
|
||||
case 'events': return '/events';
|
||||
case 'create-event': return '/events/new';
|
||||
case 'login': return '/login';
|
||||
case 'profile': return '/profile';
|
||||
case 'edit-profile': return '/profile/edit';
|
||||
case 'friends-list': return '/profile/friends';
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user