Compare commits

...

10 Commits

Author SHA1 Message Date
Sylvain Duchesne 96e28a702f docs(concept): solder la doc-debt des 6 concepts
Dette accumulée depuis le 13/07 (27 marqueurs). Au-delà du vidage, trois
corrections de doctrine réellement fausse — c'est ce que le reconcile devait
attraper :

- app-security : `sharedWallet.ts` capture le mot de passe à l'ÉVALUATION du
  module. Tant qu'un repli existait, un global posé trop tard ne faisait que
  dégrader ; depuis que le wallet partagé est l'unique mode, il rend la barrière
  INUTILISABLE (écran d'erreur, aucun champ). Conséquence non anticipée de la
  décision shared-wallet-only → nouveau caveat.
- bdd-testing : la doctrine rendait des tests faux-verts. `ctx.newPage()` sur le
  profil persistant relit l'IndexedDB local et ne prouve JAMAIS la durabilité
  broker ; seul un contexte partagé neuf tranche. Un agent suivant la doctrine
  écrivait un test qui passe sans rien vérifier → nouveau caveat.
- app-architecture : `knowledge_routing` décrivait encore une route `/login`
  disparue, et `knowledge_screen-pattern` citait `LoginScreen` qui n'existe
  plus. Nouveau caveat sur les deux espaces d'id vus depuis un écran.

Aussi : data-layer/knowledge_context-internals décrit la jointure
participation→profil et corrige un mécanisme de changement d'identité périmé ;
tech-stack raccroche la table des scripts au vrai point d'entrée cucumber ;
functional-domain note qu'« implémenté » ≠ « durable ».

Trois marqueurs soldés comme sans objet : ils visaient
`reconnexion-socket-mort.{feature,steps.ts}`, absents de l'arbre ET de tout
l'historique — expérience abandonnée avant tout commit. Ce qu'elle devait
établir est capturé ailleurs (caveat de durabilité, post-mortem polyfill, fiche
INBOX socket-death).

Liens morts vers une décision disparue avec le concept `nextgraph-platform`
réparés. Reste au lint : le brief 07-06 (superseded) porte des file:line et des
références aux internes NextGraph — laissé intact, il décrit l'Option-B encore
implémentée et se dissoudra à la graduation.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-27 14:43:09 +02:00
Sylvain Duchesne a8401bd143 docs(concept): inscriptions — Participation lisible + drapeau active, purge par le créateur
Affinement PO du 2026-07-27. La Participation devient LISIBLE par tous et se
réduit à trois choses : référence à l'événement, booléen `active`, did cap-less
vers le profil protected du participant. Pas de description pour l'instant.

Ce que ça débloque : une suppression n'est pas détectable sans la clé (vérifié),
ce qui imposait un nudge forgeable pour la désinscription. Un objet lisible avec
un drapeau change la nature du problème — l'annulation n'est plus à DÉTECTER,
elle est à LIRE. Le blocage disparaît au lieu d'être contourné.

Le principe qui tient l'ensemble : la vérité est dans l'objet que le participant
contrôle, tout message n'est qu'un indice. Un faux « purge X » conduit le
créateur à lire X, la voir active, et ne rien faire. La forgerie devient
structurellement inoffensive — d'où l'absence de besoin de signer les dépôts
d'inbox, ce qui tombe bien : NextGraph ne l'offre pas (inbox non authentifiée,
vérification de signature non implémentée et exigeant de déchiffrer).

Le pointeur d'identité vise le profil protected existant, pas un second document
par participation : les connexions en détiennent déjà le cap. Ajouter une
connexion ne réécrit donc rien — on scelle une fois, durablement. Un champ
chiffré dans la Participation aurait exigé de re-sceller à N destinataires et de
réécrire à chaque nouvelle connexion (et n'est pas un primitif NextGraph : la
granularité de chiffrement est le document, en tout-ou-rien).

Arbitrages assumés : pas de filtrage à la lecture (Set.size est une borne haute,
exacte après purge — obsolescence acceptée pour garder la lecture en O(1)) ;
la purge incombe au créateur ; pas de description.

Point ouvert noté : Participation passe en scope public alors que la doctrine
produit la place en protected. Ce leaf décrit l'implémenté — à mettre à jour à
la graduation du brief, pas avant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-27 13:49:18 +02:00
Sylvain Duchesne ab077d8080 docs(concept): réserve durable sur le pseudonyme permanent de l'overlay
app-security/caveat_stable-overlay-pseudonym (nouveau) — toute référence
cap-less vers un document protected expose le `✌️` du store, identique partout
et pour toujours. BLAKE3 non inversible le rend OPAQUE, d'où la tentation de le
croire INOFFENSIF : ce sont deux choses différentes. C'est la constance qui
expose, pas la lisibilité. Un seul recoupement, une seule fois, et tout
l'historique bascule — y compris ce qui a été publié des années plus tôt.

Aucune porte de sortie, vérifié sur quatre axes : pas de rotation d'overlay,
store id généré une fois pour toutes, aucune migration de contenu, aucune forme
de référence n'évitant d'exposer l'overlay. Le renouvellement de capabilities
ne toucherait que l'inner ; l'outer y survit.

Placé en app-security et non dans le brief inscriptions : un brief se dissout à
sa graduation, la réserve doit lui survivre. Le brief n'en garde qu'un résumé
et pointe dessus. Déclencheurs élargis (anonymat, pseudonyme, traçage,
corrélation, overlay, cap-less) pour qu'elle remonte quand on s'apprête à
concevoir de l'« anonyme ».

Consigne pratique qui en découle : ne jamais présenter une action comme
« anonyme » si elle fait circuler une référence cap-less — c'est pseudonyme,
et le pseudonyme est permanent.

Dédup par `✌️` validée par le PO ; le brief le note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-27 12:31:55 +02:00
Sylvain Duchesne 42dbfd0c34 docs(concept): modèle d'inscription recalé + l'inbox NextGraph suit aussi les manques
Le brief inscriptions est réécrit sur le modèle reposé par le PO : tout est
clés et URLs, sans notion d'appartenance. Le participant crée une Participation
chiffrée, dépose son did (URI sans ReadCap) dans l'inbox de l'événement ; le
créateur traite l'inbox automatiquement, déduplique sans pouvoir lire, et range
la référence dans un Set porté par l'événement ; compteur = Set.size ; seules
les connexions détiennent la clé et reconnaissent la personne.

La dédup s'appuie sur un fait vérifié dans nextgraph-rs : l'overlay (segment
`✌️` d'un NURI) est STORE-scopé, jamais document-scopé. Deux Participations
d'une même personne portent donc le même `✌️`. Contrepartie actée dans le
brief : ce `✌️` est un pseudonyme stable et permanent — c'est le MÊME bit
d'information qui permet de dédupliquer sans lire et de tracer d'un événement
à l'autre ; on ne peut pas garder l'un sans l'autre.

Retiré du brief : le trilemme et la piste de dédup par vérification de
signature. Ils reposaient sur une notion de membership importée de l'état
courant du source Rust, où elle est un échafaudage inerte — erreur de méthode
désormais consignée en règle.

Règles :
- rule_capture-nextgraph-findings (nouvelle) — toute connaissance établie sur
  le fonctionnement réel de NextGraph se consigne AU MOMENT de la découverte
  dans la doc du polyfill ; distinguer VÉRIFIÉ d'INFÉRÉ ; ne jamais déduire la
  forme cible de l'état courant du source.
- rule_file-nextgraph-bugs → rule_nextgraph-inbox — l'inbox reçoit désormais
  DEUX familles : les dysfonctionnements ET les manques dont on a besoin. Une
  fiche de manque dit ce que le polyfill émule en attendant et ce qu'il faudra
  en RETIRER quand ça atterrit en amont : l'inbox devient un suivi de
  l'avancement de NextGraph, pas un simple bug-tracker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-27 12:07:06 +02:00
Sylvain Duchesne 5b536ff981 docs(concept): brief inscriptions par Set + règle de report des bugs NextGraph
- data-layer/brief_2026-07-20_attendance-set-model : réaligner les inscriptions
  sur la vision initiale — objet participation auto-possédé (la vérité) + Set
  curé de références cap-less sur l'événement + cap scellé aux seules
  connexions ⇒ compteur = `Set.size`, présence anonyme par défaut, personne ne
  désinscrit autrui. Inclut la revue adverse (trilemme anonyme/dédup/inviolable)
  et les verdicts du spike P0 vérifiés dans `nextgraph-rs` :
  fetch d'existence sans clé = OUI, détection de suppression sans clé = NON
  (⇒ la désinscription passe par un nudge), confidentialité = OUI.
  Statut : direction cible, PAS un pivot immédiat.
- data-layer/rule_file-nextgraph-bugs : tout dysfonctionnement NextGraph
  identifié donne lieu à une fiche dans `../../nextgraph/orm-tests/INBOX/`.
- to-discuss : alignement ReadCap/WriteCap, terminologie identité NextGraph.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-27 11:30:12 +02:00
Sylvain Duchesne c5e627c5fc fix(participants): joindre participation→profil à travers les deux espaces d'id
Une Participation stocke son user comme principal stable
`urn:festipod:user:<clé>`, alors qu'un UserProfile a pour `id` son NURI
`did🆖`. La jointure brute `partUserIds.includes(u.id)` ne matchait donc
jamais en mode connecté → chaque participant s'affichait « inconnu ».

- `resolveParticipantUser` : match direct (espace seed demo) puis, à défaut,
  match sur `normalizeIdentifier(username)` après retrait du préfixe principal.
- `USER_PRINCIPAL_PREFIX` : source unique du préfixe, partagée par l'écriture
  (`currentUserId`) et la lecture, pour qu'elles ne divergent pas.
- EventDetailScreen : filtrer soi-même sur `currentUser?.id` (id de profil,
  même espace que `p.id`) et non sur `currentUserId` (principal).

Aussi : épingle `packageManager` pnpm (l'install passe par pnpm, cf.
rule_bun-first) — le runtime/test/build restent Bun.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-27 11:30:00 +02:00
Sylvain Duchesne 7e65a83d42 fix(test): cucumber via l'entrée JS réelle (@cucumber/cucumber/bin) — pnpm casse le shim .bin
pnpm installe node_modules/.bin/cucumber-js comme shim shell (pas du JS) → 'node --import tsx/esm node_modules/.bin/cucumber-js' échoue. Pointer sur l'entrée JS réelle du paquet. Répare test:data et cucumber:run.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-20 13:17:36 +02:00
Sylvain Duchesne 3a49376f17 test(reconnexion): repro à froid sans état local (@wip) + persistance/pause (@wip)
reconnexion-froide-sans-local = test décisif broker-vs-local (verdict LOCAL-ONLY), @wip. persistance-e2e + pause @wip. rename identifiant dans reconnexion/isolation/harness-ng.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-20 13:16:04 +02:00
Sylvain Duchesne e326bebd42 feat(auth): wallet partagé = seul mode + purge identifiant-wallet↔username
AccessGateScreen 3-branches (erreur config si pas de wallet partagé) ; renommage username→identifier de l'identité du wallet (registration, ngSession, hooks, steps auth) sans toucher UserProfile.username ; .env.example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-20 13:16:04 +02:00
Sylvain Duchesne 46ed894621 feat(data): logs [app][data] identité-first + participantCount avant→après
Préfixe identité-first ; label participation ; valeur compteur avant/après écriture owner + lecture affichage. Additif.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014GbGgNEHRejVKoREvFuDFg
2026-07-20 13:16:04 +02:00
52 changed files with 1322 additions and 188 deletions
+40
View File
@@ -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`.
-9
View File
@@ -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)
+6 -1
View File
@@ -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.
-20
View File
@@ -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]].
-8
View File
@@ -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)
+8 -2
View File
@@ -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).
+4
View File
@@ -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
View File
@@ -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"
}
+20 -6
View File
@@ -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}`);
}
});
+3 -3
View File
@@ -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. */
+137 -47
View File
@@ -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>;
+3 -3
View File
@@ -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);
+4 -2
View File
@@ -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;
+7 -7
View File
@@ -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.
+5 -5
View File
@@ -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 };
},
+1 -1
View File
@@ -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.
*/