Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96e28a702f | |||
| a8401bd143 | |||
| ab077d8080 | |||
| 42dbfd0c34 | |||
| 5b536ff981 | |||
| c5e627c5fc | |||
| 7e65a83d42 | |||
| 3a49376f17 | |||
| e326bebd42 | |||
| 46ed894621 |
@@ -0,0 +1,40 @@
|
||||
# Festipod — variables d'environnement (exemple)
|
||||
#
|
||||
# Copier en `.env` (chargé automatiquement par Bun) 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).
|
||||
# Sans elles, l'app tombe en mode dégradé : la barrière d'accès n'affiche que le
|
||||
# champ identifiant, sans l'assistance de chargement du portefeuille partagé.
|
||||
|
||||
# ── Portefeuille partagé (stopgap staging) ─────────────────────────────────
|
||||
# Mot de passe du portefeuille partagé.
|
||||
# VIDE => hasSharedWallet() faux => la barrière n'affiche QUE le champ identifiant
|
||||
# (pas les 3 étapes « télécharger + importer le portefeuille »). REQUIS en staging
|
||||
# pour l'onboarding d'un appareil qui n'a pas encore de wallet.
|
||||
FESTIPOD_SHARED_WALLET_PASSWORD=
|
||||
|
||||
# Chemin ABSOLU vers le fichier portefeuille partagé (.ngw). Servi en
|
||||
# téléchargement à /shared-wallet.ngw depuis la barrière d'accès.
|
||||
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/client pour `pnpm run
|
||||
# link:polyfill` (lien local réactif). Défaut = ../nextgraph/ng-eventually-js/packages/client.
|
||||
NG_EVENTUALLY_LOCAL=
|
||||
|
||||
# ── Build only (build.ts / `bun run build`, PAS le runtime) ────────────────
|
||||
# ACCESS_GATE_DISABLED=1 => build SANS barrière d'accès (l'app démarre directement).
|
||||
# Réservé à un build de démo/no-gate ; ne pas utiliser pour un déploiement réel.
|
||||
ACCESS_GATE_DISABLED=
|
||||
@@ -1,9 +0,0 @@
|
||||
# Doc-debt — app-architecture
|
||||
|
||||
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
|
||||
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
|
||||
|
||||
## Raw markers (consolidate into blocks, then delete)
|
||||
- TOUCHED src/shared/context/FestipodDataContext.tsx @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/auth/screens/AccessGateScreen.tsx @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/shared/context/AccountContext.tsx @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
@@ -19,6 +19,7 @@ Comment le code de l'app est **structuré** et **assemblé**. Architecture *feat
|
||||
- [[knowledge_routing]] — routing path-based (History API), table de routes, hooks
|
||||
- [[knowledge_screens]] — inventaire des écrans, registre, lib de composants
|
||||
- [[knowledge_screen-pattern]] — anatomie canonique d'un écran (sans props, layout flex, showToast)
|
||||
- [[caveat_identity-ids-in-screens]] — `currentUserId` (principal) vs `currentUser.id` (NURI de profil) : deux espaces d'id non interchangeables
|
||||
- [[knowledge_styling-system]] — `src/index.css`, classes `app-*`, vars, pièges (Tailwind non-utilisé, `user-content` inerte)
|
||||
- [[cookbook_add-screen]] — procédure pour câbler un nouvel écran (registre + router + shell)
|
||||
- `tech-stack` — build, bundler Bun, commandes
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: Un écran manipule DEUX ids de l'utilisateur courant qui ne sont pas interchangeables — currentUserId (principal urn:festipod:user:…) pour les queries de participation/amitié, currentUser.id (NURI du profil) pour comparer aux profils rendus ; se tromper ne lève aucune erreur, ça rend une liste vide ou se compte soi-même comme un participant inconnu
|
||||
last_checked: 2026-07-27
|
||||
---
|
||||
|
||||
# Piège : deux ids de l'utilisateur courant dans un écran
|
||||
|
||||
`useFestipodData()` expose **deux** identifiants de l'utilisateur courant. Ils vivent dans des **espaces différents** et ne sont **jamais égaux en mode connecté** :
|
||||
|
||||
| Valeur | Espace | À quoi elle sert |
|
||||
|---|---|---|
|
||||
| `currentUserId` | **principal** stable dérivé de l'identifiant de connexion (`urn:festipod:user:<clé>`) | c'est ce que les **participations** et **amitiés** stockent |
|
||||
| `currentUser.id` | **NURI du document de profil** (`did:ng:…`) | c'est ce que portent les **profils** rendus |
|
||||
|
||||
En mode seed/demo les deux coïncident (`user-1`) — **le piège ne se manifeste qu'en connecté**, et jamais sous forme d'erreur : juste un résultat faux.
|
||||
|
||||
## La règle
|
||||
|
||||
- Les queries qui **filtrent des participations/amitiés** — `getUserEvents(userId)`, `isParticipating(eventId, userId?)`, `getFriends(userId?)` — attendent le **principal**. Leur valeur par défaut (`currentUserId`) est correcte ; **ne leur passe pas** un `user.id` de profil, sinon la liste revient **vide**.
|
||||
- `getEventParticipants(eventId)` rend des **profils**. Toute comparaison sur son résultat (typiquement « me retirer de la liste ») se fait donc sur **`currentUser?.id`**, jamais sur `currentUserId`.
|
||||
|
||||
## Ce que coûte l'erreur (observé)
|
||||
|
||||
- Comparer `participant.id !== currentUserId` pour se filtrer soi-même **ne retire rien** : on apparaît dans sa propre liste, et comme la ligne n'est plus reconnue elle s'affiche en « participant inconnu ».
|
||||
- Symétriquement, un écran qui affiche les événements d'**un autre utilisateur** à partir de son **id de profil** (`getUserEvents(viewedUser.id)`) rend une liste vide en connecté — même cause.
|
||||
|
||||
La jointure participation→profil elle-même n'est **pas** l'affaire de l'écran : elle est faite dans le provider (`resolveParticipantUser`), à travers l'identifiant normalisé. Mécanique complète et invariant d'écriture/lecture : concept `data-layer`, [[knowledge_context-internals]].
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: src/app/ est le shell réel de l'app — App.tsx empile les providers (Theme > NextGraph > FestipodData > Router) et bascule l'écran selon la route
|
||||
summary: src/app/ est le shell réel de l'app — App.tsx empile les providers (Theme > NextGraph > Account > FestipodData > Router), AuthGate garde tout écran routé derrière la barrière d'accès, et le shell bascule l'écran selon la route
|
||||
last_checked: 2026-07-27
|
||||
---
|
||||
|
||||
# App shell
|
||||
@@ -15,12 +16,28 @@ summary: src/app/ est le shell réel de l'app — App.tsx empile les providers (
|
||||
|
||||
```
|
||||
ThemeProvider
|
||||
└ NextGraphProvider (cycle de connexion NextGraph — concept data-layer)
|
||||
└ FestipodDataProvider (données, mode connected/demo — concept data-layer)
|
||||
└ RouterProvider (route courante + navigate)
|
||||
└ NextGraphProvider (cycle de connexion NextGraph — concept data-layer)
|
||||
└ AccountProvider (identité courante = l'identifiant — concept app-security)
|
||||
└ FestipodDataProvider (données, mode connected/demo — concept data-layer)
|
||||
└ RouterProvider (route courante + navigate)
|
||||
└ div.app-container
|
||||
├ AuthGate (barrière d'accès)
|
||||
│ └ AppContent (switch route.page → écran)
|
||||
└ ToastContainer
|
||||
```
|
||||
|
||||
Le composant racine lit `useRouter()` pour résoudre `route.page` → écran à rendre.
|
||||
`AppContent` lit `useRouter()` pour résoudre `route.page` → écran à rendre.
|
||||
|
||||
### Invariants d'ordre (ce qui casse si tu déplaces une couche)
|
||||
|
||||
- **`AccountProvider` est AU-DESSUS de `FestipodDataProvider`.** Le provider de données appelle `useAccount()` pour dériver son principal (`currentUserId`) *et* pour réinitialiser sa session au changement d'identité. Inverser l'ordre casse toute la résolution d'identité, silencieusement.
|
||||
- **`AuthGate` est À L'INTÉRIEUR du router** : il lit `useRouter()`/`useNavigate()` pour quitter la route d'accueil déconnectée une fois connecté **et** identifié. Le sortir du `RouterProvider` le casse.
|
||||
- **`AuthGate` enveloppe TOUT écran routé.** Tant que le wallet n'est pas ouvert **ou** que l'identifiant n'est pas résolu, `AccessGateScreen` est rendu **à la place** d'`AppContent`. Conséquence : **aucun écran ne peut supposer qu'il est atteignable sans identité** — sauf barrière désactivée (voir ci-dessous).
|
||||
- **`ToastContainer` est HORS d'`AuthGate`** (mais dans `.app-container`) : il est monté quel que soit l'état de la barrière.
|
||||
|
||||
### Désactivation de la barrière (deux consommateurs)
|
||||
|
||||
`AuthGate` est **ON par défaut** ; il ne s'efface que si `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ === true`, posé soit par `build.ts` (depuis `ACCESS_GATE_DISABLED=1`, build sans barrière), soit par le harness de test via `addInitScript` pour les `@e2e` (qui exercent les écrans, pas le flux d'auth). **Impact** : le flux de barrière n'est donc **pas** couvert par les `@e2e` — ses gardes sont des tests `@ui` (concept `bdd-testing`).
|
||||
|
||||
## Points d'entrée
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ Chaque module peut contenir :
|
||||
| Répertoire | Contenu |
|
||||
|---|---|
|
||||
| `components/` | Lib de composants UI (voir [[knowledge_screens]]) |
|
||||
| `context/` | `ThemeContext`, `NextGraphContext`, `FestipodDataContext` (voir concept `data-layer`) |
|
||||
| `context/` | `ThemeContext`, `NextGraphContext`, `AccountContext` (identité courante — concept `app-security`), `FestipodDataContext` (concept `data-layer`) ; leur **ordre d'empilement** est contraint, voir [[knowledge_app-shell]] |
|
||||
| `data/` | User stories, `features.ts` (auto-généré), `seedData.ts`, `types.ts` |
|
||||
| `hooks/` | `useShapeWithDefaults` (NextGraph) |
|
||||
| `shapes/` | SHEX + bindings ORM (voir concept `data-layer`) |
|
||||
|
||||
@@ -12,7 +12,6 @@ Routing **path-based** via l'History API — router maison dans `src/app/router.
|
||||
| Path | Écran |
|
||||
|---|---|
|
||||
| `/` | WelcomeScreen |
|
||||
| `/login` | LoginScreen |
|
||||
| `/home` | HomeScreen |
|
||||
| `/events` | EventsScreen |
|
||||
| `/events/new` | CreateEventScreen |
|
||||
@@ -29,7 +28,7 @@ Routing **path-based** via l'History API — router maison dans `src/app/router.
|
||||
| `/users/:id` | UserProfileScreen |
|
||||
| `/settings` | SettingsScreen |
|
||||
|
||||
> Cette table reflète `parsePath()` dans `router.tsx` — y revenir si elle évolue, c'est la source de vérité.
|
||||
> Cette table reflète `parsePath()` dans `router.tsx` — y revenir si elle évolue, c'est la source de vérité. Il n'y a **pas** de route d'authentification : la barrière d'accès n'est pas routée, elle est rendue *au-dessus* du switch de routes par `AuthGate` (voir [[knowledge_app-shell]]).
|
||||
|
||||
## Hooks
|
||||
|
||||
|
||||
@@ -34,7 +34,10 @@ export function MyScreen() { // fonction nommée, JAMAIS de props
|
||||
|
||||
## Invariants
|
||||
|
||||
- **Zéro prop** : l'écran ne reçoit rien ; tout vient du contexte/hooks (`useFestipodData`, `useNavigate`, `useParams`). Exceptions légitimes : `LoginScreen`/`WelcomeScreen` n'utilisent pas `useFestipodData` (auth/intro).
|
||||
- **Zéro prop** : l'écran ne reçoit rien ; tout vient du contexte/hooks (`useFestipodData`, `useNavigate`, `useParams`). Deux exceptions, de nature différente :
|
||||
- `WelcomeScreen` n'utilise pas `useFestipodData` (intro) — mais reste sans props. (`LoginScreen`/`ConnexionScreen` n'existent plus.)
|
||||
- **`AccessGateScreen` est la seule vraie exception au zéro-prop** : ce n'est **pas un écran routé**, il est rendu par `src/app/AuthGate.tsx` qui lui passe `status`/`error`/`initialIdentifier`/`onEnter`. Il est donc **hors registre et hors table de routes**, et n'a accès ni au router ni aux données. Voir [[knowledge_screens]] et [[knowledge_app-shell]].
|
||||
- **Identité : deux espaces d'id.** `currentUserId` (principal) et `currentUser.id` (NURI de profil) ne sont **pas** interchangeables selon la query — voir [[caveat_identity-ids-in-screens]] avant de comparer un id dans un écran.
|
||||
- **Layout** : flex colonne pleine hauteur ; `Header` en haut, contenu en `flex:1; overflow:auto`, `BottomNav` en bas **uniquement pour les écrans hub** (Home, Events, Profile, Friends). Les écrans de flux (création, édition, détail) n'ont pas de `BottomNav`.
|
||||
- **Feedback** : `showToast(message, 'success'|'info'|'error')` (mécanisme `ToastContainer` exporté par `sketchy/`).
|
||||
- **Libellés** : **français, en dur** — aucun i18n, aucune clé de traduction dans le projet.
|
||||
|
||||
@@ -30,7 +30,9 @@ Utilisé notamment par Storybook (voir concept `tech-stack`) pour parcourir les
|
||||
- **home/** : `welcome`, `home`, `settings`
|
||||
- **event/** : `events`, `event-detail`, `create-event`, `update-event`, `invite`, `participants-list`, `meeting-points`
|
||||
- **user/** : `profile`, `update-profile`, `user-profile`, `friends-list`, `share-profile`
|
||||
- **auth/** : `AccessGateScreen` — la **barrière d'accès** (login NextGraph + saisie de l'identifiant), rendue par `src/app/AuthGate.tsx`, **hors registre/routing** (ce n'est pas un écran routé). Les anciens `LoginScreen` puis `ConnexionScreen` ont été retirés (cf. concept `app-security`, [[knowledge_authentication]]).
|
||||
- **auth/** : `WelcomeScreen` (intro, routé `/`) et `AccessGateScreen` — la **barrière d'accès** (login NextGraph + saisie de l'identifiant), rendue par `src/app/AuthGate.tsx`, **hors registre/routing** (ce n'est pas un écran routé) et **pilotée par props** (`status`/`error`/`initialIdentifier`/`onEnter`), seule exception au zéro-prop ([[knowledge_screen-pattern]]). Les anciens `LoginScreen` puis `ConnexionScreen` ont été retirés (cf. concept `app-security`, [[knowledge_authentication]]).
|
||||
|
||||
Structurellement, cet écran ne rend **pas** un layout d'écran standard mais un **choix entre trois branches d'accès** mutuellement exclusives, pilotées par `status` + la présence d'un wallet partagé. **Impact** : un nouveau cas d'accès s'ajoute comme une branche ici, **pas** comme une route. Le contenu et l'ordre des branches sont doctrine `app-security` ([[knowledge_authentication]]) — ne pas les redéfinir depuis ici.
|
||||
|
||||
> Le mapping path → écran est dans [[knowledge_routing]]. La plupart des écrans consomment `useFestipodData()` (concept `data-layer`) ; exceptions : `WelcomeScreen` et la barrière `AccessGateScreen`.
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# Doc-debt — app-security
|
||||
|
||||
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
|
||||
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
|
||||
|
||||
## Raw markers (consolidate into blocks, then delete)
|
||||
- TOUCHED src/modules/auth/screens/AccessGateScreen.tsx @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/auth/steps/ui/barriere-acces.steps.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/auth/steps/data/connexion.steps.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
@@ -2,7 +2,7 @@
|
||||
type: _overview
|
||||
summary: Sécurité & confidentialité de Festipod — l'isolation entre périmètres est assurée par le SDK de données, l'app lui fait confiance et ne porte aucune logique d'autorisation dans les écrans ; authentification par wallet ; matrice d'autorisations cible en incubation
|
||||
triggers:
|
||||
keywords: [sécurité, security, confidentialité, privacy, accès, "access control", contrôle d'accès, trust, confiance, authz, autorisation, permission, wallet, auth, authentification, anonyme, identité, login, scope, isolation]
|
||||
keywords: [sécurité, security, confidentialité, privacy, accès, "access control", contrôle d'accès, trust, confiance, authz, autorisation, permission, wallet, auth, authentification, anonyme, anonymat, pseudonyme, traçage, corrélation, overlay, cap-less, identité, login, scope, isolation]
|
||||
paths: ["src/modules/auth/**", "src/shared/context/NextGraphContext.tsx"]
|
||||
---
|
||||
|
||||
@@ -13,6 +13,11 @@ Le modèle de **sécurité, confidentialité et autorisations** de Festipod.
|
||||
- **Modèle appliqué** — l'**isolation entre périmètres** (public / protected / private) est **assurée par le SDK de données** (`@ng-eventually/client`), qui n'expose à chaque utilisateur que ce à quoi il a droit. L'app **fait confiance** au SDK : aucun écran ne porte de logique d'autorisation. Voir [[knowledge_trust-model]].
|
||||
- **Matrice d'autorisations cible** — le détail *qui peut faire quoi* par acteur × verbe (données personnelles = réseau, anonymat via inbox de notification) : [[brief_2026-05-18_authorization-matrix]]. **Incubation.** Graduera en `rule_`/`behavior_` à mesure que le produit se cale.
|
||||
|
||||
## Pièges (lire AVANT de concevoir quoi que ce soit d'« anonyme »)
|
||||
|
||||
- [[caveat_stable-overlay-pseudonym]] — une référence cap-less expose un **pseudonyme permanent** de la personne ; un seul recoupement dé-anonymise **rétroactivement** tout son historique, et aucune rotation n'est connue
|
||||
- [[caveat_shared-wallet-global-before-gate-import]] — le wallet partagé étant l'unique mode, un global de mot de passe posé **après** l'import de la barrière la rend inutilisable (écran d'erreur de config, aucun champ)
|
||||
|
||||
## Liens
|
||||
|
||||
- [[knowledge_trust-model]] — l'app délègue l'isolation au SDK, pas de contrôle d'accès dans les écrans
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: Le mot de passe du wallet partagé est capturé à l'ÉVALUATION de src/modules/auth/sharedWallet.ts ; depuis que le wallet partagé est l'unique mode, une valeur absente à cet instant ne donne plus un formulaire dégradé mais un écran d'erreur de config SANS champ identifiant — tout point d'entrée qui rend AccessGateScreen doit poser le global AVANT le premier import du module
|
||||
last_checked: 2026-07-27
|
||||
---
|
||||
|
||||
# Piège : poser le global du wallet partagé AVANT d'importer la barrière
|
||||
|
||||
**L'invariant.** `src/modules/auth/sharedWallet.ts` lit `globalThis.__FESTIPOD_SHARED_WALLET_PASSWORD__` **une seule fois, à l'évaluation du module** (la constante `SHARED_WALLET_PASSWORD`, exposée par `hasSharedWallet()`). Une valeur posée *après* ce premier import n'est jamais relue.
|
||||
|
||||
**Pourquoi c'est devenu bloquant.** Tant que « pas de wallet partagé » était un mode replié, un global manquant dégradait vers un formulaire encore utilisable — l'ordre d'évaluation était cosmétique. Depuis [[decision_2026-07-20_shared-wallet-only-mode]], `hasSharedWallet() === false` est une **erreur de configuration** : `AccessGateScreen` rend un bloc d'erreur **sans champ identifiant**. La barrière devient une impasse, pas un login dégradé.
|
||||
|
||||
## Impact — si je touche X, Y casse
|
||||
|
||||
- **Import statique = piège.** Un `import` statique de `AccessGateScreen` (ou de n'importe quel module qui remonte à `sharedWallet.ts`) depuis un point d'entrée qui pose lui-même le global est **hoisté au-dessus de l'affectation** → mot de passe vide → écran d'erreur, sans erreur JS pour le signaler. Le remède est l'**import dynamique** (`await import(...)`) exécuté après avoir posé le global.
|
||||
- **Points d'entrée concernés aujourd'hui** : le frontend servi depuis `src/` (`src/app/frontend.tsx` récupère `/festipod-config.json`, pose le global, puis importe l'app dynamiquement — mécanique détaillée dans tech-stack → [[knowledge_build-pipeline]]) et le harness `@ui` qui rend la barrière (`src/modules/auth/steps/ui/barriere-acces.steps.ts`, même séquence pose-puis-lazy-import). Un bundle produit par `build.ts` n'est **pas** concerné : la valeur y est inline par `define`.
|
||||
- **Exploitation** : un serveur sans `FESTIPOD_SHARED_WALLET_PASSWORD` ne sert **aucune** barrière fonctionnelle — par conception (échec franc). À traiter comme une panne de configuration, pas comme un bug d'écran.
|
||||
|
||||
**Vérifié (2026-07-27)** : capture à l'évaluation dans `sharedWallet.ts`, et garde `!hasSharedWallet()` en première branche de `AccessGateScreen`.
|
||||
|
||||
> Réserve : l'en-tête de `sharedWallet.ts` décrit encore l'ancien repli (« the gate falls back to the plain flow ») — commentaire périmé, c'est le rendu de `AccessGateScreen` qui fait foi.
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: Toute référence cap-less vers un document protected d'une personne expose le `:v:` de son store — un pseudonyme STABLE ET PERMANENT, identique partout et pour toujours. Ne dit pas qui, mais un seul recoupement dé-anonymise RÉTROACTIVEMENT toutes ses références passées et futures. C'est le même bit d'information qui permet la dédup anonyme. Aucune rotation connue.
|
||||
last_checked: 2026-07-27
|
||||
---
|
||||
|
||||
# Piège : le `:v:` d'une référence cap-less est un pseudonyme permanent
|
||||
|
||||
**À lire avant de concevoir quoi que ce soit qui fasse circuler des références cap-less** (inscriptions, invitations, mentions, index, notifications).
|
||||
|
||||
## Le fait
|
||||
|
||||
Un NURI s'écrit `did:ng:o:{document}:v:{overlay}`. Le segment `:v:` ne vient **pas du document** mais de **son store** — et une personne a **un seul** store *protected*. Donc :
|
||||
|
||||
> **Toutes** les références cap-less vers **n'importe lequel** des documents protected d'une personne portent le **même** `:v:`. Partout, et pour toujours.
|
||||
|
||||
VÉRIFIÉ dans `nextgraph-rs` (le détail et les pointeurs vivent côté polyfill, `docs/readcap-and-nuri-model.md`) : la valeur injectée à la création d'un document est l'overlay du store contenant ; un `Repo` ne porte aucun overlay propre, et les accès blocs d'un `Store` passent tous par **son** `overlay_id` — un overlay par-document est donc structurellement impossible, pas seulement absent.
|
||||
|
||||
## Pourquoi c'est un piège et pas juste une limite
|
||||
|
||||
Ce `:v:` **ne dit pas qui** — c'est un `BLAKE3` non inversible du store id. La tentation est donc de le traiter comme opaque, donc inoffensif. Il ne l'est pas : c'est un **handle constant**.
|
||||
|
||||
- **Corrélation** — quiconque collecte des références cap-less relie entre elles toutes celles d'une même personne, sans jamais l'identifier. Présence récurrente, appartenances, rythme.
|
||||
- **Dé-anonymisation rétroactive** — c'est le vrai danger. Il suffit d'**un seul** recoupement, **une seule fois** (une personne qui se nomme, un canal qui fuit, un croisement avec une donnée externe) pour que `:v:X` soit attaché à une identité. À cet instant, **tout** l'historique lié à ce `:v:` bascule d'un coup — y compris ce qui a été publié des années plus tôt en croyant à l'anonymat.
|
||||
- **Aucune porte de sortie** — VÉRIFIÉ, sur quatre axes : pas de rotation d'overlay (l'outer est un hash pur du store id, sans secret) ; le store id est généré une seule fois à la création de l'identité et jamais régénéré ; aucun chemin de migration de contenu vers un nouveau store ; et aucune forme de référence ne permet de localiser un document sans exposer l'overlay de son store. Le renouvellement de capabilities ne changerait que l'overlay *inner* — l'outer, seul présent dans les NURIs cap-less, y survivrait. **La seule sortie est d'abandonner l'identité entière**, ce qui n'emporte aucun contenu. Signalé en amont comme possible défaut de conception (`orm-tests/INBOX/2026-07-27-outer-overlay-permanent-pseudonym-no-rotation.md`, cf. [[rule_nextgraph-inbox]]).
|
||||
|
||||
## Le couplage à ne pas espérer défaire
|
||||
|
||||
Ce même `:v:` est ce qui permet de **dédupliquer sans lire** — deux références de même `:v:` viennent de la même personne, c'est la base du compteur de participants anonyme ([[brief_2026-07-20_attendance-set-model]] côté `data-layer`).
|
||||
|
||||
**C'est le même bit d'information.** Dédup anonyme et non-traçabilité ne sont pas deux exigences à concilier : ce sont deux lectures d'une seule et même donnée. On ne peut pas obtenir l'une en supprimant l'autre. Le seul curseur réel est le **découpage en stores** — qui déplace l'arbitrage sans le faire disparaître.
|
||||
|
||||
Et ce n'est **pas** un artefact du polyfill : la propriété survit au vrai NextGraph.
|
||||
|
||||
## Ce qu'il faut en faire
|
||||
|
||||
- **Ne jamais présenter à l'utilisateur** une action comme « anonyme » sans réserve si elle fait circuler une référence cap-less. Elle est **pseudonyme**, et le pseudonyme est permanent.
|
||||
- **Compter** les occurrences d'un `:v:` qu'on expose : chaque contexte supplémentaire où il apparaît augmente la surface de recoupement.
|
||||
- **Revérifier** ce caveat si NextGraph introduit une rotation d'overlay ou une forme de référence indirecte — il deviendrait alors caduc, ce qui serait une bonne nouvelle.
|
||||
|
||||
Liens : [[knowledge_trust-model]], [[brief_2026-05-18_authorization-matrix]], data-layer ([[brief_2026-07-20_attendance-set-model]], [[rule_capture-nextgraph-findings]], [[rule_nextgraph-inbox]]).
|
||||
@@ -7,7 +7,7 @@ summary: L'identifiant de l'espace virtuel se saisit à la barrière d'accès (A
|
||||
|
||||
## Contexte
|
||||
|
||||
Le flux stopgap de [[decision_2026-06-15_shared-wallet-login-flow]] enchaînait **deux
|
||||
Le flux stopgap antérieur (décision du 2026-06-15, fiche disparue avec le concept `nextgraph-platform` — voir `git log`) enchaînait **deux
|
||||
écrans** : (1) `AccessGateScreen`, la barrière d'accès (vrai login NextGraph, ouverture du
|
||||
wallet partagé) ; (2) `ConnexionScreen`, un « login perçu » où l'utilisateur choisissait un
|
||||
**nom d'utilisateur**. Cette identité applicative était en réalité la clé du **wallet virtuel**
|
||||
@@ -40,6 +40,6 @@ n'est pas posé, puis l'app directement — sans écran intermédiaire.
|
||||
|
||||
## Portée
|
||||
|
||||
Supersede la partie « écran 2 / login perçu » de [[decision_2026-06-15_shared-wallet-login-flow]]
|
||||
Supersede la partie « écran 2 / login perçu » du flux stopgap du 2026-06-15
|
||||
(l'ouverture du wallet partagé via broker reste inchangée). État courant du flux :
|
||||
[[knowledge_authentication]].
|
||||
|
||||
@@ -9,7 +9,9 @@ summary: L'identité d'un utilisateur = son wallet NextGraph ; tous les utilisat
|
||||
|
||||
## Flux
|
||||
|
||||
- La **barrière d'accès** (`AccessGateScreen`, rendue par `src/app/AuthGate.tsx`) est le vrai login NextGraph : elle ouvre le wallet partagé via la redirection broker. **Dans le même acte**, l'utilisateur saisit un **identifiant** qui nomme son espace virtuel (`onEnter`). Il n'y a **plus d'écran « login perçu » séparé** (l'ancien `ConnexionScreen` « choisissez un nom d'utilisateur » a été retiré — cf. [[decision_2026-07-06_identifier-at-access-barrier]] ; supersede le flux à deux écrans de [[decision_2026-06-15_shared-wallet-login-flow]]).
|
||||
- La **barrière d'accès** (`AccessGateScreen`, rendue par `src/app/AuthGate.tsx`) est le vrai login NextGraph : elle ouvre le wallet partagé via la redirection broker. **Dans le même acte**, l'utilisateur saisit un **identifiant** qui nomme son espace virtuel (`onEnter`). Il n'y a **plus d'écran « login perçu » séparé** (l'ancien `ConnexionScreen` « choisissez un nom d'utilisateur » a été retiré — cf. [[decision_2026-07-06_identifier-at-access-barrier]] ; supersede le flux à deux écrans du stopgap du 2026-06-15).
|
||||
- **Le wallet partagé est le SEUL mode supporté** : `AccessGateScreen` a **trois branches** — (1) *erreur de configuration* si aucun wallet partagé n'est configuré (plus de formulaire nu en impasse), (2) flux d'**import assisté** tant que la session n'est pas connectée, (3) **champ identifiant seul** une fois connecté. Voir [[decision_2026-07-20_shared-wallet-only-mode]], et le piège d'ordre d'évaluation [[caveat_shared-wallet-global-before-gate-import]] (le global du mot de passe doit être posé avant le premier import de l'écran, sinon on tombe sur la branche 1).
|
||||
- **Vocabulaire du code** : l'identité du wallet s'appelle `identifier` partout (`registration.ts`, `ngSession`, hooks et steps de test) — **jamais** `username`, qui désigne exclusivement le handle de profil `UserProfile.username`. Ne pas ré-étiqueter l'un en l'autre : ce sont deux espaces d'identité distincts.
|
||||
- Cet **identifiant est un id technique** (un pseudo en pratique, **pas** un username Festipod) : il est **normalisé** (trim, `@` retiré, **minuscules**) puis persisté (`AccountContext` → `IdentityStore`), donc un rechargement — ou un autre appareil rouvrant le même wallet partagé — retombe sur le même espace. C'est cet id qui est donné au SDK (`setCurrentUser`) et sur lequel les caps et le compte shim sont clés.
|
||||
- **Porté cross-frontière par un PARAM D'URL `?id=`** (source de vérité), PAS par localStorage. L'app tourne dans deux contextes — **top-level** (`127.0.0.1:3000` direct, `window.self === window.top`, où s'affiche la barrière) et **iframe** (embarquée sous `nextgraph.net` après le round-trip broker, `window.self !== window.top`). Le navigateur **partitionne le storage par site top-level** : le localStorage du top-level et celui de l'iframe sont **deux partitions distinctes** → localStorage NE PEUT PAS porter l'identité d'un contexte à l'autre (symptôme observé : deux valeurs divergentes selon le contexte). Le SDK redirige via `location.href = broker + encodeURIComponent(window.location.href)` (embarque l'URL app complète, query comprise, dans le `o=` rechargé en iframe), donc un **param d'URL traverse**. `AuthGate` écrit `?id=<identifiant>` (`history.replaceState`) **avant** `connect()` ; `AccountContext` résout l'identifiant par priorité **(1) `?id=` de l'URL** puis **(2) localStorage** (préremplissage/convenance same-partition uniquement). Clé localStorage : `festipod.account.identifier`.
|
||||
- **Saisi UNE SEULE FOIS au premier accès + prérempli au retour.** Au rechargement top-level, la session NG n'est pas restaurée d'office (`NextGraphContext` repart en `disconnected`) : `AuthGate` réaffiche la barrière tant que `status !== 'connected'`, mais le champ d'`AccessGateScreen` est **prérempli** (prop `initialIdentifier`) — jamais un champ nu et vide. Régressions gardées par `src/modules/auth/features/{barriere-acces-identifiant,identifiant-resolution}.feature` (@ui) — d'autant plus utiles que le flux de barrière est **désactivé** dans les tests @e2e (`__FESTIPOD_ACCESS_GATE_DISABLED__`), donc invisible à cette couche.
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# Doc-debt — bdd-testing
|
||||
|
||||
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
|
||||
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
|
||||
|
||||
## Raw markers (consolidate into blocks, then delete)
|
||||
- TOUCHED src/modules/event/features/reconnexion-persistance-e2e.feature @2026-07-13 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/event/steps/e2e/reconnexion-persistance.steps.ts @2026-07-13 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/shared/test-harness/harness-ng.tsx @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/shared/test-harness/harness.tsx @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/event/features/reconnexion-socket-mort.feature @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/event/steps/data/reconnexion-socket-mort.steps.ts @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/event/features/reconnexion-meme-identite.feature @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/event/steps/data/reconnexion.steps.ts @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/event/features/reconnexion-froide-sans-local.feature @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/event/steps/data/reconnexion-froide-sans-local.steps.ts @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/auth/steps/ui/barriere-acces.steps.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/shared/support/hooks.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/event/steps/data/isolation.steps.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/auth/steps/data/connexion.steps.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
@@ -31,6 +31,7 @@ Tests BDD **Cucumber/Gherkin en français** (`Etant donné`, `Quand`, `Alors`) s
|
||||
- [[knowledge_data-layer-broker]] — couche `@data` : harness broker, cycle de vie wallet, bridge
|
||||
- [[knowledge_e2e-layer]] — couche `@e2e` : app réelle dans l'iframe
|
||||
- [[knowledge_multibrowser-harness]] — plusieurs navigateurs isolés × modèle de wallet (private/shared), injection storageState
|
||||
- [[caveat_reconnexion-froide-local-vs-broker]] — « page fraîche » ≠ démarrage à froid : quel montage prouve la durabilité broker, et lequel relit le local
|
||||
- [[decision_2026-03-12_headless-wallet-creation]] — pourquoi le wallet de test est créé en UI headless
|
||||
- [[caveat_source-grep-vestiges]] — vestiges de l'ère « analyse de source » dans `world.ts`
|
||||
- [[cookbook_add-scenario]] — ajouter un scénario/step (couches, piège de sérialisation `evaluate`, `@wip`)
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: Une « page fraîche » ouverte via ctx.newPage() sur le contexte Chromium PERSISTANT ne prouve JAMAIS la durabilité broker — elle relit l'IndexedDB local du même profil. Seul un contexte non-persistant issu de freshBrowser, amorcé uniquement par le storageState capturé au BeforeAll, tranche broker-vs-local.
|
||||
last_checked: 2026-07-27
|
||||
---
|
||||
|
||||
# Piège : « page fraîche » ≠ démarrage à froid (local vs broker)
|
||||
|
||||
Les scénarios de **reconnexion** posent tous la même question — *l'utilisateur retrouve-t-il ses
|
||||
propres données après avoir fermé et rouvert ?* — mais **selon le contexte navigateur choisi, ils
|
||||
ne répondent pas à la même question**. C'est le piège : le montage le plus naturel (`ctx.newPage()`)
|
||||
donne un vert qui ne prouve rien sur le broker.
|
||||
|
||||
## Les deux montages, et ce que chacun prouve
|
||||
|
||||
| Montage | Où | Ce qu'il prouve | Ce qu'il ne prouve PAS |
|
||||
|---|---|---|---|
|
||||
| `this.page!.context().newPage()` — page fraîche sur le contexte **persistant** (`.playwright-profile`) | `reconnexion.steps.ts` (@data), `reconnexion-persistance.steps.ts` (@e2e) | nouveau login broker → **session verifier fraîche** (mémoire vide), remontage complet des providers | rien sur la **durabilité broker** : le profil détient **encore les repos locaux** en IndexedDB, un lecteur « frais » peut donc rouvrir **depuis le local** |
|
||||
| `spawnContext('shared')` — contexte **non-persistant** issu de `freshBrowser` | `reconnexion-froide-sans-local.steps.ts` (@data) | la donnée **a atteint le broker** (ou non) | rien sur le parcours UI réel (c'est le harness, pas l'app) |
|
||||
|
||||
**Invariant.** Toute assertion de la forme « l'écriture est durable côté broker » **exige** le second
|
||||
montage. Écrire cette assertion sur une page fraîche du contexte persistant produit un test
|
||||
faussement vert (ou un rouge qu'on impute au broker alors qu'il est local/timing).
|
||||
|
||||
## Ce qui rend le verdict « no-local » valide (à ne pas casser)
|
||||
|
||||
Trois conditions, toutes vérifiées dans `reconnexion-froide-sans-local.steps.ts` :
|
||||
|
||||
1. **Process séparé** — `freshBrowser` est un `chromium.launch` non-persistant, distinct du profil
|
||||
porteur du wallet (cf. [[knowledge_multibrowser-harness]] pour l'isolation prouvée jusqu'à
|
||||
l'origine broker).
|
||||
2. **Partition hermétique** — chaque `newContext()` Playwright a son propre stockage ; aucun
|
||||
IndexedDB partagé avec la page d'écriture.
|
||||
3. **Le seul état pré-injecté est `pool.sharedWalletState`**, capturé **une fois au `BeforeAll`**,
|
||||
donc **avant** que le scénario n'écrive quoi que ce soit → le snapshot **ne peut pas** contenir la
|
||||
donnée sous test.
|
||||
|
||||
> **Impact si on touche à la capture du storageState** (`hooks.ts` `BeforeAll` → `pool.sharedWalletState`) :
|
||||
> la déplacer plus tard, la ré-capturer par scénario, ou y ajouter un warm-up qui écrit des données
|
||||
> **invalide silencieusement** le verdict de tous les scénarios « à froid sans local » — ils
|
||||
> passeraient au vert en relisant le snapshot. Le step **échoue franchement** si
|
||||
> `sharedWalletState` est absent (c'est voulu : pas de verdict plutôt qu'un faux verdict).
|
||||
|
||||
## Reconnexion ≠ isolation : c'est l'identifiant qui décide
|
||||
|
||||
`isolation.steps.ts` et `reconnexion.steps.ts` montent **la même mécanique** (page fraîche + un
|
||||
identifiant injecté dans `localStorage['festipod.account.identifier']` via `addInitScript`, avant
|
||||
tout script, sur toutes les origines). Une seule chose les sépare :
|
||||
|
||||
- **reconnexion** : on réinjecte `this.freshIdentifier` — **la MÊME identité** que la page d'écriture.
|
||||
- **isolation** : on frappe un **nouvel** identifiant → identité B distincte.
|
||||
|
||||
Changer cet identifiant transforme donc silencieusement un test de reconnexion en test d'isolation
|
||||
(et réciproquement). `this.freshIdentifier` est posé par le `Before` de `hooks.ts` pour **tout**
|
||||
scénario `@data`/`@e2e` mono-navigateur.
|
||||
|
||||
## Lecture : réactive, même quand on « attend longtemps »
|
||||
|
||||
Les `Then` de reconnexion lisent l'état **réactif** (`homeEventTitles` sur le bridge, via
|
||||
`waitForFunction`) — jamais une boucle de re-lecture broker ([[rule_no-broker-polling]]). Le step de
|
||||
diagnostic long (« … en laissant jusqu'à 60 secondes à la barrière avec rechargements ») boucle bien,
|
||||
mais sur **l'état réactif déjà poussé** + des **rechargements complets** de la page (chaque reload =
|
||||
nouveau montage = nouvelle tentative de barrière de sync) : c'est le fallback pragmatique explicitement
|
||||
autorisé par la règle, pas du polling broker. Le distinguo à garder : *observer l'état réactif* ↔
|
||||
*ré-émettre une lecture broker*.
|
||||
|
||||
## État courant des scénarios
|
||||
|
||||
`reconnexion-froide-sans-local.feature`, le scénario `@reconnexion-pause` de
|
||||
`reconnexion-meme-identite.feature` et `reconnexion-persistance-e2e.feature` sont **`@wip`** : ce sont
|
||||
des **instruments de diagnostic** (ils impriment un verdict sur stdout / en pièce jointe Cucumber),
|
||||
pas des gardes de régression. `@wip` est exclu du run par défaut (`cucumber.json`) — les lancer
|
||||
explicitement avec `--tags`. Le scénario **non-`@wip`** de `reconnexion-meme-identite.feature`, lui,
|
||||
est une vraie garde et doit rester vert.
|
||||
|
||||
> Le *pourquoi* côté NextGraph (ce qu'une écriture doit franchir pour être durable, comportement du
|
||||
> socket, réouverture des repos) appartient au SDK `@ng-eventually/client` — pas à ce repo. Ici on ne
|
||||
> décrit que **le montage de test qui rend un verdict lisible**.
|
||||
|
||||
## Liens
|
||||
|
||||
- [[knowledge_data-layer-broker]] — la couche `@data` mono-navigateur (profil persistant).
|
||||
- [[knowledge_multibrowser-harness]] — `freshBrowser`, `spawnContext`, `pool.sharedWalletState`.
|
||||
- [[knowledge_e2e-layer]] — le pendant `@e2e` (app réelle) du montage « fermer et rouvrir ».
|
||||
- [[rule_no-broker-polling]] — la ligne rouge que les steps d'attente ne doivent pas franchir.
|
||||
@@ -8,7 +8,7 @@ last_checked: 2026-07-06
|
||||
|
||||
Le profil Chromium persistant `.playwright-profile` (racine du working tree) porte le **wallet
|
||||
partagé** ouvert par toute la suite `@data`/`@e2e`. Ce wallet **accumule des données à chaque
|
||||
run** : comptes shim (un par scénario, via l'identifiant frais `freshScenarioUsername`), docs
|
||||
run** : comptes shim (un par scénario, via l'identifiant frais `freshScenarioIdentifier`), docs
|
||||
d'entités seedés, dépôts d'inbox historiques… Le private store est le **point d'ancrage du shim**
|
||||
(résolution de compte) et est interrogé par **toute** lecture/écriture (`resolveAccount`,
|
||||
`listMyEntityDocs`, …).
|
||||
@@ -26,7 +26,7 @@ recréer un frais :
|
||||
mv .playwright-profile /tmp/festipod-bloated-$(date +%s)
|
||||
```
|
||||
|
||||
L'identifiant frais par scénario (`freshScenarioUsername`) borne le *registre* des comptes mais
|
||||
L'identifiant frais par scénario (`freshScenarioIdentifier`) borne le *registre* des comptes mais
|
||||
**pas** la croissance physique du private store partagé — d'où la récurrence. Une hygiène durable
|
||||
(purge périodique / wallet jetable par run) reste à mettre en place ; en attendant, si les
|
||||
`resolveAccount failed`/timeouts réapparaissent, repartir d'un profil frais.
|
||||
|
||||
@@ -27,7 +27,7 @@ Tags de scénario : `@ui` / `@data` / `@e2e` (couche) + **`@wip`** pour un scén
|
||||
|
||||
## Config
|
||||
|
||||
`cucumber.json` : `import` de `src/shared/support/**`, `src/shared/steps/**`, `src/modules/*/steps/**` ; `paths` = `src/modules/*/features/**`; `tags: "not @wip"` (exclut les scénarios WIP) ; `language: fr`. **Runner = Node + tsx** (`node --import tsx/esm node_modules/.bin/cucumber-js`), pas Bun — les plugins (Playwright, happy-dom) ne chargent pas en import Bun natif. Ne pas « bunifier » `cucumber:run`/`test:data`.
|
||||
`cucumber.json` : `import` de `src/shared/support/**`, `src/shared/steps/**`, `src/modules/*/steps/**` ; `paths` = `src/modules/*/features/**`; `tags: "not @wip"` (exclut les scénarios WIP) ; `language: fr`. **Runner = Node + tsx**, pas Bun — les plugins (Playwright, happy-dom) ne chargent pas en import Bun natif. Ne pas « bunifier » `cucumber:run`/`test:data`. ⚠️ Le runner doit pointer sur l'**entrée JS réelle du paquet** (`node_modules/@cucumber/cucumber/bin/cucumber.js`), **jamais** sur `node_modules/.bin/cucumber-js` : selon l'installeur, `.bin/` contient un **shim shell** (pas du JS) que `node --import tsx/esm` ne peut pas exécuter.
|
||||
|
||||
## Le harness de test est buildé à la demande
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Couche @data — Playwright pilote Chromium (profil persistant) qui s'authentifie au broker NextGraph réel chargeant harness-ng.tsx en iframe ; cycle de vie wallet automatisé (création + login bootstrap), bridge window.__testData, fallback mock
|
||||
last_checked: 2026-07-05
|
||||
summary: Couche @data — Playwright pilote Chromium (profil persistant) qui s'authentifie au broker NextGraph réel chargeant harness-ng.tsx en iframe ; cycle de vie wallet automatisé (création + login bootstrap), bridge window.__testData, fallback mock ; isolation par identifiant virtuel frais (this.freshIdentifier), plus de purge par scénario
|
||||
last_checked: 2026-07-27
|
||||
---
|
||||
|
||||
# Couche `@data` (broker réel)
|
||||
@@ -50,17 +50,22 @@ Cucumber → Playwright (Chromium, profil persistant)
|
||||
`ensureCurrentUser()` avant `joinEvent` (sinon participation écrite sans user → jetée en
|
||||
lecture, ne fait jamais l'aller-retour) et attendent (`waitForFunction`) que la participation
|
||||
soit relue.
|
||||
- **Caveat wallet persistant + isolation par scénario (T03.j)** : le wallet partagé **accumule**
|
||||
le registre de comptes émulé et les docs per-entité à chaque scénario/run. Le fan-out de lecture
|
||||
(`listEntityDocs` = `allAccounts()` → 1 SELECT/compte) parcourt tous les docs de tous les comptes
|
||||
→ ralentit et fait *timeouter* les steps quand le wallet est pollué. Ce registre vit **côté
|
||||
broker** : supprimer `.playwright-profile/` ne le nettoie PAS (re-sync depuis le broker) et force
|
||||
une re-auth lente — mauvais levier. À la place, le `Before` @data appelle
|
||||
`window.__testData.resetDataState()` : **UN** SPARQL DELETE sur le graphe ancre (private-store)
|
||||
qui efface tous les records `urn:ng-eventually:shim:Account` → `allAccounts()` s'effondre à vide →
|
||||
le fan-out se **borne** à ce que le scénario courant reprovisionne (comptes recréés paresseusement
|
||||
par `ensureAccount`). O(1) sur UN graphe — **pas** un delete en fan-out (qui saturait le navigateur,
|
||||
cf. T03.i `authClearParticipation` retiré). Borné à ≤10s (`Promise.race`) pour ne pas disputer le
|
||||
budget 60s du `Before` (login broker déjà lent). Infra de test uniquement — ne touche ni la lib ni
|
||||
le modèle produit ni le chemin de lecture applicatif. Le seed connecté reste **allégé** (peu de
|
||||
docs) car chaque `docCreate` est un aller-retour broker sériel ~2s.
|
||||
- **Isolation par scénario = identifiant virtuel frais, PAS de purge.** Le `Before` @data mint un
|
||||
identifiant unique par scénario (`freshScenarioIdentifier` dans `hooks.ts`), l'expose en
|
||||
`this.freshIdentifier` sur le World, et l'injecte par `addInitScript` dans
|
||||
`localStorage['festipod.account.identifier']` **sur toutes les origines** (y compris l'iframe
|
||||
harness sur 127.0.0.1) — avant tout script. Le shim sert alors un **compte virtuel frais et vide**,
|
||||
dont le registre part vide *par construction* : **rien à purger**. L'ancien reset par scénario
|
||||
(`window.__testData.resetDataState()`, un SPARQL DELETE des records
|
||||
`urn:ng-eventually:shim:Account` sur le graphe ancre) **n'est plus appelé** — il coûtait jusqu'à
|
||||
10 s prélevés sur le budget 60 s du `Before`, déjà mangé par le login broker. Le helper existe
|
||||
encore sur le bridge (`harness-ng.tsx`) mais n'est plus dans le chemin par défaut : ne pas le
|
||||
remettre dans le `Before` sans mesurer.
|
||||
- **Ce que l'identifiant frais NE borne PAS** : la croissance *physique* du wallet partagé — voir
|
||||
[[caveat_wallet-bloat-hang]] (profil à mettre de côté quand les lectures ancrées se mettent à
|
||||
*hang*).
|
||||
- `this.freshIdentifier` est aussi ce qui distingue un test de **reconnexion** (même identifiant
|
||||
réinjecté) d'un test d'**isolation** (nouvel identifiant) — cf.
|
||||
[[caveat_reconnexion-froide-local-vs-broker]].
|
||||
- Le seed connecté reste **allégé** (peu de docs) car chaque `docCreate` est un aller-retour broker
|
||||
sériel ~2s.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Couche @e2e — Playwright boote l'app RÉELLE (pas un harness) dans l'iframe broker, interagit via appFrame.evaluate()/locator(), réutilise setupBrokerPage() de @data ; teste navigation/redirects/clics, pas de fallback mock
|
||||
summary: Couche @e2e — Playwright boote l'app RÉELLE (pas un harness) dans l'iframe broker, interagit via appFrame.evaluate()/locator(), réutilise setupBrokerPage() de @data ; teste navigation/redirects/clics, pas de fallback mock ; identité par scénario (this.freshIdentifier) + barrière d'accès désactivée par init script ; idiome « fermer et rouvrir » pour les scénarios de reconnexion
|
||||
last_checked: 2026-07-27
|
||||
---
|
||||
|
||||
# Couche `@e2e` (app réelle)
|
||||
@@ -42,6 +43,45 @@ Navigation : `window.history.pushState` + dispatch `popstate` (routing path-base
|
||||
|
||||
> **Ne pas re-vérifier en `@e2e` ce que `@ui` couvre déjà** — `@e2e` doit casser quand la *collaboration* entre couches casse, pas quand une icône change (cf. [[rule_test-layer-contracts]]).
|
||||
|
||||
## Identité du scénario + barrière d'accès
|
||||
|
||||
Deux réglages posés par le `Before` de `hooks.ts` conditionnent **tout** scénario `@e2e` :
|
||||
|
||||
- **`this.freshIdentifier`** — un identifiant virtuel **unique par scénario**, injecté par
|
||||
`addInitScript` dans `localStorage['festipod.account.identifier']` sur **toutes** les origines
|
||||
avant tout script. L'app réelle boote donc directement sur cette identité, et chaque scénario part
|
||||
d'un espace vide. C'est la **même** mécanique qu'en `@data` (même champ du World).
|
||||
- **Barrière d'accès désactivée** — `browserContext.addInitScript` pose
|
||||
`globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ = true` sur le contexte **persistant** : `@e2e` voit
|
||||
l'app, pas l'`AccessGateScreen`. Les contextes **frais** (`@humain`, cf.
|
||||
[[knowledge_multibrowser-harness]]) n'héritent pas de ce réglage → barrière ON chez eux.
|
||||
|
||||
> **Impact :** toute page ouverte à la main dans un step (`ctx.newPage()`) doit **re-poser les deux
|
||||
> init scripts elle-même** — `addInitScript` du contexte ne s'applique qu'aux pages du contexte, et
|
||||
> l'identifiant doit être écrit **avant** le premier script de l'app.
|
||||
|
||||
## Idiome « fermer et rouvrir » (scénarios de reconnexion)
|
||||
|
||||
`reconnexion-persistance-e2e.feature` / `src/modules/event/steps/e2e/reconnexion-persistance.steps.ts`
|
||||
reproduisent, dans la VRAIE app, le parcours « je crée, je ferme, je reviens » :
|
||||
|
||||
1. **Création par le vrai formulaire** — le step pilote l'assistant de création réel au DOM
|
||||
(assistant en 3 étapes, sélection par *placeholder* : nom de l'événement, lieu ; bouton de
|
||||
soumission par son libellé). ⚠️ **Ces steps sont couplés aux libellés FR de l'écran de création** :
|
||||
renommer un placeholder ou le bouton de soumission casse le scénario, pas l'app.
|
||||
2. **Réouverture** — seconde page sur le **même** contexte persistant, avec la **même**
|
||||
`this.freshIdentifier` + la barrière désactivée, puis `pool.setupBrokerPage(page, pool.appUrl!)`
|
||||
→ nouveau login broker, session verifier fraîche.
|
||||
3. **Preuve** — le step capture la console des **deux** pages et publie un résumé via `this.attach`
|
||||
(pièce jointe Cucumber) + stdout ; un dump brut des lignes de connexion/sync est **opt-in** par
|
||||
la variable d'environnement `RECO_RAW_DUMP=1` (bruyant, coupé par défaut).
|
||||
|
||||
> **Limite à connaître** : ce montage prouve la reconnexion *du parcours*, **pas** la durabilité
|
||||
> broker de l'écriture — la seconde page partage l'IndexedDB du profil persistant. Voir
|
||||
> [[caveat_reconnexion-froide-local-vs-broker]] pour le montage qui, lui, tranche broker-vs-local.
|
||||
|
||||
Le scénario est **`@wip`** (instrument de diagnostic, exclu du run par défaut).
|
||||
|
||||
## Smoke `@smoke` — garde la classe « page blanche une fois connecté »
|
||||
|
||||
`@e2e @smoke` (`src/modules/home/features/accueil-connecte-rend.feature`) garde une
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Couche @ui — renderHelper.tsx rend tout écran dans LocalDataProvider + happy-dom, world.renderCurrentScreen() l'invoque à chaque navigateTo, assertions sur le DOM rendu avec les fixtures de seed déterministes
|
||||
summary: Couche @ui — renderHelper.tsx rend tout écran dans LocalDataProvider + happy-dom, world.renderCurrentScreen() l'invoque à chaque navigateTo, assertions sur le DOM rendu avec les fixtures de seed déterministes ; piège des écrans qui lisent un global injecté au build (barrière d'accès → import lazy obligatoire)
|
||||
last_checked: 2026-07-27
|
||||
---
|
||||
|
||||
# Couche `@ui`
|
||||
@@ -30,4 +31,27 @@ expect(labels.some(t => t.includes("Nom de l'événement *"))).to.be.true;
|
||||
- `currentScreenId: string | null` — l'écran courant.
|
||||
- Helpers d'assertion : `getDomText()` (texte du DOM), `hasText(t)`, `hasField(name)`, `hasElement(selector)` — ils **préfèrent le DOM rendu** mais **retombent sur la source** des écrans pour les steps non migrés (vestige, voir [[caveat_source-grep-vestiges]]).
|
||||
|
||||
## ⚠️ Écrans qui lisent un global injecté au **build** (barrière d'accès)
|
||||
|
||||
`src/modules/auth/sharedWallet.ts` **capture au moment de l'évaluation du module** un global posé par
|
||||
`build.ts` (`__FESTIPOD_SHARED_WALLET_PASSWORD__`). Le harness `@ui` tourne sous Node **sans passer
|
||||
par le build** → ce global est absent, `hasSharedWallet()` retourne faux, et comme le **wallet
|
||||
partagé est le seul mode supporté** (concept `app-security`), `AccessGateScreen` rend sa branche
|
||||
**erreur de configuration** : **aucun champ identifiant** dans le DOM → tous les steps de la barrière
|
||||
échouent avec un message trompeur (« champ introuvable »).
|
||||
|
||||
**Le montage obligatoire** (appliqué dans `src/modules/auth/steps/ui/barriere-acces.steps.ts`) :
|
||||
|
||||
1. poser le global **en tête du module de steps**, avant tout import de l'écran ;
|
||||
2. **importer l'écran paresseusement** (`await import(...)` mémoïsé) — un `import` statique serait
|
||||
**hissé au-dessus** de l'affectation et `sharedWallet.ts` capturerait une valeur vide.
|
||||
|
||||
> **Impacts si tu touches à ça :**
|
||||
> - Ajouter un `import` statique de `AccessGateScreen` (ou de tout module qui atteint
|
||||
> `sharedWallet.ts`) dans **n'importe quel** fichier de steps `@ui` ré-introduit le bug — Cucumber
|
||||
> charge tous les modules de steps, l'écran serait évalué avant que le global soit posé.
|
||||
> - Le déterminisme actuel repose sur le fait que **ce fichier est le seul** module `@ui` à atteindre
|
||||
> `sharedWallet.ts`. Un second point d'entrée rendrait l'ordre d'évaluation non garanti → il
|
||||
> faudrait alors déplacer l'injection du global dans le support partagé, pas la dupliquer.
|
||||
|
||||
> Les classes `app-*` confirment le thème moderne (cf. `app-architecture`). Les anti-patterns (regex sur source, détails d'implémentation) sont proscrits par [[rule_test-layer-contracts]]. Pour écrire un nouveau scénario, voir [[cookbook_add-scenario]].
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
# Doc-debt — data-layer
|
||||
|
||||
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
|
||||
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
|
||||
|
||||
## Raw markers (consolidate into blocks, then delete)
|
||||
- TOUCHED src/shared/context/FestipodDataContext.tsx @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/shared/utils/ngSession.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
@@ -2,7 +2,7 @@
|
||||
type: _overview
|
||||
summary: Comment Festipod persiste ses données via le SDK @ng-eventually/client — entités stockées comme documents par scope, écriture SPARQL directe + lecture par modèle union, stack SHEX, modes connected/demo, seed
|
||||
triggers:
|
||||
keywords: [nextgraph, "@ng-eventually", union, readUnion, readEntities, SHEX, shape, scope, "@graph", NURI, sparql, seed, wallet, FestipodData, ngSession, ngGraph, bootstrap, document, entité, déconnexion, reconnexion, durabilité, outbox, SerializationError]
|
||||
keywords: [nextgraph, "@ng-eventually", polyfill, union, readUnion, readEntities, SHEX, shape, scope, "@graph", NURI, overlay, ReadCap, WriteCap, cap-less, sparql, seed, wallet, FestipodData, ngSession, ngGraph, bootstrap, document, entité, déconnexion, reconnexion, durabilité, outbox, SerializationError]
|
||||
paths: ["src/shared/shapes/**", "src/shared/data/readEntities.ts", "src/shared/data/entityWrites.ts", "src/shared/context/NextGraphContext.tsx", "src/shared/context/FestipodDataContext.tsx", "src/shared/utils/ng*", "src/shared/data/seedData.ts"]
|
||||
---
|
||||
|
||||
@@ -18,11 +18,17 @@ Comment Festipod **persiste ses données** via NextGraph (P2P, local-first, chif
|
||||
- [[knowledge_data-modes]] — connected (SDK) vs disconnected/demo (état local seedé), choix du provider
|
||||
- [[knowledge_entities]] — types `Fp*` et leurs shapes SHEX
|
||||
- [[knowledge_seed-data]] — données de seed, `CURRENT_USER_ID`
|
||||
- [[knowledge_context-internals]] — pièges de `FestipodDataContext` (currentUser, auto-seed dev, `participantCount` cache, no-op local)
|
||||
- [[knowledge_context-internals]] — pièges de `FestipodDataContext` (currentUser, **deux espaces d'id** principal↔NURI de profil, auto-seed dev, `participantCount` cache, reset au changement d'identité, no-op local)
|
||||
|
||||
## Règles d'écriture
|
||||
|
||||
- [[rule_document-per-entity]] — chaque entité = **son propre document** (par scope), jamais au niveau du store ; c'est ce qui rend l'isolation par-document du SDK possible
|
||||
- [[rule_app-uses-sdk-surface-only]] — l'app se comporte comme si NextGraph était fini ; tout contournement vit dans le polyfill
|
||||
|
||||
## Ce qui sort de ce repo (deux destinations, ne pas les confondre)
|
||||
|
||||
- [[rule_capture-nextgraph-findings]] — une **connaissance** établie sur le fonctionnement réel de NextGraph → doc de référence du **polyfill**, au moment de la découverte
|
||||
- [[rule_nextgraph-inbox]] — un **dysfonctionnement** de NextGraph, ou un **manque** dont on a besoin et qu'on émule en attendant → fiche dans `orm-tests/INBOX/`, qui suit l'avancement amont et dit quoi retirer du polyfill
|
||||
|
||||
## Pièges (lire avant de toucher aux suppressions / aux champs d'event)
|
||||
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
---
|
||||
type: brief
|
||||
summary: Modèle cible des inscriptions — Participation LISIBLE par tous (réf. événement + booléen `active` + did cap-less vers le profil du participant), déposée dans l'inbox de l'événement ; le créateur traite l'inbox, déduplique sur l'overlay sans savoir qui, range la référence dans un Set de l'événement et PURGE les annulées ; compteur = Set.size sans filtrage (borne haute assumée) ; seules les connexions détiennent le cap du profil et reconnaissent la personne. Supersede l'Option-B (compteur muté + userId en clair).
|
||||
---
|
||||
|
||||
# Brief (2026-07-20, révisé 2026-07-27) — inscriptions par Set
|
||||
|
||||
## Le modèle
|
||||
|
||||
Posé et affiné par le PO les 2026-07-27. Tout est **clés et URLs** — pas de rôle, pas d'appartenance, pas de liste d'autorisation.
|
||||
|
||||
1. Le participant crée un objet **Participation**, **lisible par tous**, contenant : la **référence à l'événement**, un **booléen `active`**, et un **did cap-less vers son profil** *protected*. **Rien d'autre** — pas de description pour l'instant.
|
||||
2. Il dépose le **did de la Participation** dans l'**inbox de l'événement**.
|
||||
3. Le **créateur** traite son inbox **automatiquement**, dès qu'il est en ligne.
|
||||
4. Il **déduplique** (voir plus bas) — **sans savoir qui est le participant** : il détient le did du profil, pas son cap.
|
||||
5. Il range une **référence** à la Participation dans un **Set** porté par le document de l'événement.
|
||||
6. N'importe qui lit **`Set.size`** → le nombre de participants.
|
||||
7. Une personne **connectée** au participant détient le cap de son profil, le lit, et **reconnaît** la personne.
|
||||
|
||||
**Désinscription** : le participant passe `active` à faux **sur son propre objet**. Le créateur le constate en lisant, et **purge** — il retire la référence du Set.
|
||||
|
||||
Trois propriétés en découlent : **présence anonyme par défaut** (le créateur lui-même ne voit pas qui) ; **personne ne modifie l'inscription d'autrui** (seul le participant détient la clé d'écriture de son objet) ; **aucun `userId` en clair** ne circule.
|
||||
|
||||
### Le principe qui tient tout : la vérité est dans l'objet, les messages sont des indices
|
||||
|
||||
C'est l'objet **contrôlé par le participant** qui fait foi. Tout message — dépôt d'inbox, notification de purge — n'est qu'un **indice** qui déclenche une vérification, jamais une autorité.
|
||||
|
||||
Conséquence : la **forgerie devient structurellement inoffensive**. Un faux « purge X » conduit le créateur à lire X, constater qu'elle est encore active, et ne rien faire. C'est pourquoi les dépôts d'inbox **n'ont pas besoin d'être signés** — ce qui tombe bien, puisque NextGraph ne l'offre pas (voir tableau).
|
||||
|
||||
### Pourquoi un booléen plutôt qu'une suppression
|
||||
|
||||
Une **suppression** n'est **pas détectable** sans la clé de lecture (VÉRIFIÉ : append-only, tombstone chiffré). Un objet **lisible** avec un **drapeau** transforme le problème : l'annulation n'est plus à *détecter*, elle est à *lire*. Le blocage disparaît au lieu d'être contourné par un message forgeable.
|
||||
|
||||
### Pourquoi le pointeur d'identité vise le profil existant
|
||||
|
||||
Pas besoin d'un second document par participation : le **profil protected** du participant joue ce rôle, et ses connexions en détiennent **déjà** le cap — c'est la définition d'« être connecté ». Un tiers voit un did opaque.
|
||||
|
||||
L'avantage sur un champ chiffré dans la Participation : **ajouter une connexion ne réécrit rien**. On lui scelle le cap du profil, une fois, durablement. Un champ chiffré exigerait de re-sceller à N destinataires et de réécrire la Participation à chaque nouvelle connexion. *(Accessoirement, un champ chiffré n'est pas un primitif NextGraph : la granularité de chiffrement est le document, en tout-ou-rien.)*
|
||||
|
||||
## Sur quoi ça repose — faits établis dans NextGraph
|
||||
|
||||
Vérifiés par lecture de `nextgraph-rs`. Détail et pointeurs côté polyfill (`docs/readcap-and-nuri-model.md`) — cf. [[rule_capture-nextgraph-findings]].
|
||||
|
||||
| Fait | Statut | Rôle ici |
|
||||
|---|---|---|
|
||||
| L'**overlay** (`:v:`) est **store-scopé**, jamais document-scopé | VÉRIFIÉ | **La clé de dédup** |
|
||||
| Un NURI cap-less **nomme sans donner à lire** | VÉRIFIÉ | Le did du profil pointe sans divulguer |
|
||||
| Un cap se **scelle durablement** à un destinataire (pas d'ACL re-déclarée) | VÉRIFIÉ | Le cap du profil, scellé une fois aux connexions |
|
||||
| Sans la clé, les blocs restent du **ciphertext** | VÉRIFIÉ | Le créateur ne peut vraiment pas lire le profil |
|
||||
| Une **suppression** n'est **PAS** détectable sans la clé | VÉRIFIÉ | **Pourquoi c'est un drapeau, pas une suppression** |
|
||||
| Un dépôt d'inbox n'est **PAS authentifié** (sealed box anonyme) | VÉRIFIÉ | **Pourquoi les messages doivent rester des indices** |
|
||||
| La vérification de signature d'auteur **n'est pas implémentée** au runtime, et exigerait de déchiffrer | VÉRIFIÉ | Écarte l'alternative « dépôt d'inbox signé » |
|
||||
|
||||
## La dédup : sur quoi exactement
|
||||
|
||||
**Validé par le PO (2026-07-27).**
|
||||
|
||||
Le segment `:v:` d'un NURI ne vient **pas du document** mais de **son store**. Or une personne a un seul store par scope. Donc **toutes ses Participations portent le même `:v:`**, quel que soit le nombre d'objets qu'elle crée. Le créateur déduplique là-dessus : deux références de même `:v:` dans le Set d'un même événement = la même personne. **Sans jamais savoir qui.**
|
||||
|
||||
C'est le critère **robuste** — plus que le did du profil, qu'un participant pourrait multiplier en créant plusieurs documents de profil dans son store.
|
||||
|
||||
Conséquence de conception : le Set est **indexé par `:v:`** — au plus une référence par `:v:`. `Set.size` = nombre de `:v:` distincts = nombre de personnes distinctes.
|
||||
|
||||
### La contrepartie — réserve durable, à ne pas perdre
|
||||
|
||||
> **Elle vit dans `app-security/`[[caveat_stable-overlay-pseudonym]]**, pas ici. Ce brief a vocation à être dissous à sa graduation ; la réserve doit lui survivre.
|
||||
|
||||
En bref : ce `:v:` est un **pseudonyme stable et permanent** de la personne, présent dans toute référence cap-less vers ses documents. Il ne dit pas *qui*, mais un **seul** recoupement dé-anonymise **rétroactivement** tout son historique — et **aucune porte de sortie n'existe** (aucune rotation possible, VÉRIFIÉ). C'est **le même bit d'information** qui permet de dédupliquer sans lire et de tracer d'un événement à l'autre : les deux ne se séparent pas. Rendre la Participation publique **augmente la surface de collecte** de ce pseudonyme.
|
||||
|
||||
## Arbitrages assumés (PO, 2026-07-27)
|
||||
|
||||
- **Pas de filtrage à la lecture.** Le compteur est `Set.size`, **sans** vérifier les `active`. On accepte le **risque d'obsolescence** : une participation annulée compte encore tant que le créateur n'a pas purgé. `Set.size` est donc une **borne haute**, exacte après purge. *Motif : garder la lecture simple et en O(1).*
|
||||
- **La purge incombe au créateur.** Pas de service curateur, pas de rattrapage par les lecteurs.
|
||||
- **Pas de description** dans la Participation pour l'instant. *(À rouvrir quand le besoin viendra : ce qu'on y mettrait deviendrait public.)*
|
||||
- **Créateur hors-ligne** : le Set ne bouge pas tant qu'il n'a pas traité son inbox. Accepté.
|
||||
|
||||
## Ce qui change vs l'implémentation actuelle (Option-B)
|
||||
|
||||
L'existant ([[brief_2026-07-06_reactive-reads-and-attendance]]) dérive un `participantCount` **muté en place** depuis des marqueurs d'inbox portant le **`userId` en clair**.
|
||||
|
||||
- **Retirer le `userId`** des dépôts d'inbox → ne reste que le **did de la Participation**.
|
||||
- **Compter des références distinctes** (par `:v:`), plus des `userId`.
|
||||
- **`event.participantCount` muté disparaît** au profit de `Set.size`.
|
||||
- La **résolution d'identité** passe par la **lecture du profil** (donc par son cap), plus par le marqueur.
|
||||
- **La désinscription cesse d'être une suppression** → un `active` à faux + purge par le créateur. Cf. [[caveat_participation-deletion]], dont l'exigence (« autoritative, ne doit pas réapparaître ») reste valable mais change de mécanisme.
|
||||
|
||||
Reste valable tel quel : la **lecture réactive**, le **ré-armement à la reconnexion**, le **fix d'espaces d'id** déjà livré.
|
||||
|
||||
## Points ouverts
|
||||
|
||||
- **Scope de Participation** — elle devient **publique** alors que la doctrine produit actuelle la place en *protected* ([[knowledge_data-scopes-and-discovery]], concept `functional-domain`). Ce leaf décrit **ce qui est implémenté** : ne pas le modifier tant que ce brief n'a pas gradué, mais **le mettre à jour à ce moment-là**.
|
||||
- **Reconnaissance par les connexions** (étape 7) — comment le cap du profil est scellé, et ce qu'il advient d'une connexion rompue (la révocation est un re-key grossier et non rétroactif). Explicitement remis à un 2e temps.
|
||||
- **Validation d'existence** — le créateur *peut* vérifier qu'un did pointe sur un objet réel sans clé (protocole `Ext`). **Pas requis** ; durcissement optionnel, et à ne pas rendre load-bearing : la garde correspondante n'est pas branchée côté NextGraph et pourrait l'être un jour.
|
||||
|
||||
## Dépendances
|
||||
|
||||
- **Bloquant** : l'**émulation caps du polyfill**. `caps.ts` modélise aujourd'hui une **ACL** (set de principals par document) là où le réel est **possession de clé**, et le contenu reste lisible en clair (`sparqlQuery`, `inbox.read` contournent le filtre). Tant que ce n'est pas corrigé, coder l'anonymat côté Festipod produirait du code qui **prétend** isoler sans isoler. Brief polyfill `2026-07-20-caps-emulation-alignment`, lot P1.
|
||||
- **Parké** : la **terminologie identité** (wallet / user / profil) — cf. `.project/to-discuss.md`.
|
||||
|
||||
## Statut : modèle tranché, mise en œuvre gatée
|
||||
|
||||
Le modèle est **arrêté** (PO, 2026-07-27) et ses fondations sont **vérifiées**. Ce qui reste gaté, c'est la **mise en œuvre** : elle attend le lot P1 du polyfill. **Ne pas retirer l'Option-B** d'ici là.
|
||||
|
||||
Liens : [[brief_2026-07-06_reactive-reads-and-attendance]] (superseded), [[caveat_participation-deletion]], [[rule_capture-nextgraph-findings]], [[rule_document-per-entity]], app-security ([[caveat_stable-overlay-pseudonym]], [[brief_2026-05-18_authorization-matrix]], [[knowledge_trust-model]]), polyfill `readcap-and-nuri-model.md` + `docs/vision.md`.
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Pièges internes de FestipodDataContext — currentUserId = principal stable dérivé de l'identifiant, auto-seed OPT-IN (FESTIPOD_AUTO_SEED, OFF par défaut), participantCount dérivé Option-B (fiable à la connexion du propriétaire via lecture inbox gated sur barrière ; source unique = event.participantCount), instrumentation useShapeQuery (spinner+timing), mutations no-op en mode local malgré le toast
|
||||
last_checked: 2026-07-07
|
||||
summary: Pièges internes de FestipodDataContext — currentUserId = principal stable dérivé de l'identifiant, DEUX espaces d'id joints par l'identifiant normalisé (resolveParticipantUser / USER_PRINCIPAL_PREFIX), auto-seed OPT-IN (FESTIPOD_AUTO_SEED, OFF par défaut), participantCount dérivé Option-B (fiable à la connexion du propriétaire via lecture inbox gated sur barrière ; source unique = event.participantCount), reset de session au changement d'identité (overlay + caps), instrumentation useShapeQuery (spinner+timing) + logs identité-first, mutations no-op en mode local malgré le toast
|
||||
last_checked: 2026-07-27
|
||||
---
|
||||
|
||||
# Internals & pièges de `FestipodDataContext`
|
||||
@@ -14,6 +14,27 @@ En mode connected, le **principal** du currentUser (`currentUserId`) n'est **pas
|
||||
- L'objet `currentUser` (le profil affiché) est, lui, résolu par `users.find(u => normalizeIdentifier(u.username) === identifiant)` avec **fallback** `@mariedupont` puis `users[0]` — un fallback silencieux si l'identifiant ne correspond à aucun profil (l'identifiant est un id d'espace, pas forcément le `username` d'un profil seedé).
|
||||
- Sans identifiant connecté (dev/demo), `currentUserId` retombe sur l'IRI du profil lu (ou `''` si le wallet est vide → `Participation` avec `user: ''` invalide) : ne créer une participation qu'une fois le principal résolu.
|
||||
|
||||
## DEUX espaces d'id se rencontrent — joindre une participation à son profil
|
||||
|
||||
**Invariant.** Une `Participation` stocke son user comme **principal** (`urn:festipod:user:<identifiant-normalisé>`, = `currentUserId`), alors qu'un `UserProfile` a pour `id` le **NURI de son document** (`did:ng:…`). En mode connecté, **ces deux valeurs ne sont jamais égales**. Une jointure brute `participation.userId === profile.id` ne matche donc **jamais** — symptôme livré puis corrigé (2026-07-27) : chaque participant s'affichait « participant inconnu ». Toute jointure participation→profil passe par **`resolveParticipantUser`** (`FestipodDataContext`), jamais par une comparaison directe.
|
||||
|
||||
Le **pont** entre les deux espaces est l'**identifiant normalisé** : `principal − préfixe` == `normalizeIdentifier(profile.username)` (la même égalité que la résolution de `currentUser`). D'où l'ordre d'essai de `resolveParticipantUser` : (1) **match direct** `u.id === userId` — l'espace du seed demo, où les deux côtés valent le même id nu (`user-1`) et où le username seedé `@mariedupont` ne normaliserait *pas* vers cet id, donc le direct doit passer en premier ; (2) à défaut, **match sur l'identifiant normalisé** après retrait du préfixe.
|
||||
|
||||
**`USER_PRINCIPAL_PREFIX` est la source unique du préfixe**, partagée par l'**écriture** (dérivation de `currentUserId`) et la **lecture** (`resolveParticipantUser`). Si tu changes la forme du principal, change-la **là** : sinon écriture et lecture divergent en silence et la jointure retombe sur « inconnu » sans lever d'erreur.
|
||||
|
||||
Un **troisième** espace d'id existe et ne participe **pas** à cette jointure : l'`uid` de dépôt d'inbox (`mint…`) — il identifie un **dépôt** pour le compteur, jamais un utilisateur.
|
||||
|
||||
> **Horizon.** Ce paragraphe décrit l'**implémenté** (Option-B). Le modèle cible retire le `userId` en clair et fait passer la résolution d'identité par la **lecture du profil** — cf. [[brief_2026-07-20_attendance-set-model]], dont la mise en œuvre est gatée. Le fix des espaces d'id y est explicitement noté comme **restant valable** : ne pas le défaire en anticipant la cible.
|
||||
|
||||
### Quel espace attend chaque query (contrat de `buildQueries`)
|
||||
|
||||
| Query | Ce qu'elle attend / rend |
|
||||
|---|---|
|
||||
| `getUserEvents(userId)`, `isParticipating(eventId, userId?)`, `getFriends(userId?)` | **attendent le principal** (elles filtrent sur `participation.userId` / `friendship.userId`) — leur défaut est `currentUserId`, correct |
|
||||
| `getEventParticipants(eventId)` | **rend des profils** (`FpUserData` → `id` = NURI), la jointure étant faite en interne |
|
||||
|
||||
**Impact côté écran** : se filtrer soi-même hors d'une liste de participants se compare à **`currentUser?.id`** (NURI de profil, même espace que les éléments rendus), **pas** à `currentUserId` (principal) — sinon on ne se retire pas et on se voit soi-même apparaître comme un participant de plus. Inversement, passer un **id de profil** à `getUserEvents`/`isParticipating` rend une liste **vide** en mode connecté. Voir `app-architecture`, [[caveat_identity-ids-in-screens]].
|
||||
|
||||
## Lecture = `watchShape` (surface SDK), plus de machinerie bespoke
|
||||
|
||||
**Depuis 2026-07-10** : `useNgData` lit via `useShapeQuery(shape, scope)` (binding
|
||||
@@ -66,16 +87,28 @@ Le `@id` d'un événement **est** son NURI de document (`did:ng:o:<repo>[:v:<ove
|
||||
|
||||
## Changement d'identité = session fraîche (isolation)
|
||||
|
||||
Le jeu de lecture par besoin (`publicDocs`/`protectedDocs`) **accumule** les docs de scope de l'identité courante (pour ne pas perdre un doc juste créé avant la re-liste). Or le stopgap wallet-partagé garde **un seul arbre React** au travers d'un faux-logout + re-login sous un **autre identifiant** (pas de rechargement — `AccountContext.login` ne fait que réécrire l'identifiant en localStorage, `AuthGate` ne remonte rien). Sans réinitialisation, **les docs PROTECTED de l'identité précédente (ses participations) survivent dans le jeu de lecture de la nouvelle identité et fuient** via la lecture union : le cap gate ne peut pas les filtrer quand le registre de caps (en mémoire) ne gouverne pas ce doc *cette* session (doc persisté d'un run antérieur, ou chargement frais où les caps sont vides). Symptôme observé : un utilisateur B voyait la participation de A (et l'événement de A apparaissait sur l'**accueil** de B, car l'accueil = `getUserEvents(currentUserId)`, cf. concept `app-architecture`).
|
||||
> **Historique du symptôme** (le paragraphe qui suit décrit le montage d'alors — le jeu de lecture bespoke `publicDocs`/`protectedDocs`/`readTick` **n'existe plus** depuis le passage à `watchShape`). Il est conservé parce qu'il explique *pourquoi* la règle du reset existe ; le **mécanisme courant** est décrit plus bas.
|
||||
|
||||
**Règle** : traiter **tout changement d'identifiant** comme une session fraîche — un `useEffect([identifier])` (ref-gardé pour ne pas tirer au premier mount) vide `publicDocs`/`protectedDocs`, appelle `resetCaps()` + `resetRegistryCache()`, puis bump le read tick ; l'effet de listing reconstruit le jeu **borné à la nouvelle identité**. L'isolation reste par-document/émulée (concept `app-security`, [[knowledge_trust-model]]) ; ce reset ne fait que supprimer le report d'état inter-identités.
|
||||
Le jeu de lecture par besoin (`publicDocs`/`protectedDocs`) **accumulait** les docs de scope de l'identité courante (pour ne pas perdre un doc juste créé avant la re-liste). Or le stopgap wallet-partagé garde **un seul arbre React** au travers d'un faux-logout + re-login sous un **autre identifiant** (pas de rechargement — `AccountContext.login` ne fait que réécrire l'identifiant en localStorage, `AuthGate` ne remonte rien). Sans réinitialisation, **les docs PROTECTED de l'identité précédente (ses participations) survivent dans le jeu de lecture de la nouvelle identité et fuient** via la lecture union : le cap gate ne peut pas les filtrer quand le registre de caps (en mémoire) ne gouverne pas ce doc *cette* session (doc persisté d'un run antérieur, ou chargement frais où les caps sont vides). Symptôme observé : un utilisateur B voyait la participation de A (et l'événement de A apparaissait sur l'**accueil** de B, car l'accueil = `getUserEvents(currentUserId)`, cf. concept `app-architecture`).
|
||||
|
||||
**Mécanisme confirmé empiriquement (2026-07-07)** : le leak se reproduit UNIQUEMENT quand DEUX conditions coïncident — (a) le jeu de lecture porte encore le doc PROTECTED de A au travers du switch (pas de reset), ET (b) le registre de caps en mémoire ne gouverne pas ce doc (`resetCaps()` déjà tiré / caps vides pour un doc persisté d'une session antérieure au reload). Alors la participation de A traverse la lecture union de B (le filtre par-document n'a aucun cap à vérifier). Avec le reset ci-dessus tiré, `setProtectedDocs([])` retire le doc de A du jeu de lecture de B AVANT que la lecture cap-less ne l'expose → plus de fuite quel que soit l'état des caps. **Régression gardée** par le scénario `@data` « Une identité fraîche ne voit pas la participation d'une autre » (event/isolation-deux-identites.feature) : A crée E + s'y inscrit, B (page fraîche sur le même wallet, identifiant distinct) n'a NI E sur son accueil (`getUserEvents(B)`), NI `isParticipating(E,B)`, ET ne lit AUCUNE participation portant le principal de A. Le symptôme historique « B voit “Je participe” » survenait surtout quand B **réutilisait un identifiant déjà employé par A** (même principal normalisé) sur un wallet **bloaté** (docs persistés d'un run antérieur, caps vides).
|
||||
**Règle** : traiter **tout changement d'identifiant** comme une **session fraîche**. Un `useEffect([identifier])`, **ref-gardé** (il ne tire pas au premier mount, seulement sur un vrai changement de valeur), remet à zéro **tout l'état de session porté par l'app**. L'isolation reste par-document/émulée (concept `app-security`, [[knowledge_trust-model]]) ; ce reset ne fait que supprimer le report d'état inter-identités.
|
||||
|
||||
**Mécanisme courant** (depuis la lecture par `watchShape`) : la **lecture** n'a plus rien à réinitialiser — `watchShape` re-résout son scope sur le nouveau `getCurrentUser()` au prochain push. Ce que l'effet vide est l'état **app-side** : `ownedEventIds` (le jeu du matérialiseur du propriétaire), la map `joinUids` (uid de dépôt de la session courante), l'**overlay optimiste** (`pendingAddEvents`/`pendingAddParticipations`/`pendingRemoveIds` — sinon les mutations de l'ancienne identité saignent dans les lectures de la nouvelle), puis `resetCaps()` + `resetRegistryCache()`.
|
||||
|
||||
> **Impact — l'invariant à ne pas casser** : **tout nouvel état de session** ajouté au provider (cache, `useRef`, overlay, jeu de docs) doit être ajouté à cet effet. Un état oublié **fuit d'une identité à l'autre** sans erreur — c'est exactement la classe de bug que la garde de régression ci-dessous couvre.
|
||||
|
||||
**Mécanisme confirmé empiriquement (2026-07-07)** : le leak se reproduit UNIQUEMENT quand DEUX conditions coïncident — (a) le jeu de lecture porte encore le doc PROTECTED de A au travers du switch (pas de reset), ET (b) le registre de caps en mémoire ne gouverne pas ce doc (`resetCaps()` déjà tiré / caps vides pour un doc persisté d'une session antérieure au reload). Alors la participation de A traverse la lecture union de B (le filtre par-document n'a aucun cap à vérifier). Avec le reset tiré, le doc de A quittait le jeu de lecture de B AVANT que la lecture cap-less ne l'expose → plus de fuite quel que soit l'état des caps (à l'époque via `setProtectedDocs([])` ; aujourd'hui c'est `watchShape` qui re-résout le scope, et le reset ne porte plus que l'état app-side listé plus haut). **Régression gardée** par le scénario `@data` « Une identité fraîche ne voit pas la participation d'une autre » (event/isolation-deux-identites.feature) : A crée E + s'y inscrit, B (page fraîche sur le même wallet, identifiant distinct) n'a NI E sur son accueil (`getUserEvents(B)`), NI `isParticipating(E,B)`, ET ne lit AUCUNE participation portant le principal de A. Le symptôme historique « B voit “Je participe” » survenait surtout quand B **réutilisait un identifiant déjà employé par A** (même principal normalisé) sur un wallet **bloaté** (docs persistés d'un run antérieur, caps vides).
|
||||
|
||||
## Instrumentation `useShapeQuery` — spinner global + timing
|
||||
|
||||
`useShapeQuery` (binding `useSyncExternalStore` sur `watchShape`) instrumente **chaque cycle de requête** : au début d'un cycle il s'enregistre dans un store module-level `src/shared/data/pendingQueries.ts` (`beginQuery`/`resolveQuery`, Set d'ids — idempotent, sûr sous StrictMode), et à la 1re transition `isPending → isSuccess|isError` (le « premier résultat », équivalent readPromise) il se résout ET logge le délai : `[FestipodData] <shape>/<scope> premier résultat en <N>ms (n=<len>)` (le délai des événements Event/public est donc visible nommément). Le `cycleId` est mémoïsé sur `[shapeKey, scope]` → un switch d'identité/scope recrée l'observable ET un nouveau cycle (re-`beginQuery`), et le cleanup résout au démontage (jamais bloqué). Le hook `usePendingQueries()` expose le nombre de requêtes en attente ; `HomeScreen` affiche un `Spinner` (sketchy, `.app-spinner` + `@keyframes app-spin` dans `index.css`) à côté du titre « Festipod » tant que le compte > 0 → il ne s'arrête que quand **toutes** les requêtes en cours ont reçu leur premier résultat. Toute future `useShapeQuery` y contribue automatiquement. La mesure vit côté app (délai perçu React), **pas** dans le polyfill.
|
||||
|
||||
## Convention de log — préfixe identité-first, et compteur avant→après
|
||||
|
||||
Tout log DATA du provider passe par **`logPrefix`** : `[<currentUserId>][app][data]` quand le principal est résolu, `[app][data]` sinon (état transitoire de connexion). Raison : avec le wallet partagé, **deux identités partagent la même console** (deux onglets / un multi-navigateur) — une ligne non préfixée ne dit pas *de qui* elle parle et devient inexploitable pour diagnostiquer une fuite ou un compteur bloqué. **Ajouter un log DATA = réutiliser `logPrefix`**, pas un `console.log` nu.
|
||||
|
||||
Deux points de mesure sont posés **par paire** et servent ensemble : le matérialiseur du propriétaire logge `participantCount` **avant → après** son écriture, et la lecture d'affichage logge la valeur **telle qu'exposée au rendu**. Les comparer tranche un compteur bloqué entre un problème **DONNÉE** (jamais incrémenté) et un problème **AFFICHAGE** (incrémenté mais pas relu avant la session suivante). Ne pas retirer l'un des deux sans l'autre — isolément ils ne diagnostiquent rien.
|
||||
|
||||
## Mutations no-op en mode local
|
||||
|
||||
En mode local/demo (`useLocalData`), `createEvent`/`joinEvent`/`leaveEvent`/`updateEvent` sont des **no-ops** (`console.log`, l'état ne change pas) — mais les écrans affichent quand même un **toast de succès** (« Tu participes »). UX potentiellement trompeuse : l'utilisateur croit s'être inscrit alors que rien n'a changé. Voir [[knowledge_data-modes]] pour le choix du provider selon le statut.
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
---
|
||||
type: rule
|
||||
summary: Toute connaissance importante établie sur le fonctionnement RÉEL de NextGraph (mécanisme du cœur/broker/verifier, sémantique d'un primitif, propriété de forme) → la consigner AU MOMENT de la découverte dans la doc de référence du polyfill `../../nextgraph/ng-eventually-js/docs/`, jamais dans le repo Festipod ; distinguer VÉRIFIÉ d'INFÉRÉ, et ne jamais déduire la forme CIBLE de l'état COURANT du source
|
||||
---
|
||||
|
||||
# Règle : consigner toute connaissance NextGraph au moment où on l'établit
|
||||
|
||||
Quand une enquête établit un **fait important sur le fonctionnement réel de NextGraph** — le mécanisme d'un primitif, la sémantique d'une structure, une propriété de forme (« l'overlay est *store*-scopé, jamais document-scopé »), une garde d'accès, ce qu'une opération exige ou n'exige pas — **écris-le tout de suite** dans la documentation de référence du polyfill :
|
||||
|
||||
`../../nextgraph/ng-eventually-js/docs/` (depuis la racine de ce repo) — typiquement la fiche de référence du sujet (modèle de caps/NURI, état courant, référence SDK).
|
||||
|
||||
**Jamais dans le repo Festipod.** `AGENTS.md` l'interdit explicitement : la doctrine Festipod décrit *comment Festipod utilise le SDK*, pas l'état de NextGraph. Cf. [[rule_app-uses-sdk-surface-only]].
|
||||
|
||||
## Au moment de la découverte — pas à la fin
|
||||
|
||||
Le « je consignerai en fin de session » ne marche pas : le contexte est compacté avant, et le fait est perdu. Ces connaissances coûtent **très cher** à établir (plusieurs enquêtes d'agents dans le source Rust, souvent contradictoires avant convergence) et sont **invérifiables de mémoire** — une seconde session repaiera le prix fort pour la même réponse, ou pire, se contentera d'une intuition fausse.
|
||||
|
||||
## Le piège central : état courant ≠ forme cible
|
||||
|
||||
**Ne jamais lire l'état courant de `nextgraph-rs` pour en DÉDUIRE la forme cible.** Le source contient de l'**échafaudage inachevé** qui ressemble à du modèle : on peut y lire des types d'appartenance et de permissions qui sont **inertes au runtime** (jamais appelés hors tests unitaires, structures construites vides). En déduire un primitif « membership » et le façonner dans le polyfill, c'est y graver une forme qui n'existera pas — exactement le mode d'échec que le polyfill existe pour empêcher.
|
||||
|
||||
Le source sert à **vérifier un mécanisme existant**, jamais à **inférer une intention**. L'intention se demande au concepteur de NextGraph.
|
||||
|
||||
## Forme de la note
|
||||
|
||||
- **Distinguer VÉRIFIÉ** (chemin lu de bout en bout, ou mieux : observé à l'exécution) d'**INFÉRÉ** (déduit, non tracé). Un fait porteur non marqué se transforme silencieusement en certitude.
|
||||
- **Pointer des symboles**, pas des numéros de ligne (volatils) — et dater la note.
|
||||
- Écrire aussi la **conséquence** du fait, pas seulement le fait : c'est elle qu'on relira.
|
||||
- Un fait qui **contredit** une note existante → corriger la note, ne pas empiler.
|
||||
|
||||
## Règle sœur
|
||||
|
||||
Celle-ci vise la **connaissance** — ce qui *est* ; [[rule_nextgraph-inbox]] vise ce qu'il faut **remonter ou attendre** — les dysfonctionnements et les manques (→ `../../nextgraph/orm-tests/INBOX/`). Une même enquête produit souvent les deux : ranger chaque moitié à sa place. Cf. [[knowledge_nextgraph-stack]].
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
type: rule
|
||||
summary: L'inbox NextGraph partagée `../../nextgraph/orm-tests/INBOX/` reçoit DEUX familles de fiches — les dysfonctionnements (un primitif se comporte mal) ET les manques (un primitif dont on a besoin, pas encore implémenté, qu'on émule dans le polyfill en attendant). Elle sert de suivi de l'avancement de NextGraph : quand un manque est comblé en amont, sa fiche dit quoi RETIRER du polyfill.
|
||||
---
|
||||
|
||||
# Règle : l'inbox NextGraph reçoit les dysfonctionnements ET les manques
|
||||
|
||||
L'inbox NextGraph partagée est `../../nextgraph/orm-tests/INBOX/` (depuis la racine de ce repo) — dans le repo frère `nextgraph/orm-tests`, qui héberge les tests d'intégration ORM contre un vrai broker (`tests/standalone/` pour les repros).
|
||||
|
||||
Elle n'est **pas** qu'un bug-tracker. Elle a **deux entrées** et **une boucle de sortie**.
|
||||
|
||||
## Entrée 1 — les dysfonctionnements
|
||||
|
||||
Un primitif NextGraph existe mais **se comporte mal** : socket qui meurt (`SerializationError`), pas de reconnexion automatique, `doc_subscribe` qui ne délivre pas ou tarde, cold-open de repo lent, écriture non durable côté broker, panique atteignable.
|
||||
|
||||
## Entrée 2 — les manques dont on a besoin
|
||||
|
||||
Un primitif **n'est pas encore implémenté** (ou n'est qu'un échafaudage inerte) alors que notre modèle en dépend. Le déposer aussi, avec les trois informations qui font sa valeur :
|
||||
|
||||
- **ce dont on a besoin** et pourquoi — le modèle qui en dépend ;
|
||||
- **ce que le polyfill fait en attendant** — l'émulation qui bouche le trou ;
|
||||
- **ce qu'il faudra retirer** du polyfill le jour où ça atterrit en amont.
|
||||
|
||||
C'est ce troisième point qui transforme la fiche en **ticket de nettoyage**. Sans lui, l'émulation survit à sa raison d'être et le polyfill se met à diverger de la cible — exactement ce qu'il existe pour éviter.
|
||||
|
||||
## Ce qui ne qualifie PAS
|
||||
|
||||
Un bug de l'**app** (effet React mal câblé, gating d'un effet) ou un **câblage du polyfill** (mauvais NURI, souscription non ré-armée). Ceux-là se corrigent **chez nous**. La distinction est cruciale : d'abord prouver que c'est le primitif qui faute — idéalement par un test — pas notre intégration. Cf. [[rule_app-uses-sdk-surface-only]].
|
||||
|
||||
## La boucle : l'inbox suit l'avancement de NextGraph
|
||||
|
||||
Les fiches ne partent pas seulement vers l'amont, elles se **relisent** : ensemble, elles disent où en est NextGraph par rapport à ce dont Festipod a besoin. Quand une fiche se résout en amont, la mise à jour du polyfill suit — souvent en **retirant** de l'émulation devenue inutile, pas en ajoutant du code.
|
||||
|
||||
## Format de la fiche
|
||||
|
||||
Nom : `YYYY-MM-DD-<slug>.md`. Contenu : nature (**dysfonctionnement** ou **manque**), symptôme ou besoin, **preuve verbatim** (logs, mesures, pointeurs source marqués « à re-vérifier »), repro quand c'est un dysfonctionnement (idéalement un standalone dans `orm-tests/tests/standalone/`), attendu vs observé, et — pour un manque — le **contournement polyfill** et **ce qu'il faudra retirer**. Sévérité + statut.
|
||||
|
||||
L'inbox reçoit le **rapport actionnable pour les mainteneurs NextGraph** ; un post-mortem plus long peut vivre côté polyfill.
|
||||
|
||||
## Règle sœur
|
||||
|
||||
Celle-ci vise ce qu'il faut **remonter ou attendre** ; [[rule_capture-nextgraph-findings]] vise la **connaissance** établie sur le fonctionnement réel (→ doc de référence du polyfill). Une même enquête produit souvent les deux : ranger chaque moitié à sa place. Cf. [[knowledge_nextgraph-stack]].
|
||||
@@ -1,10 +0,0 @@
|
||||
# Doc-debt — functional-domain
|
||||
|
||||
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
|
||||
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
|
||||
|
||||
## Raw markers (consolidate into blocks, then delete)
|
||||
- TOUCHED src/modules/event/features/reconnexion-persistance-e2e.feature @2026-07-13 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/event/features/reconnexion-socket-mort.feature @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/event/features/reconnexion-meme-identite.feature @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
- TOUCHED src/modules/event/features/reconnexion-froide-sans-local.feature @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
|
||||
@@ -17,6 +17,8 @@ summary: Ce qui est implémenté aujourd'hui (cycle événement + point de renco
|
||||
|
||||
> L'inscription/désinscription au point de rencontre est **réellement branchée** côté données : `joinEvent` persiste une Participation, notifie l'hôte du PdR et crée une Notification ; `leaveEvent` supprime la Participation de façon autoritative (cf. concept `data-layer`, [[caveat_participation-deletion]] côté data-layer). La découverte publique — un utilisateur voit un événement public d'un autre — fonctionne aussi.
|
||||
|
||||
> **Réserve produit — la persistance n'est pas garantie de bout en bout.** Un événement créé peut **disparaître** après une période d'inactivité puis une reconnexion sous la même identité (même wallet). C'est un **défaut ouvert**, pas une propriété du modèle produit : impact et pointeur côté `data-layer` → [[caveat_write-durability-across-disconnect]]. Conséquence pour le domaine : « mes événements / mes inscriptions » se comportent comme *implémentés* mais **pas encore comme durables** — ne pas bâtir de promesse produit (rappels, historique, engagement) dessus tant que ce caveat est ouvert. Les scénarios `src/modules/event/features/reconnexion-*.feature` du module `event` sont la garde de non-régression de cette promesse (statut d'exécution : concept `bdd-testing`).
|
||||
|
||||
## Évolutions identifiées (non implémentées)
|
||||
|
||||
- **Abonnement à une communauté d'intérêt** pour découvrir ses événements (discovery distribué).
|
||||
|
||||
@@ -25,7 +25,7 @@ summary: Composants de la stack (Bun runtime/build/test, install via pnpm, React
|
||||
| `start` | `NODE_ENV=production bun src/index.ts` — prod, depuis `src/` (pas `dist/`) |
|
||||
| `build` | `bun run build.ts` — bundler Bun + Tailwind → `dist/` ([[knowledge_build-pipeline]]) |
|
||||
| `test:cucumber` | enchaîne `cucumber:run` → `cucumber:report` → `features:parse` → `steps:extract` |
|
||||
| `cucumber:run` | `node --import tsx/esm …/cucumber-js` — **via Node+tsx, pas Bun** (compat plugins Playwright/happy-dom) |
|
||||
| `cucumber:run` | `node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js` — **via Node+tsx, pas Bun** (compat plugins Playwright/happy-dom), et via l'**entrée JS réelle du paquet**, pas le shim `.bin/` (voir Pièges) |
|
||||
| `test:data` | idem `--tags @data` |
|
||||
| `test:auth-setup` | `bun scripts/setup-test-auth.ts` — bootstrap wallet de test persistant |
|
||||
| `cucumber:report` | `bun scripts/parse-test-results.ts` — `cucumber-report.json` → HTML |
|
||||
@@ -39,4 +39,5 @@ summary: Composants de la stack (Bun runtime/build/test, install via pnpm, React
|
||||
## Pièges
|
||||
|
||||
- **`cucumber:run`/`test:data` tournent sous Node+tsx**, pas Bun — les plugins de test ne chargent pas en import Bun natif. Ne pas « bunifier » ces scripts.
|
||||
- **Ne jamais faire pointer un script sur `node_modules/.bin/*`.** L'install passe par pnpm ([[rule_bun-first]] §exception), qui y place des **shims shell** et non des entrées JS : `node --import tsx/esm node_modules/.bin/cucumber-js` échoue. Invoquer l'**entrée JS réelle du paquet** (`node_modules/@cucumber/cucumber/bin/cucumber.js`). Vaut pour tout script npm qui lancerait un binaire de dépendance sous `node`.
|
||||
- **`build:orm` cible `./src/shapes/shex` et `./src/shapes/orm`**, alors que les shapes réelles vivent sous **`src/shared/shapes/`** — le chemin du script est vraisemblablement **périmé** (à corriger ou exécuter avec les bons chemins ; vérifier avant de régénérer l'ORM).
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# To discuss
|
||||
|
||||
- [ ] revoir l'implémentation des ReadCap et WriteCap, aligner avec NextGraph et vérifier que le polyfill enforce bien la logique de droits d'accès en attendant que cela soit implémenté
|
||||
- [ ] clarifier la terminologie identité NextGraph (wallet = liste de clés ; user = données + username ; profils = identités contenues dans le user) et réconcilier avec decision_2026-07-06 (« identifiant = wallet »), decision_2026-07-20 (« username dans le profil ») et le modèle principal/identity du polyfill
|
||||
+4
-3
@@ -8,8 +8,8 @@
|
||||
"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",
|
||||
@@ -64,5 +64,6 @@
|
||||
"onlyBuiltDependencies": [
|
||||
"bun"
|
||||
]
|
||||
}
|
||||
},
|
||||
"packageManager": "pnpm@10.26.0+sha512.3b3f6c725ebe712506c0ab1ad4133cf86b1f4b687effce62a9b38b4d72e3954242e643190fc51fa1642949c735f403debd44f5cb0edd657abe63a8b6a7e1e402"
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
/**
|
||||
* AccessGateScreen — the *technical access barrier* of the stopgap.
|
||||
*
|
||||
* SHARED WALLET IS THE SOLE SUPPORTED MODE. Festipod does not function without
|
||||
* the shared wallet (the SDK polyfill runs on it). "No shared wallet configured"
|
||||
* is therefore NOT an offered flow — it is a loud MISCONFIGURATION error
|
||||
* (`!hasSharedWallet()` → a config-error block, no functional form). Configure it
|
||||
* via FESTIPOD_SHARED_WALLET_PASSWORD.
|
||||
*
|
||||
* STOPGAP (see decision_2026-06-15_shared-wallet-login-flow). This is the
|
||||
* REAL NextGraph login, shown before the app renders. Because it precedes the
|
||||
* app, the user reads it as "access to the test environment", not as an app
|
||||
* login. The user also types an IDENTIFIER here — the id that names their
|
||||
* virtual space (a technical id, a pseudo in practice, not a Festipod username).
|
||||
* virtual space (a technical id, a pseudo in practice, NOT a Festipod profile
|
||||
* handle like `@mariedupont`).
|
||||
* Clicking "Entrer" records that identifier and triggers `connect()`, which
|
||||
* redirects to the broker to open the SHARED wallet. After return the identity
|
||||
* is already set (persisted before the redirect), so NG auto-connects straight
|
||||
* into the app — there is no separate "pick a username" screen.
|
||||
* into the app — there is no separate "choose a handle" screen.
|
||||
*
|
||||
* ASSISTED IMPORT (see decision_2026-06-17). The hosted broker can't import a
|
||||
* wallet inline during
|
||||
@@ -17,8 +24,8 @@
|
||||
* dead-end. We therefore HAND the user the shared wallet FILE (download) + the
|
||||
* shared password and guide a one-time import on nextgraph.eu ("Import a Wallet
|
||||
* File"), BEFORE they click "Entrer". The wallet FILE is the correct static
|
||||
* primitive — a TextCode is a transient 5-min transfer, unusable to embed. Shown
|
||||
* only when a shared wallet is configured (FESTIPOD_SHARED_WALLET_PASSWORD).
|
||||
* primitive — a TextCode is a transient 5-min transfer, unusable to embed. This
|
||||
* assisted flow is the default whenever the shared wallet is open pending.
|
||||
*/
|
||||
|
||||
import { useState, type ReactNode } from 'react';
|
||||
@@ -58,7 +65,7 @@ export function AccessGateScreen({ status, error, initialIdentifier, onEnter }:
|
||||
const connecting = status === 'connecting';
|
||||
const [copied, setCopied] = useState(false);
|
||||
// The identifier that names this virtual space (a technical id — a pseudo in
|
||||
// practice, but not a Festipod username). Entered HERE, at wallet access, so a
|
||||
// practice, but not a Festipod profile handle). Entered HERE, at wallet access, so a
|
||||
// single act both names the space and opens it. Normalized (lowercased) upstream.
|
||||
// PREFILLED from the stored identifier so a returning user (reload / broker
|
||||
// round-trip) sees the value they already chose and never re-types it.
|
||||
@@ -107,7 +114,14 @@ export function AccessGateScreen({ status, error, initialIdentifier, onEnter }:
|
||||
<Title style={{ textAlign: 'center', fontSize: 30, marginBottom: 4 }}>Festipod</Title>
|
||||
<Text style={{ textAlign: 'center', marginBottom: 24, color: '#888' }}>Espace de test</Text>
|
||||
|
||||
{hasSharedWallet() && status !== 'connected' ? (
|
||||
{!hasSharedWallet() ? (
|
||||
// SOLE-MODE guard: Festipod cannot run without the shared wallet, so a
|
||||
// missing one is a misconfiguration, NOT a functional login form.
|
||||
<Text style={{ textAlign: 'center', fontSize: 14, color: '#c92a2a', lineHeight: 1.5, margin: '0 0 12px' }}>
|
||||
Portefeuille partagé non configuré. Festipod ne fonctionne pas sans
|
||||
(définir <code>FESTIPOD_SHARED_WALLET_PASSWORD</code>).
|
||||
</Text>
|
||||
) : status !== 'connected' ? (
|
||||
<>
|
||||
<Text style={{ textAlign: 'center', fontSize: 14, color: '#666', margin: '0 0 20px', lineHeight: 1.5 }}>
|
||||
Première connexion sur cet appareil ?<br />Chargez le portefeuille partagé, une seule fois.
|
||||
|
||||
@@ -5,8 +5,8 @@ import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
// --- Setup ---
|
||||
|
||||
Given('le portefeuille est vide', async function (this: FestipodWorld) {
|
||||
// Each @data scenario runs under a UNIQUE username (see hooks.ts
|
||||
// freshScenarioUsername), so the shim hands it a FRESH, EMPTY virtual wallet:
|
||||
// Each @data scenario runs under a UNIQUE identifier (see hooks.ts
|
||||
// freshScenarioIdentifier), so the shim hands it a FRESH, EMPTY virtual wallet:
|
||||
// "le portefeuille est vide" is trivially true on entry. So this is a fast
|
||||
// INSTANT CHECK — assert the reactive read already shows nothing — NOT the old
|
||||
// `clearWallet` per-entity-doc fan-out (a full physical-wallet enumeration that
|
||||
|
||||
@@ -8,14 +8,35 @@
|
||||
*
|
||||
* Guards the reported regression: on return the barrier used to re-ask for a
|
||||
* bare, empty identifier despite one being stored. See AuthGate.tsx.
|
||||
*
|
||||
* SHARED WALLET IS THE SOLE SUPPORTED MODE (see AccessGateScreen header): the
|
||||
* identifier field lives INSIDE the assisted-import flow, which renders only when
|
||||
* a shared wallet is configured; otherwise the barrier shows a config-error with
|
||||
* NO field. Production always configures one, but the @ui node harness does not
|
||||
* inject the build global, so we set it HERE — before the screen module is first
|
||||
* imported, so `sharedWallet.ts` captures it at module-eval — and lazy-import the
|
||||
* screen. This file is the only @ui module that reaches sharedWallet.ts, so this
|
||||
* ordering is deterministic.
|
||||
*/
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import React from 'react';
|
||||
import { renderElement } from '../../../../shared/test-harness/renderHelper';
|
||||
import { AccessGateScreen } from '../../screens/AccessGateScreen';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
globalThis.__FESTIPOD_SHARED_WALLET_PASSWORD__ = 'test-shared-wallet';
|
||||
|
||||
// Lazy so sharedWallet.ts evaluates AFTER the global above is set (a static
|
||||
// import would hoist above it, capturing an empty password → config-error).
|
||||
type Gate = typeof import('../../screens/AccessGateScreen')['AccessGateScreen'];
|
||||
let gateComponent: Gate | null = null;
|
||||
async function loadGate(): Promise<Gate> {
|
||||
if (!gateComponent) {
|
||||
gateComponent = (await import('../../screens/AccessGateScreen')).AccessGateScreen;
|
||||
}
|
||||
return gateComponent;
|
||||
}
|
||||
|
||||
// Local per-scenario state (kept off the World to avoid touching its type).
|
||||
interface GateState {
|
||||
doc: Document | null;
|
||||
@@ -34,6 +55,7 @@ function stateFor(world: object): GateState {
|
||||
async function renderGate(world: object, initialIdentifier?: string): Promise<void> {
|
||||
const s = stateFor(world);
|
||||
s.entered = null;
|
||||
const AccessGateScreen = await loadGate();
|
||||
// 'connecting' would disable the button; 'disconnected' is the returning-user
|
||||
// state (session not yet restored) — the exact case that re-prompted before.
|
||||
s.doc = await renderElement(
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# language: fr
|
||||
@EVENT @priority-1 @data
|
||||
Fonctionnalité: Reconnexion à froid SANS état local NextGraph (durabilité broker réelle)
|
||||
En tant qu'utilisateur qui crée un événement puis se reconnecte sous la MÊME
|
||||
identité depuis un appareil/onglet SANS aucune donnée NextGraph locale,
|
||||
Je dois relire MON PROPRE événement — sinon c'est qu'il n'a jamais atteint le broker.
|
||||
|
||||
# POURQUOI CE SCÉNARIO (2026-07-14) — question unique qu'il tranche.
|
||||
# Le scénario @data « reconnexion-meme-identite » reconnecte A via ctx.newPage()
|
||||
# sur le MÊME contexte persistant (.playwright-profile). Ce contexte détient
|
||||
# ENCORE les repos de A en IndexedDB LOCAL — un « verifier frais » y rouvre donc
|
||||
# depuis le LOCAL et ne prouve jamais la durabilité BROKER. C'est le raccourci à
|
||||
# supprimer.
|
||||
#
|
||||
# Ici, A se reconnecte depuis un CONTEXTE NAVIGATEUR FRAIS, NON-PERSISTANT
|
||||
# (freshBrowser), partition de stockage hermétique séparée de la page d'écriture
|
||||
# → AUCUN IndexedDB partagé. Le seul état pré-injecté est le storageState du
|
||||
# wallet partagé capturé au BeforeAll (AVANT que A ne soit créé) : il ne peut donc
|
||||
# PAS contenir l'événement de A. La seule source possible de l'événement est le
|
||||
# BROKER.
|
||||
#
|
||||
# VERDICT :
|
||||
# - l'événement REAPPARAÎT → BROKER-DURABLE (l'écriture a atteint le broker).
|
||||
# - l'événement RESTE ABSENT → LOCAL-ONLY (@data ne peut pas prouver la
|
||||
# durabilité broker du scope propre de A ; le RED antérieur était un artefact
|
||||
# local/timing).
|
||||
# Lecture RÉACTIVE uniquement (waitForFunction sur homeEventTitles), jamais de
|
||||
# boucle de re-lecture broker (rule_no-broker-polling).
|
||||
|
||||
@wip @data @reco-cold-nolocal
|
||||
Scénario: A relit son propre événement après une reconnexion à froid sans état local
|
||||
Étant donné que l'identité A crée l'événement "Événement froid sans local de A" et s'y inscrit
|
||||
Quand un navigateur frais non-persistant recharge pour la MÊME identité A avec le wallet partagé
|
||||
Alors l'événement "Événement froid sans local de A" est sur l'accueil de la page fraîche A
|
||||
@@ -34,3 +34,19 @@ Fonctionnalité: Reconnexion d'une même identité sur le wallet persistant
|
||||
Alors l'événement "Événement de reconnexion de A" est sur l'accueil de la page fraîche A
|
||||
Et la page fraîche A est participante de l'événement "Événement de reconnexion de A"
|
||||
Et le compte autoritatif de participation de A à l'événement "Événement de reconnexion de A" est 1
|
||||
|
||||
# REPRODUCTION par ATTENTE SIMPLE (aucune déconnexion injectée) : identique au
|
||||
# scénario ci-dessus, mais une PAUSE sépare l'écriture (création + inscription)
|
||||
# de la reconnexion fraîche. Hypothèse sous test : le socket broker meurt tout
|
||||
# seul pendant une pause (SOCKET IS CLOSED … SerializationError observé en
|
||||
# Firefox réel), l'écriture n'atteint jamais durablement le broker, et la
|
||||
# reconnexion relit alors un événement disparu (Err(TopicNotFound) / REPLAY
|
||||
# TOPIC NOT FOUND / readScopeIndex → 0). RED attendu si le socket meurt en env
|
||||
# de test ; PASS si le socket de test survit à la pause (résultat négatif
|
||||
# valide — la mort spontanée serait alors spécifique à Firefox réel).
|
||||
@wip @data @reconnexion-pause
|
||||
Scénario: Une pause avant la reconnexion ne doit pas faire perdre l'événement
|
||||
Étant donné que l'identité A crée l'événement "Événement de reconnexion de A (pause)" et s'y inscrit
|
||||
Quand on attend 20 secondes sans aucune activité
|
||||
Et une page fraîche pour la MÊME identité A recharge sur le même wallet
|
||||
Alors l'événement "Événement de reconnexion de A (pause)" finit par apparaître sur la page fraîche A en laissant jusqu'à 60 secondes à la barrière avec rechargements
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
# language: fr
|
||||
@EVENT @priority-1
|
||||
Fonctionnalité: Persistance d'un événement à la reconnexion (app réelle, @e2e)
|
||||
En tant qu'utilisateur qui, dans la VRAIE app, crée un événement puis FERME
|
||||
et ROUVRE l'app sous la MÊME identité (même wallet, session verifier fraîche),
|
||||
Je dois retrouver mon événement à la reconnexion
|
||||
Afin que rien ne disparaisse quand je reviens.
|
||||
|
||||
# POURQUOI CE SCÉNARIO EXISTE (2026-07-13)
|
||||
# Bug rapporté en condition RÉELLE : user1 crée un événement (visible), ferme et
|
||||
# se reconnecte (même identité, même wallet) → l'événement a DISPARU ; la console
|
||||
# montre `REPLAY TOPIC NOT FOUND` en masse, un re-provisioning (fork) de compte,
|
||||
# et l'ancien docPublic relit vide.
|
||||
#
|
||||
# Le scénario @data « reconnexion-meme-identite » PASSE, mais il charge un HARNESS
|
||||
# de test, PAS l'app réelle — il ne valide donc pas le parcours de l'utilisateur.
|
||||
# Ce scénario @e2e boote la VRAIE app dans l'iframe broker (setupBrokerPage),
|
||||
# crée l'événement via le VRAI formulaire, puis ouvre une SECONDE page/session
|
||||
# broker FRAÎCHE pour la MÊME identité — le miroir fidèle de « fermer et rouvrir ».
|
||||
#
|
||||
# HYPOTHÈSE À TESTER (NON présumée vraie) : les écritures faites avant que le
|
||||
# broker soit vraiment connecté partiraient dans une outbox non-durable → jamais
|
||||
# acceptées → REPLAY TOPIC NOT FOUND à la reconnexion → perte. Le step de
|
||||
# reconnexion CAPTURE les logs console des DEUX pages (timing des `WRITE` vs
|
||||
# `CONNECTION ESTABLISHED`, occurrences de `REPLAY TOPIC NOT FOUND`) comme preuve.
|
||||
|
||||
@e2e @wip
|
||||
Scénario: Un événement créé survit à une reconnexion fidèle de la même identité
|
||||
Étant donné que l'utilisateur crée un événement "Événement persistant e2e" via le vrai formulaire
|
||||
Et l'événement "Événement persistant e2e" apparaît sur l'accueil de l'utilisateur
|
||||
Quand l'utilisateur ferme et rouvre l'app sous la même identité dans une session broker fraîche
|
||||
Alors l'événement "Événement persistant e2e" est toujours présent après reconnexion
|
||||
@@ -8,7 +8,7 @@ export function EventDetailScreen() {
|
||||
const { eventId } = useParams();
|
||||
const {
|
||||
getEvent,
|
||||
currentUserId,
|
||||
currentUser,
|
||||
isParticipating,
|
||||
joinEvent,
|
||||
leaveEvent,
|
||||
@@ -30,7 +30,13 @@ export function EventDetailScreen() {
|
||||
}));
|
||||
|
||||
const isOwner = true;
|
||||
const knownParticipants = participants.filter(p => p.id !== currentUserId);
|
||||
// Preview list shows the OTHER participants (deliberate — the total is in the
|
||||
// header count; the full list at "Voir tous les participants" shows everyone).
|
||||
// Compare on the PROFILE id: `currentUser.id` is the resolved profile NURI, the
|
||||
// same space as `p.id` — whereas `currentUserId` is the `urn:festipod:user:`
|
||||
// principal, which never equals a profile id in connected mode (so the old
|
||||
// `p.id !== currentUserId` failed to drop self, leaking it in as "1 unknown").
|
||||
const knownParticipants = participants.filter(p => p.id !== currentUser?.id);
|
||||
|
||||
const handleToggleJoin = () => {
|
||||
if (!eventId) return;
|
||||
|
||||
@@ -4,13 +4,13 @@ import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
import { pool } from '../../../../shared/support/browserPool';
|
||||
|
||||
// Two-identity isolation (@data, real broker). Identity A (the fresh per-scenario
|
||||
// username set in localStorage) creates an event E and joins it; a genuinely-
|
||||
// identifier set in localStorage) creates an event E and joins it; a genuinely-
|
||||
// different identity B is brought up on the SAME wallet; B must read NONE of A's
|
||||
// protected participation, E must not be on B's home, isParticipating(E,B) false.
|
||||
//
|
||||
// B is brought up via a FRESH PAGE on the SAME persistent wallet context with B's
|
||||
// identifier in localStorage — the closest analogue to the real app's re-enter-
|
||||
// gate / reload path (a brand-new NgDataProvider mount, username=B, on a wallet
|
||||
// gate / reload path (a brand-new NgDataProvider mount, identifier=B, on a wallet
|
||||
// that already holds A's docs). This exercises the identity-switch reset that
|
||||
// keeps A's protected docs out of B's read set.
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { When } from '@cucumber/cucumber';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
import { pool, spawnContext } from '../../../../shared/support/browserPool';
|
||||
|
||||
// RECONNEXION À FROID SANS ÉTAT LOCAL (@data, broker réel). Tranche la question
|
||||
// unique : A relit-il son propre événement DEPUIS LE BROKER, ou depuis l'IndexedDB
|
||||
// LOCAL du profil persistant ? Le scénario « reconnexion-meme-identite » reconnecte
|
||||
// A via ctx.newPage() sur le MÊME contexte persistant — ce contexte détient encore
|
||||
// les repos de A en local, donc un « verifier frais » y rouvre depuis le local et
|
||||
// ne prouve jamais la durabilité broker. Ici on supprime ce raccourci.
|
||||
//
|
||||
// GARANTIE « no-local » (ce qui rend le verdict valide) :
|
||||
// 1. La page d'écriture est le contexte PERSISTANT (.playwright-profile).
|
||||
// 2. La reconnexion utilise un contexte issu de `freshBrowser` (chromium.launch
|
||||
// NON-persistant) → process séparé, partition de stockage HERMÉTIQUE (garantie
|
||||
// Playwright, isolation prouvée jusqu'à l'origine broker nextgraph.net par
|
||||
// knowledge_multibrowser-harness). Il ne partage AUCUN IndexedDB avec la page
|
||||
// d'écriture.
|
||||
// 3. Le seul état pré-injecté est `pool.sharedWalletState`, capturé au BeforeAll,
|
||||
// AVANT que ce scénario ne crée l'événement de A. Le snapshot ne peut donc PAS
|
||||
// contenir l'événement de A.
|
||||
// ⇒ Le contexte de reconnexion n'a AUCUNE copie locale de l'événement fraîchement
|
||||
// créé par A ; sa seule source possible est le BROKER.
|
||||
//
|
||||
// Réutilise le Given « l'identité A crée l'événement {string} et s'y inscrit »
|
||||
// (isolation.steps.ts, écrit sur la page persistante this.appFrame) et le Then
|
||||
// « l'événement {string} est sur l'accueil de la page fraîche A » (reconnexion.steps.ts,
|
||||
// lecture RÉACTIVE via waitForFunction sur homeEventTitles — jamais de polling broker).
|
||||
|
||||
When(
|
||||
'un navigateur frais non-persistant recharge pour la MÊME identité A avec le wallet partagé',
|
||||
{ timeout: 120000 },
|
||||
async function (this: FestipodWorld) {
|
||||
// SAME identity A: the per-scenario virtual identifier set by the Before hook.
|
||||
const aIdentifier = (this as any).freshIdentifier as string;
|
||||
if (!pool.sharedWalletState) {
|
||||
throw new Error(
|
||||
'sharedWalletState non capturé au BeforeAll — impossible de provisionner un ' +
|
||||
'contexte frais avec le wallet partagé A. Sans lui, PAS de reconnexion no-local ' +
|
||||
'(le verdict serait invalide). STOP.',
|
||||
);
|
||||
}
|
||||
|
||||
// FRESH, non-persistent, hermetic context seeded with ONLY the shared wallet
|
||||
// storageState (captured before A's event existed). Separate storage partition
|
||||
// from the persistent write page → no shared IndexedDB, no local copy of A's
|
||||
// just-created event. Its ONLY source for A's event is the broker.
|
||||
const ctx = await spawnContext('shared');
|
||||
(this as any).recoColdCtx = ctx; // closed by freshBrowser.close() in AfterAll
|
||||
const freshPage = await ctx.newPage();
|
||||
|
||||
// SAME identity A: inject A's app-level identifier on every origin BEFORE any
|
||||
// script (incl. the harness iframe on 127.0.0.1), so the shim keys to the SAME
|
||||
// virtual account A — a reconnect, not an identity switch. Identical injection
|
||||
// to isolation.steps.ts / reconnexion.steps.ts, but into a FRESH context.
|
||||
await freshPage.addInitScript((u: string) => {
|
||||
try { window.localStorage.setItem('festipod.account.identifier', u); } catch { /* opaque */ }
|
||||
}, aIdentifier);
|
||||
await freshPage.addInitScript(() => {
|
||||
(globalThis as Record<string, unknown>).__FESTIPOD_ACCESS_GATE_DISABLED__ = true;
|
||||
});
|
||||
|
||||
// Broad console capture (ALL types) so the SDK diagnostic lines
|
||||
// (BARRIER synced|timed-out, OUTBOX, readScopeIndex → N, CONNECTION ESTABLISHED,
|
||||
// REPLAY TOPIC NOT FOUND, …) surface verbatim to stdout as the verdict's proof.
|
||||
freshPage.on('console', (msg) => { console.log(`[ColdFreshA:${msg.type()}]`, msg.text()); });
|
||||
freshPage.on('pageerror', (err) => console.error('[ColdFreshA pageerror]', err.message));
|
||||
|
||||
// New broker login on the SAME shared wallet → fresh verifier session whose
|
||||
// local repos are EMPTY for A's event (this fresh context never held it). This
|
||||
// is exactly the cold-start read path the reconnect must heal from the broker.
|
||||
const freshFrame = await pool.setupBrokerPage!(freshPage, pool.harnessUrl!);
|
||||
await freshFrame.waitForFunction(
|
||||
() => (window as any).__testData?.ready === true,
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
// Resolve A's principal (profile read hydrated) before the reactive Then reads.
|
||||
await freshFrame.evaluate(async () => {
|
||||
const td = (window as any).__testData;
|
||||
await td.ensureCurrentUser();
|
||||
});
|
||||
|
||||
(this as any).recoFreshFrame = freshFrame;
|
||||
(this as any).recoFreshPage = freshPage;
|
||||
},
|
||||
);
|
||||
@@ -11,25 +11,104 @@ import { pool } from '../../../../shared/support/browserPool';
|
||||
// listing path (listMyEntityDocs → readScopeIndex, then readUnion/readDoc) queries
|
||||
// repos not yet in `self.repos` at cold-start and silently returns 0 rows.
|
||||
//
|
||||
// Identity A is the scenario's fresh virtual-wallet username (this.freshUser, set
|
||||
// Identity A is the scenario's fresh virtual-wallet identifier (this.freshIdentifier, set
|
||||
// by the Before hook into localStorage on every origin). A creates E via the REAL
|
||||
// app path (createEventReal) and joins it (appJoinEvent) on the MAIN page. Then a
|
||||
// FRESH PAGE is brought up on the SAME persistent wallet context with the SAME
|
||||
// identifier A in localStorage BEFORE any script — the closest analogue to the real
|
||||
// app's re-enter-gate / reload path (a brand-new NgDataProvider mount + a fresh
|
||||
// broker session that must re-open A's own repos). Montage identical to
|
||||
// isolation.steps.ts, except the fresh page reuses this.freshUser (SAME A) rather
|
||||
// isolation.steps.ts, except the fresh page reuses this.freshIdentifier (SAME A) rather
|
||||
// than minting a new identifier B.
|
||||
|
||||
// The Given "l'identité A crée l'événement {string} et s'y inscrit" is REUSED from
|
||||
// isolation.steps.ts (same wording, same behavior — A creates E and joins on the
|
||||
// main page). It stores this.isoEventId / this.isoAId, which the steps below read.
|
||||
|
||||
// REPRODUCTION step (no injected disconnect): a plain, passive wait on the SAME
|
||||
// main page A just wrote through. The hypothesis under test is that the broker
|
||||
// socket dies SPONTANEOUSLY during an idle pause (observed in real Firefox as
|
||||
// "SOCKET IS CLOSED … SerializationError") — we do NOT provoke it. A broad
|
||||
// console listener (ALL message types, not just 'error') is attached here so the
|
||||
// signature lines (socket-closed, TopicNotFound, REPLAY TOPIC NOT FOUND,
|
||||
// readScopeIndex → 0) are captured verbatim to stdout regardless of the console
|
||||
// method the SDK/wasm layer used to emit them.
|
||||
When('on attend {int} secondes sans aucune activité', { timeout: 60000 }, async function (this: FestipodWorld, seconds: number) {
|
||||
const page = this.page!;
|
||||
const onConsole = (msg: import('playwright').ConsoleMessage) => {
|
||||
console.log(`[PauseWatch:${msg.type()}] ${msg.text()}`);
|
||||
};
|
||||
page.on('console', onConsole);
|
||||
console.log(`[PauseWatch] starting a ${seconds}s PASSIVE pause (no disconnect injected) — watching for a spontaneous socket death…`);
|
||||
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
||||
console.log(`[PauseWatch] ${seconds}s pause complete — proceeding to the fresh reconnection.`);
|
||||
// Leave the listener attached: the signature may also surface once the fresh
|
||||
// page's reconnection kicks the outbox (SENDING EVENTS FROM OUTBOX …).
|
||||
});
|
||||
|
||||
// DIAGNOSTIC (real loss vs read-timeout): after the fresh reconnection reads the
|
||||
// home EMPTY, keep giving the sync barrier more time — up to ~60s — and force a
|
||||
// couple of FULL RELOADS of the fresh page. Each reload remounts NgDataProvider
|
||||
// and re-opens A's repos, i.e. a BRAND-NEW barrier attempt (open-repo.ts). We poll
|
||||
// the REACTIVE home set (homeEventTitles reads AD() fed by the subscription push —
|
||||
// NOT a broker re-read; this is the reactive state a real reloading user watches),
|
||||
// recording the exact elapsed ms at which the title appears (if ever). The verdict:
|
||||
// - title APPEARS within 60s → READ-TIMEOUT (data IS on the broker, the 8s
|
||||
// bootstrap barrier was just too short / needed a remount to re-sync).
|
||||
// - title ABSENT after 60s + reloads → REAL LOSS (the write never became durable).
|
||||
// The broad console listener attached in the reconnection step keeps flowing the
|
||||
// `BARRIER … synced|timed-out` lines to stdout across the reloads.
|
||||
Then('l\'événement {string} finit par apparaître sur la page fraîche A en laissant jusqu\'à 60 secondes à la barrière avec rechargements', { timeout: 120000 }, async function (this: FestipodWorld, title: string) {
|
||||
const freshPage = (this as any).recoFreshPage as import('playwright').Page;
|
||||
let freshFrame = (this as any).recoFreshFrame as import('playwright').Frame;
|
||||
const startedAt = Date.now();
|
||||
const BUDGET_MS = 60000;
|
||||
const reloadAtMs = [20000, 40000]; // force a fresh barrier attempt at these marks
|
||||
let reloadIdx = 0;
|
||||
let appearedAtMs = -1;
|
||||
|
||||
const readHome = async (): Promise<string[]> => {
|
||||
try {
|
||||
return await freshFrame.evaluate((t: string) => {
|
||||
const td = (window as any).__testData;
|
||||
return td && td.homeEventTitles ? td.homeEventTitles() : [];
|
||||
}, title);
|
||||
} catch { return []; }
|
||||
};
|
||||
|
||||
while (Date.now() - startedAt < BUDGET_MS) {
|
||||
const elapsed = Date.now() - startedAt;
|
||||
const titles = await readHome();
|
||||
if (titles.includes(title)) { appearedAtMs = elapsed; break; }
|
||||
// At each reload mark, do a FULL reload → new NgDataProvider mount → new barrier.
|
||||
if (reloadIdx < reloadAtMs.length && elapsed >= reloadAtMs[reloadIdx]) {
|
||||
reloadIdx++;
|
||||
console.log(`[LongPoll] t=${elapsed}ms still ABSENT — forcing a full reload (#${reloadIdx}) to re-attempt the barrier…`);
|
||||
try {
|
||||
freshFrame = await pool.setupBrokerPage!(freshPage, pool.harnessUrl!);
|
||||
await freshFrame.waitForFunction(() => (window as any).__testData?.ready === true, { timeout: 60000 });
|
||||
await freshFrame.evaluate(async () => { await (window as any).__testData.ensureCurrentUser(); });
|
||||
(this as any).recoFreshFrame = freshFrame;
|
||||
} catch (e) {
|
||||
console.log(`[LongPoll] reload #${reloadIdx} failed: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
|
||||
if (appearedAtMs >= 0) {
|
||||
console.log(`[LongPoll] VERDICT = READ-TIMEOUT — "${title}" APPEARED at t=${appearedAtMs}ms (data was on the broker; the bootstrap barrier was just too short).`);
|
||||
} else {
|
||||
console.log(`[LongPoll] VERDICT = REAL LOSS — "${title}" still ABSENT after ${BUDGET_MS}ms and ${reloadIdx} reload(s) (write never became durable).`);
|
||||
}
|
||||
expect(appearedAtMs, `"${title}" should eventually appear within ${BUDGET_MS}ms if the data reached the broker (RED here ⇒ real loss)`).to.be.at.least(0);
|
||||
});
|
||||
|
||||
When('une page fraîche pour la MÊME identité A recharge sur le même wallet', { timeout: 120000 }, async function (this: FestipodWorld) {
|
||||
// SAME identity A as the main page: reuse the scenario's fresh virtual-wallet
|
||||
// username (set by the Before hook). NOT a new identifier — this is a reconnect,
|
||||
// identifier (set by the Before hook). NOT a new identifier — this is a reconnect,
|
||||
// not an identity switch.
|
||||
const aIdentifier = (this as any).freshUser as string;
|
||||
const aIdentifier = (this as any).freshIdentifier as string;
|
||||
const ctx = this.page!.context();
|
||||
const freshPage = await ctx.newPage();
|
||||
await freshPage.addInitScript((u: string) => {
|
||||
@@ -38,7 +117,11 @@ When('une page fraîche pour la MÊME identité A recharge sur le même wallet',
|
||||
await freshPage.addInitScript(() => {
|
||||
(globalThis as Record<string, unknown>).__FESTIPOD_ACCESS_GATE_DISABLED__ = true;
|
||||
});
|
||||
freshPage.on('console', (msg) => { if (msg.type() === 'error') console.error('[FreshApage console]', msg.text()); });
|
||||
// Broadened to ALL console types (not just 'error') for the pause-reproduction
|
||||
// investigation: the SDK's diagnostic lines (logStage: BARRIER/OUTBOX/
|
||||
// readScopeIndex) are emitted via console.log, not console.error, and would
|
||||
// otherwise be invisible here — purely diagnostic, does not affect assertions.
|
||||
freshPage.on('console', (msg) => { console.log(`[FreshApage:${msg.type()}]`, msg.text()); });
|
||||
// New broker login → fresh verifier session on the SAME persistent wallet.
|
||||
const freshFrame = await pool.setupBrokerPage!(freshPage, pool.harnessUrl!);
|
||||
await freshFrame.waitForFunction(() => (window as any).__testData?.ready === true, { timeout: 60000 });
|
||||
@@ -50,6 +133,7 @@ When('une page fraîche pour la MÊME identité A recharge sur le même wallet',
|
||||
await new Promise(r => setTimeout(r, 6000));
|
||||
});
|
||||
(this as any).recoFreshFrame = freshFrame;
|
||||
(this as any).recoFreshPage = freshPage;
|
||||
});
|
||||
|
||||
// The cold-start read is a BOUNDED SYNC-LAG (measured: the just-created public
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
import { pool } from '../../../../shared/support/browserPool';
|
||||
|
||||
// RECONNECTION-PERSISTENCE at the @e2e layer (REAL app, real broker).
|
||||
//
|
||||
// Mirrors reconnexion.steps.ts (@data) but drives the REAL app (pool.appUrl),
|
||||
// NOT the harness. The main page is booted by the @e2e Before hook: identity =
|
||||
// this.freshIdentifier (set into localStorage['festipod.account.identifier'] on every
|
||||
// origin by the hook), gate disabled → the real app boots directly on that
|
||||
// identity. We create an event via the REAL create form (same DOM path the app
|
||||
// user takes), verify it appears, then open a SECOND page in the SAME persistent
|
||||
// wallet context with the SAME identifier + gate disabled, and a FRESH broker
|
||||
// login → a fresh verifier session (empty memory) that must re-read everything
|
||||
// from the broker. That is the faithful analogue of "close and reopen".
|
||||
//
|
||||
// The step captures console logs from BOTH pages and reports (via cucumber
|
||||
// attachments) the timing of `WRITE`-ish lines vs `CONNECTION ESTABLISHED`, and
|
||||
// any `REPLAY TOPIC NOT FOUND` — the evidence for/against the offline-write
|
||||
// hypothesis. NO fix is implemented here — observation only.
|
||||
|
||||
interface StampedLog { t: number; text: string; }
|
||||
|
||||
function attachConsoleCapture(page: import('playwright').Page, bucket: StampedLog[]): void {
|
||||
page.on('console', (msg) => {
|
||||
const text = msg.text();
|
||||
bucket.push({ t: Date.now(), text });
|
||||
});
|
||||
}
|
||||
|
||||
function summarizeLogs(label: string, logs: StampedLog[]): string {
|
||||
const t0 = logs.length ? logs[0]!.t : Date.now();
|
||||
const rel = (t: number) => `+${((t - t0) / 1000).toFixed(2)}s`;
|
||||
|
||||
const isConnected = (s: string) => /CONNECTION ESTABLISHED WITH peer/i.test(s);
|
||||
const isWrite = (s: string) => /\bWRITE\b/i.test(s) || /\[polyfill\].*write/i.test(s);
|
||||
const isReplayMiss = (s: string) => /REPLAY TOPIC NOT FOUND/i.test(s);
|
||||
const isResolveAccount = (s: string) => /resolveAccount/i.test(s);
|
||||
const isReadScope = (s: string) => /readScopeIndex/i.test(s);
|
||||
|
||||
const connectedAt = logs.filter((l) => isConnected(l.text)).map((l) => l.t);
|
||||
const writes = logs.filter((l) => isWrite(l.text));
|
||||
const replayMisses = logs.filter((l) => isReplayMiss(l.text));
|
||||
const resolveAccounts = logs.filter((l) => isResolveAccount(l.text));
|
||||
const readScopes = logs.filter((l) => isReadScope(l.text));
|
||||
|
||||
const firstConnected = connectedAt.length ? Math.min(...connectedAt) : null;
|
||||
|
||||
const lines: string[] = [];
|
||||
lines.push(`===== ${label} — console summary (${logs.length} lines) =====`);
|
||||
lines.push(`CONNECTION ESTABLISHED WITH peer: ${connectedAt.length} occurrence(s)` +
|
||||
(firstConnected != null ? ` — first at ${rel(firstConnected)}` : ''));
|
||||
lines.push(`WRITE-ish lines: ${writes.length}`);
|
||||
if (writes.length && firstConnected != null) {
|
||||
const before = writes.filter((w) => w.t < firstConnected).length;
|
||||
const after = writes.length - before;
|
||||
lines.push(` WRITE before first CONNECTION ESTABLISHED: ${before}`);
|
||||
lines.push(` WRITE after first CONNECTION ESTABLISHED: ${after}`);
|
||||
} else if (writes.length && firstConnected == null) {
|
||||
lines.push(` (no CONNECTION ESTABLISHED seen — cannot classify WRITE timing)`);
|
||||
}
|
||||
lines.push(`REPLAY TOPIC NOT FOUND: ${replayMisses.length} occurrence(s)`);
|
||||
lines.push(`resolveAccount lines: ${resolveAccounts.length}`);
|
||||
lines.push(`readScopeIndex lines: ${readScopes.length}`);
|
||||
|
||||
const showcase = (title: string, arr: StampedLog[], n: number) => {
|
||||
if (!arr.length) return;
|
||||
lines.push(`--- ${title} (first ${Math.min(n, arr.length)}) ---`);
|
||||
for (const l of arr.slice(0, n)) lines.push(` ${rel(l.t)} ${l.text.slice(0, 240)}`);
|
||||
};
|
||||
showcase('WRITE lines', writes, 8);
|
||||
showcase('REPLAY TOPIC NOT FOUND lines', replayMisses, 8);
|
||||
showcase('resolveAccount lines', resolveAccounts, 6);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
// --- Step 1: create the event via the REAL create form on the main page ---
|
||||
|
||||
Given('l\'utilisateur crée un événement {string} via le vrai formulaire', { timeout: 90000 }, async function (this: FestipodWorld, title: string) {
|
||||
// Start capturing console on the MAIN page from BEFORE the create write, so we
|
||||
// observe whether the create WRITE lands before/after CONNECTION ESTABLISHED.
|
||||
const mainLogs: StampedLog[] = [];
|
||||
(this as any).recoMainLogs = mainLogs;
|
||||
attachConsoleCapture(this.page!, mainLogs);
|
||||
|
||||
const frame = this.appFrame!;
|
||||
|
||||
// Navigate to the real create form and fill it via the DOM, exactly like the
|
||||
// real app user (mirrors evenement.steps.ts). 3-step wizard.
|
||||
await frame.evaluate(() => {
|
||||
window.history.pushState(null, '', '/events/new');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
});
|
||||
const formReady = await frame.waitForFunction(
|
||||
() => !!document.querySelector('input[placeholder="Donnez un nom à votre événement"]'),
|
||||
{ timeout: 15000 },
|
||||
).then(() => true).catch(() => false);
|
||||
if (!formReady) {
|
||||
const debug = await frame.evaluate(() => ({
|
||||
pathname: window.location.pathname,
|
||||
rootText: document.getElementById('root')?.textContent?.substring(0, 300),
|
||||
}));
|
||||
throw new Error(`Create form not found. Path: ${debug.pathname}, content: ${debug.rootText}`);
|
||||
}
|
||||
|
||||
// Step 1: name + start date
|
||||
await frame.locator('input[placeholder="Donnez un nom à votre événement"]').fill(title);
|
||||
await frame.locator('input[type="date"]').first().fill('2026-08-15');
|
||||
await frame.locator('button', { hasText: 'Suivant' }).first().click();
|
||||
await frame.waitForTimeout(500);
|
||||
|
||||
// Possible step 2 (similar-event warning) → Next again
|
||||
const onStep2 = await frame.evaluate(
|
||||
() => document.body.textContent?.includes('Événement similaire détecté') ?? false,
|
||||
);
|
||||
if (onStep2) {
|
||||
await frame.locator('button', { hasText: 'Suivant' }).first().click();
|
||||
await frame.waitForTimeout(500);
|
||||
}
|
||||
|
||||
// Step 3: time + place, then submit
|
||||
await frame.locator('input[type="time"]').first().fill('14:00').catch(() => {});
|
||||
await frame.locator('input[placeholder="Ajouter un lieu"]').fill('Parc Bordelais, Bordeaux').catch(() => {});
|
||||
|
||||
const submit = frame.locator('button', { hasText: 'Relayer l\'événement' });
|
||||
await submit.first().click();
|
||||
await frame.waitForTimeout(2000);
|
||||
|
||||
// Confirm we left the form (detail or home) — sanity that the create went through.
|
||||
await frame.waitForFunction(
|
||||
(t: string) => document.getElementById('root')?.textContent?.includes(t) ?? false,
|
||||
title,
|
||||
{ timeout: 15000 },
|
||||
).catch(() => { /* asserted more strictly by the next step */ });
|
||||
});
|
||||
|
||||
Given('l\'événement {string} apparaît sur l\'accueil de l\'utilisateur', { timeout: 60000 }, async function (this: FestipodWorld, title: string) {
|
||||
const frame = this.appFrame!;
|
||||
// Navigate home; if home (participation-filtered) is empty, fall back to
|
||||
// /events (Découvrir, no participation filter) — the created event is public.
|
||||
await frame.evaluate(() => {
|
||||
window.history.pushState(null, '', '/home');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
});
|
||||
let seen = await frame.waitForFunction(
|
||||
(t: string) => document.getElementById('root')?.textContent?.includes(t) ?? false,
|
||||
title,
|
||||
{ timeout: 15000 },
|
||||
).then(() => true).catch(() => false);
|
||||
if (!seen) {
|
||||
await frame.evaluate(() => {
|
||||
window.history.pushState(null, '', '/events');
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
});
|
||||
seen = await frame.waitForFunction(
|
||||
(t: string) => document.getElementById('root')?.textContent?.includes(t) ?? false,
|
||||
title,
|
||||
{ timeout: 20000 },
|
||||
).then(() => true).catch(() => false);
|
||||
}
|
||||
if (!seen) {
|
||||
const debug = await frame.evaluate(() => ({
|
||||
pathname: window.location.pathname,
|
||||
rootText: document.getElementById('root')?.textContent?.substring(0, 400),
|
||||
}));
|
||||
expect.fail(`Created event "${title}" not visible before reconnect. Path: ${debug.pathname}, content: ${debug.rootText}`);
|
||||
}
|
||||
});
|
||||
|
||||
// --- Step 2: reconnect faithfully — a fresh page/session for the SAME identity ---
|
||||
|
||||
When('l\'utilisateur ferme et rouvre l\'app sous la même identité dans une session broker fraîche', { timeout: 180000 }, async function (this: FestipodWorld) {
|
||||
const identifier = (this as any).freshIdentifier as string;
|
||||
const ctx = this.page!.context();
|
||||
|
||||
const freshPage = await ctx.newPage();
|
||||
// SAME identity in localStorage BEFORE any script (what the reopened app reads,
|
||||
// gate disabled) + gate disabled so the real app boots straight onto that id.
|
||||
await freshPage.addInitScript((u: string) => {
|
||||
try { window.localStorage.setItem('festipod.account.identifier', u); } catch { /* opaque */ }
|
||||
}, identifier);
|
||||
await freshPage.addInitScript(() => {
|
||||
(globalThis as Record<string, unknown>).__FESTIPOD_ACCESS_GATE_DISABLED__ = true;
|
||||
});
|
||||
|
||||
const freshLogs: StampedLog[] = [];
|
||||
(this as any).recoFreshLogs = freshLogs;
|
||||
attachConsoleCapture(freshPage, freshLogs);
|
||||
freshPage.on('pageerror', (err) => freshLogs.push({ t: Date.now(), text: `pageerror: ${err.message}` }));
|
||||
|
||||
// NEW broker login → fresh verifier session on the SAME persistent wallet.
|
||||
const freshFrame = await pool.setupBrokerPage!(freshPage, pool.appUrl!);
|
||||
// Wait for the real app to render.
|
||||
await freshFrame.waitForFunction(
|
||||
() => {
|
||||
const root = document.getElementById('root');
|
||||
return !!root && root.innerHTML.length > 100;
|
||||
},
|
||||
{ timeout: 60000 },
|
||||
);
|
||||
// Let NG connect + the cold-start read path run.
|
||||
await freshFrame.waitForTimeout(6000);
|
||||
(this as any).recoFreshFrame = freshFrame;
|
||||
(this as any).recoFreshPage = freshPage;
|
||||
});
|
||||
|
||||
Then('l\'événement {string} est toujours présent après reconnexion', { timeout: 90000 }, async function (this: FestipodWorld, title: string) {
|
||||
const freshFrame = (this as any).recoFreshFrame as import('playwright').Frame;
|
||||
|
||||
// Poll BOTH home (participation-filtered) and /events (Découvrir, public list),
|
||||
// re-navigating each attempt so the cold-start union read has time to converge.
|
||||
// This is NOT broker-polling (rule_no-broker-polling): the app is reactive; we
|
||||
// re-read the RENDERED DOM until the reactive set settles, bounded by timeout.
|
||||
const deadline = Date.now() + 60000;
|
||||
let found = false;
|
||||
while (Date.now() < deadline && !found) {
|
||||
for (const path of ['/home', '/events']) {
|
||||
await freshFrame.evaluate((p: string) => {
|
||||
window.history.pushState(null, '', p);
|
||||
window.dispatchEvent(new PopStateEvent('popstate'));
|
||||
}, path);
|
||||
found = await freshFrame.waitForFunction(
|
||||
(t: string) => document.getElementById('root')?.textContent?.includes(t) ?? false,
|
||||
title,
|
||||
{ timeout: 6000 },
|
||||
).then(() => true).catch(() => false);
|
||||
if (found) break;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Report console evidence from BOTH pages regardless of pass/fail ---
|
||||
const mainLogs = ((this as any).recoMainLogs ?? []) as StampedLog[];
|
||||
const freshLogs = ((this as any).recoFreshLogs ?? []) as StampedLog[];
|
||||
const report =
|
||||
summarizeLogs('MAIN PAGE (creator)', mainLogs) + '\n\n' +
|
||||
summarizeLogs('FRESH PAGE (reconnect)', freshLogs) + '\n\n' +
|
||||
`RESULT: event "${title}" ${found ? 'SURVIVED (visible after reconnect)' : 'DISAPPEARED (NOT visible after reconnect)'}`;
|
||||
this.attach(report, 'text/plain');
|
||||
// Also echo to stdout so it lands in the raw run output.
|
||||
console.log('\n' + report + '\n');
|
||||
|
||||
// Opt-in RAW dump of connection/sync lines (RECO_RAW_DUMP=1) — the evidence
|
||||
// that the FRESH page is a genuine cold boot (own WASM worker + own broker
|
||||
// handshake), used to argue reconnection FIDELITY. Off by default (noise).
|
||||
if (process.env.RECO_RAW_DUMP === '1') {
|
||||
const dumpRaw = (label: string, logs: StampedLog[]) => {
|
||||
const t0 = logs.length ? logs[0]!.t : Date.now();
|
||||
const hits = logs.filter((l) => /peer|CONNECTION|ESTABLISHED|REPLAY|broker|verifier|worker|bootstrap|open_repo|\bsync\b/i.test(l.text));
|
||||
console.log(`\n### RAW (${label}) — ${hits.length} connection/sync lines ###`);
|
||||
for (const l of hits) console.log(`+${((l.t - t0) / 1000).toFixed(2)}s ${l.text.slice(0, 200)}`);
|
||||
};
|
||||
dumpRaw('MAIN', mainLogs);
|
||||
dumpRaw('FRESH', freshLogs);
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
const debug = await freshFrame.evaluate(() => ({
|
||||
pathname: window.location.pathname,
|
||||
rootText: document.getElementById('root')?.textContent?.substring(0, 500),
|
||||
}));
|
||||
expect.fail(`Reconnected fresh page for the SAME identity did NOT show "${title}". Path: ${debug.pathname}, content: ${debug.rootText}`);
|
||||
}
|
||||
});
|
||||
@@ -49,9 +49,9 @@ import { setCurrentUser } from '@ng-eventually/client/polyfill';
|
||||
|
||||
// Festipod localStorage key for the current identifier (same-partition
|
||||
// prefill/convenience only — never the cross-frontier carrier; that's the URL
|
||||
// param). Changed from the historical 'festipod.account.username' → any
|
||||
// pre-existing stored "logins" under the old key are dropped (acceptable: this
|
||||
// is a stopgap test env; the URL param carries identity anyway).
|
||||
// param). Renamed to `.identifier` from a historical key that mislabeled this
|
||||
// account id → any pre-existing stored logins under the old key are dropped
|
||||
// (acceptable: this is a stopgap test env; the URL param carries identity anyway).
|
||||
const STORAGE_KEY = 'festipod.account.identifier';
|
||||
|
||||
/** Name of the URL param that carries the identifier across the broker frontier. */
|
||||
|
||||
@@ -99,6 +99,42 @@ function nextId(prefix: string): string {
|
||||
return `${prefix}-${++idCounter}`;
|
||||
}
|
||||
|
||||
// The STABLE user-principal prefix. A Participation stores its user (`fp:user`) as
|
||||
// this principal derived from the login identifier — `urn:festipod:user:<key>` —
|
||||
// NOT as the UserProfile's `did:ng:` document NURI. `currentUserId` is minted with
|
||||
// the SAME prefix below, so a participation keyed on it stays consistent with the
|
||||
// identity the SDK/caps derive. The single source of truth for the prefix, shared
|
||||
// by the WRITE (currentUserId) and the READ (resolveParticipantUser) so they never
|
||||
// drift.
|
||||
const USER_PRINCIPAL_PREFIX = 'urn:festipod:user:';
|
||||
|
||||
/**
|
||||
* Resolve a Participation's `fp:user` to its UserProfile across the TWO id spaces
|
||||
* that meet at this join (the root cause of the "unknown participant" bug):
|
||||
* • a Participation stores `urn:festipod:user:<normalized-identifier>` (the stable
|
||||
* principal = `currentUserId`), while
|
||||
* • a UserProfile's `id` is its `did:ng:` document NURI — never that principal.
|
||||
* The bridge is the NORMALIZED IDENTIFIER, which equals `normalizeIdentifier(username)`
|
||||
* for the matching profile (the exact equality `currentUser` resolution already uses).
|
||||
* So: strip the principal prefix off the participation's userId, and compare the
|
||||
* remainder to `normalizeIdentifier(profile.username)`. In demo/local mode both sides
|
||||
* are the bare seed id (`user-1`), matched directly by `u.id === userId` — which is
|
||||
* why the direct match is tried FIRST (the seed username `@mariedupont` would not
|
||||
* normalize to `user-1`). A per-deposit materializer uid (`mint...`, e.g.
|
||||
* `mrktnoke-rzd699dk`) is a THIRD, unrelated space: it identifies an inbox deposit
|
||||
* for the count, never a user — it does not participate in this join.
|
||||
*/
|
||||
function resolveParticipantUser(userId: string, users: FpUserData[]): FpUserData | undefined {
|
||||
// 1) Direct id match — demo/local seed space (`user-1`), or any coincident space.
|
||||
const direct = users.find(u => u.id === userId);
|
||||
if (direct) return direct;
|
||||
// 2) NG principal space: `urn:festipod:user:<key>` → match on normalized username.
|
||||
const key = userId.startsWith(USER_PRINCIPAL_PREFIX)
|
||||
? userId.slice(USER_PRINCIPAL_PREFIX.length)
|
||||
: userId;
|
||||
return users.find(u => u.username && normalizeIdentifier(u.username) === key);
|
||||
}
|
||||
|
||||
// NG shape → app type mapping lives in `../data/shapeAdapters` (domain adapters
|
||||
// over the SDK's `watchShape` subjects).
|
||||
|
||||
@@ -118,8 +154,18 @@ function buildQueries(
|
||||
const getUser = (id: string) => users.find(u => u.id === id);
|
||||
|
||||
const getEventParticipants = (eventId: string) => {
|
||||
const partUserIds = participations.filter(p => p.eventId === eventId).map(p => p.userId);
|
||||
return users.filter(u => partUserIds.includes(u.id));
|
||||
// Resolve each of the event's participations to its UserProfile across the two
|
||||
// id spaces (participation principal vs profile NURI) — see
|
||||
// `resolveParticipantUser`. A raw `partUserIds.includes(u.id)` join never
|
||||
// matched in connected mode (principal ≠ NURI) → every participant rendered as
|
||||
// "unknown". Deduped, returned in `users` order to match the prior contract.
|
||||
const eventParts = participations.filter(p => p.eventId === eventId);
|
||||
const resolvedIds = new Set<string>();
|
||||
for (const p of eventParts) {
|
||||
const u = resolveParticipantUser(p.userId, users);
|
||||
if (u) resolvedIds.add(u.id);
|
||||
}
|
||||
return users.filter(u => resolvedIds.has(u.id));
|
||||
};
|
||||
|
||||
const getUserEvents = (userId: string) => {
|
||||
@@ -168,41 +214,44 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
|
||||
? users.find(u => normalizeIdentifier(u.username) === normalizeIdentifier(identifier))
|
||||
: undefined;
|
||||
const currentUserId = empty ? '' : (accountUser?.id ?? CURRENT_USER_ID);
|
||||
// Identity-first log prefix: current user id when resolved, else the bare
|
||||
// `[app][data]` form (e.g. the transient `empty` connecting state).
|
||||
const logPrefix = currentUserId ? `[${currentUserId}][app][data]` : '[app][data]';
|
||||
const currentUser = users.find(u => u.id === currentUserId);
|
||||
const selectedEvent = events.find(e => e.id === selectedEventId);
|
||||
const selectedUser = users.find(u => u.id === selectedUserId);
|
||||
|
||||
const queries = buildQueries(events, users, participations, meetingPoints, friendships, currentUserId);
|
||||
|
||||
console.log('[FestipodData] Render —', empty ? 'connecting (empty)' : 'local',
|
||||
console.log(`${logPrefix} Render —`, empty ? 'connecting (empty)' : 'local',
|
||||
'| events:', events.length,
|
||||
'| selectedEvent:', selectedEvent?.title ?? '(none)');
|
||||
|
||||
// Local mode: mutations are no-ops (static defaults)
|
||||
const createEvent = useCallback(async (event: Omit<FpEventData, 'id'>): Promise<FpEventData> => {
|
||||
console.log('[FestipodData] createEvent (local, no-op):', event.title);
|
||||
console.log(`${logPrefix} createEvent (local, no-op):`, event.title);
|
||||
return { ...event, id: nextId('event') };
|
||||
}, []);
|
||||
}, [logPrefix]);
|
||||
const updateEvent = useCallback((_id: string, _updates: Partial<FpEventData>) => {
|
||||
console.log('[FestipodData] updateEvent (local, no-op)');
|
||||
}, []);
|
||||
console.log(`${logPrefix} updateEvent (local, no-op)`);
|
||||
}, [logPrefix]);
|
||||
const joinEvent = useCallback((_eventId: string) => {
|
||||
console.log('[FestipodData] joinEvent (local, no-op)');
|
||||
}, []);
|
||||
console.log(`${logPrefix} joinEvent (local, no-op)`);
|
||||
}, [logPrefix]);
|
||||
const leaveEvent = useCallback((_eventId: string) => {
|
||||
console.log('[FestipodData] leaveEvent (local, no-op)');
|
||||
}, []);
|
||||
console.log(`${logPrefix} leaveEvent (local, no-op)`);
|
||||
}, [logPrefix]);
|
||||
const addMeetingPoint = useCallback((_mp: Omit<FpMeetingPointData, 'id'>) => {
|
||||
console.log('[FestipodData] addMeetingPoint (local, no-op)');
|
||||
}, []);
|
||||
console.log(`${logPrefix} addMeetingPoint (local, no-op)`);
|
||||
}, [logPrefix]);
|
||||
const addFriend = useCallback((_friendId: string) => {
|
||||
console.log('[FestipodData] addFriend (local, no-op)');
|
||||
}, []);
|
||||
console.log(`${logPrefix} addFriend (local, no-op)`);
|
||||
}, [logPrefix]);
|
||||
const updateProfile = useCallback((_updates: Partial<FpUserData>) => {
|
||||
console.log('[FestipodData] updateProfile (local, no-op)');
|
||||
}, []);
|
||||
console.log(`${logPrefix} updateProfile (local, no-op)`);
|
||||
}, [logPrefix]);
|
||||
const loadTestData = useCallback(async (): Promise<BootstrapResult> => {
|
||||
console.log('[FestipodData] loadTestData (local, no-op)');
|
||||
console.log(`${logPrefix} loadTestData (local, no-op)`);
|
||||
return { seeded: false, userIdMap: new Map(), eventIdMap: new Map(), createdDocs: { public: [], protected: [] } };
|
||||
}, []);
|
||||
|
||||
@@ -386,7 +435,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
if (cancelled) return;
|
||||
setOwnedEventIds(prev => [...new Set([...prev, ...myPublic])]);
|
||||
} catch (err) {
|
||||
console.error('[FestipodData] owned-events resolution failed:', err);
|
||||
console.error(`${logPrefix} owned-events resolution failed:`, err);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
@@ -429,14 +478,14 @@ function useNgData(): FestipodDataContextValue {
|
||||
if (!readReady) return; // still syncing — do NOT mistake pending for empty
|
||||
const walletHasData = events.length > 0 || users.length > 0;
|
||||
if (!shouldAutoSeed(walletHasData)) {
|
||||
console.log('[FestipodData] Auto-seed (FESTIPOD_AUTO_SEED): wallet already has data — skip');
|
||||
console.log(`${logPrefix} Auto-seed (FESTIPOD_AUTO_SEED): wallet already has data — skip`);
|
||||
return;
|
||||
}
|
||||
// Enabled AND synced-empty → a real empty wallet. Seed once.
|
||||
hasTriedAutoSeed.current = true;
|
||||
console.log('[FestipodData] Auto-seed (FESTIPOD_AUTO_SEED): wallet empty (synced), bootstrapping…');
|
||||
console.log(`${logPrefix} Auto-seed (FESTIPOD_AUTO_SEED): wallet empty (synced), bootstrapping…`);
|
||||
bootstrapWallet(false, createEntityDoc, identifier || undefined)
|
||||
.catch(err => console.error('[FestipodData] Auto-seed failed:', err));
|
||||
.catch(err => console.error(`${logPrefix} Auto-seed failed:`, err));
|
||||
// The reactive `watchShape` reads pick the seeded per-entity docs up on their
|
||||
// own (each createEntityDoc appends to the scope index → the container-index
|
||||
// subscription re-resolves → the new docs enter the read). No registerDoc/relist.
|
||||
@@ -459,8 +508,22 @@ function useNgData(): FestipodDataContextValue {
|
||||
// participations keyed on it are consistent with reads and isolation. Falls
|
||||
// back to the read profile's IRI only when there is no login (dev/demo).
|
||||
const currentUserId =
|
||||
(identifier ? `urn:festipod:user:${normalizeIdentifier(identifier)}` : (currentUser?.id || ''));
|
||||
(identifier ? `${USER_PRINCIPAL_PREFIX}${normalizeIdentifier(identifier)}` : (currentUser?.id || ''));
|
||||
// Identity-first log prefix, reused by every DATA log below (including the
|
||||
// closures defined earlier in this function body — they only execute after
|
||||
// this render has finished, by which point `logPrefix` is initialized).
|
||||
const logPrefix = currentUserId ? `[${currentUserId}][app][data]` : '[app][data]';
|
||||
const selectedEvent = events.find(e => e.id === selectedEventId);
|
||||
// DISPLAY READ — log participantCount exactly as currently exposed for
|
||||
// rendering. Compared against the owner-materializer's WRITE logs below, this
|
||||
// pinpoints whether a stuck counter is a DATA problem (never incremented) or a
|
||||
// DISPLAY/read problem (incremented but not re-read until the next session).
|
||||
if (selectedEvent) {
|
||||
console.log(
|
||||
`${logPrefix} participation read for display — event=${canonicalEventId(selectedEvent.id)} ` +
|
||||
`"${selectedEvent.title}": participantCount lu = ${selectedEvent.participantCount}`,
|
||||
);
|
||||
}
|
||||
const selectedUser = users.find(u => u.id === selectedUserId);
|
||||
|
||||
// --- OWNER MATERIALIZER (Option B, brief §B.2 + T02.c notifications) -------
|
||||
@@ -518,11 +581,23 @@ function useNgData(): FestipodDataContextValue {
|
||||
// deposits inside `materializeAttendance` / `readRegistrationNotifications`.
|
||||
const targetInbox = await hostInboxNuri('');
|
||||
console.log(
|
||||
`[Attendance] owner materialize START (trigger=${trigger}) — ${owned.length} owned ` +
|
||||
`${logPrefix} owner participation materialize START (trigger=${trigger}) — ${owned.length} owned ` +
|
||||
`event(s), inbox=${targetInbox}`,
|
||||
);
|
||||
const notifs: FpNotificationData[] = [];
|
||||
for (const evId of owned) {
|
||||
// BEFORE — the event's readable detail (short id + title) and the
|
||||
// participantCount value as currently READ/exposed (the app-side `events`
|
||||
// state), captured before this cycle's derive+write. Comparing this to the
|
||||
// AFTER log below tells whether the counter is a DATA problem (never
|
||||
// incremented) or a DISPLAY/read problem (incremented but not re-read).
|
||||
const knownEvent = events.find(e => e.id === evId);
|
||||
const knownCount = knownEvent?.participantCount;
|
||||
console.log(
|
||||
`${logPrefix} participation materialize — event=${canonicalEventId(evId)}` +
|
||||
(knownEvent?.title ? ` "${knownEvent.title}"` : '') +
|
||||
` — participantCount before write (as currently read) = ${knownCount ?? '(unknown)'}`,
|
||||
);
|
||||
// (1) COUNT — derive the distinct active-registration set for this event
|
||||
// and write it on MY OWN event doc (only when it changed). The read inside
|
||||
// `materializeAttendance` is BARRIER-GATED (`inbox.readSynced`): at the
|
||||
@@ -540,21 +615,34 @@ function useNgData(): FestipodDataContextValue {
|
||||
if (prevCount !== nextCount) {
|
||||
materializedCountRef.current.set(evId, nextCount);
|
||||
console.log(
|
||||
`[Attendance] owner materialize — event=${canonicalEventId(evId)}: ` +
|
||||
`${logPrefix} owner participation materialize — event=${canonicalEventId(evId)}: ` +
|
||||
`participantCount ${prevCount ?? '(none)'} → ${nextCount} (writing own doc)`,
|
||||
);
|
||||
// The write lands on the owned event doc, which `watchShape('public')`
|
||||
// already subscribes → the reactive read re-renders the new count on
|
||||
// the broker push (no manual re-query).
|
||||
let writeOk = true;
|
||||
await updateEntityField(evId, evId, 'participantCount', int(nextCount))
|
||||
.catch(err => {
|
||||
// Revert the memo so a transient write failure retries next trigger.
|
||||
writeOk = false;
|
||||
materializedCountRef.current.delete(evId);
|
||||
console.error('[Attendance] owner materialize count WRITE FAILED:', err);
|
||||
console.error(`${logPrefix} owner participation materialize count WRITE FAILED:`, err);
|
||||
});
|
||||
if (writeOk) {
|
||||
// AFTER — the write has landed on the owner's own doc. N → M reuses the
|
||||
// SAME "before" reference point logged above, so it is directly
|
||||
// comparable: a DISPLAY-read log (see `selectedEvent`, above in this
|
||||
// file) still showing the old N after this fires means the counter data
|
||||
// is fine and it is the read side that lags.
|
||||
console.log(
|
||||
`${logPrefix} participation materialize — event=${canonicalEventId(evId)}: ` +
|
||||
`participantCount AFTER write = ${knownCount ?? '(unknown)'} → ${nextCount}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(
|
||||
`[Attendance] owner materialize — event=${canonicalEventId(evId)}: ` +
|
||||
`${logPrefix} owner participation materialize — event=${canonicalEventId(evId)}: ` +
|
||||
`participantCount unchanged (${nextCount}) — no write`,
|
||||
);
|
||||
}
|
||||
@@ -571,7 +659,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[Attendance] owner materialization failed:', err);
|
||||
console.error(`${logPrefix} owner participation materialization failed:`, err);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -634,7 +722,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
events, users, participations, meetingPoints, friendships, currentUserId,
|
||||
);
|
||||
|
||||
console.log('[FestipodData] Render — NG | events:', events.length,
|
||||
console.log(`${logPrefix} Render — NG | events:`, events.length,
|
||||
'| users:', users.length, '| participations:', participations.length,
|
||||
'| selectedEvent:', selectedEvent?.title ?? '(none)');
|
||||
|
||||
@@ -647,7 +735,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
// private) — the app carries no access logic.
|
||||
|
||||
const createEvent = useCallback(async (event: Omit<FpEventData, 'id'>): Promise<FpEventData> => {
|
||||
console.log('[FestipodData] createEvent (NG):', event.title);
|
||||
console.log(`${logPrefix} createEvent (NG):`, event.title);
|
||||
// Owner principal = the account identifier (what setCurrentUser declares). The
|
||||
// SDK create returns THIS entity's OWN public document and declares its
|
||||
// ReadCap policy (public → world-readable). Fall back to a generic account
|
||||
@@ -697,13 +785,13 @@ function useNgData(): FestipodDataContextValue {
|
||||
submitEventToIndex(
|
||||
{ doc: eventGraph, id: addedEvent["@id"], title: event.title },
|
||||
null,
|
||||
).catch(err => console.error('[FestipodData] submit event to index failed:', err));
|
||||
).catch(err => console.error(`${logPrefix} submit event to index failed:`, err));
|
||||
}
|
||||
return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` };
|
||||
}, [currentUserId, identifier]);
|
||||
|
||||
const updateEvent = useCallback(async (id: string, updates: Partial<FpEventData>) => {
|
||||
console.log('[FestipodData] updateEvent (NG):', id, updates);
|
||||
console.log(`${logPrefix} updateEvent (NG):`, id, updates);
|
||||
// The event's `@id` IS its own document NURI (one entity = one document), so
|
||||
// it is both the write graph and the subject. Persist each provided mutable
|
||||
// field DIRECTLY via SPARQL (the durable write); `watchShape` re-reads on the
|
||||
@@ -718,19 +806,19 @@ function useNgData(): FestipodDataContextValue {
|
||||
if (updates.date !== undefined) persists.push(updateEntityField(graph, id, 'date', str(updates.date)));
|
||||
if (updates.location !== undefined) persists.push(updateEntityField(graph, id, 'location', str(updates.location)));
|
||||
if (updates.distance !== undefined) persists.push(updateEntityField(graph, id, 'distance', flt(updates.distance)));
|
||||
await Promise.all(persists).catch(err => console.error('[FestipodData] persist event update failed:', err));
|
||||
await Promise.all(persists).catch(err => console.error(`${logPrefix} persist event update failed:`, err));
|
||||
}, []);
|
||||
|
||||
const joinEvent = useCallback(async (eventId: string, userId?: string) => {
|
||||
const uid = userId || currentUserId;
|
||||
console.log('[FestipodData] joinEvent (NG):', eventId, 'user:', uid);
|
||||
console.log(`${logPrefix} joinEvent (NG):`, eventId, 'user:', uid);
|
||||
// A Participation MUST carry a user principal (SHEX `fp:user` is mandatory) —
|
||||
// writing one without it produces an entity the ORM drops on read (the
|
||||
// participation silently never round-trips). Refuse an empty principal rather
|
||||
// than persist a broken participation. The caller resolves a real user id (the
|
||||
// current user's IRI) before joining.
|
||||
if (!uid) {
|
||||
console.error('[FestipodData] joinEvent: empty user principal — refusing to write a participation with no fp:user.');
|
||||
console.error(`${logPrefix} joinEvent: empty user principal — refusing to write a participation with no fp:user.`);
|
||||
return;
|
||||
}
|
||||
// IDEMPOTENCE — check AUTHORITATIVELY against the broker, not the reactive set.
|
||||
@@ -739,7 +827,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
// one participation"). The broker query sees the real state regardless of lag.
|
||||
const already = await countUserParticipations(identifier || uid || 'anon', eventId, uid).catch(() => 0);
|
||||
if (already > 0) {
|
||||
console.log('[FestipodData] Already participating (broker-confirmed), skipping');
|
||||
console.log(`${logPrefix} Already participating (broker-confirmed), skipping`);
|
||||
return;
|
||||
}
|
||||
// 1) Persist the Participation as its OWN document in the PROTECTED scope
|
||||
@@ -787,7 +875,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
// Carry the joiner's participation-doc NURI so the owner (if a connection)
|
||||
// could read it in clear; the count itself does not depend on reading it.
|
||||
console.log(
|
||||
`[Attendance] joinEvent — depositing registration into event inbox: ` +
|
||||
`${logPrefix} joinEvent — depositing participation registration into event inbox: ` +
|
||||
`event=${canonicalEventId(eventId)} user=${uid} (count now moves via the OWNER ` +
|
||||
`materializing this deposit on its own doc, at its next connection)`,
|
||||
);
|
||||
@@ -805,13 +893,13 @@ function useNgData(): FestipodDataContextValue {
|
||||
// notification id from the inbox and same-ms/anon deposits never collide.
|
||||
setNotifications(prev => [...prev, { ...notif, id: `notif-${depositUid}` }]);
|
||||
} catch (err) {
|
||||
console.error('[FestipodData] joinEvent inbox/notify failed:', err);
|
||||
console.error(`${logPrefix} joinEvent inbox/notify failed:`, err);
|
||||
}
|
||||
}, [events, currentUserId, identifier]);
|
||||
|
||||
const leaveEvent = useCallback(async (eventId: string, userId?: string) => {
|
||||
const uid = userId || currentUserId;
|
||||
console.log('[FestipodData] leaveEvent (NG):', eventId, 'user:', uid);
|
||||
console.log(`${logPrefix} leaveEvent (NG):`, eventId, 'user:', uid);
|
||||
// Find the participation in the union-read set. Each participation is its OWN
|
||||
// document (writeEntity uses the doc NURI as the subject), so `part.id` is BOTH
|
||||
// the subject IRI AND the graph NURI it lives in.
|
||||
@@ -828,14 +916,14 @@ function useNgData(): FestipodDataContextValue {
|
||||
try {
|
||||
result = await deleteParticipation(graphNuri, eventId, uid, subjectIri);
|
||||
} catch (err) {
|
||||
console.error('[FestipodData] SPARQL DELETE participation failed:', err);
|
||||
console.error(`${logPrefix} SPARQL DELETE participation failed:`, err);
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
// AUTHORITATIVE verification: only proceed once the broker RE-QUERY confirms
|
||||
// the participation is gone (remaining === 0). If the delete matched nothing,
|
||||
// surface it rather than falsely flip the UI (it would resurrect on re-sync).
|
||||
if (result.remaining > 0) {
|
||||
const msg = `[FestipodData] leaveEvent: SPARQL delete removed nothing ` +
|
||||
const msg = `${logPrefix} leaveEvent: SPARQL delete removed nothing ` +
|
||||
`(before=${result.before}, remaining=${result.remaining}, bySubject=${result.bySubject}) ` +
|
||||
`for event=${eventId} user=${uid} — NOT flipping UI (would resurrect).`;
|
||||
console.error(msg);
|
||||
@@ -871,13 +959,13 @@ function useNgData(): FestipodDataContextValue {
|
||||
// otherwise the owner falls back to (eventId, userId) matching.
|
||||
const regUid = joinUidsRef.current.get(`${eventId}|${uid}`);
|
||||
console.log(
|
||||
`[Attendance] leaveEvent — depositing leave marker into event inbox: ` +
|
||||
`${logPrefix} leaveEvent — depositing participation leave marker into event inbox: ` +
|
||||
`event=${canonicalEventId(eventId)} user=${uid} regUid=${regUid ?? '(none)'}`,
|
||||
);
|
||||
await depositLeave(targetInbox, eventId, registrantId, regUid);
|
||||
joinUidsRef.current.delete(`${eventId}|${uid}`);
|
||||
} catch (err) {
|
||||
console.error('[FestipodData] leaveEvent inbox deposit failed:', err);
|
||||
console.error(`${logPrefix} leaveEvent inbox deposit failed:`, err);
|
||||
}
|
||||
// The participation doc is subscribed by `watchShape('protected')`; the SPARQL
|
||||
// DELETE pushes → the reactive read drops it (`isParticipating` reflects it).
|
||||
@@ -900,7 +988,7 @@ function useNgData(): FestipodDataContextValue {
|
||||
}, [currentUserId]);
|
||||
|
||||
const updateProfile = useCallback(async (updates: Partial<FpUserData>) => {
|
||||
console.log('[FestipodData] updateProfile (NG):', updates);
|
||||
console.log(`${logPrefix} updateProfile (NG):`, updates);
|
||||
// The current user's profile is its own document (subject IRI = doc NURI).
|
||||
const target = currentUser ?? users[0];
|
||||
if (!target) return;
|
||||
@@ -911,11 +999,11 @@ function useNgData(): FestipodDataContextValue {
|
||||
if (updates.username !== undefined) persists.push(updateEntityField(graph, graph, 'username', str(updates.username)));
|
||||
if (updates.role !== undefined) persists.push(updateEntityField(graph, graph, 'role', str(updates.role)));
|
||||
if (updates.isPublic !== undefined) persists.push(updateEntityField(graph, graph, 'isPublic', bool(updates.isPublic)));
|
||||
await Promise.all(persists).catch(err => console.error('[FestipodData] persist profile update failed:', err));
|
||||
await Promise.all(persists).catch(err => console.error(`${logPrefix} persist profile update failed:`, err));
|
||||
}, [currentUser, users]);
|
||||
|
||||
const loadTestData = useCallback(async (): Promise<BootstrapResult> => {
|
||||
console.log('[FestipodData] loadTestData (NG)');
|
||||
console.log(`${logPrefix} loadTestData (NG)`);
|
||||
// An EXPLICIT load is authoritative — SUPPRESS the dev auto-seed so only ONE
|
||||
// seed runs (marking the guard at the START, before the awaited seed, closes
|
||||
// the window where the auto-seed effect could also fire on a still-empty read).
|
||||
@@ -959,7 +1047,9 @@ function NgDataProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
export function FestipodDataProvider({ children }: { children: ReactNode }) {
|
||||
const { status } = useNextGraph();
|
||||
console.log('[FestipodData] Provider — NG status:', status);
|
||||
// No identity resolved at this level (only NG connection status is known here) —
|
||||
// identity-first prefix falls back to the bare `[app][data]` form.
|
||||
console.log('[app][data] Provider — NG status:', status);
|
||||
|
||||
if (status === 'connected') {
|
||||
return <NgDataProvider>{children}</NgDataProvider>;
|
||||
|
||||
@@ -343,17 +343,17 @@ export async function readRegistrationNotifications(
|
||||
* just-written participation, so a second join checking only the reactive set would
|
||||
* write a duplicate. Querying the broker sees the real state regardless of read lag.
|
||||
*
|
||||
* Scoped to the CURRENT account (`username`) via `listMyEntityDocs` — a user's own
|
||||
* Scoped to the CURRENT account (`identifier`) via `listMyEntityDocs` — a user's own
|
||||
* participations live in their own account, so there is NO need to fan out over all
|
||||
* accounts (which would open/sync other accounts' unsynced docs → the ~75s hang).
|
||||
*/
|
||||
export async function countUserParticipations(
|
||||
username: string,
|
||||
identifier: string,
|
||||
eventId: string,
|
||||
userId: string,
|
||||
): Promise<number> {
|
||||
const sid = (await sessionPromise).session_id;
|
||||
const docs_ = await listMyEntityDocs(username, 'protected');
|
||||
const docs_ = await listMyEntityDocs(identifier, 'protected');
|
||||
let total = 0;
|
||||
for (const g of docs_) {
|
||||
total += await countParticipations(sid, g, eventId, userId).catch(() => 0);
|
||||
|
||||
@@ -111,9 +111,11 @@ export function useShapeQuery<T = UnionSubject>(
|
||||
// "readDoc → N rows" logs gain an app-level running total.
|
||||
recordSet(label, n);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[FestipodData] set reçu: ${n} objets ${label} (${scope}) en ${elapsed}ms`);
|
||||
// No identity in scope at this call site (this hook receives no currentUserId) —
|
||||
// identity-first prefix falls back to the bare `[app][data]` form.
|
||||
console.log(`[app][data] set reçu: ${n} objets ${label} (${scope}) en ${elapsed}ms`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[FestipodData] totaux — ${totalsSummary()} (${totalSets()} sets reçus)`);
|
||||
console.log(`[app][data] totaux — ${totalsSummary()} (${totalSets()} sets reçus)`);
|
||||
}, [query, cycleId, shapeKey, scope]);
|
||||
|
||||
return query;
|
||||
|
||||
@@ -22,7 +22,7 @@ setDefaultTimeout(90000);
|
||||
// account (whose shim key uses a sentinel prefix `normalizeIdentifier` can't emit).
|
||||
const RUN_NONCE = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
||||
let scenarioSeq = 0;
|
||||
function freshScenarioUsername(): string {
|
||||
function freshScenarioIdentifier(): string {
|
||||
scenarioSeq += 1;
|
||||
return `test-${RUN_NONCE}-${scenarioSeq}`;
|
||||
}
|
||||
@@ -580,7 +580,7 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
|
||||
// the run self-heals instead of cascading failures across the rest.
|
||||
this.page = await newWalletPageResilient();
|
||||
|
||||
// FRESH VIRTUAL WALLET per scenario (see freshScenarioUsername above). Set a
|
||||
// FRESH VIRTUAL WALLET per scenario (see freshScenarioIdentifier above). Set a
|
||||
// UNIQUE app-level identifier into localStorage['festipod.account.identifier']
|
||||
// on EVERY origin (the init script runs in each frame before its scripts do —
|
||||
// including the harness iframe on 127.0.0.1). At mount the harness's
|
||||
@@ -588,11 +588,11 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
|
||||
// login(DEFAULT_HARNESS_USER)` is skipped and the scenario runs on a fresh,
|
||||
// empty virtual wallet. Overwrites any value persisted in the Chromium profile
|
||||
// (init scripts run on each navigation), so no accumulated wallet leaks in.
|
||||
const freshUser = freshScenarioUsername();
|
||||
(this as any).freshUser = freshUser;
|
||||
const freshIdentifier = freshScenarioIdentifier();
|
||||
(this as any).freshIdentifier = freshIdentifier;
|
||||
await this.page.addInitScript((u: string) => {
|
||||
try { window.localStorage.setItem('festipod.account.identifier', u); } catch { /* opaque origin */ }
|
||||
}, freshUser);
|
||||
}, freshIdentifier);
|
||||
|
||||
// Capture console for debugging AND collect into the World so smoke
|
||||
// scenarios can assert no runtime error was emitted during the connected
|
||||
@@ -622,8 +622,8 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
|
||||
);
|
||||
|
||||
// NO per-scenario registry/wallet reset needed anymore (was T03.j
|
||||
// resetDataState). Each @data scenario now runs under a UNIQUE username
|
||||
// (freshScenarioUsername, set into localStorage above), so the shim hands it
|
||||
// resetDataState). Each @data scenario now runs under a UNIQUE identifier
|
||||
// (freshScenarioIdentifier, set into localStorage above), so the shim hands it
|
||||
// a FRESH, EMPTY virtual wallet whose account registry starts empty by
|
||||
// construction — nothing to purge. This also drops the ≤10s reset cost that
|
||||
// shared the Before hook's budget with the (slow) broker login.
|
||||
|
||||
@@ -356,7 +356,7 @@ function ConnectedHarness() {
|
||||
// Enumerate the CURRENT account's own protected docs — the read-by-need
|
||||
// path the APP uses (registration.countUserParticipations →
|
||||
// listMyEntityDocs), NOT the all-accounts `listEntityDocs` fan-out. Each
|
||||
// @data scenario runs under a FRESH virtual account (freshScenarioUsername
|
||||
// @data scenario runs under a FRESH virtual account (freshScenarioIdentifier
|
||||
// in localStorage), whose participation docs live ONLY in that account's
|
||||
// protected scope index. The all-accounts fan-out (`allAccounts()`) does
|
||||
// not surface the fresh account here (its registry record isn't in the
|
||||
@@ -489,7 +489,7 @@ function ConnectedHarness() {
|
||||
const priv = `did:ng:${session.private_store_id}`;
|
||||
const SHIM = 'urn:ng-eventually:shim';
|
||||
const t0 = Date.now();
|
||||
// Delete every Account record (and its username/doc* predicates) from the
|
||||
// Delete every Account record (and its identity/doc* predicates) from the
|
||||
// anchor graph. `?p ?o` with the `a shim:Account` guard scopes the delete
|
||||
// strictly to registry triples, leaving anything else in the private
|
||||
// store intact.
|
||||
@@ -611,13 +611,13 @@ function ConnectedHarness() {
|
||||
* (3 docs + SPARQL INSERT), drop the cache, reload from the wallet via
|
||||
* SPARQL SELECT. Validates: doc_create ×3 + shim sparql_update/query.
|
||||
*/
|
||||
async validateShim(username: string) {
|
||||
async validateShim(identifier: string) {
|
||||
const reg = await import('../utils/storeRegistry');
|
||||
reg.resetRegistryCache();
|
||||
const created = await reg.ensureAccount(username);
|
||||
const created = await reg.ensureAccount(identifier);
|
||||
reg.resetRegistryCache();
|
||||
const reloaded = (await reg.allAccounts()).find(
|
||||
a => a.id === username,
|
||||
a => a.id === identifier,
|
||||
) ?? null;
|
||||
return { created, reloaded };
|
||||
},
|
||||
|
||||
@@ -82,7 +82,7 @@ export async function login() {
|
||||
* REAL NextGraph logout — stops the session of the SHARED wallet.
|
||||
*
|
||||
* STOPGAP: must stay HIDDEN (Settings/debug only). The everyday "Déconnexion"
|
||||
* is the FAUX one (AccountContext.logout, clears the username only). Calling
|
||||
* is the FAUX one (AccountContext.logout, clears the identifier only). Calling
|
||||
* this forces a new broker redirect on the next access — see
|
||||
* decision_2026-06-15_shared-wallet-login-flow.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user