Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 96e28a702f | |||
| a8401bd143 | |||
| ab077d8080 | |||
| 42dbfd0c34 | |||
| 5b536ff981 | |||
| c5e627c5fc | |||
| 7e65a83d42 | |||
| 3a49376f17 | |||
| e326bebd42 | |||
| 46ed894621 | |||
| a21d9b0735 | |||
| f6fc3c262e | |||
| 62693667a8 | |||
| 82004a30b0 | |||
| 0958d70132 | |||
| c0fd69344b | |||
| 39b67feea0 | |||
| c7e924abe7 | |||
| 13da2d9e03 | |||
| 7302936502 | |||
| 4ffa055d62 | |||
| 0fc8479e22 | |||
| 7dab6e44e2 | |||
| 91ee3567aa | |||
| f366ee29a7 | |||
| 04a2de0b17 | |||
| 9e62bdea53 | |||
| 38266d96f8 | |||
| 2295af610a | |||
| 4c80ada3de | |||
| 517045c257 | |||
| c07150cb27 | |||
| 005c052bc6 | |||
| c869c56a17 | |||
| 65bd67cc20 | |||
| 0f164300f0 | |||
| 767a18e98c | |||
| 22487ed575 | |||
| cd2a45c254 | |||
| e62a17e5a2 | |||
| 4e96659bd7 | |||
| 84bc87d13c | |||
| af58667b4f | |||
| 01d65238ce | |||
| 6ceec5e161 | |||
| 3dfd549af3 | |||
| 17543f04c3 | |||
| 25b1c033d9 | |||
| e951eaaf96 | |||
| 0911b1f9de | |||
| 02cda056b8 | |||
| 8ca79c6d16 | |||
| 8bb19b687b | |||
| eafb4403b9 | |||
| 966ba9855c | |||
| 3ad06dfaec | |||
| 82c2cb5f27 | |||
| bc3d270bd4 | |||
| a436c3bd79 | |||
| 337a1e000d | |||
| 619b94ac0e | |||
| db9eb1cf47 | |||
| aabb2b77f7 | |||
| 83604cbc16 | |||
| aacc2ec3ee | |||
| 555c670b22 | |||
| 3f47ea886f | |||
| a54c119b4d | |||
| d69fd7a5f9 | |||
| 685f6d379d | |||
| c52e581e4f | |||
| 98c796054e | |||
| 073150ef61 | |||
| aec338441c | |||
| e270cc6063 | |||
| 9af128cb22 | |||
| 3ca2d10c49 |
@@ -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=
|
||||
@@ -43,3 +43,4 @@ playwright/.auth/
|
||||
storybook-static
|
||||
dist-staging/
|
||||
*.ngw
|
||||
.tasks/
|
||||
|
||||
@@ -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,9 +30,11 @@ Utilisé notamment par Storybook (voir concept `tech-stack`) pour parcourir les
|
||||
- **home/** : `welcome`, `home`, `settings`
|
||||
- **event/** : `events`, `event-detail`, `create-event`, `update-event`, `invite`, `participants-list`, `meeting-points`
|
||||
- **user/** : `profile`, `update-profile`, `user-profile`, `friends-list`, `share-profile`
|
||||
- **auth/** : `login`
|
||||
- **auth/** : `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]]).
|
||||
|
||||
> Le mapping path → écran est dans [[knowledge_routing]]. La plupart des écrans consomment `useFestipodData()` (concept `data-layer`) ; exceptions : `LoginScreen`/`WelcomeScreen`.
|
||||
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`.
|
||||
|
||||
## Piège : registre incomplet
|
||||
|
||||
|
||||
@@ -1,23 +1,26 @@
|
||||
---
|
||||
type: _overview
|
||||
summary: Sécurité & confidentialité de Festipod — posture ACTUELLE (mono-store, confiance broker, aucun contrôle d'accès côté app) et modèle d'autorisations CIBLE (incubation) ; authentification par wallet NextGraph
|
||||
summary: Sécurité & confidentialité de Festipod — l'isolation entre périmètres est assurée par le SDK de données, l'app lui fait confiance et ne porte aucune logique d'autorisation dans les écrans ; authentification par wallet ; matrice d'autorisations cible en incubation
|
||||
triggers:
|
||||
keywords: [sécurité, security, confidentialité, privacy, accès, "access control", contrôle d'accès, trust, confiance, authz, autorisation, permission, wallet, auth, authentification, anonyme, identité, login]
|
||||
keywords: [sécurité, security, confidentialité, privacy, accès, "access control", contrôle d'accès, trust, confiance, authz, autorisation, permission, wallet, auth, authentification, anonyme, anonymat, pseudonyme, traçage, corrélation, overlay, cap-less, identité, login, scope, isolation]
|
||||
paths: ["src/modules/auth/**", "src/shared/context/NextGraphContext.tsx"]
|
||||
---
|
||||
|
||||
# App security
|
||||
|
||||
Le modèle de **sécurité, confidentialité et autorisations** de Festipod. Le pilier se lit en deux temps :
|
||||
Le modèle de **sécurité, confidentialité et autorisations** de Festipod.
|
||||
|
||||
- **Actuel** — ce que le code applique aujourd'hui : voir [[knowledge_trust-model]]. Résumé brutal : **aucun contrôle d'accès côté app**, l'app affiche le `private_store` de l'utilisateur connecté et fait confiance au broker. Mono-user de fait.
|
||||
- **Cible** — le modèle d'autorisations dérivé (qui peut faire quoi, données personnelles = réseau, anonymat via inbox) : [[brief_2026-05-18_authorization-matrix]]. **Incubation, non implémenté.** Il graduera en `rule_`/`behavior_` quand le multi-user atterrira (chantiers data dans le concept `nextgraph-platform`).
|
||||
- **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.
|
||||
|
||||
L'écart entre les deux est volontaire : tant que l'app est mono-store (cf. concept `data-layer`), il n'y a rien à autoriser côté app.
|
||||
## 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]] — posture de sécurité actuelle (mono-store, confiance broker, pas d'enforcement app)
|
||||
- [[knowledge_authentication]] — auth par wallet NextGraph, tous authentifiés, pas d'accès anonyme
|
||||
- [[brief_2026-05-18_authorization-matrix]] — modèle d'autorisations cible (incubation)
|
||||
- `nextgraph-platform` — les primitives (stores, capabilities, inbox) et les chantiers data qui porteront la cible
|
||||
- [[knowledge_trust-model]] — l'app délègue l'isolation au SDK, pas de contrôle d'accès dans les écrans
|
||||
- [[knowledge_authentication]] — auth par wallet, tous authentifiés, pas d'accès anonyme
|
||||
- [[brief_2026-05-18_authorization-matrix]] — matrice d'autorisations cible (incubation)
|
||||
- Concept `functional-domain` → [[knowledge_data-scopes-and-discovery]] — quel scope pour quelle entité (fait produit)
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
---
|
||||
type: brief
|
||||
summary: Matrice d'autorisations par type de donnée (PdR, inscription, événement, profil, connexion) ; dérive que 3 stores natifs par utilisateur + Dialog stores suffisent, aucun Group store sur le périmètre validé ; questions ouvertes sur modèle d'écriture événement et identité de l'hôte
|
||||
summary: Matrice d'autorisations cible par type de donnée (PdR, inscription, événement, profil, connexion) exprimée en scopes public/protected/private + dialog ; décisions cadre acquises (tous authentifiés, PdR publics, données personnelles = réseau, notification par inbox identifiée-ou-anonyme) ; questions ouvertes sur modèle d'écriture événement et identité de l'hôte
|
||||
last_updated: 2026-05-18
|
||||
---
|
||||
|
||||
# Matrice d'autorisations et inventaire des requêtes
|
||||
|
||||
**Status:** Incubating — analyse en cours
|
||||
**Last updated:** 2026-05-18
|
||||
**Status:** Incubating — modèle cible, non figé en règles.
|
||||
|
||||
## Context
|
||||
|
||||
Préalable au refactor multi-store ([[brief_2026-05-17_multi-store-refactor]]) et à toute évolution multi-user. La structure de stores NextGraph cible doit être *dérivée* de : (1) une matrice d'autorisations ; (2) un inventaire des requêtes par écran ; (3) les partitions naturelles qui en découlent (données partageant autorisations *et* schéma d'accès).
|
||||
|
||||
C'est aussi le **modèle de confidentialité/sécurité** de Festipod (pilier sécurité), non encore implémenté.
|
||||
Le modèle **cible** de qui-peut-quoi. La confidentialité de Festipod se dérive de : (1) une matrice d'autorisations par acteur × verbe ; (2) l'inventaire des requêtes par écran ; (3) les **périmètres** (scopes) qui en découlent — données partageant à la fois autorisation *et* schéma d'accès. Le placement concret entité → scope est un fait produit : concept `functional-domain` → [[knowledge_data-scopes-and-discovery]]. L'isolation est **assurée par le SDK de données** ([[knowledge_trust-model]]).
|
||||
|
||||
## Cadre
|
||||
|
||||
@@ -33,7 +30,7 @@ C'est aussi le **modèle de confidentialité/sécurité** de Festipod (pilier s
|
||||
- **Hôte = détenteur des droits d'écriture** sur un PdR (1 hôte, le créateur ; le fait d'être hôte est public).
|
||||
- **Informations personnelles = réservées au réseau.** Visibles seulement au titulaire et à ses connexions : participations, intégralité du profil, liste de connexions, et tout état déclaratif dont la divulgation serait une fuite. Statut « public » (PdR, événement) et « personnel » (profil, participations, connexions) coexistent dans le même utilisateur.
|
||||
- **Connexion bilatérale.** Existe après acceptation des deux côtés. Deux objets : `DemandeDeConnexion` (unilatérale, transitoire) et `Connexion` (bilatérale, persistante).
|
||||
- **Notification d'inscription via l'inbox NextGraph du PdR.** L'acte « s'inscrire » est composite : (a) écriture d'un objet `Inscription` dans le `protected_store` de l'inscrit, (b) dépôt d'un lien (DID cap) dans l'**inbox** du document PdR. Identification du sender par résolution du DID contre le graphe de connexions de l'hôte : connexion → inscription complète visible ; sinon → lien opaque (« quelqu'un (DID…) s'est inscrit »). Anonymat partiel **natif aux capabilities** (cf. [[knowledge_stores-permissions]] §Inbox).
|
||||
- **Notification d'inscription via l'inbox du PdR.** L'acte « s'inscrire » est composite : (a) écriture d'un objet `Inscription` dans le périmètre *protected* de l'inscrit, (b) dépôt d'un lien dans l'**inbox** du document PdR. L'expéditeur est **identifié si connexion de l'hôte, anonyme sinon** — propriété du modèle de données.
|
||||
- **Adhésion à une communauté / suivi : hors périmètre actuel.**
|
||||
|
||||
## Matrice par type de donnée
|
||||
@@ -63,7 +60,7 @@ Notes : pas de différenciation `C` (les connexions sont un filtre d'affichage U
|
||||
| modifier | ? **à trancher** (selon champs) | ✗ | ✗ | ✗ | ✗ |
|
||||
| supprimer | ✓ (se désinscrire ; retirer le lien de l'inbox si possible) | ✗ | cond : modération inbox seule (ne supprime pas l'objet) | ✗ | ✗ |
|
||||
|
||||
**Visibilité hôte : résolue** (identifiée si connecté, anonyme sinon — natif). **Questions ouvertes :** champs modifiables d'une inscription (booléen seul ou +commentaire/statut/accompagnants ?) ; **suppression côté inbox** — un déposant peut-il retirer son lien d'un doc qu'il ne contrôle pas ? (à vérifier au protocole).
|
||||
**Visibilité hôte : résolue** (identifiée si connecté, anonyme sinon). **Questions ouvertes :** champs modifiables d'une inscription (booléen seul ou +commentaire/statut/accompagnants ?) ; **suppression côté inbox** — un déposant peut-il retirer son lien d'un doc qu'il ne contrôle pas ?
|
||||
|
||||
### Événement
|
||||
|
||||
@@ -74,7 +71,7 @@ Notes : pas de différenciation `C` (les connexions sont un filtre d'affichage U
|
||||
| modifier | ? **à trancher** | ? **à trancher** | ? **à trancher** |
|
||||
| supprimer | ? **à trancher** | ✗ | ✗ |
|
||||
|
||||
**Questions ouvertes :** qui peut **modifier** un événement déclaré — déclarant seul (propriétaire) ? tout utilisateur (wiki) ? personne (immuable) ? Central pour la déduplication (cf. concept `functional-domain`, [[brief_2026-06-15_event-deduplication]] côté functional-domain). Qui peut **supprimer**, et que deviennent les PdR greffés (orphelins/cascade/marqué supprimé) ?
|
||||
**Questions ouvertes :** qui peut **modifier** un événement déclaré — déclarant seul (propriétaire) ? tout utilisateur (wiki) ? personne (immuable) ? Central pour la déduplication (concept `functional-domain`, [[brief_2026-06-15_event-deduplication]]). Qui peut **supprimer**, et que deviennent les PdR greffés (orphelins/cascade/marqué supprimé) ?
|
||||
|
||||
### Profil utilisateur
|
||||
|
||||
@@ -89,7 +86,7 @@ Notes : pas de différenciation `C` (les connexions sont un filtre d'affichage U
|
||||
| modifier | ✓ | ✗ | ✗ |
|
||||
| supprimer (compte) | ✓ | ✗ | ✗ |
|
||||
|
||||
**Tension à résoudre :** un PdR est lisible par tous, mais son hôte ne devrait pas être identifiable par un lambda. Trois positions : (i) **pseudonyme par DID seul** (nom/avatar résolus seulement aux connexions) ; (ii) **identité dénormalisée dans l'offre** (l'hôte choisit une « carte de visite » par PdR, vivant dans l'objet PdR, profil fermé) ; (iii) **anonymat de l'hôte** (identité révélée seulement aux connexions). À trancher. Autres : composition champ-par-champ de chaque périmètre ; statut du `username` (public/réseau/supprimé ?).
|
||||
**Tension à résoudre :** un PdR est lisible par tous, mais son hôte ne devrait pas être identifiable par un lambda. Trois positions : (i) **pseudonyme par identité seule** (nom/avatar résolus seulement aux connexions) ; (ii) **identité dénormalisée dans l'offre** (l'hôte choisit une « carte de visite » par PdR, vivant dans l'objet PdR, profil fermé) ; (iii) **anonymat de l'hôte** (identité révélée seulement aux connexions). À trancher. Autres : composition champ-par-champ de chaque périmètre ; statut du `username` (public/réseau/supprimé ?).
|
||||
|
||||
### Connexion (lien d'amitié)
|
||||
|
||||
@@ -105,31 +102,18 @@ Bilatérale. `DemandeDeConnexion` (unilatérale, en attente) → `Connexion` (bi
|
||||
|
||||
**Questions ouvertes :** granularité côté Bob (voit-il toute la liste d'Alice ou juste A↔B ? — conséquence du principe : toute la liste) ; découvrabilité « amis d'amis » (Alice voit-elle Bob↔Carole ? — non, sauf si Carole ∈ connexions(Alice)).
|
||||
|
||||
## Partitions naturelles dérivées
|
||||
## Périmètres dérivés
|
||||
|
||||
Heuristique : même store si (a) même cellule d'autorisation en écriture *et* (b) accédées ensemble. À partir des seuls points validés, **trois périmètres** émergent — qui correspondent **presque parfaitement aux 3 stores natifs**.
|
||||
Heuristique : même périmètre si (a) même cellule d'autorisation en écriture *et* (b) accédées ensemble. Trois **scopes** émergent, plus le cas bilatéral :
|
||||
|
||||
| Périmètre | Écriture | Lecture | Données validées |
|
||||
| Périmètre | Écriture | Lecture | Données |
|
||||
|---|---|---|---|
|
||||
| **Public** ↔ `public_store` | Alice seule | Tous | PdR hébergés par Alice ; événements déclarés *(sous réserve du modèle d'écriture)* |
|
||||
| **Réseau** ↔ `protected_store` | Alice seule | Alice + connexions | Profil réseau ; participations ; index des connexions |
|
||||
| **Privé** ↔ `private_store` | Alice seule | Alice seule | Profil privé (settings, email, préférences) |
|
||||
| **public** | Alice seule | Tous | PdR hébergés par Alice ; événements déclarés *(sous réserve du modèle d'écriture)* |
|
||||
| **protected** (réseau) | Alice seule | Alice + connexions | Profil réseau ; participations ; index des connexions |
|
||||
| **private** | Alice seule | Alice seule | Profil privé (settings, email, préférences) |
|
||||
| **dialog** (A↔B) | Alice et Bob | Alice et Bob | La `Connexion` bilatérale (+ matière à messagerie future) |
|
||||
|
||||
### Cas particulier : la Connexion bilatérale
|
||||
|
||||
Donnée à *deux* écrivains → ne tient dans aucun store individuel. Primitive native : le **Dialog store**. Modèle : **une `Connexion` A↔B = un Dialog store** (contient l'objet + matière à messagerie future) ; l'**index « toutes les connexions d'Alice »** vit dans le `protected_store` d'Alice (liste les NURIs des Dialog stores). La `DemandeDeConnexion` : soit dans un Dialog store provisoire, soit dans le `public_store` du destinataire (à trancher selon le SDK).
|
||||
|
||||
### Inbox du document PdR
|
||||
|
||||
Le doc PdR (dans le `public_store` de l'hôte) a une **inbox** native : reçoit les dépôts d'inscription (liens DID cap), plus tard commentaires/signaux. **Pas un store séparé**, attribut du document. Pas d'impact sur les partitions.
|
||||
|
||||
### Ce qui ne demande aucun Group store
|
||||
|
||||
Sur le périmètre validé, **aucune donnée ne demande de Group store**. Tout tient dans : 3 stores natifs par utilisateur + Dialog stores + inboxes natives. Les Group stores ne deviennent nécessaires que si le modèle d'écriture événement est « wiki », ou si communautés/suivi/collaboration multi-hôte reviennent dans le périmètre.
|
||||
|
||||
### Implication pour [[brief_2026-05-17_multi-store-refactor]]
|
||||
|
||||
Ce brief y propose une structure à 4 niveaux de Group stores. **Cette analyse dérive une structure différente** (3 stores natifs + Dialog, sans Group) parce que les concepts qui justifient les Group stores ont été mis hors périmètre. À reconcilier à l'exécution.
|
||||
La **`Connexion` bilatérale** a *deux* écrivains → périmètre **dialog** dédié à la paire ; l'**index « toutes les connexions d'Alice »** vit en *protected* (liste les références des connexions). L'**inbox du PdR** est un attribut du document public, pas un périmètre séparé.
|
||||
|
||||
## Inventaire des requêtes par écran
|
||||
|
||||
@@ -137,7 +121,6 @@ Ce brief y propose une structure à 4 niveaux de Group stores. **Cette analyse d
|
||||
|
||||
## See Also
|
||||
|
||||
- [[brief_2026-05-17_multi-store-refactor]] — consommateur principal
|
||||
- [[brief_2026-06-15_shared-wallet-shim]] — stopgap reprenant ces périmètres
|
||||
- `README.md §Modèle fonctionnel` / concept `functional-domain` — source des acteurs
|
||||
- Concept `data-layer` — état actuel mono-store
|
||||
- Concept `functional-domain` → [[knowledge_data-scopes-and-discovery]] — placement entité → scope + découverte
|
||||
- [[knowledge_trust-model]] — l'isolation est assurée par le SDK
|
||||
- `README.md §Modèle fonctionnel` — source des acteurs
|
||||
|
||||
@@ -0,0 +1,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]]).
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
type: decision
|
||||
summary: L'identifiant de l'espace virtuel se saisit à la barrière d'accès (AccessGateScreen), dans le même acte que l'ouverture du wallet ; l'écran de « login perçu » séparé (ConnexionScreen, « choisissez un nom d'utilisateur ») est retiré ; l'identifiant est un id technique normalisé en minuscules, pas un username Festipod
|
||||
---
|
||||
|
||||
# Décision (2026-07-06) : identifiant saisi à la barrière d'accès
|
||||
|
||||
## Contexte
|
||||
|
||||
Le flux stopgap 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**
|
||||
(clé du compte shim / cap owner), pas un username produit — le cadrage « nom d'utilisateur »
|
||||
était donc trompeur (logique `setUsername` confuse).
|
||||
|
||||
## Décision
|
||||
|
||||
L'utilisateur saisit son **identifiant** directement dans `AccessGateScreen`, **dans le même
|
||||
acte** qui ouvre le wallet (« Entrer » enregistre l'identifiant puis déclenche `connect()`).
|
||||
`ConnexionScreen` est **supprimé**. L'identifiant :
|
||||
|
||||
- est un **id technique** qui nomme l'espace virtuel (un pseudo en pratique, **pas** un
|
||||
username Festipod) ;
|
||||
- est **normalisé** à la saisie (trim, `@` retiré, **minuscules**) et persisté avant la
|
||||
redirection broker (donc il survit au round-trip) ;
|
||||
- **est** l'id d'identité remis au SDK (`setCurrentUser`), et la clé des caps et du compte
|
||||
shim — plus de handle à casse mixte à réconcilier.
|
||||
|
||||
`AuthGate` affiche donc la barrière tant que le wallet n'est pas ouvert **ou** que l'identifiant
|
||||
n'est pas posé, puis l'app directement — sans écran intermédiaire.
|
||||
|
||||
## Alternatives écartées
|
||||
|
||||
- **Garder les deux écrans** : le second écran « nom d'utilisateur » perpétuait la confusion
|
||||
entre identité-produit et identifiant-de-wallet, et ajoutait une étape sans valeur.
|
||||
- **Dériver l'identifiant du wallet** (pas de saisie) : impossible ici — le wallet partagé est
|
||||
unique ; l'identifiant est précisément ce qui distingue les espaces virtuels au sein de ce
|
||||
wallet (émulation, cf. concept `data-layer` et le SDK `@ng-eventually/client`).
|
||||
|
||||
## Portée
|
||||
|
||||
Supersede la partie « écran 2 / login perçu » du flux stopgap du 2026-06-15
|
||||
(l'ouverture du wallet partagé via broker reste inchangée). État courant du flux :
|
||||
[[knowledge_authentication]].
|
||||
@@ -0,0 +1,32 @@
|
||||
---
|
||||
type: decision
|
||||
summary: Le wallet partagé est le SEUL mode de fonctionnement (le polyfill @ng-eventually/client en dépend comme backend de données) ; le repli « sans wallet partagé » est retiré — mauvaise config → écran d'erreur franc, plus de formulaire nu. Réaffirme que l'identifiant de la barrière = id du wallet/espace, distinct du username du profil.
|
||||
---
|
||||
|
||||
# Décision (2026-07-20) — le wallet partagé est l'unique mode ; identifiant ≠ username du profil
|
||||
|
||||
## Contexte
|
||||
|
||||
Régression observée : à l'ouverture, l'app tombait sur un **formulaire nu demandant un identifiant**, sans l'assistance de chargement du portefeuille. Cause : `FESTIPOD_SHARED_WALLET_PASSWORD` non défini dans l'environnement du serveur → `hasSharedWallet()` faux → `AccessGateScreen` basculait sur son mode replié. Or ce mode est une **impasse** : un appareil sans wallet ne peut pas se connecter une fois l'assistance d'import masquée. En parallèle, l'ancienne notion de « username » traînait encore pour désigner l'**identité du wallet**, ce qui la confondait avec le vrai username du profil.
|
||||
|
||||
## Décision
|
||||
|
||||
1. **Le wallet partagé est le seul mode supporté.** Festipod ne fonctionne pas sans lui — le polyfill `@ng-eventually/client` s'en sert comme backend de données (voir [[knowledge_authentication]], `rule_app-uses-sdk-surface-only`). `hasSharedWallet() === false` n'est donc **pas un mode fonctionnel** : c'est une **mauvaise configuration** → `AccessGateScreen` affiche un **écran d'erreur franc** (« Portefeuille partagé non configuré, définir `FESTIPOD_SHARED_WALLET_PASSWORD` »), jamais le formulaire nu en impasse.
|
||||
|
||||
2. **L'identifiant de la barrière ≠ le username du profil.** L'identifiant saisi à `AccessGateScreen` est l'**id technique du wallet/espace** (normalisé en minuscules, porté par le param d'URL `?id=`), pas un username. Le **username** est un concept distinct qui vit dans `UserProfile` (`@handle`, prédicat `http://festipod.org/username`). Le code et les tests ne doivent plus étiqueter l'identité du wallet « username/user » (renommé en `identifier`). Réaffirme et prolonge [[decision_2026-07-06_identifier-at-access-barrier]].
|
||||
|
||||
## Conséquences
|
||||
|
||||
- `AccessGateScreen` : rendu 3-branches (erreur config / flux d'import assisté quand non connecté / champ identifiant seul quand déjà connecté).
|
||||
- Renommage `username → identifier` de l'identité du wallet dans l'infra de test (`freshScenarioIdentifier`, `freshIdentifier`), `registration.ts`, `ngSession`, + commentaires ; **`UserProfile.username` intact** (profil, seed, affichage, SHEX).
|
||||
- `.env.example` ajouté à la racine pour rendre la config explicite (dont `FESTIPOD_SHARED_WALLET_PASSWORD`, `FESTIPOD_SHARED_WALLET_FILE`).
|
||||
|
||||
## Alternative écartée
|
||||
|
||||
Garder le repli sans-wallet comme futur « flux wallet-propre » : écarté **pour l'instant** — aucun flux wallet-propre à court terme, et le repli silencieux créait une impasse trompeuse. À réintroduire **explicitement** le jour où un mode wallet-propre (chaque utilisateur avec son propre wallet NextGraph) existera, hors stopgap.
|
||||
|
||||
## Liens
|
||||
|
||||
- Stopgap wallet partagé : `decision_2026-06-15_shared-wallet-login-flow` (référencé par `AccessGateScreen`/`AccountContext`).
|
||||
- [[decision_2026-07-06_identifier-at-access-barrier]] — l'identifiant à la barrière.
|
||||
- [[knowledge_authentication]], [[knowledge_trust-model]].
|
||||
@@ -1,20 +1,24 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Authentification = possession d'un wallet NextGraph ; tous les utilisateurs sont authentifiés (pas d'accès anonyme) ; l'auth passe par le redirect/iframe broker, et l'app n'auto-connecte que dans l'iframe
|
||||
summary: L'identité d'un utilisateur = son wallet NextGraph ; tous les utilisateurs sont authentifiés (pas d'accès anonyme) ; l'auth est déléguée au SDK, l'app n'a pas de comptes/mots de passe applicatifs
|
||||
---
|
||||
|
||||
# Authentification
|
||||
|
||||
**L'identité d'un utilisateur = son wallet NextGraph.** Il n'y a **pas d'accès anonyme** à l'app : tout utilisateur est authentifié (cf. concept `functional-domain`). Il n'y a pas de système de comptes/mots de passe applicatif — l'auth est déléguée à NextGraph.
|
||||
**L'identité d'un utilisateur = son wallet NextGraph.** Il n'y a **pas d'accès anonyme** à l'app : tout utilisateur est authentifié (cf. concept `functional-domain`). Il n'y a **pas de système de comptes/mots de passe applicatif** — l'authentification est **déléguée au SDK de données** (`@ng-eventually/client`) : ouvrir sa session, c'est ouvrir son wallet.
|
||||
|
||||
## Flux
|
||||
|
||||
- `LoginScreen` (`src/modules/auth/screens/`) déclenche la connexion via `useNextGraph()` (ne consomme pas `useFestipodData`).
|
||||
- Le flux standard `@ng-org/web` est un **redirect vers le broker** (`nextgraph.net/redir/`) qui recharge l'app dans une **iframe** après authentification (détail dans concept `nextgraph-platform`, [[knowledge_integration-model]] côté nextgraph-platform).
|
||||
- **L'app n'auto-connecte que dans l'iframe broker** (`window.self !== window.top`) — sinon `initNgWeb()` redirigerait toute la page. Cette règle vit côté data-layer ([[rule_conditional-ng-init]]) car elle concerne le cycle `NextGraphContext`, mais elle a une conséquence sécurité directe : **hors iframe, aucune session n'est ouverte sans action explicite** de l'utilisateur.
|
||||
- La **barrière d'accès** (`AccessGateScreen`, rendue par `src/app/AuthGate.tsx`) est le vrai login NextGraph : elle ouvre le wallet partagé via la redirection broker. **Dans le même acte**, l'utilisateur saisit un **identifiant** qui nomme son espace virtuel (`onEnter`). Il n'y a **plus d'écran « login perçu » séparé** (l'ancien `ConnexionScreen` « choisissez un nom d'utilisateur » a été retiré — cf. [[decision_2026-07-06_identifier-at-access-barrier]] ; supersede le flux à deux écrans 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.
|
||||
- Une fois la session ouverte, l'utilisateur courant et son accès aux stores par scope sont fournis par `NextGraphContext`.
|
||||
|
||||
## Le wallet de test
|
||||
|
||||
Les tests `@data`/`@e2e` créent/ouvrent un wallet réel (`festipod-tests`/`festipod-tests`, profil persistant) — voir concept `bdd-testing`. Ce sont des **credentials de test en clair**, sans enjeu de sécurité, dédiés au staging (cohérent avec la posture « utilisateurs amicaux » du stopgap, concept `nextgraph-platform`).
|
||||
Les tests `@data`/`@e2e` ouvrent un wallet réel (`festipod-tests`, profil persistant) — voir concept `bdd-testing`. Ce sont des **credentials de test en clair**, sans enjeu de sécurité, dédiés au staging.
|
||||
|
||||
> Le modèle d'autorisations qui s'appuiera sur cette identité (connexions bilatérales, données personnelles = réseau, anonymat hôte) est en incubation : [[brief_2026-05-18_authorization-matrix]].
|
||||
> Le modèle d'autorisations qui s'appuiera sur cette identité (connexions bilatérales, données personnelles = réseau, anonymat de l'hôte) est en incubation : [[brief_2026-05-18_authorization-matrix]].
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Posture de sécurité actuelle — aucun contrôle d'accès côté app, l'app lit/affiche le private_store de l'utilisateur connecté et fait confiance au broker NextGraph pour ne retourner que des données autorisées ; mono-user de fait
|
||||
last_checked: 2026-06-15
|
||||
summary: L'isolation entre périmètres (public/protected/private) est assurée par le SDK de données ; l'app lui fait confiance et n'affiche que ce qu'il retourne — aucun contrôle d'accès dans les écrans, toute la confidentialité repose sur le SDK
|
||||
last_checked: 2026-07-06
|
||||
---
|
||||
|
||||
# Modèle de confiance actuel
|
||||
# Modèle de confiance
|
||||
|
||||
**Posture observée dans `src/shared/context/FestipodDataContext.tsx` (`useNgData`) :** l'app lit tout ce que les subscriptions ORM retournent depuis le `private_store` de l'utilisateur connecté et l'affiche **sans aucun filtre d'autorisation côté app**.
|
||||
**Posture :** l'app lit les données via les subscriptions ORM du SDK `@ng-eventually/client` et les affiche **sans logique d'autorisation côté app** (`src/shared/context/FestipodDataContext.tsx`, `useNgData`).
|
||||
|
||||
Conséquences (à connaître avant de raisonner sécurité) :
|
||||
Principes :
|
||||
|
||||
1. **Aucun contrôle d'accès applicatif.** Pas de vérification « l'utilisateur a-t-il le droit de voir cette donnée ». L'app suppose que **le broker/NextGraph ne retourne que ce que l'utilisateur peut voir**. Toute la confidentialité repose sur cette confiance dans la couche NextGraph, pas sur du code Festipod.
|
||||
2. **Mono-store, donc mono-user de fait.** Tout (events, profils, participations) vit dans le `private_store` de l'utilisateur connecté (cf. concept `data-layer`, [[decision_2026-03-17_private-store-nuri-scope]] côté data-layer). Un autre utilisateur ne voit rien — par construction, le `private_store` n'est pas partageable. Il n'y a donc rien à « autoriser » : chacun ne voit que ses propres données.
|
||||
3. **Pas de séparation de périmètres.** Le découpage public / réseau / privé du modèle cible ([[brief_2026-05-18_authorization-matrix]]) **n'existe pas encore** dans le code : aucun `protected_store`/`public_store` n'est utilisé pour le métier.
|
||||
1. **L'isolation est déléguée au SDK.** Chaque entité vit dans le store de son **scope** (public / protected / private, cf. concept `functional-domain` → [[knowledge_data-scopes-and-discovery]]) ; le SDK **n'expose à l'utilisateur courant que ce à quoi il a droit**. L'app suppose que ce qu'elle reçoit est déjà autorisé — la confidentialité repose sur le SDK, pas sur du code Festipod.
|
||||
2. **Les écrans ne portent aucune règle d'accès.** Pas de vérification « cet utilisateur a-t-il le droit de voir cette donnée » dans les composants ni dans le contexte de données. La séparation public / réseau / privé est une propriété du **placement par scope**, pas d'un filtre applicatif.
|
||||
3. **La relation entre utilisateurs (« connexions ») est une notion applicative, pas une primitive du SDK.** NextGraph n'a pas de primitive de connexion/amitié bilatérale ; côté SDK il n'existe qu'un **grant de lecture dirigé** vers une identité. L'app **possède** donc son graphe de relations (`src/shared/utils/connections.ts`) et le **traduit** en grants dirigés par document remis au SDK — elle ne délègue pas la notion de relation au SDK, seulement l'**application** de l'isolation qui en découle. Ce que l'app déclare au SDK reste minimal : **son identité** (l'identifiant, cf. [[knowledge_authentication]]) et **ces grants** ; elle ne porte toujours aucune logique d'accès dans les écrans.
|
||||
|
||||
## Le piège pour la suite
|
||||
## Le point de vigilance
|
||||
|
||||
Le jour où le multi-user arrive (lecture cross-wallet, voir les briefs de `nextgraph-platform`), cette **absence d'enforcement applicatif devient un risque** : si la séparation reste portée seulement par la crypto/capabilities NextGraph et que l'app continue d'afficher « tout ce qu'elle reçoit », une fuite de capability = une fuite de données. Le stopgap `shared-wallet-shim` (concept `nextgraph-platform`) prévoit d'ailleurs un **filtre d'isolation applicatif** explicite parce que, dans ce mode, un seul wallet rend tout physiquement lisible.
|
||||
Parce que l'app **affiche tout ce qu'elle reçoit**, la confidentialité tient entièrement à ce que le SDK n'expose que le légitime. C'est un choix assumé (l'app reste mince), mais il implique de **ne jamais réintroduire côté écran une donnée que le scope n'aurait pas dû laisser passer**.
|
||||
|
||||
> À vérifier si on doute : `useNgData` dans `FestipodDataContext.tsx` ne contient aucune branche de filtrage par identité ; les seuls IDs manipulés sont ceux du wallet courant.
|
||||
> À vérifier si on doute : `useNgData` dans `FestipodDataContext.tsx` ne contient aucune branche de filtrage par identité — c'est intentionnel, l'isolation vient d'en dessous.
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: Le wallet de test partagé (.playwright-profile) accumule des données à chaque run ; passé un seuil, les sparql_query ancrées au private store hangent (>15s) et toute la suite @data échoue au setup — repartir d'un profil frais restaure des lectures ~1s
|
||||
last_checked: 2026-07-06
|
||||
---
|
||||
|
||||
# Piège : le wallet de test se gonfle et fait *hang* les lectures @data
|
||||
|
||||
Le profil Chromium persistant `.playwright-profile` (racine du working tree) porte le **wallet
|
||||
partagé** ouvert par toute la suite `@data`/`@e2e`. Ce wallet **accumule des données à chaque
|
||||
run** : comptes shim (un par scénario, via l'identifiant frais `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`, …).
|
||||
|
||||
**Symptôme.** Passé un certain volume (observé ~99 Mo de profil), une `sparql_query` **ancrée au
|
||||
private store** ne revient plus sous 15 s — elle *hang*. Comme la résolution de compte est sur le
|
||||
chemin de **chaque** read/write, **toute la suite @data échoue au setup** (0 événement chargé,
|
||||
timeouts), sans erreur explicite. Diagnostic vérifié : sur un wallet frais la même requête revient
|
||||
en **~1,5 s** et le seed complète normalement.
|
||||
|
||||
**Contournement.** Mettre le profil gonflé de côté et laisser le hook d'auth (beforeAll) en
|
||||
recréer un frais :
|
||||
|
||||
```bash
|
||||
mv .playwright-profile /tmp/festipod-bloated-$(date +%s)
|
||||
```
|
||||
|
||||
L'identifiant frais par scénario (`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.
|
||||
|
||||
> Le *pourquoi* côté broker (comment une requête ancrée touche le repo du private store) appartient
|
||||
> au SDK `@ng-eventually/client`, pas ici — ce caveat ne décrit que la conséquence côté tests.
|
||||
@@ -23,11 +23,11 @@ Steps **partagés** (cross-domaine) dans `src/shared/steps/ui/` :
|
||||
|
||||
Les noms français des écrans (`"accueil"`, `"détail événement"`, `"mon profil"`…) mappent vers les IDs d'écran via `screenNameMap`.
|
||||
|
||||
Tags de scénario : `@ui` / `@data` / `@e2e` (couche) + **`@wip`** pour un scénario dont les steps ne sont pas encore implémentés. Un `Contexte` (Background) fréquent — « Étant donné que je suis connecté » — ne fait que poser un flag `isAuthenticated`, pas d'auth réelle en `@ui`.
|
||||
Tags de scénario : `@ui` / `@data` / `@e2e` (couche) + **`@wip`** pour un scénario dont les steps ne sont pas encore implémentés **ou dont le comportement applicatif n'est pas encore fiable** (usage : marquer un attendu réel qui échoue à cause d'un bug produit, pas un test obsolète — ex. historique : la désinscription qui ne se reflétait pas dans l'UI, `@wip` **levé** depuis sa résolution T02.c, cf [[caveat_participation-deletion]]). **`@wip` est EXCLU du run par défaut** (`cucumber.json: "tags": "not @wip"`) : ces scénarios documentent un attendu sans casser la suite ; retirer le `@wip` quand c'est fiable. Un `Contexte` (Background) fréquent — « Étant donné que je suis connecté » — ne fait que poser un flag `isAuthenticated`, pas d'auth réelle en `@ui`.
|
||||
|
||||
## Config
|
||||
|
||||
`cucumber.json` : `import` de `src/shared/support/**`, `src/shared/steps/**`, `src/modules/*/steps/**` ; `paths` = `src/modules/*/features/**`; `language: fr`. **Runner = Node + tsx** (`node --import tsx/esm node_modules/.bin/cucumber-js`), pas Bun — les plugins (Playwright, happy-dom) ne chargent pas en import Bun natif. Ne pas « bunifier » `cucumber:run`/`test:data`.
|
||||
`cucumber.json` : `import` de `src/shared/support/**`, `src/shared/steps/**`, `src/modules/*/steps/**` ; `paths` = `src/modules/*/features/**`; `tags: "not @wip"` (exclut les scénarios WIP) ; `language: fr`. **Runner = Node + tsx**, 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,6 +1,7 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Couche @data — Playwright pilote Chromium (profil persistant) qui s'authentifie au broker NextGraph réel chargeant harness-ng.tsx en iframe ; cycle de vie wallet automatisé (création + login bootstrap), bridge window.__testData, fallback mock
|
||||
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)
|
||||
@@ -21,7 +22,7 @@ Cucumber → Playwright (Chromium, profil persistant)
|
||||
|
||||
## Cycle de vie du wallet (automatisé, CI-ready)
|
||||
|
||||
- **Premier run** : pas de marker `.wallet-ready` → Chromium headless crée le wallet (`nextgraph.eu` → Create Wallet → ToS sur `account.nextgraph.eu` → username/password → submit), **puis se logge** — ce login déclenche le bootstrap du verifier depuis le broker distant (peuple `self.repos`, sauvé en localStorage). **Sans ce login initial, toutes les écritures échoueraient en `RepoNotFound`.** Marker écrit.
|
||||
- **Premier run** : pas de marker `.wallet-ready` → Chromium headless crée le wallet (`nextgraph.eu` → Create Wallet → ToS sur `account.nextgraph.eu` → username/password → submit), **puis se logge** — ce login initial est requis pour amorcer la session (sauvé en localStorage) ; sans lui, les écritures ne passeraient pas. Marker écrit.
|
||||
- **Runs suivants** : marker trouvé → login automatisé (click Login → wallet → password → submit) → harness en iframe → `window.__testData.ready`.
|
||||
- Credentials wallet : `festipod-tests` / `festipod-tests`.
|
||||
|
||||
@@ -32,5 +33,39 @@ Cucumber → Playwright (Chromium, profil persistant)
|
||||
- **Flags Chromium** (`--disable-web-security`, `--allow-insecure-localhost`, désactivation de Private Network Access) : nécessaires car le broker public charge un harness `http://127.0.0.1` en iframe.
|
||||
- **Profil persistant** `.playwright-profile/` (gitignored, wallet en localStorage) — exige le vrai binaire Chrome, pas `chrome-headless-shell`.
|
||||
- **Serveur HTTP** lancé en `BeforeAll` (port auto), sert le HTML + `/harness.js` (fichiers séparés — le script inline casse à cause de caractères spéciaux du bundle).
|
||||
- **Subscriptions ORM** : les 3 shapes avec scope `did:ng:${session.private_store_id}` (cf. concept `data-layer`).
|
||||
- **Bridge `window.__testData`** : `events`/`users`/`participations` (sets live), `currentUserId`, lookups (`getEvent`, `getEventByTitle`), mutations (`joinEvent`, `leaveEvent`, `updateEvent`), requêtes (`isParticipating`, `getEventParticipants`).
|
||||
- **Bridge = le vrai chemin app (per-entité).** Depuis le passage à *un document par entité*
|
||||
(concept `data-layer`, [[rule_document-per-entity]]), le bridge `window.__testData`
|
||||
(`events`/`users`/`participations`, `joinEvent`/`leaveEvent`/`isParticipating`/
|
||||
`getEventParticipants`, `loadTestData`) **délègue au contexte de données de l'app**
|
||||
(`appData` via `FestipodDataProvider`) — c'est le chemin per-entité réel des écrans, pas une
|
||||
lecture au niveau du store-racine. Le harness monte donc l'**`AccountProvider`** et se logge
|
||||
par défaut (`@mariedupont`) pour établir l'identité courante (sans quoi le filtre ReadCap ne
|
||||
laisserait passer que le public). Il lit `appData` via une **ref vivante** (un snapshot capturé
|
||||
devient périmé après un re-rendu de seed).
|
||||
- Chemins probes de bas niveau conservés (scope store-racine `protectedNuri`) pour les
|
||||
scénarios ReadCap/isolation qui *gouvernent* ce document : `rawJoin`/`rawParticipations`,
|
||||
`governDocument`/`governProtected`/`documentNuri`, `FilterProbe`/`FanoutProbe`.
|
||||
- **Identité avant écriture.** Une `Participation` a un `fp:user` obligatoire ; comme la lecture
|
||||
du profil peut retarder derrière les events publics, les steps attendent
|
||||
`ensureCurrentUser()` avant `joinEvent` (sinon participation écrite sans user → jetée en
|
||||
lecture, ne fait jamais l'aller-retour) et attendent (`waitForFunction`) que la participation
|
||||
soit relue.
|
||||
- **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,65 @@ 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
|
||||
CLASSE de régression : un crash de rendu qui ne survient QUE une fois l'app connectée
|
||||
et montée sur des données réelles (symptôme : seul le bandeau de l'iframe broker
|
||||
s'affiche, `#root` reste vide). Le smoke réutilise le boot du hook `Before` @e2e,
|
||||
navigue vers l'accueil connecté et asserte DEUX choses :
|
||||
1. **HomeScreen a réellement monté** — présence de marqueurs forts (`.app-navbar` +
|
||||
bouton `[aria-label="Relayer un événement"]`), absents d'un spinner / du bandeau
|
||||
broker. Un `throw` dans un composant/provider monté après connexion démonte l'arbre
|
||||
(aucun `ErrorBoundary`) → ces marqueurs disparaissent → rouge.
|
||||
2. **Zéro erreur runtime** — `this.pageErrors` (voir ci-dessous) doit être vide.
|
||||
|
||||
Le hook `Before` @e2e **collecte** désormais dans le World les `pageerror` +
|
||||
`console.error` de la page app (champ `pageErrors`, réinitialisé par scénario) — c'est
|
||||
ce qui rend l'assertion « pas d'erreur » possible. Le run par défaut de `bun run
|
||||
validate` exécute `@smoke and not @wip` (pas tout `@e2e`, pour rester rapide).
|
||||
**Preuve de détection** : un `throw` en tête de `HomeScreen` fait virer le smoke au
|
||||
rouge ; sans lui, vert.
|
||||
|
||||
## Fichiers clés
|
||||
|
||||
`src/shared/support/hooks.ts` (lifecycle Playwright), `world.ts` (champs `page`/`appFrame`), `scripts/debug-browser.ts` (debug headed), `.playwright-profile{,-debug}/` (gitignored).
|
||||
`src/shared/support/hooks.ts` (lifecycle Playwright + collecte `pageErrors`), `world.ts` (champs `page`/`appFrame`/`pageErrors`), `scripts/debug-browser.ts` (debug headed), `.playwright-profile{,-debug}/` (gitignored).
|
||||
|
||||
@@ -6,7 +6,7 @@ last_checked: 2026-06-16
|
||||
|
||||
# Harness multi-navigateur (private-wallet vs shared-wallet)
|
||||
|
||||
Capacité du harness `@data`/`@e2e` à piloter **plusieurs navigateurs isolés** dans un même scénario, sous **deux axes orthogonaux**. Sert à tester le stopgap wallet partagé (cf. concept `nextgraph-platform` → `brief_2026-06-15_shared-wallet-shim`) **et** le modèle cible (chacun son wallet).
|
||||
Capacité du harness `@data`/`@e2e` à piloter **plusieurs navigateurs isolés** dans un même scénario, sous **deux axes orthogonaux**. Permet de tester à la fois le modèle « chacun son wallet » (`@private-wallet`) et le modèle « wallet partagé entre navigateurs » (`@shared-wallet`).
|
||||
|
||||
## Les deux axes (orthogonaux)
|
||||
|
||||
@@ -15,7 +15,7 @@ Capacité du harness `@data`/`@e2e` à piloter **plusieurs navigateurs isolés**
|
||||
| **Nombre de navigateurs** (machinerie) | 1..N contextes nommés isolés | `openBrowser(name, …)` + steps `… dans le navigateur "X"` |
|
||||
| **Modèle de wallet** | identité NG distincte vs partagée | **phrasing du step + tag** (voir ci-dessous) |
|
||||
|
||||
Ne **pas** confondre `@multibrowser` (plusieurs navigateurs) avec `@shared-wallet` (même wallet) : on fait du multibrowser **en private** (utile dès que NextGraph livrera la lecture cross-wallet — le modèle cible) **et en shared** (stopgap), et on compare les deux setups avec les **mêmes** steps de comportement.
|
||||
Ne **pas** confondre `@multibrowser` (plusieurs navigateurs) avec `@shared-wallet` (même wallet) : on fait du multibrowser **en private** (chacun son wallet) **et en shared** (wallet partagé), et on compare les deux setups avec les **mêmes** steps de comportement.
|
||||
|
||||
## Modèle de wallet : phrasing + tags
|
||||
|
||||
@@ -38,11 +38,11 @@ Ne **pas** confondre `@multibrowser` (plusieurs navigateurs) avec `@shared-walle
|
||||
|
||||
## Parcours humain — e2e du mécanisme produit (vert)
|
||||
|
||||
Scénario `@humain` : valide le flux RÉEL de distribution du wallet **de bout en bout, via la vraie app**, pas l'injection de test (cf. concept `nextgraph-platform` → `decision_2026-06-17_assisted-wallet-import`). Un navigateur vierge ouvre l'app staging → l'`AccessGateScreen` propose le **fichier** + le **mot de passe** → on télécharge le fichier **depuis l'écran**, on vérifie que le mot de passe affiché **égale** celui du wallet → import sur `nextgraph.eu` « Import a Wallet File » → retour → clic « Entrer » → app connectée (`ConnexionScreen`).
|
||||
Scénario `@humain` : valide le flux RÉEL de distribution du wallet **de bout en bout, via la vraie app**, pas l'injection de test. Un navigateur vierge ouvre l'app staging → l'`AccessGateScreen` propose le **fichier** + le **mot de passe** → on télécharge le fichier **depuis l'écran**, on vérifie que le mot de passe affiché **égale** celui du wallet → import sur `nextgraph.eu` « Import a Wallet File » → retour → on **saisit un identifiant** puis clic « Entrer » (nommer l'espace et ouvrir le wallet = un seul acte, cf. concept `app-security` [[decision_2026-07-06_identifier-at-access-barrier]]) → app connectée, arrivée directe sur l'accueil (plus d'écran « nom d'utilisateur » séparé).
|
||||
|
||||
- **Wallet e2e** : un fichier `.ngw` (`festipod-e2e-tests`, mot de passe = identifiant) placé **à la racine du worktree** ; `findE2eWalletFile()` le localise (`*.ngw`). Gitignoré → chaque environnement doit l'ajouter (sinon erreur claire).
|
||||
- `pool.ensureStagingApp()` (`hooks.ts`) — build **isolé** `bun run build.ts --outdir=dist-staging` (barrière d'accès **ON par défaut** ; mot de passe gravé + **fichier copié** en `/shared-wallet.ngw`, cf. `build.ts`), servi statiquement. Mémoïsé, lazy (seul `@humain` le paie).
|
||||
- **Bypass de la barrière pour `@e2e`** : le harness fait `browserContext.addInitScript` sur le **contexte persistant** pour poser `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ = true` (s'applique à l'iframe app avant ses scripts) → `@e2e` voit l'app directement, pas la barrière. Les contextes frais (`@humain`) n'y touchent pas → barrière ON (cf. `decision_2026-06-17`). L'ancien `LoginScreen` `/login` a été retiré.
|
||||
- **Bypass de la barrière pour `@e2e`** : le harness fait `browserContext.addInitScript` sur le **contexte persistant** pour poser `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ = true` (s'applique à l'iframe app avant ses scripts) → `@e2e` voit l'app directement, pas la barrière. Les contextes frais (`@humain`) n'y touchent pas → barrière ON. L'ancien `LoginScreen` `/login` a été retiré.
|
||||
- `pool.importWalletViaFile(page, filePath, password)` — `nextgraph.eu/#/wallet/login` → `setInputFiles('input[type=file]')` (attendre que la SPA rende, sinon `EncryptionError`) → champ password → unlock.
|
||||
- `pool.completeBrokerLogin(page, appUrl, walletPassword?)` — moitié « login broker » extraite de `setupBrokerPage`. **Attente robuste** : après le redirect (multi-hop), attend l'iframe app OU le lien « Click here to login with your wallet », puis déverrouille avec le mot de passe. La session broker n'étant **pas** persistée entre lancements, ce login wallet est requis à chaque run (warm-up + `@e2e` + `@humain`).
|
||||
|
||||
@@ -68,4 +68,3 @@ Scénario `@humain` : valide le flux RÉEL de distribution du wallet **de bout e
|
||||
|
||||
- [[knowledge_data-layer-broker]] — la couche `@data` mono-navigateur (profil persistant) que cette capability étend.
|
||||
- [[cookbook_add-scenario]] — convention `@wip`, pièges de steps.
|
||||
- Concept `nextgraph-platform` → `brief_2026-06-15_shared-wallet-shim` — le stopgap wallet partagé que ce harness sert à tester.
|
||||
|
||||
@@ -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]].
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
type: rule
|
||||
summary: Ne JAMAIS poller le broker (re-lire en boucle « c'est là ? »). NextGraph est par abonnement — la donnée arrive par PUSH, et le 1er `State` d'un `doc_subscribe` est la barrière de sync déterministe (après lui : présence garantie / absence définitive). Tests ET app attendent le push / l'état réactif settlé, jamais une boucle de re-lecture broker.
|
||||
last_checked: 2026-07-09
|
||||
---
|
||||
|
||||
# Ne jamais poller le broker — attendre l'abonnement
|
||||
|
||||
NextGraph est **par abonnement (réactif)**. Une lecture n'est PAS « interroge en
|
||||
boucle jusqu'à ce que ça apparaisse » ; c'est « abonne-toi, réagis au push ». Le
|
||||
**1er `State`** d'un `doc_subscribe` marque la fin de la synchronisation initiale
|
||||
(barrière synchrone) : après lui, la **présence** d'une donnée est **garantie** et
|
||||
l'**absence** est **définitive**. Contrat vérifié empiriquement côté SDK
|
||||
(`@ng-eventually/client`, test e2e « CONTRAT 3 »).
|
||||
|
||||
## L'anti-pattern à bannir
|
||||
|
||||
```
|
||||
for (i = 0; i < N; i++) { if (await authParticipationCount(...) === X) break; sleep(500); }
|
||||
```
|
||||
|
||||
Toute boucle qui **re-interroge le broker** (`authParticipationCount`,
|
||||
`listMyEntityDocs`, `sparql_query` répétés) pour « attendre » une donnée est
|
||||
proscrite : elle masque le vrai mécanisme, fragilise le test (timeout deviné), et
|
||||
contredit frontalement le modèle NextGraph. C'est la remarque qui a fait supprimer
|
||||
l'ancien caveat qui, à tort, érigeait le polling en pratique.
|
||||
|
||||
## Ce qu'il faut faire
|
||||
|
||||
Attendre le **push réactif**. En pratique (app ET test) : l'état réactif
|
||||
(`AD().*` alimenté par `subscribeDoc` dans le contexte de données) se met à jour
|
||||
**au push**. On attend que CET état reflète l'attendu — on **observe l'état réactif
|
||||
settlé**, on ne ré-émet PAS de lecture broker. Le mécanisme de données est
|
||||
l'abonnement ; l'attente ne fait qu'**observer le résultat réactif**.
|
||||
|
||||
- App : l'écran est déjà réactif (`subscribeDoc` → re-render au push) — pas de poll
|
||||
applicatif, pas de spinner piloté par timeout deviné (si un état d'attente est
|
||||
voulu, il vient de la barrière d'abonnement native, pas d'un signal ajouté).
|
||||
- Test : **un helper qui attend le push/la barrière de façon fiable est bienvenu**
|
||||
(fiabilise sans fragiliser). Ce qui est banni, c'est la **boucle de re-lecture**,
|
||||
pas l'attente d'un signal.
|
||||
- **Fallback pragmatique** : si attendre strictement le push/signal s'avère fragile
|
||||
d'une manière ou d'une autre, un **intervalle court** (`setInterval` / re-check
|
||||
rapproché) qui **observe l'état réactif DÉJÀ mis à jour** (l'état local alimenté
|
||||
par l'abonnement — PAS une re-lecture broker) est acceptable : c'est au plus près
|
||||
de ce que vit l'utilisateur, qui **attend** simplement que l'écran (réactif) se
|
||||
mette à jour. La ligne rouge est invariante : **ne jamais re-interroger le broker
|
||||
en boucle** ; observer l'état réactif settlé, oui.
|
||||
|
||||
Voir aussi [[caveat_wallet-bloat-hang]] (autre source de flakiness @data,
|
||||
orthogonale). Le mécanisme non-polling côté lib (`open-repo` : subscribe + attendre
|
||||
le 1er State + lire) vit dans le repo `@ng-eventually/client`, pas ici.
|
||||
@@ -1,35 +1,39 @@
|
||||
---
|
||||
type: _overview
|
||||
summary: Couche données NextGraph telle qu'utilisée AUJOURD'HUI (mono-store) — stack ORM/SHEX, modes connected/demo, entités, seed, et 3 règles d'écriture critiques
|
||||
summary: Comment Festipod persiste ses données via le SDK @ng-eventually/client — entités stockées comme documents par scope, écriture SPARQL directe + lecture par modèle union, stack SHEX, modes connected/demo, seed
|
||||
triggers:
|
||||
keywords: [nextgraph, useShape, ORM, SHEX, shape, store, private_store, "@graph", NURI, sparql, sparql_update, seed, wallet, RepoNotFound, FestipodData, ngGraph, bootstrap]
|
||||
paths: ["src/shared/shapes/**", "src/shared/hooks/useShape*", "src/shared/context/NextGraphContext.tsx", "src/shared/context/FestipodDataContext.tsx", "src/shared/utils/ng*", "src/shared/data/seedData.ts"]
|
||||
keywords: [nextgraph, "@ng-eventually", 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"]
|
||||
---
|
||||
|
||||
# Data layer
|
||||
|
||||
Comment Festipod **persiste ses données aujourd'hui** via NextGraph (P2P, local-first, chiffré). État actuel : **mono-store** — tout atterrit dans le `private_store` de l'utilisateur connecté.
|
||||
Comment Festipod **persiste ses données** via NextGraph (P2P, local-first, chiffré de bout en bout). Le SDK de données est **`@ng-eventually/client`** : on le traite comme un SDK NextGraph fini — chaque entité est un **document** placé dans le store de son **scope** (public / protected / private). L'**écriture** est un SPARQL direct dans le document de l'entité ; la **lecture** est le **modèle union** (résoudre les documents par besoin → ouvrir/sync → **une** requête `sparql_query` sans ancre sur l'union → re-query sur signal), et non un abonnement ORM réactif en fan-out (qui *hang*). Voir [[rule_document-per-entity]]. Le mapping *quelle entité → quel scope* est un fait **produit** (concept `functional-domain`, [[knowledge_data-scopes-and-discovery]]) ; ce concept décrit la **mécanique de persistance**.
|
||||
|
||||
> Distinction importante : ce concept décrit le **code actuel**. Le modèle *cible* (multi-store, multi-user, autorisations) est de la doctrine **prospective** qui vit dans le concept `nextgraph-platform` (briefs). NextGraph comme **système externe** (stores, permissions, inbox, SDK) y est aussi documenté.
|
||||
|
||||
**À lire avant de toucher aux écritures :** les 3 règles ci-dessous — chacune corrige un bug réel (`RepoNotFound`, suppression non persistée, redirect intempestif).
|
||||
|
||||
## Règles d'écriture (chacune adossée à une décision)
|
||||
|
||||
- [[rule_private-store-scope]] ← [[decision_2026-03-17_private-store-nuri-scope]]
|
||||
- [[rule_conditional-ng-init]] ← [[decision_2026-03-13_conditional-ng-init-broker-detection]]
|
||||
|
||||
## Pièges (lire avant de toucher au contexte / aux suppressions / aux champs d'event)
|
||||
|
||||
- [[knowledge_context-internals]] — currentUser `@mariedupont`, auto-seed dev, `participantCount` cache, IRI vide, no-op local
|
||||
- [[caveat_participation-deletion]] — `leaveEvent` via `ngSet.delete()` (décision SPARQL annulée), persistance possiblement partielle
|
||||
- [[caveat_event-fields-not-persisted]] — `startTime`/`themes`… perdus en connecté (SHEX incomplet)
|
||||
> **Frontière SDK.** Le SDK de données de Festipod est `@ng-eventually/client` — initialisé/injecté **une seule fois** via `ngSession.configure(...)`. On l'écrit comme un SDK NextGraph **fini** : ne jamais documenter ici l'état courant de NextGraph (contraintes, contournements, internes broker) — cela vit dans le repo `@ng-eventually/client`. Voir [[knowledge_nextgraph-stack]].
|
||||
|
||||
## Modèle & données
|
||||
|
||||
- [[knowledge_nextgraph-stack]] — paquets `@ng-org/*`, SHEX, ORM, `build:orm`
|
||||
- [[knowledge_data-modes]] — connected vs disconnected/demo, providers selon le statut NG
|
||||
- [[knowledge_entities]] — types `Fp*` et shapes
|
||||
- [[knowledge_nextgraph-stack]] — SDK `@ng-eventually/client`, shapes SHEX, ORM réactif, `build:orm`, injection via `ngSession`
|
||||
- [[knowledge_data-modes]] — connected (SDK) vs disconnected/demo (état local seedé), choix du provider
|
||||
- [[knowledge_entities]] — types `Fp*` et leurs shapes SHEX
|
||||
- [[knowledge_seed-data]] — données de seed, `CURRENT_USER_ID`
|
||||
- [[knowledge_context-internals]] — pièges de `FestipodDataContext` (currentUser, **deux espaces d'id** principal↔NURI de profil, auto-seed dev, `participantCount` cache, reset au changement d'identité, no-op local)
|
||||
|
||||
> Sécurité/confidentialité (mono-store, confiance broker) : concept `app-security`.
|
||||
## Règles d'écriture
|
||||
|
||||
- [[rule_document-per-entity]] — chaque entité = **son propre document** (par scope), jamais au niveau du store ; c'est ce qui rend l'isolation par-document du SDK possible
|
||||
- [[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)
|
||||
|
||||
- [[caveat_participation-deletion]] — la désinscription doit être **autoritative** et ne pas réapparaître
|
||||
- [[caveat_event-fields-not-persisted]] — `startTime`/`themes`… non couverts par la shape Event → perdus en connecté
|
||||
- [[caveat_write-durability-across-disconnect]] — une écriture juste avant une inactivité/chute de socket peut être **perdue** (non durable broker) ; compte survit. Incident ouvert → post-mortem dans le polyfill
|
||||
|
||||
> Confidentialité (isolation par scope, confiance dans le SDK) : concept `app-security`. Périmètres produit par entité + découverte : concept `functional-domain`.
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
---
|
||||
type: brief
|
||||
summary: Design d'implémentation — rendre les lectures RÉACTIVES cross-session via doc_subscribe (par-document, sans fan-out ORM qui hang) et remplacer le participantCount muté-en-place par le flux Option-B (l'inscrit dépose dans l'inbox de l'événement, le propriétaire matérialise et incrémente son propre doc) ; plan de test 2-browsers réel sans polling
|
||||
---
|
||||
|
||||
# Reactive reads + participant-count correct (Option B)
|
||||
|
||||
Brief d'implémentation, ancré dans le code courant. Objectif : deux évolutions couplées de la couche données Festipod (mode connected / `@ng-eventually/client`).
|
||||
|
||||
1. **Lectures réactives cross-session** — remplacer le one-shot `readUnion` + `bumpRead` (re-query manuel, local-only) par une réactivité réelle poussée par le broker, **sans jamais poller** et **sans le fan-out ORM qui hang**.
|
||||
2. **Compteur de participants correct (Option B)** — supprimer la violation d'isolation actuelle (l'inscrit écrit `participantCount` sur le doc de l'événement qui ne lui appartient pas) et la remplacer par le flux dépôt-inbox → matérialisation-propriétaire.
|
||||
|
||||
Ce brief décrit **quoi construire et dans quel ordre**. Aucune modification de code n'est faite ici.
|
||||
|
||||
Références transverses : [[knowledge_context-internals]], [[rule_document-per-entity]], [[caveat_participation-deletion]], `functional-domain/knowledge_data-scopes-and-discovery`, `app-security/knowledge_trust-model`, et le contrat SDK `@ng-eventually/client` (`docs/sdk-reference.md`, `docs/read-model.md`, `docs/nextgraph-current-state.md`).
|
||||
|
||||
---
|
||||
|
||||
## 0. État courant (le point de départ, fichier:fonction)
|
||||
|
||||
### Lecture (one-shot, re-query manuel)
|
||||
`src/shared/context/FestipodDataContext.tsx` → `useNgData()` :
|
||||
- Le jeu de docs à lire **par besoin** est deux `useState` : `publicDocs` / `protectedDocs` (l.232-233). Il est alimenté par (a) l'effet de listing (l.302-332) qui appelle `listMyEntityDocs(owner, 'public'|'protected')` (borné à mon compte) + `readDiscoveredEvents()` (l'index global), et (b) `registerDoc(scope, nuri)` (l.251-255) qui ajoute un doc fraîchement créé.
|
||||
- La **lecture réelle** (l.347-364) : `readEntities(allReadDocs)` → `readModel.readUnion(docs)` (un `sparql_query` ancré par doc, en parallèle, tolérant par-doc). Elle **re-tourne** quand `allReadDocs` change **ou** quand `readTick` change.
|
||||
- `readTick`/`bumpRead` (l.236-237) = **signal de re-query manuel**, bumpé après chaque mutation. **Il n'y a AUCUN signal venant du broker** : une écriture faite par une AUTRE session n'incrémente jamais `readTick` de cette session → **pas de réactivité cross-session**. C'est le trou que ce brief comble.
|
||||
- `listTick`/`relist` (l.246-247) rejoue l'effet de listing après un seed.
|
||||
|
||||
### Écriture du compteur (la violation à retirer)
|
||||
- `joinEvent` (l.597-668) : après avoir écrit sa propre `Participation` (doc protected, l.621-631), il fait `updateEntityField(eventId, eventId, 'participantCount', int(next))` sur **le doc de l'événement** (l.635-640) — or ce doc appartient au **propriétaire de l'événement**, pas à l'inscrit. C'est un write hors-scope. Il dépose *aussi* dans l'inbox via `depositRegistration` (l.652) — ce dépôt-là est le bon canal ; c'est l'écriture directe du `participantCount` qui est à supprimer.
|
||||
- `leaveEvent` (l.670-712) : symétriquement, décrémente `participantCount` sur le doc de l'événement (l.705-710) après le DELETE autoritatif de la participation.
|
||||
- `caveat_participation-deletion` : le DELETE de participation doit rester **autoritatif** (SPARQL DELETE-WHERE via `deleteParticipation`, `src/shared/data/registration.ts` l.260-334, vérifié `remaining === 0`) — ce brief ne change pas ce contrat.
|
||||
- [[knowledge_context-internals]] documente déjà que `participantCount` est un **cache muté en place**, jamais recalculé, et « pas une source de vérité ». Option B en fait une valeur **dérivée et possédée par le propriétaire**.
|
||||
|
||||
### Affichage (déjà « compte + anonyme », à conserver)
|
||||
`src/modules/event/screens/EventDetailScreen.tsx` :
|
||||
- `joined = isParticipating(eventId)` (l.20).
|
||||
- `participants = getEventParticipants(eventId)` (l.21) → dans le contexte, `getEventParticipants` (FestipodDataContext l.108-111) filtre les `participations` connues par `eventId` et joint les `users` **lisibles** (donc seulement mes connexions, cf. cap protected).
|
||||
- `knownParticipants = participants.filter(p => p.id !== currentUserId)` (l.33).
|
||||
- Le libellé **« Participants ({event.participantCount}) »** (l.146) affiche le **compte dérivé**, et `knownParticipants.length < event.participantCount` rend les **placeholders « voir tous les participants »** (l.163-170) — c'est exactement le modèle « compte + anonymes » voulu. **Cet affichage ne change pas** : Option B ne fait que rendre `participantCount` correct et réactif, et les `knownParticipants` restent gouvernés par le cap de lecture protected.
|
||||
|
||||
### Les watchers polling de la lib (à remplacer)
|
||||
Confirmé par lecture de la lib (`packages/client/src/`) :
|
||||
- `inbox.watch(target, onDeposits, {intervalMs=1000})` (`inbox.ts:195-223`) = **`setInterval` polling**, se déclenche uniquement sur changement de `deposits.length`.
|
||||
- `discovery.watchIndex(onEntries, {intervalMs=1000})` (`discovery.ts:163-187`) = **`setInterval` polling** identique.
|
||||
- `useShape` (`use-shape.ts:12`) EST poussé/réactif, mais **seulement sûr sur UN seul document déjà ouvert** — le fan-out `graphs:[…]` hang (§2).
|
||||
- **Aucun wrapper `doc_subscribe` n'est exposé aujourd'hui** dans `docs.ts` (qui n'expose que `docCreate` / `sparqlUpdate` / `sparqlQuery`). Le primitif `ng.doc_subscribe` est atteignable *untyped* via le proxy `ng` (`ng-proxy.ts:54-56` passthrough), mais il n'y a **pas de couche typée** → **la lib doit en ajouter une** (§A).
|
||||
|
||||
---
|
||||
|
||||
## 1. Les primitives plateforme (nextgraph-rs, vérifié)
|
||||
|
||||
- `doc_subscribe(repo_o: String, session_id, callback)` (`sdk/js/lib-wasm/src/lib.rs:1907`) est **par-document** : un seul NURI de repo, un callback. Il monte une souscription sur **une branche** du doc (`verifier.rs:352` `create_branch_subscription`), pousse d'abord un `TabInfo` + `State` initial (`verifier.rs:470-477`), puis un flux de `Patch` à chaque commit.
|
||||
- Le push : à chaque transaction vérifiée sur une branche B, le vérifieur appelle `push_app_response(&B, AppResponse::…)` (`verifier.rs:252`) sur le `Sender` enregistré dans `branch_subscriptions[B]` (`verifier.rs:115`). **Unité de souscription = une branche d'un doc.**
|
||||
- Le **fan-out ORM** vit ailleurs : `orm_start_graph(scope.graphs[], …)` (un seul appel sur un tableau). Là, un **seul** repo non-synchronisé dans le tableau fait que `open_for_target → resolve_target` retourne `RepoNotFound` (`request_processor.rs:147-171`, et surtout la boucle `initialize.rs:125-128` où le `?` **avorte toute la souscription**). Le `readyPromise` ne se résout jamais → **hang ~75s** (`nextgraph-current-state.md` § *The ORM fan-out hang*, cité dans `read-model.md:93-98` et l'en-tête de `read-model.ts:24-31`). **Corollaire : `doc_subscribe` par-doc n'a PAS ce défaut** — il ne subit pas de fan-out, donc un doc absent ne casse que sa propre souscription.
|
||||
- **Write membership-bound, pas d'append** (confirmé, `repo.rs:584` `verify_permission` : auteur non-membre → `PermissionDenied` ; `commit.rs` : une transaction exige `WriteAsync`/`WriteSync`, obtenus uniquement par grant du propriétaire ; **aucune variante `Append` dans `PermissionV0`**). ⇒ **Option A est impossible** : un inscrit ne peut pas écrire/incrémenter un compteur sur le doc public d'un autre. D'où Option B via l'inbox.
|
||||
- **Inbox = primitif plateforme réel** (`server_broker.rs:826` `inbox_post` : aucun contrôle de membership sur l'émetteur ; message scellé à la clé de l'inbox, lisible seulement par les *readers* enregistrés). C'est exactement le canal « n'importe qui dépose, seul le propriétaire dépile ». Aujourd'hui la lib l'émule sur le wallet partagé (`inbox.ts` post/read RDF), le natif étant différé.
|
||||
|
||||
---
|
||||
|
||||
## A. Lectures réactives — le design
|
||||
|
||||
### Principe : `doc_subscribe` par-doc comme **signal de changement**, `readUnion` reste le lecteur
|
||||
On **ne** rend **pas** `readUnion` réactif et on **n'introduit pas** de fan-out ORM. On garde le pattern documenté (`read-model.md:100-110`) :
|
||||
|
||||
> une souscription réactive légère (`doc_subscribe`, ou l'ORM sur un seul store déjà ouvert — jamais un fan-out par-entité) sur les docs synchronisés ; sur son signal de changement, re-jouer le jeu borné de `sparql_query` par-doc (`readUnion`).
|
||||
|
||||
Concrètement :
|
||||
|
||||
1. **La lib expose un wrapper typé `doc_subscribe`.** Il n'existe pas aujourd'hui. Ajouter dans `packages/client/src/docs.ts` (ou un nouveau `subscribe.ts`) une fonction, p.ex. :
|
||||
```ts
|
||||
// renvoie un unsubscribe ; onChange appelé au State initial puis à chaque Patch
|
||||
export function subscribeDoc(nuri: Nuri, onChange: (r: AppResponse) => void): () => void
|
||||
```
|
||||
qui wrappe `ng.doc_subscribe(nuri, sessionId, cb)` et normalise l'AppResponse (initial + patches) + la fermeture du flux. C'est **par-document** (un NURI), donc immunisé au hang du fan-out.
|
||||
- Exposer aussi un helper pour souscrire **un ensemble** de docs en montant **une souscription par doc** (map `nuri → unsubscribe`), avec **isolation par-doc des erreurs** : un `RepoNotFound` / doc non-synchronisé ne fait échouer QUE sa propre souscription (retry/skip), jamais les autres. C'est le point-clé qui évite de reproduire le fan-out. Le contrat SDK (`sdk-reference.md`) devra documenter ce wrapper.
|
||||
|
||||
2. **Le contexte data (FestipodDataContext) monte une souscription par-doc sur le jeu qu'il lit déjà.** Le jeu `allReadDocs` (union `publicDocs` ∪ `protectedDocs`) est déjà borné et par-besoin. Nouvel effet dans `useNgData()` :
|
||||
```
|
||||
useEffect(() => {
|
||||
const unsubs = allReadDocs.map(nuri => subscribeDoc(nuri, () => bumpRead()));
|
||||
return () => unsubs.forEach(u => u());
|
||||
}, [allReadDocs]);
|
||||
```
|
||||
→ sur **tout** patch d'un des docs abonnés (écrit par CETTE session OU une autre), `bumpRead()` re-déclenche le `readUnion` existant (l.347-364). **`readTick`/`bumpRead` restent** — ils cessent d'être « manuel après ma mutation » pour devenir « poussé par le broker ». La forme du contexte (valeurs `events`/`users`/`participations` en `useState`) **ne change pas** ; les écrans continuent de lire via `useFestipodData()` sans modification.
|
||||
|
||||
3. **Entrée de NOUVEAUX docs dans le jeu abonné, sans fan-out hang :**
|
||||
- **Nouvel événement découvert** : la découverte réactive remplace `discovery.watchIndex` (setInterval) par une **souscription `doc_subscribe` sur le doc d'index global** (l'inbox d'index, un seul doc — `resolveInboxAnchor`-style). À chaque patch de l'index → re-lire `readDiscoveredEvents()` → les nouveaux `doc` NURIs entrent dans `publicDocs` (via `setPublicDocs`), ce qui **agrandit `allReadDocs`**, ce qui **remonte la souscription par-doc** (nouveau `useEffect` ci-dessus) → le nouvel événement est lu ET désormais abonné. Pas de fan-out : chaque doc est abonné **individuellement**, quand il entre.
|
||||
- **Nouveau dépôt d'inbox** (nouveau participant, notification hôte) : idem, remplacer `inbox.watch` (setInterval) par une **souscription `doc_subscribe` sur le doc-inbox** concerné (un seul doc). Un patch → re-matérialiser (§B).
|
||||
- **Doc que je viens de créer** : `registerDoc` continue de l'ajouter à `publicDocs`/`protectedDocs` → il entre dans `allReadDocs` → il est abonné. (`bumpRead` immédiat garde la latence perçue nulle localement.)
|
||||
|
||||
4. **La lib remplace ses watchers polling** : `inbox.watch` et `discovery.watchIndex` deviennent des wrappers `doc_subscribe` sur le doc-inbox / doc-index respectif (un doc chacun — pas de fan-out). Signature publique conservée (callback + unsubscribe) pour ne pas casser les appelants ; l'implémentation passe de `setInterval(read)` à `subscribeDoc(anchor, () => read().then(onX))`.
|
||||
|
||||
### Ce qui NE change pas
|
||||
- `readUnion` reste one-shot, par-doc, tolérant (un doc en échec → `[]`, jamais d'abort).
|
||||
- Le mapping `readEntities` (`src/shared/data/readEntities.ts`) est inchangé.
|
||||
- **Aucun `useShape({graphs:[…]})` par-entité n'est introduit** — le seul `useShape` restant est le `FanoutProbe` du harness de test (qui sert justement à *démontrer* le hang), pas un chemin applicatif.
|
||||
|
||||
---
|
||||
|
||||
## B. Compteur de participants — Option B (dépôt → matérialisation propriétaire)
|
||||
|
||||
### Les documents / inboxes impliqués
|
||||
- **Doc de participation de l'inscrit** : protected, **possédé par l'inscrit** (déjà créé par `joinEvent`, `createEntityDoc(owner,'protected')` + `writeEntity(ENTITY_TYPE.participation, …)`). Lisible en clair par les **connexions** de l'inscrit uniquement (cap protected + `declareConnections`).
|
||||
- **Inbox de l'événement** : résolue par `hostInboxNuri(eventId)` → `resolveInboxAnchor()` (aujourd'hui une anchor unique ; à migration, un doc-inbox par événement — `hostInboxNuri` réserve déjà le param `eventId`). C'est là que l'inscrit **dépose le lien de participation**.
|
||||
- **Doc de l'événement** : public, **possédé par le propriétaire**. C'est **le propriétaire** qui y écrit `participantCount` — jamais l'inscrit.
|
||||
- **(référence) enregistrée par le propriétaire** : une entrée reliant le compte incrémenté au dépôt (idempotence + audit) ; peut vivre dans le doc de l'événement (référence de dépôt déjà matérialisé) ou un doc protected du propriétaire.
|
||||
|
||||
### Le flux (qui écrit quoi)
|
||||
1. **Inscrit — `joinEvent`** (modifié) :
|
||||
- Écrit sa propre `Participation` (protected, à lui) — **inchangé**.
|
||||
- **Dépose dans l'inbox de l'événement** un payload `{ kind:'new-participant', eventId, participationDoc, participantId, uid }` via `depositRegistration` (aujourd'hui `inbox.post(target, {from:null, payload})`, `registration.ts:110-125`). `from` reste anonyme au transport (le SDK lie `from` à l'identité et rejette un spoof — cf. `registration.ts:106-108`) ; l'identité domaine voyage dans le payload. **Le dépôt porte le NURI du doc de participation** (`participationDoc`) pour que le propriétaire, s'il est une connexion, puisse le lire en clair.
|
||||
- **SUPPRIME l'écriture de `participantCount` sur le doc de l'événement** (l.635-640 actuelles). L'inscrit n'écrit plus jamais sur le doc d'un autre.
|
||||
2. **Propriétaire — matérialisation (quand connecté)** : la session du propriétaire est abonnée (`doc_subscribe`, §A.3) au doc-inbox de son événement. Sur un nouveau dépôt `new-participant` :
|
||||
- dédup via `uid` (idempotence : ne pas re-compter un dépôt déjà matérialisé — vérifier la (référence) enregistrée) ;
|
||||
- **incrémente `participantCount` sur SON PROPRE doc d'événement** (`updateEntityField(eventDoc, eventDoc, 'participantCount', int(next))`) — **c'est le propriétaire qui écrit son propre doc**, pas un privilège de lecture ni un write hors-scope ;
|
||||
- enregistre la **(référence)** du dépôt matérialisé (marqueur d'idempotence).
|
||||
- Cette logique remplace/prolonge l'effet de **matérialisation des notifications** existant (FestipodDataContext l.443-479, `readRegistrationNotifications`) : aujourd'hui il ne fait que surfacer des notifications ; il devient aussi le point où le compteur est incrémenté. Le déclencheur passe du polling implicite à la souscription `doc_subscribe` sur l'inbox.
|
||||
3. **Autres sessions voient le compte changer** : le doc de l'événement est **public**, donc **toute** session qui l'a dans son `allReadDocs` y est abonnée (§A). L'écriture du propriétaire produit un patch → `bumpRead()` → `readUnion` re-lit → `event.participantCount` mis à jour → `EventDetailScreen` re-rend « Participants (N) » **sans reload ni action**. C'est le chemin réactif complet, cross-session.
|
||||
|
||||
### Désinscription (symétrique, autoritative)
|
||||
- `leaveEvent` : garde le **DELETE autoritatif** de la participation (`deleteParticipation`, vérifié `remaining === 0`) — [[caveat_participation-deletion]] intact (ne doit pas ressusciter).
|
||||
- **Retire la décrémentation directe** de `participantCount` par l'inscrit (l.705-710). À la place, l'inscrit **dépose un `leave`** (`{ kind:'leave-participant', eventId, uid }`) dans l'inbox de l'événement ; le propriétaire matérialise → **décrémente son propre doc** (idempotent via `uid`, `max(0, n-1)`, et refuse de re-décrémenter un `uid` déjà traité pour ne pas « ressusciter » un compte faux).
|
||||
- **Cas propriétaire hors-ligne = comportement éventuel ACCEPTÉ** : si le propriétaire n'est pas connecté, le dépôt reste dans l'inbox ; le compte n'est **pas** mis à jour tant qu'il ne se reconnecte pas et ne matérialise pas. **C'est un comportement accepté** (cohérence à terme, local-first). Les autres voient le compte se corriger quand le propriétaire revient. À énoncer tel quel dans le contrat produit.
|
||||
|
||||
### Identité (C)
|
||||
- Un participant est montré **par son nom** uniquement si le viewer est une **connexion** du participant : le doc de participation + le profil du participant sont protected, donc lisibles en clair seulement via le cap accordé par `declareConnections` (`src/shared/utils/connections.ts` → `grantRead(protectedDocsOf(owner), neighbour)`). Sinon le doc reste illisible → le participant n'apparaît **pas** dans `getEventParticipants` (qui joint sur les `users`/`participations` lus) → il tombe dans les **placeholders « inconnu »** de `EventDetailScreen` (l.163-170), le compte dérivé restant visible via `participantCount`.
|
||||
- **Aucune lecture privilégiée de l'hôte** : le propriétaire ne lit pas les participations ; il ne fait que **compter des dépôts** et écrire son propre compteur. Il ne voit un participant nommé que s'il en est une connexion — exactement comme n'importe quel viewer. C'est conforme à `functional-domain/knowledge_data-scopes-and-discovery` (« identifié si connu, anonyme sinon ») et à `app-security/knowledge_trust-model` (pas de contrôle d'accès applicatif, l'isolation est par-document déléguée au SDK).
|
||||
|
||||
---
|
||||
|
||||
## D. Plan de test (e2e réel, sans polling)
|
||||
|
||||
### D.1 — POLYFILL bas-niveau : `doc_subscribe` réagit vraiment
|
||||
But : prouver que la primitive réactive fonctionne, indépendamment de Festipod.
|
||||
- Emplacement : test unité/intégration de la lib (`packages/client`) — ou un `@data` Festipod si le harness broker est requis.
|
||||
- Setup : deux « vues » du **même** doc (deux souscriptions, ou une souscription + une écriture par un autre chemin). Monter `subscribeDoc(nuri, onChange)`, écrire dans le doc via `sparqlUpdate`.
|
||||
- **Assertion** : `onChange` est appelé (State initial) **puis** re-appelé après l'écriture, **sans polling** (aucun `setInterval` ; l'assertion attend un event, pas un timeout). Vérifier qu'une écriture sur un **autre** doc ne déclenche PAS `onChange` (isolation par-branche). Vérifier qu'un doc non-synchronisé qui échoue **n'avorte pas** les autres souscriptions (par-doc).
|
||||
|
||||
### D.2 — FESTIPOD app-level : 2 navigateurs réels, sans reload ni action de A
|
||||
But : B s'inscrit → l'`EventDetailScreen` de A montre `participantCount` incrémenté **et** un « participant inconnu », **sans que A recharge ni n'agisse**.
|
||||
- Étendre `src/modules/event/features/e2e-multibrowser.feature` (`@multibrowser @shared-wallet`) et `src/modules/event/steps/e2e/multibrowser-features.steps.ts`.
|
||||
- Nouveau scénario (esquisse Gherkin FR) :
|
||||
```
|
||||
Scénario: Un participant apparaît réactivement dans l'autre navigateur sans reload
|
||||
Étant donné un navigateur "A" avec le wallet partagé
|
||||
Et un navigateur "B" avec le wallet partagé
|
||||
Et le navigateur "A" charge l'application via le broker
|
||||
Et le navigateur "B" charge l'application via le broker
|
||||
Et le navigateur "A" est connecté à NextGraph
|
||||
Et le navigateur "B" est connecté à NextGraph
|
||||
Et le navigateur "A" crée l'événement "Apéro réactif"
|
||||
Et le navigateur "A" ouvre le détail de l'événement "Apéro réactif"
|
||||
Et le compteur de participants affiché dans "A" pour "Apéro réactif" vaut 1
|
||||
Quand le navigateur "B" s'inscrit à l'événement "Apéro réactif"
|
||||
Alors sans recharger, le compteur de participants affiché dans "A" pour "Apéro réactif" passe à 2
|
||||
Et le navigateur "A" affiche un participant "inconnu" pour "Apéro réactif"
|
||||
```
|
||||
- **Assertions exactes** :
|
||||
1. `participantCount` **côté A** passe de 1 à 2 — assert via `frame.waitForFunction` sur l'état réactif du contexte (`__testData.events` → l'event → `participantCount === 2`) **puis** confirmé sur le DOM rendu (le libellé « Participants (2) » de `EventDetailScreen`), **sans appel de `loadAppInBrowser`/reload** entre le join de B et l'assertion de A.
|
||||
2. **Placeholder inconnu** : `knownParticipants.length < participantCount` → assert présence du bloc « Voir tous les participants » (ou un compteur d'anonymes = `participantCount − knownParticipants.length ≥ 1`), le participant B n'étant PAS une connexion de A → non nommé.
|
||||
3. **Négatif no-polling** : le passage 1→2 arrive via souscription (event-driven) ; le test attend l'event, il ne doit pas dépendre d'un `waitForTimeout` fixe comme *source* de la mise à jour (un timeout de garde reste toléré pour laisser la sync broker, comme dans le scénario désinscription existant l.131).
|
||||
- **Helpers harness nécessaires** (dans `harness-ng.tsx`, exposés sur `window.__testData`, et répliqués dans les DEUX harness — cf. `bdd-testing/cookbook_add-scenario`) :
|
||||
- un getter du `participantCount` réactif pour un event (déjà accessible via `__testData.events`).
|
||||
- un accès au **rendu** `EventDetailScreen` de A **sans navigation manuelle** : soit monter l'app réelle sur la route détail (chemin @e2e), soit exposer `knownParticipants` / le compte d'anonymes. Réutiliser `createEventReal` (l.232), `appJoinEvent` (l.245), `readInboxDeposits` (l.283), `authParticipationCount` (l.302).
|
||||
- un hook « le propriétaire a matérialisé » : comme A est le propriétaire ET connecté, sa souscription inbox doit incrémenter son propre doc — le test observe le résultat (count 2) sans piloter la matérialisation à la main.
|
||||
- **Symétrie désinscription** : étendre le scénario existant « la désinscription ne ressuscite pas » (l.36-48) d'une assertion réactive : après le leave de B, `participantCount` côté A **repasse à 1 sans reload**, et `authParticipationCount === 0` (déjà couvert).
|
||||
|
||||
---
|
||||
|
||||
## E. Risques / questions ouvertes
|
||||
|
||||
1. **Le hang du fan-out** (le risque n°1). Le design l'évite **par construction** : souscription **par-document** (`doc_subscribe`), jamais `orm_start_graph(graphs:[…])`. À garder comme invariant : tout nouveau doc entre via une souscription **individuelle** avec isolation d'erreur par-doc — un doc non-synchronisé ne doit jamais pouvoir avorter les autres souscriptions ni bloquer le `readUnion` (qui reste tolérant par-doc). Risque résiduel : le **volume** de souscriptions par-doc (une par doc lu) — à valider sur le broker réel ; sinon, plafonner/prioriser les docs abonnés (event courant + son inbox + mes docs) plutôt que l'union entière.
|
||||
|
||||
2. **Compte propriétaire hors-ligne = éventuel — DÉCIDÉ (2026-07-06).** Tant que le propriétaire n'est pas connecté, aucun dépôt n'est matérialisé → `participantCount` reste périmé pour les autres (la participation elle-même est persistée côté broker — rien n'est perdu, seul l'agrégat attend la reconnexion de l'hôte). Accepté pour la V1. **Plus tard, un SERVICE prendra le relai** quand le propriétaire est déconnecté (le paquet différé `@ng-eventually/service` — le « curateur » évoqué dans les docs inbox de la lib) : un acteur toujours disponible matérialisera l'inbox à la place de l'hôte. Pas de fallback « N+ en attente » en V1.
|
||||
|
||||
3. **`doc_subscribe` par-doc — FAIT (lib `c0498a6`).** La lib expose désormais `subscribeDoc`/`subscribeDocs` (isolation d'erreur par-doc, pas de fan-out ORM), `inbox.watch`/`discovery.watchIndex` sont passés en `doc_subscribe` (plus de polling), et le contrat est dans `sdk-reference.md`. Validé broker réel (le callback traverse le RPC iframe et fire sur changement). Reste : brancher la souscription dans le chemin de lecture app (P3).
|
||||
|
||||
> **Hooks réactifs du SDK** (précision) : l'adaptateur React de NextGraph expose `useShape` (shapes RDF réactives) et `useDiscrete` (docs CRDT discrets) — pas de `useQuery`. La lib ré-expose `useShape`. Pour la lecture UNION de N docs (le cas de Festipod), `useShape`/l'ORM en fan-out *hangue* ; le chemin réactif app passe donc par `subscribeDocs` (par-doc) + re-`readUnion`, éventuellement enveloppé en un hook de lecture réactive côté lib (à décider en P3).
|
||||
|
||||
Autres points à trancher :
|
||||
|
||||
> ⚠️ **RECADRÉ + CORRIGÉ (2026-07-13).** L'affirmation ci-dessous « Prouvé par l'e2e D.2 … sans reload » était **FAUSSE** (le « vert » venait d'un wallet bloaté). Mais surtout le **cadrage « réactif / sans reload / push cross-session » était un SUR-CADRAGE** : la spec réelle est **« le propriétaire traite son inbox de façon fiable à sa PROCHAINE CONNEXION »** (pas de notification live temps-réel entre deux utilisateurs connectés). Bug corrigé sous ce cadrage : le materializer lisait l'inbox **avant sa sync** (→ 0 mémoïsé). Fix = lecture inbox **gated sur barrière** (`inbox.readSynced` = `ensureRepoOpen` + `read`) + déclenchement à la connexion + source unique `event.participantCount`. Scénario `event/e2e-multibrowser.feature` **reframé « à la prochaine connexion » et dé-`@wip`, VERT sur profil frais** (une reconnexion/re-matérialisation de A est le mécanisme accepté). Détail : [[knowledge_context-internals]] §participantCount. Le plan de phasage ci-dessous doit être relu à cette lumière (le « sans reload » n'est plus l'exigence).
|
||||
|
||||
- **Ordre de phasage :** ~~(P1) lib : `subscribeDoc` + variante multi-doc + tests D.1~~ **FAIT (`c0498a6`)** ; ~~(P2) lib : remplacer `inbox.watch`/`discovery.watchIndex` par `doc_subscribe`~~ **FAIT (`c0498a6`)** ; ~~(P3) app : brancher la souscription par-doc dans `useNgData` (bumpRead poussé) + découverte réactive~~ **FAIT (branche `ng-eventually`, non commité)** — `useNgData` monte un effet `subscribeDocs(allReadDocs, …)` clé sur un join trié des NURIs (`readDocKey`, anti-boucle : un patch → `bumpRead` → re-`readUnion` ne change pas le set → pas de re-souscription ; le reset d'identité `prevOwnerRef` vide le set → `readDocKey=''` → cleanup unsubscribe, puis re-listing → re-souscription sur le set reconstruit) + un effet de découverte réactive `watchDiscoveredEvents()` (wrapper app sur `discovery.watchIndex`, déjà `doc_subscribe`) → `relist()`. `readUnion` reste le lecteur one-shot tolérant. **Prouvé par l'e2e D.2** (`e2e-multibrowser.feature`, scénario « Un participant apparaît réactivement… », @multibrowser @shared-wallet, 12 steps verts en isolation) : B s'inscrit → A voit `participantCount === 2` + un participant « inconnu » **sans reload ni action**, via `doc_subscribe` sur le doc public de l'événement (le join en P3 écrit encore ce compteur, cf. §B.5 — c'est ce qui valide P3 avant P4). ; (P4) app : Option B join (retirer le write compteur de l'inscrit, matérialisation propriétaire) ; (P5) app : Option B leave symétrique ; ~~(P6) e2e D.2~~ **FAIT avec P3** (le scénario réactif ci-dessus ; la symétrie désinscription réactive reste à ajouter avec P5). P1→P3 livrent la réactivité ; P4→P6 le compteur correct. On peut livrer P1–P3 avant P4–P6.
|
||||
- **Idempotence de la matérialisation** : le `uid` par-dépôt (`RegistrationPayload.uid`, `registration.ts:56`) est le pivot ; la (référence) enregistrée par le propriétaire doit être consultée avant tout incrément/décrément pour ne jamais double-compter (rejeu de sync) ni « ressusciter » un compte.
|
||||
- **Migration inbox natif** : aujourd'hui l'inbox est émulée sur le wallet partagé (`inbox.ts` post/read RDF). À la migration vers l'inbox broker natif (`inbox_post`/`inbox_pop_for_user`, scellé), le flux Option B **reste valide** (dépôt non-membre autorisé, lecture réservée aux *readers* = propriétaire), mais le wrapper `subscribeDoc` sur l'inbox devra viser le mécanisme natif de notification de dépôt. À vérifier au moment de la migration.
|
||||
@@ -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`.
|
||||
@@ -10,8 +10,8 @@ Le type app `FpEventData` (`src/shared/data/types.ts`) et le seed (`seedData.ts`
|
||||
|
||||
## Conséquence
|
||||
|
||||
En **mode connected** (NextGraph), le mapping (`mapEvent` dans `FestipodDataContext.tsx`) ne lit/écrit que les champs de la shape. Les champs hors-shape sont **silencieusement perdus** : remplis par des defaults ou vides. Or des écrans **les affichent** (ex. `startTime`/`endTime` dans `EventDetailScreen`) — donc en mode démo (seed local) ils apparaissent, mais en connecté ils disparaissent. Décalage observable seulement à l'usage.
|
||||
En **mode connected** (SDK), le mapping (`mapEvent` dans `FestipodDataContext.tsx`) ne lit/écrit que les champs de la shape. Les champs hors-shape sont **silencieusement perdus** : remplis par des defaults ou vides. Or des écrans **les affichent** (ex. `startTime`/`endTime` dans `EventDetailScreen`) — donc en mode démo (seed local) ils apparaissent, mais en connecté ils disparaissent. Décalage observable seulement à l'usage.
|
||||
|
||||
## Pour corriger (si on veut les persister)
|
||||
|
||||
Ajouter les champs à `festipodShapes.shex` puis `bun run build:orm`, et étendre `mapEvent`. C'est aussi un prérequis de la modélisation complète du point de rencontre (cf. concept `nextgraph-platform`, [[brief_2026-05-21_fork-nextgraph-inbox]] §Couche 3). Tant que ce n'est pas fait, **ne pas se fier aux champs date/heure/thèmes en mode connecté**.
|
||||
Ajouter les champs à `festipodShapes.shex` puis `bun run build:orm`, et étendre `mapEvent`. Tant que ce n'est pas fait, **ne pas se fier aux champs date/heure/thèmes en mode connecté**.
|
||||
|
||||
@@ -1,23 +1,15 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: La suppression de Participation (leaveEvent) se fait via ngSet.delete() — le bug de non-persistance qui avait motivé SPARQL DELETE est en grande partie corrigé, mais la persistance peut rester partielle ; vérifier après refresh
|
||||
last_checked: 2026-06-15
|
||||
summary: La désinscription à un point de rencontre doit être AUTORITATIVE — une fois la Participation supprimée, elle ne doit plus réapparaître ; vérifier après un vrai rafraîchissement que l'inscription a bien disparu côté données
|
||||
last_checked: 2026-07-03
|
||||
---
|
||||
|
||||
# Caveat : suppression de Participation via `ngSet.delete()`
|
||||
# Caveat : la désinscription doit être autoritative
|
||||
|
||||
**État actuel du code** (`src/shared/context/FestipodDataContext.tsx`, `leaveEvent` en mode NG) : la suppression d'une `Participation` se fait via **`participationsShape.ngSet.delete(ngPart)`** — pas via `ng.sparql_update()` DELETE WHERE.
|
||||
Contrat métier : quand un utilisateur **se désinscrit** d'un point de rencontre (`leaveEvent` dans `src/shared/context/FestipodDataContext.tsx`), la `Participation` doit être **supprimée durablement**. Elle ne doit **pas ressusciter** après une resynchronisation.
|
||||
|
||||
## Histoire (important)
|
||||
## Le piège
|
||||
|
||||
Une décision antérieure ([[decision_2026-03-17_sparql-delete-for-orm-objects]], **annulée le 2026-06-15**) imposait SPARQL DELETE car `ngSet.delete()` ne persistait pas (l'objet réapparaissait au refresh). Ce **bug du `@ng-org/orm` a depuis été en grande partie corrigé** : `ngSet.delete()` est redevenu le chemin utilisé.
|
||||
Refléter la suppression uniquement dans l'état réactif de l'UI ne suffit pas : l'inscription peut réapparaître si la suppression n'est pas **persistée** côté données. La désinscription doit donc être **autoritative** au niveau du document, pas seulement au niveau de l'affichage.
|
||||
|
||||
## Le piège (pourquoi un caveat et pas une règle)
|
||||
|
||||
La correction **semble partielle** : selon les cas, la suppression via `ngSet.delete()` peut ne **pas se propager complètement** au broker. Donc :
|
||||
|
||||
- **Ne pas tenir pour acquis** que `leaveEvent` persiste à coup sûr — **vérifier après un vrai refresh** que la participation a bien disparu côté wallet.
|
||||
- Si une suppression se révèle non persistée, le repli connu reste `ng.sparql_update()` avec `DELETE WHERE { GRAPH <…> { <…> ?p ?o } }` (le mécanisme décrit dans la décision annulée). **Ne pas combiner** les deux (conflit CRDT — c'était l'autre enseignement de la décision).
|
||||
- Re-tester ce point à chaque montée de version de `@ng-org/orm`.
|
||||
|
||||
> À valider : ouvrir `FestipodDataContext.tsx` → `leaveEvent` (mode NG, `console.log('Deleting participation via ngSet.delete()')`). Si le code est repassé à `sparql_update`, mettre ce caveat à jour ou le promouvoir en règle.
|
||||
**À vérifier après toute évolution de `leaveEvent`** : s'inscrire puis se désinscrire, faire un **vrai rafraîchissement**, et confirmer que la participation a bien disparu (le bouton ne doit pas rester « ✓ Je participe »). Couvert par le scénario `@e2e` « Se désinscrire d'un événement » (`src/modules/event/features/cycle-de-vie-evenement.feature`) et un `@data` « désinscription persistante » (`inscription-inbox.feature`).
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: Une entité écrite juste avant une inactivité/chute de socket peut être perdue silencieusement (jamais durable côté broker) ; le compte survit (pas de fork). Observé Firefox. Le SDK ne confirme pas la durabilité et ne se reconnecte pas seul.
|
||||
last_checked: 2026-07-14
|
||||
---
|
||||
|
||||
# Piège : une écriture juste avant une déconnexion n'est pas garantie durable
|
||||
|
||||
**Symptôme produit.** L'utilisateur crée une entité (un événement), ça semble réussir, puis une **période d'inactivité** survient ; au rechargement / à la reconnexion, l'entité a **disparu**. Le scope se relit **vide**. L'**identité/compte survit** — ce n'est PAS un fork, c'est une écriture non durable.
|
||||
|
||||
**Mécanisme (résumé, non tranché).** Le socket broker peut mourir spontanément pendant l'idle (`SOCKET IS CLOSED … SerializationError`). L'écriture était dans l'outbox local ; au retour, le replay échoue (`Err(TopicNotFound)`) et l'entité est abandonnée. **Observé Firefox uniquement** à ce jour. Un test @data à froid (2026-07-14) a par ailleurs montré qu'une session **fraîche** (aucun état local, même compte A) ne récupère **pas** le scope propre de A depuis le broker : le test de reconnexion @data qui « passait » relisait en fait l'IndexedDB **locale**. Reste à trancher : **perte à l'écriture** vs **échec de réhydratation à froid** (mécanismes distincts) — voir le post-mortem dans le polyfill.
|
||||
|
||||
**Pourquoi l'app ne le voit pas.** `NgStatus` est dérivé **une seule fois** de la session initiale → aveugle aux chutes en cours de session. Le canal `disconnections_subscribe` du SDK se déclenche sur la panne mais **n'est pas consommé** (ni polyfill ni app). Aucune API ne confirme qu'une écriture a atteint le broker.
|
||||
|
||||
**Ne pas documenter ici les internes NextGraph.** Frontière SDK (voir [[knowledge_nextgraph-stack]]) : cause racine, chaîne causale (socket, reconnexion en TODO) et pistes de correction vivent dans le repo `@ng-eventually/client` → `docs/incidents/2026-07-14-write-loss-on-disconnect.md`. Cette fiche ne garde que l'**impact consommateur** + le pointeur.
|
||||
|
||||
**Statut : ouvert, non traité (2026-07-14).** À revisiter quand le core/SDK adresse la reconnexion ou expose une confirmation de durabilité — ce caveat tombera alors. Voir aussi le débat lecture-à-froid vs perte réelle dans [[brief_2026-07-06_reactive-reads-and-attendance]] (le `BARRIER timed-out` de @data est une signature distincte, non confirmée comme ce bug).
|
||||
-35
@@ -1,35 +0,0 @@
|
||||
---
|
||||
type: decision
|
||||
summary: Décision 2026-03-13 — auto-init NextGraph seulement quand dans l'iframe broker (window.self !== window.top), sinon initNgWeb() redirige la page et casse le dev/démo standalone
|
||||
---
|
||||
|
||||
# Conditional NextGraph Init Based on Broker Iframe Detection
|
||||
|
||||
**Date:** 2026-03-13 14:00
|
||||
**Status:** Accepted
|
||||
|
||||
## Context
|
||||
|
||||
`initNgWeb()` de `@ng-org/web` teste `window.self === window.top`. En standalone (hors iframe), il redirige toute la page vers `nextgraph.net/redir/` pour déclencher l'auth broker. Résultat : l'app redirigeait à chaque chargement — même en dev ou quand l'utilisateur n'avait pas cliqué « Se connecter ».
|
||||
|
||||
## Options Considered
|
||||
|
||||
### Option A: toujours auto-init NG au mount
|
||||
- Plus simple (pas de branchement).
|
||||
- **Contre** : redirect immédiat vers le broker en standalone ; casse le workflow de dev ; l'utilisateur voit la page de login broker au lieu de l'app.
|
||||
|
||||
### Option B: auto-init conditionnel selon détection iframe
|
||||
- En iframe, le broker a déjà authentifié → auto-init sûr ; en standalone, l'utilisateur doit cliquer « Se connecter » ; préserve l'expérience démo/dev ; calque la propre logique de détection de `@ng-org/web`.
|
||||
- **Contre** : repose sur l'heuristique `window.self !== window.top` (théoriquement faillible si embarqué dans une iframe non-broker).
|
||||
|
||||
## Decision
|
||||
|
||||
**Option B.** `NextGraphContext` calcule `isInsideBroker = typeof window !== 'undefined' && window.self !== window.top` au niveau module. `useEffect` n'auto-appelle `initNg()` que si `isInsideBroker`. Le callback `connect()` reste disponible pour la connexion explicite. De plus, `FestipodDataContext` rend des données vides (pas le seed) pendant `connecting` pour éviter de flasher le contenu démo.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positif :** l'app charge sans rediriger (standalone dev/démo) ; en iframe broker, connexion fluide et automatique ; pas de flash de seed pendant la connexion.
|
||||
**Négatif :** aucun significatif.
|
||||
**Risque :** si `@ng-org/web` change sa logique de détection, notre garde peut diverger — les garder alignés.
|
||||
|
||||
> Règle dérivée : [[rule_conditional-ng-init]].
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
type: decision
|
||||
summary: Décision 2026-03-17 — utiliser private_store_id comme scope useShape ET @graph (calqué sur expense-tracker-rdf) pour que orm_start_graph ouvre le repo et que les écritures ne lèvent plus RepoNotFound
|
||||
---
|
||||
|
||||
# Use private_store_id as useShape scope and @graph
|
||||
|
||||
**Date:** 2026-03-17 16:00
|
||||
**Status:** Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Cliquer « Charger données de test » chargeait les données en mémoire (signaux ORM) mais produisait des `RepoNotFound` sur `doc_create` et `orm_frontend_update`. Les données disparaissaient au reload car les écritures SPARQL n'atteignaient jamais le broker. La HashMap `self.repos` du verifier ne contenait pas le repo du private store → `resolve_target()` échouait.
|
||||
|
||||
## Options Considered
|
||||
|
||||
### Option A: `did:ng:i` scope + `doc_create` pour @graph
|
||||
- `did:ng:i` bien documenté comme scope d'abonnement, `doc_create` renvoie un vrai NURI.
|
||||
- **Contre** : `did:ng:i` passe par `NuriTargetV0::UserSite` qui n'ouvre pas les repos individuels ; `doc_create` appelle `resolve_target(PrivateStore)` qui exige le repo dans `self.repos` → échoue ; exige une logique de retry/timing complexe.
|
||||
|
||||
### Option B: `private_store_id` comme scope ET @graph
|
||||
- Calque exact de l'exemple `expense-tracker-rdf` qui fonctionne ; `orm_start_graph` avec le NURI du private store ouvre le repo dans `self.repos` ; les écritures `orm_frontend_update` trouvent ensuite le repo. Simple, sans retry.
|
||||
- **Contre** : un peu moins flexible que `did:ng:i` (scopé à un store) ; exige de passer la session à `useShapeWithDefaults`.
|
||||
|
||||
### Option C: `did:ng:i` scope + réutiliser le @graph d'une entité existante
|
||||
- Marche pour les users qui ont déjà des données.
|
||||
- **Contre** : échoue pour les wallets vides (aucune entité à réutiliser) ; retombe sur `doc_create` et le même `RepoNotFound`.
|
||||
|
||||
## Decision
|
||||
|
||||
**Option B** : `did:ng:${session.private_store_id}` comme scope `useShape` ET `@graph` d'écriture, exactement comme `expense-tracker-rdf`. `useShapeWithDefaults` accepte un `storeNuri` ; `FestipodDataContext.useNgData()` récupère la session via `useNextGraph()` et passe le NURI du private store. `ensureGraphNuri()` simplifié : entités existantes d'abord (optimisation), sinon fallback `private_store`.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positif :** écritures immédiates après connexion (sans retry) ; persistance au reload ; aligné sur les exemples officiels ; les 7 scénarios e2e passent (dont la persistance).
|
||||
**Négatif :** signature de `useShapeWithDefaults` modifiée (param `storeNuri`).
|
||||
**Risque :** si NextGraph change le comportement du private store, ça casse.
|
||||
|
||||
> Règle dérivée : [[rule_private-store-scope]]. Décision *remise en cause* par le futur multi-store : [[brief_2026-05-17_multi-store-refactor]].
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
type: decision
|
||||
summary: Décision 2026-03-17 — supprimer les objets ORM via ng.sparql_update (DELETE WHERE) seul, car ngSet.delete() ne persiste pas et les combiner crée un conflit CRDT
|
||||
---
|
||||
|
||||
# Use SPARQL DELETE instead of ORM ngSet.delete() for object removal
|
||||
|
||||
**Date:** 2026-03-17 18:00
|
||||
**Status:** ~~Accepted~~ → **Superseded (2026-06-15)**
|
||||
|
||||
> **Annulée le 2026-06-15.** Le bug de non-persistance de `ngSet.delete()` qui motivait cette décision a depuis été en grande partie corrigé côté `@ng-org/orm` : le code (`leaveEvent`) est repassé à `ngSet.delete()`. La persistance reste toutefois possiblement partielle — l'état courant et le repli SPARQL sont décrits dans [[caveat_participation-deletion]]. Décision conservée comme mémoire d'arbitrage (le conflit CRDT « ne pas combiner les deux » reste vrai).
|
||||
|
||||
## Context
|
||||
|
||||
Quitter un event exige de supprimer l'objet `Participation` du store NextGraph. `DeepSignalSet.delete()` met à jour l'état réactif local (UI immédiate) mais **ne persiste pas** au broker — après refresh, la participation réapparaît.
|
||||
|
||||
## Options Considered
|
||||
|
||||
### Option A: ORM `ngSet.delete(item)`
|
||||
- API officielle (README ORM), update réactif local instantané.
|
||||
- **Contre** : ne persiste pas en pratique (`delete()` renvoie `true`, set local à jour, mais objet de retour après refresh) ; `graph_orm_update` semble mal gérer les patches "remove" pour objets de set top-level (bug moteur probable) ; échoue silencieusement.
|
||||
|
||||
### Option B: `ng.sparql_update()` avec SPARQL DELETE
|
||||
- `DELETE WHERE { GRAPH <graph> { <subject> ?p ?o } }` retire tous les triples RDF.
|
||||
- **Pour** : persiste (survit au refresh) ; le broker confirme via `GraphOrmUpdate` remove qui retire réactivement l'item du set ORM ; contrôle direct.
|
||||
- **Contre** : pas instantané (round-trip SPARQL + callback broker, ~50ms) ; ne doit pas être combiné avec `ngSet.delete()`.
|
||||
|
||||
### Option C: les deux ensemble
|
||||
- **Ne marche pas** : le patch ORM `.delete()` et le DELETE SPARQL entrent en conflit CRDT → ni UI ni persistance.
|
||||
|
||||
## Decision
|
||||
|
||||
**Option B : SPARQL DELETE seul.** Le broker renvoie un `GraphOrmUpdate` `op: "remove"` qui retire réactivement l'item du set ORM (UI à jour, juste pas synchrone). **Ne pas** appeler `ngSet.delete()` à côté.
|
||||
|
||||
```typescript
|
||||
// FestipodDataContext.tsx leaveEvent():
|
||||
const session = await sessionPromise;
|
||||
await ng.sparql_update(
|
||||
session.session_id,
|
||||
`DELETE WHERE { GRAPH <${partGraph}> { <${partId}> ?p ?o } }`,
|
||||
partGraph,
|
||||
);
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positif :** suppression persistée ; source de vérité unique (broker → ORM → UI).
|
||||
**Négatif :** léger délai UI (~50ms) ; diverge des exemples README ORM.
|
||||
**Risque :** si `ng.sparql_update` change, ça casse ; toute future suppression doit suivre le même pattern ; revisiter si `ngSet.delete()` est corrigé en montée de version.
|
||||
|
||||
> État courant (la règle a été retirée) : [[caveat_participation-deletion]].
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Pièges internes de FestipodDataContext — currentUser NG résolu par username '@mariedupont' (fallback users[0]), auto-seed dev-only après 3s sans retry, participantCount muté en place (cache), currentUserId vide → IRI invalide, mutations no-op en mode local malgré le toast
|
||||
last_checked: 2026-06-15
|
||||
summary: Pièges internes de FestipodDataContext — currentUserId = principal stable dérivé de l'identifiant, 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`
|
||||
@@ -10,20 +10,104 @@ Comportements non évidents de `src/shared/context/FestipodDataContext.tsx` à c
|
||||
|
||||
## Résolution du `currentUser` (mode NG)
|
||||
|
||||
En mode connected, le currentUser n'est **pas** `CURRENT_USER_ID` ('user-1', qui ne vaut qu'en mode local). Il est résolu par **`users.find(u => u.username === '@mariedupont') || users[0]`** (vers ligne 286). Pièges :
|
||||
- **Fallback silencieux** sur `users[0]` si `@mariedupont` absent → currentUser arbitraire.
|
||||
- Si le wallet est **vide** (`users.length === 0`), `currentUserId` devient `''` → toute `Participation` créée a un `user: ''` (**IRI invalide**), sans alerte. Bug silencieux possible à la première connexion sur un wallet vierge.
|
||||
- L'IRI du currentUser diffère entre mode local (ID de seed statique) et mode NG (IRI NextGraph dynamique) — ne pas comparer les deux.
|
||||
En mode connected, le **principal** du currentUser (`currentUserId`) n'est **pas** `CURRENT_USER_ID` ('user-1', mode local) ni l'IRI du profil lu. Quand un identifiant est connecté, c'est un id **stable dérivé de l'identifiant** : `urn:festipod:user:<identifiant-normalisé>`, disponible immédiatement (sans dépendre de la lecture du profil protégé) et invariant sur la session — c'est la même clé que `setCurrentUser`, le cap owner et le compte shim (cf. [[rule_document-per-entity]], corollaire d'identité). Pièges restants :
|
||||
- L'objet `currentUser` (le profil affiché) est, lui, résolu par `users.find(u => normalizeIdentifier(u.username) === identifiant)` avec **fallback** `@mariedupont` puis `users[0]` — un fallback silencieux si l'identifiant ne correspond à aucun profil (l'identifiant est un id d'espace, pas forcément le `username` d'un profil seedé).
|
||||
- Sans identifiant connecté (dev/demo), `currentUserId` retombe sur l'IRI du profil lu (ou `''` si le wallet est vide → `Participation` avec `user: ''` invalide) : ne créer une participation qu'une fois le principal résolu.
|
||||
|
||||
## 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
|
||||
`useSyncExternalStore` sur `watchShape` du polyfill) — TROIS lectures useQuery-shaped
|
||||
(events/public, users/protected, participations/protected) + adaptateurs Fp
|
||||
(`shapeAdapters.ts`). Supprimés : `readEntities`, `subscribeDocs`+`bumpRead`+`readTick`,
|
||||
le listing manuel (`publicDocs`/`protectedDocs`/`registerDoc` pour la lecture),
|
||||
`relist`. `ready` = combinaison des `isSuccess`. Cf. [[rule_app-uses-sdk-surface-only]].
|
||||
|
||||
**Visibilité immédiate des mutations = overlay OPTIMISTE** (pas de `registerDoc`) :
|
||||
`createEvent`/`joinEvent`/`leaveEvent` alimentent `pendingAddEvents`/
|
||||
`pendingAddParticipations`/`pendingRemoveIds` ; l'état exposé = merge(réactif, adds)
|
||||
moins removes, dédupé par id (id = NURI du doc). Réconciliation auto au push
|
||||
(un add qui apparaît dans le réactif / un remove qui en disparaît est retiré) —
|
||||
jamais de poll ([[rule_no-broker-polling]]). Vidé au changement d'identité.
|
||||
|
||||
## Auto-seed de dev
|
||||
|
||||
Un auto-seed se déclenche (vers lignes 263-283) **uniquement hors production** (`process.env.NODE_ENV !== 'production'`), après un **`setTimeout` de ~3s**, si les sets events ET users sont vides. Pièges :
|
||||
- **Pas de retry** : `hasTriedAutoSeed` (useRef) est posé une fois ; si le seed échoue, jamais réessayé (écran vide, juste un `console.error`).
|
||||
- Le délai de 3s est **heuristique** : si l'hydratation ORM est lente, le seed peut partir alors que des données arrivent.
|
||||
**Depuis 2026-07-13, l'auto-seed est OPT-IN et OFF par défaut** : il ne se déclenche que si la variable d'env `FESTIPOD_AUTO_SEED` est définie (`=1`), plus sur `NODE_ENV`. Variable absente → **aucun seed automatique**, même en dev (`autoSeedEnabled()`/`shouldAutoSeed()`, `src/shared/utils/autoSeed.ts` ; livrée en dev via la route runtime `/festipod-config.json` + `define` compile-time dans `build.ts`, même mécanisme que le shared-wallet — cf. `tech-stack/knowledge_build-pipeline`). Le seed **explicite** (`loadTestData()`, tests @data) est inchangé. Motivation : le seed auto répété bloatait le wallet (lenteurs de lecture, cf. [[caveat_wallet-bloat-hang]]).
|
||||
|
||||
## `participantCount` muté en place
|
||||
Quand il est activé, l'auto-seed se déclenche si events ET users sont vides — **gardé sur `isSuccess`** (la readiness de `watchShape`),
|
||||
PLUS sur un `setTimeout` de 3s : on ne décide « wallet vide » qu'une fois la sync
|
||||
**confirmée** (`isSuccess`), sinon la lecture pas-encore-finie était prise pour un
|
||||
wallet vide → re-seed à chaque reconnexion (bug corrigé). Pièges restants :
|
||||
- **Un seul seed à la fois** : `loadTestData()` pose `hasTriedAutoSeed`, l'auto-seed le
|
||||
re-teste → un chargement explicite supprime l'auto-seed en attente (sinon deux
|
||||
`bootstrapWallet` concurrents écrivent en double).
|
||||
- Le seed est **possédé par l'identité courante** (`bootstrapWallet(…, owner)`) : les
|
||||
entités protégées seedées passent le cap de lecture par-document du propriétaire.
|
||||
- **Pas de retry** : si le seed échoue, écran vide + `console.error`.
|
||||
|
||||
`joinEvent`/`leaveEvent`/`updateEvent` **mutent directement** `ngEvent.participantCount` (`+1`/`-1`) — c'est un **cache** du nombre de `Participation`, pas une valeur recalculée. Il peut **désynchroniser** des objets `Participation` réels (ex. après un crash, un rejeu, ou la suppression partielle décrite dans [[caveat_participation-deletion]]). Ne pas s'y fier comme source de vérité du nombre de participants.
|
||||
## `participantCount` — dérivé et possédé par le propriétaire (Option B)
|
||||
|
||||
> ✅ **CORRIGÉ (2026-07-13).** L'exigence est **« fiable à la PROCHAINE CONNEXION du propriétaire »** (le créateur traite son inbox à sa connexion), PAS une notification live cross-utilisateur temps-réel. Le bug était : le owner-materializer matérialisait **trop tôt** (avant que le dépôt de l'inscrit soit synchronisé) → lisait `active=0` → écrivait 0 → **mémoïsait ce 0** → ne retraitait plus. Fix : (1) **lecture inbox gated sur barrière** — `inbox.readSynced` (= `ensureRepoOpen(doc)` attend le premier `State`, PUIS `read`, comme `discovery.readIndex`) au lieu de `inbox.read`, donc un dépôt déjà synchronisé EST vu à la connexion ; (2) le materializer se déclenche **directement à la connexion** (`[ready, ownedKey]`), plus seulement sur un push ; (3) `materializedCountRef` ne verrouille plus un 0 prématuré (son seul rôle = anti-boucle : n'écrire que si la valeur dérivée change) ; (4) **source unique du NOMBRE = `event.participantCount`** (le littéral `participantCount: 1` de `CreateEventScreen` est retiré → démarre à 0 ; l'affichage ne calcule plus de nombre local). Gardé VERT (profil frais) par `event/e2e-multibrowser.feature` « Le compteur converge chez le propriétaire à sa prochaine connexion » (dé-`@wip`). Pas de polling ([[rule_no-broker-polling]]).
|
||||
|
||||
**Depuis Option B (2026-07-07)** : `participantCount` n'est plus muté en place par l'inscrit. Le flux est dépôt-inbox → matérialisation-propriétaire :
|
||||
- `joinEvent`/`leaveEvent` n'écrivent **plus** `participantCount` sur le doc de l'événement (ce serait une violation d'isolation — l'inscrit écrirait le doc d'un autre ; le write NextGraph est membership-bound, pas d'append). L'inscrit écrit seulement son **propre** doc de participation (protected) puis **dépose** un marqueur dans l'inbox de l'événement (`depositRegistration` sur join, `depositLeave` sur leave, `src/shared/data/registration.ts`).
|
||||
- La session du **propriétaire** de l'événement matérialise : elle est abonnée (`inbox.watch`, `doc_subscribe`, sans polling) à l'inbox de ses events possédés (`ownedEventIds` = `listMyEntityDocs(owner,'public')` + les events fraîchement créés), et sur chaque dépôt **recalcule** `participantCount` sur **son propre** doc d'événement (`updateEntityField` sur son doc). C'est le seul écrivain du compteur.
|
||||
- **Le compteur est DÉRIVÉ, pas incrémenté** : `materializeAttendance` (registration.ts) lit l'inbox et calcule l'**ensemble** des inscriptions actives distinctes (dépôts `new-participant` dédupés par `uid`, MOINS ceux annulés par un `leave-participant` — par `regUid` exact ou fallback `(eventId, userId)`). `participantCount = |ensemble actif|` — **pas de base « hôte »** : le créateur ne participe pas automatiquement (pas de notion d'hôte, cf. concept `functional-domain`), donc le compteur démarre à **0** à la création et n'avance que sur des inscriptions réelles. `createEvent` **n'écrit plus** de participation à la création (elle écrivait une participation hôte + posait le compteur à 1) ; le créateur voit « J'y serai » et peut rejoindre/quitter son propre événement comme tout le monde. Comme c'est une **fonction pure de l'inbox**, un rejeu de sync broker converge — jamais de double-comptage ni de décrément fantôme (idempotence). L'écriture est gardée (n'écrit que si la valeur change), anti-boucle. Couvert par le scénario `@data` « Le créateur ne participe pas automatiquement à son événement » (us-13) : compteur 0 + `isParticipating(E)===false` à la création, puis join→true / leave→false.
|
||||
- **Propriétaire hors-ligne = éventuel** : seule la session du propriétaire matérialise ; déconnecté, le compteur n'avance pas pour les autres (les participations/dépôts restent persistés — rien n'est perdu ; un futur service matérialisera à sa place).
|
||||
- Le compteur reste néanmoins un **agrégat**, pas la liste des participants nommés : `getEventParticipants` (identité nommée) reste gouverné par le cap de lecture protected ([[caveat_participation-deletion]] pour la suppression autoritative, inchangée). Cf. le brief `brief_2026-07-06_reactive-reads-and-attendance` §B.
|
||||
|
||||
### Invariant id-form : apparier sur la forme CANONIQUE de l'event-id
|
||||
|
||||
Le `@id` d'un événement **est** son NURI de document (`did:ng:o:<repo>[:v:<overlay>]`). Le matérialiseur du propriétaire apparie les **dépôts** de l'inbox aux événements possédés **par l'event-id** : `ownedEventIds` (ce que le matérialiseur itère), la **clé de dépôt** (`payload.eventId`, ce sous quoi l'inscrit dépose) et la **cible d'écriture** du compteur doivent désigner le même événement.
|
||||
|
||||
**Constat mesuré (2026-07-07)** : sur l'arbre courant ces trois voies portent le **même** NURI (suffixe `:v:<overlay>` inclus) — create-time, `listMyEntityDocs` et le `@id` relu coïncident, parce que `readUnion` **épingle le subject au NURI d'entrée** (lib `read-model.ts`, `63ecfee`). L'appariement marche donc déjà, **y compris** pour un événement possédé atteint via `listMyEntityDocs` (validé par le scénario @data « …fait converger le compteur dérivé »). La canonicalisation ci-dessous est **défensive**, pas la correction d'un bug actif. (Le non-match qu'une investigation avait cru voir était l'artefact **seedé-mais-pas-possédé** : sur un wallet persistant, le seed appartenait à une identité `test-*` d'un run antérieur → la session courante l'atteint par découverte, pas par `ownedEventIds` — comportement correct.)
|
||||
|
||||
**Règle** : apparier l'event-id sur sa **forme canonique** — l'id de repo de base, en retirant tout suffixe `:v:<overlay>` (`canonicalEventId`, `src/shared/data/registration.ts`). Cette forme canonique est utilisée pour l'**appariement** dans `materializeAttendance` / `readRegistrationNotifications`, et pour **dédupliquer** `ownedEventIds` (`ownedKey`, FestipodDataContext) afin qu'un même événement atteint par deux voies ne soit pas matérialisé deux fois. **Attention** : seul l'**appariement** utilise la forme stripée ; le compteur est toujours **écrit** sur le vrai NURI possédé (un doc vivant, ouvrable) — un id stripé ne doit jamais servir de cible d'écriture / d'ancre. C'est un invariant **côté app** (pas un détail NextGraph) : quelle que soit la façon dont la lib fait varier l'overlay, l'app apparie sur la base commune.
|
||||
|
||||
## Changement d'identité = session fraîche (isolation)
|
||||
|
||||
> **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.
|
||||
|
||||
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`).
|
||||
|
||||
**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
|
||||
|
||||
|
||||
@@ -1,29 +1,28 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Deux modes (connected = NextGraph ORM, disconnected/demo = état local seedé) ; FestipodDataContext choisit le provider selon le statut NextGraphContext, tous les écrans passent par useFestipodData()
|
||||
summary: Deux modes (connected = SDK @ng-eventually/client, disconnected/demo = état local seedé) ; FestipodDataContext choisit le provider selon le statut de connexion, tous les écrans passent par useFestipodData()
|
||||
---
|
||||
|
||||
# Modes de données & contextes
|
||||
|
||||
L'app a **deux modes**, tous deux consommés via le hook `useFestipodData()` :
|
||||
|
||||
1. **Connected** — shapes ORM NextGraph (P2P, chiffré, local-first)
|
||||
1. **Connected** — shapes ORM du SDK `@ng-eventually/client` (P2P, chiffré, local-first)
|
||||
2. **Disconnected / Demo** — état React local seedé depuis `seedData.ts` (voir [[knowledge_seed-data]])
|
||||
|
||||
## NextGraphContext (`src/shared/context/NextGraphContext.tsx`)
|
||||
|
||||
- Cycle de connexion : `disconnected` → `connecting` → `connected` | `error`.
|
||||
- Fournit la session avec les IDs de stores (private, protected, public).
|
||||
- **Auto-init conditionnel** : voir [[rule_conditional-ng-init]] (n'auto-initialise que dans l'iframe broker).
|
||||
- Fournit la session (l'utilisateur courant et son accès aux stores par scope).
|
||||
|
||||
## FestipodDataContext (`src/shared/context/FestipodDataContext.tsx`)
|
||||
|
||||
- Enveloppe les shapes via `useShapeWithDefaults()`.
|
||||
- Expose `useFestipodData()` (consommé par tous les écrans) + CRUD (`createEvent`, `updateEvent`, etc.).
|
||||
- **Provider selon le statut NG** :
|
||||
- Expose `useFestipodData()` (consommé par tous les écrans) + CRUD (`createEvent`, `updateEvent`, `joinEvent`, `leaveEvent`, etc.).
|
||||
- **Provider selon le statut de connexion** :
|
||||
- `disconnected` → `LocalDataProvider` avec seed (démo)
|
||||
- `connecting` → `LocalDataProvider` **vide** (évite de flasher le seed avant le chargement du wallet)
|
||||
- `connected` → `NgDataProvider` (données réelles du wallet)
|
||||
- `error` → `LocalDataProvider` avec seed (fallback gracieux)
|
||||
|
||||
> Réserve : certaines mutations (`joinEvent`/`leaveEvent`) sont encore des **no-ops** (`console.log`) en attendant le chantier données — cf. [[brief_2026-05-21_fork-nextgraph-inbox]] §Couche 3.
|
||||
> Les mutations sont **réellement persistées** en mode connected (`joinEvent` écrit une Participation et notifie l'hôte du PdR, `leaveEvent` supprime de façon autoritative — cf. [[caveat_participation-deletion]]). En mode local/demo elles sont des no-ops (cf. [[knowledge_context-internals]]).
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Types de données Fp* (Event, UserProfile, Participation persistés NextGraph ; MeetingPoint et Friendship encore local-only)
|
||||
summary: Types de données Fp* — Event, UserProfile, Participation, MeetingPoint et Notification sont persistés NextGraph (shapes SHEX + ORM) ; seul Friendship reste local-only (app-TS)
|
||||
last_checked: 2026-07-03
|
||||
---
|
||||
|
||||
# Entités de données
|
||||
@@ -9,12 +10,15 @@ summary: Types de données Fp* (Event, UserProfile, Participation persistés Nex
|
||||
|
||||
| Type | Persistance | Champs clés |
|
||||
|---|---|---|
|
||||
| `FpEventData` | NextGraph (shape Event) | id, title, date, location, distance, themes |
|
||||
| `FpUserData` | NextGraph (shape UserProfile) | id, name, username, bio, city, counts |
|
||||
| `FpParticipationData` | NextGraph (shape Participation) | eventId + userId + confirmed |
|
||||
| `FpMeetingPointData` | **local-only** | eventId, location, time, host |
|
||||
| `FpEventData` | SDK (shape Event) | id, title, date, location, distance, themes |
|
||||
| `FpUserData` | SDK (shape UserProfile) | id, name, username, bio, city, counts |
|
||||
| `FpParticipationData` | SDK (shape Participation) | eventId + userId + confirmed |
|
||||
| `FpMeetingPointData` | SDK (shape MeetingPoint) | eventId, location, time, host |
|
||||
| `FpNotificationData` | SDK (shape Notification) | kind, target, source |
|
||||
| `FpFriendshipData` | **local-only** | userId + friendId |
|
||||
|
||||
`MeetingPoint` et `Friendship` n'ont **pas encore de shape SHEX** ni de persistance NextGraph (cf. [[knowledge_nextgraph-stack]]). Les brancher au store est un prérequis du multi-user — voir les briefs du concept `nextgraph-platform`.
|
||||
`MeetingPoint` et `Notification` ont de vraies **shapes SHEX** (`src/shared/shapes/shex/festipodShapes.shex`) avec bindings ORM générés (`festipodShapes.shapeTypes.ts` : `FpMeetingPointShapeType`, `FpNotificationShapeType`) et **sont persistés**. `Notification` est notamment créée lors de l'inscription à un point de rencontre (`joinEvent`).
|
||||
|
||||
`Friendship` n'a **pas** de shape SHEX ni de persistance — il reste app-TS-only (cf. [[knowledge_nextgraph-stack]]).
|
||||
|
||||
> Piège : même pour `FpEvent` (persisté), plusieurs champs du type app ne sont **pas** dans la shape et sont perdus en connecté — voir [[caveat_event-fields-not-persisted]].
|
||||
|
||||
@@ -1,26 +1,34 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Paquets @ng-org/* (web, orm, shex-orm, alien-deepsignals), shapes SHEX festipodShapes, bindings ORM générés, régénérés via build:orm
|
||||
summary: Le SDK de données est @ng-eventually/client (traité comme un SDK NextGraph fini) — injecté une seule fois via ngSession.configure ; ORM réactif useShape sur shapes SHEX festipodShapes, bindings régénérés via build:orm ; ne jamais documenter l'état courant de NextGraph ici
|
||||
---
|
||||
|
||||
# Stack NextGraph (côté app)
|
||||
# Stack de données (SDK `@ng-eventually/client`)
|
||||
|
||||
Festipod persiste via **`@ng-eventually/client`** — le SDK NextGraph que l'app consomme. On le traite comme un **SDK fini et mature** : documents par entité placés par scope, capabilities, inboxes, ORM réactif.
|
||||
|
||||
```
|
||||
@ng-org/web # Runtime navigateur (proxy postMessage vers l'iframe)
|
||||
@ng-org/orm # ORM réactif basé sur les shapes RDF (useShape…)
|
||||
@ng-org/shex-orm # Génération SHEX → TypeScript
|
||||
@ng-org/alien-deepsignals # Pont de signaux réactifs
|
||||
@ng-eventually/client # LE SDK de données de l'app (ORM réactif useShape, docs, scopes, inbox)
|
||||
```
|
||||
|
||||
Installés depuis npm (`@ng-org/*`, versions alpha). Pour développer contre un build local non publié de `nextgraph-rs`, `scripts/build-ng-packages.sh` pack le monorepo en tarballs et repointe `package.json` (cf. `nextgraph-platform` — le pattern d'origine du projet, réactivable pour un fork).
|
||||
## Frontière SDK (règle d'or)
|
||||
|
||||
## Shapes SHEX
|
||||
- L'app **ne dépend que de `@ng-eventually/client`** pour la donnée.
|
||||
- Le SDK est **initialisé/injecté une seule fois** via `ngSession.configure(...)` (`src/shared/utils/ngSession.ts`) — point d'injection unique. Le reste de l'app (data-plane, lifecycle, login, types) passe par la lib.
|
||||
- **Ne jamais documenter dans ce repo l'état courant de NextGraph** (contraintes du SDK sous-jacent, contournements, internes broker/verifier) : cela vit dans le repo `@ng-eventually/client`. Ici on décrit seulement **comment Festipod utilise ce SDK**.
|
||||
|
||||
## ORM & shapes SHEX
|
||||
|
||||
L'ORM réactif (`useShape`) s'appuie sur des **shapes SHEX** : `src/shared/shapes/shex/festipodShapes.shex` définit :
|
||||
|
||||
`src/shared/shapes/shex/festipodShapes.shex` définit :
|
||||
- **Event** — titre, description, dates, lieu, thèmes, participants
|
||||
- **UserProfile** — nom, username, bio, ville, visibilité
|
||||
- **Participation** — lie event + user, statut de confirmation
|
||||
- **MeetingPoint** — point de rencontre (lieu, horaire, hôte)
|
||||
- **Notification** — notification (créée notamment à l'inscription à un PdR)
|
||||
|
||||
Bindings ORM dans `src/shared/shapes/orm/` (`*.schema.ts`, `*.shapeTypes.ts`, `*.typings.ts`). **Régénérer** avec `bun run build:orm` après toute modif `.shex`.
|
||||
Bindings ORM générés dans `src/shared/shapes/orm/` (`*.schema.ts`, `*.shapeTypes.ts`, `*.typings.ts`). **Régénérer** avec `bun run build:orm` après toute modif `.shex`.
|
||||
|
||||
> Manque côté shapes : **pas de `MeetingPoint`** ni d'entité notification — le point de rencontre est aujourd'hui local-only côté types (voir [[knowledge_entities]]). Leur modélisation est un chantier de [[brief_2026-05-21_fork-nextgraph-inbox]].
|
||||
> **Lecture recommandée = le hook réactif du SDK.** La façon canonique de lire, c'est `useShape` : on s'abonne à une shape sur un scope, on obtient la valeur courante, et le composant se re-rend à chaque changement (local **ou** distant synchronisé) — abonnement/push, jamais de polling ; les lectures one-shot sont l'exception. La référence complète du SDK (contrat de lecture/réactivité + où l'émulation courante diverge encore) vit côté lib : `packages/client/docs/sdk-reference.md` dans `@ng-eventually/client`. Ne pas recopier les internes NextGraph ici.
|
||||
|
||||
> `Friendship` n'a **pas** de shape SHEX ni de persistance — il reste app-TS-only (cf. [[knowledge_entities]]).
|
||||
|
||||
@@ -14,4 +14,4 @@ summary: seedData.ts fournit des fixtures déterministes (10 users, events, part
|
||||
|
||||
Ces fixtures servent (a) le **mode démo** (`LocalDataProvider`, cf. [[knowledge_data-modes]]) et (b) les tests **`@ui`** qui rendent les écrans avec ces données prévisibles (`Marie Dupont`/`@mariedupont` = currentUser, `Jean Durand`/`@jeandurand` existe, etc. — voir concept `bdd-testing`).
|
||||
|
||||
> `bootstrapWallet()` (`src/shared/utils/ngBootstrap.ts`) seede ces données dans le wallet NG en mode connected — déclenché uniquement par action explicite de l'utilisateur (« Charger données de test »). Sa refonte par documents/périmètres est un point des briefs `nextgraph-platform`.
|
||||
> `bootstrapWallet()` (`src/shared/utils/ngBootstrap.ts`) seede ces données dans le wallet en mode connected — déclenché uniquement par action explicite de l'utilisateur (« Charger données de test »).
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
type: rule
|
||||
summary: L'app se comporte EXACTEMENT comme si NextGraph était fini et sans défaut — elle ne consomme QUE des surfaces SDK-shaped (`useShape`, `docs`, `inbox`…) et ne raisonne JAMAIS sur un problème courant de NextGraph (hang du fan-out ORM, cold-open, etc.). La raison d'être du polyfill est le WALLET VIRTUEL ; tout contournement interne (read-model union, subscribeDoc, open-repo…) vit DANS le polyfill, invisible à l'app.
|
||||
---
|
||||
|
||||
# L'app n'utilise que la surface SDK — jamais les internes du polyfill
|
||||
|
||||
## La règle
|
||||
|
||||
L'app Festipod traite `@ng-eventually/client` comme un **SDK NextGraph fini et sans
|
||||
défaut**. Concrètement :
|
||||
|
||||
1. **Lecture réactive = `useShape`** (la surface SDK-shaped, fournie par le polyfill,
|
||||
**scopée au wallet virtuel**). L'app ne lit PAS via des internes du polyfill
|
||||
(`readModel.readUnion`, `subscribeDoc`, un read-model maison…), et ne monte PAS sa
|
||||
propre réactivité (re-run sur signal).
|
||||
2. **L'app ne raisonne JAMAIS sur l'état courant de NextGraph** : pas de code ni de
|
||||
commentaire du type « on fait X parce que le fan-out ORM hang / parce que la lecture
|
||||
à froid rend 0 ». Ces problèmes n'existent pas du point de vue de l'app.
|
||||
|
||||
## La raison d'être du polyfill = le WALLET VIRTUEL
|
||||
|
||||
Le polyfill existe pour émuler le **wallet virtuel** (plusieurs identités sur un seul
|
||||
wallet physique), que NextGraph ne fournit pas encore nativement. **Ce n'est PAS**
|
||||
« parce que le fan-out ORM hang » — ça, c'est un simple **détail d'implémentation
|
||||
interne** de la façon dont le polyfill livre un `useShape` fonctionnel. Tous les
|
||||
contournements (read-model union à la place du fan-out ORM, `open-repo`, readiness
|
||||
miroir de `readyPromise`, émulation de caps…) sont **internes au polyfill** et
|
||||
n'apparaissent jamais dans l'app.
|
||||
|
||||
## État (déviation résolue)
|
||||
|
||||
**Résolu** : `FestipodDataContext` lit désormais via `useShapeQuery` (binding
|
||||
`useSyncExternalStore` sur `watchShape` du polyfill) + adaptateurs Fp
|
||||
(`src/shared/data/shapeAdapters.ts`). Sont **supprimés** : `readEntities.ts`, la
|
||||
réactivité bespoke (`subscribeDocs`+`bumpRead`+`readTick`), le listing manuel
|
||||
(`publicDocs`/`protectedDocs`/`registerDoc` pour la lecture), et les commentaires
|
||||
raisonnant sur le hang ORM. L'auto-seed est gardé sur `isSuccess` (plus de
|
||||
chronomètre 3 s). L'app ne consomme plus que la surface SDK.
|
||||
|
||||
**Cible (rappel du design)** : le polyfill expose un `useShape` **réactif, scopé au wallet virtuel**, dont
|
||||
la **forme suit TanStack `useQuery`** — `{ data, isPending/isLoading, isSuccess, isError,
|
||||
… }` — **en anticipation de la mise à jour PRÉVUE de `useShape` par NextGraph** (qui va
|
||||
adopter ce fonctionnement). Ce n'est donc pas une invention : c'est une API future de
|
||||
NextGraph, émulée d'avance, qui s'aligne quand NextGraph la livre. Elle **distingue
|
||||
nativement** `isPending` (sync en cours) de `isSuccess` + `data` vide (synchronisé,
|
||||
réellement vide) — exactement le besoin. En interne, le hook encapsule readUnion sur
|
||||
`subscribeDoc` + le scoping identité (invisible à l'app). L'app **supprime** sa
|
||||
machinerie bespoke (`readEntities`/`subscribeDocs`/`bumpRead`) et lit via ce hook.
|
||||
|
||||
Le bug d'auto-seed (chronomètre 3 s) est un **symptôme** : avec `isSuccess`, l'auto-seed
|
||||
décide « vide » seulement une fois la sync confirmée, au lieu de deviner un délai. Voir
|
||||
[[rule_no-broker-polling]] et [[knowledge_nextgraph-stack]].
|
||||
@@ -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]].
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
type: rule
|
||||
summary: N'auto-initialiser NextGraph que dans l'iframe broker (window.self !== window.top) ; en standalone, initNgWeb() redirige toute la page — attendre un connect() explicite
|
||||
---
|
||||
|
||||
# Règle : auto-init NextGraph seulement dans l'iframe broker
|
||||
|
||||
`initNgWeb()` de `@ng-org/web` teste `window.self === window.top`. **Hors iframe** (app standalone), il **redirige toute la page** vers `nextgraph.net/redir/` pour déclencher l'auth broker.
|
||||
|
||||
Donc `NextGraphContext` calcule `isInsideBroker = window.self !== window.top` et **n'auto-appelle `initNg()` que si `isInsideBroker`**. En standalone, la connexion attend un `connect()` explicite (clic « Se connecter ») — sinon l'app redirige à chaque chargement et casse le dev/démo.
|
||||
|
||||
De plus, `FestipodDataContext` rend des données **vides** (pas le seed) pendant la phase `connecting`, pour éviter de flasher du contenu démo avant le chargement du wallet (voir [[knowledge_data-modes]]).
|
||||
|
||||
> Garder ce garde **aligné** sur la détection interne de `@ng-org/web` : si leur heuristique change, le nôtre doit suivre. Pourquoi + alternatives : [[decision_2026-03-13_conditional-ng-init-broker-detection]].
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
type: rule
|
||||
summary: Festipod persiste CHAQUE entité comme SON PROPRE document (via le SDK), placé dans son scope (public/protected/private) — jamais plusieurs entités écrites dans un document de niveau store. Le document est l'unité de partage et de droits : l'isolation du SDK est PAR-DOCUMENT, donc un document par entité est ce qui la rend possible.
|
||||
---
|
||||
|
||||
# Règle : un document par entité (jamais au niveau du store)
|
||||
|
||||
Quand Festipod crée une entité (événement, point de rencontre, profil, participation,
|
||||
notification), il l'écrit comme **son propre document**, via l'appel « créer un document » du
|
||||
SDK de données ([[knowledge_nextgraph-stack]]), en indiquant son **scope**
|
||||
(`public` / `protected` / `private`). L'entité est ensuite lue et écrite dans **ce** document.
|
||||
|
||||
**Ne jamais** écrire plusieurs entités dans un document partagé « de niveau store » (p. ex.
|
||||
tout mettre dans un seul document racine). C'est un anti-pattern qui casse l'isolation.
|
||||
|
||||
## Pourquoi
|
||||
|
||||
Le **document est l'unité de partage et de droits** du SDK : l'isolation (qui peut lire quoi)
|
||||
est appliquée **par document**. `private` → le propriétaire ; `protected` → le propriétaire +
|
||||
ses connexions ; `public` → tout le monde. Cette discrimination n'est possible **que si chaque
|
||||
entité a son propre document** : mettre plusieurs entités (voire plusieurs propriétaires) dans
|
||||
un même document rend le partage tout-ou-rien et défait l'isolation par périmètre.
|
||||
|
||||
L'isolation elle-même est **entièrement assurée par le SDK** ([[knowledge_trust-model]] du
|
||||
concept `app-security`) — l'app ne porte aucune logique d'accès ; elle déclare seulement son
|
||||
identité (au login) et ses connexions (acte de partage), puis fait confiance à ce que le SDK
|
||||
renvoie. La granularité « un document par entité » est la contrepartie côté écriture de cette
|
||||
confiance.
|
||||
|
||||
## Comment l'appliquer
|
||||
|
||||
- À la création : demander au SDK **un document pour l'entité, dans son scope**
|
||||
(`createEntityDoc(scope)`) ; y écrire l'entité. Ne pas réutiliser un document d'un autre
|
||||
périmètre ni un document de niveau store.
|
||||
- En lecture : passer par le SDK via le **modèle de lecture union** (voir plus bas) — l'app
|
||||
résout un jeu de documents *par besoin* (index de découverte pour les événements publics ;
|
||||
ses propres documents de scope pour ses entités) et le SDK ouvre/synchronise puis lit
|
||||
l'union en **une seule** requête ; pas de résolution de NURI ni de choix union/ancré côté app.
|
||||
- Le mapping *entité → scope* (événement/PdR → public, profil réseau/participation → protected,
|
||||
settings → private) est un fait produit (concept `functional-domain`,
|
||||
[[knowledge_data-scopes-and-discovery]]).
|
||||
|
||||
## Lecture : modèle union (open/sync + une requête ancrée-libre + re-query)
|
||||
|
||||
La **lecture** ne passe **PAS** par un abonnement ORM réactif en fan-out sur un jeu de documents
|
||||
par-entité (`useShape({ graphs: […] })`) : contre le vrai broker un document fraîchement créé /
|
||||
non-synchronisé dans ce fan-out fait avorter tout l'abonnement (`RepoNotFound`) → l'abonnement
|
||||
n'émet jamais son initial → **hang ~75 s**. À la place, la lecture est le **modèle union** du SDK
|
||||
([[knowledge_nextgraph-stack]], SDK `docs/read-model.md`) :
|
||||
|
||||
1. **résoudre par besoin** le jeu de NURIs à lire — événements publics via l'**index de découverte**
|
||||
(la seule énumération cross-comptes sanctionnée) ; « mes entités » (profil, participations) via
|
||||
**mes propres** documents de scope (`listMyEntityDocs(username, scope)`, borné à mon compte —
|
||||
jamais de fan-out sur tous les comptes) ;
|
||||
2. le SDK **ouvre/synchronise** ces documents puis exécute **UNE** requête `sparql_query`
|
||||
**sans ancre** sur l'union locale (`GRAPH ?g { … }`) et rend les triplets groupés par sujet
|
||||
(`src/shared/data/readEntities.ts` → `readModel.readUnion`) ;
|
||||
3. il n'y a **pas** de requête union réactive → la **réactivité = re-query** sur un signal de
|
||||
changement (un document créé/enregistré déclenche `bumpRead`).
|
||||
|
||||
Côté app, `FestipodDataContext` collecte les NURIs par besoin puis appelle `readEntities` ;
|
||||
un document fraîchement créé est aussi enregistré localement (`registerDoc`) pour apparaître
|
||||
immédiatement, avant que la re-liste ne le rattrape.
|
||||
|
||||
## Écriture directe (piège d'aller-retour)
|
||||
|
||||
L'**écriture** d'une entité se fait **directement dans son propre document** (via l'appel
|
||||
SPARQL du SDK — `src/shared/data/entityWrites.ts`, `writeEntity`), **pas** via l'ajout à un
|
||||
ensemble réactif. Raison : un ensemble réactif n'est *inscriptible* que si le document cible est
|
||||
**déjà** dans son scope d'abonnement ; or enregistrer le document fraîchement créé est un état
|
||||
React qui ne prend effet qu'au rendu **suivant** → on ne peut pas créer-puis-ajouter en une passe
|
||||
synchrone (boucle de seed, première création). Contre le vrai broker, un `add` sur un scope vide
|
||||
lève « Set is readonly because scope is empty » (les tests unitaires fake-ng ne l'attrapent pas).
|
||||
|
||||
Donc : **écriture = SPARQL direct dans le doc de l'entité** (immédiat, par-document) ;
|
||||
**lecture = union + re-query** (ci-dessus).
|
||||
|
||||
**Convention de graphe (écrire dans le graphe par défaut ancré).** L'écriture passe le NURI du
|
||||
document comme **ancre** de `docs.sparqlUpdate` et écrit le corps SPARQL **sans** clause
|
||||
`GRAPH <…>` explicite ; la lecture union interroge le même graphe par défaut ancré
|
||||
(`readEntities`/`readUnion`). C'est la forme **canonique et toujours sûre** — à conserver pour
|
||||
`writeEntity`, `updateEntityField` et `registration.ts`.
|
||||
|
||||
> **Correction (2026-07-06).** Un commentaire antérieur (et une version de ce paragraphe)
|
||||
> affirmaient qu'un corps `GRAPH <nuriDuDoc>` explicite écrit dans un graphe *nommé distinct* que
|
||||
> la lecture ancrée ne verrait pas → l'entité « disparaîtrait ». **C'est faux sur le broker
|
||||
> courant** (`@ng-org/web 0.1.2-alpha.13`) : le harness e2e réel de la lib
|
||||
> (`packages/client/e2e/`) vérifie qu'un `INSERT DATA { GRAPH <plainNuri> {…} }` **ancré** au doc
|
||||
> round-trippe (relu aussi bien en graphe par défaut qu'en `GRAPH <plainNuri>`). Le symptôme « 0
|
||||
> entité » qu'on avait attribué à ce « piège » venait en réalité du **hang de wallet gonflé** (cf.
|
||||
> `bdd-testing/caveat_wallet-bloat-hang`), pas d'un mismatch de graphe. La règle « sans wrapper
|
||||
> `GRAPH` » reste donc un choix de **simplicité/sûreté**, pas une nécessité de round-trip. (Le
|
||||
> *pourquoi* côté SDK vit dans `@ng-eventually/client`, pas ici.)
|
||||
|
||||
Idem pour la **mutation d'un champ** existant (p. ex. `participantCount`) : muter une valeur
|
||||
en mémoire ne tient pas — la re-query union relit la valeur **persistée** depuis le broker
|
||||
(retour à l'ancienne valeur) → persister via SPARQL (`updateEntityField` : DELETE puis
|
||||
INSERT du triplet) pour que le changement tienne et que la relecture concorde. Chaque champ est écrit avec le **bon terme RDF** selon la shape SHEX (xsd:integer /
|
||||
float / boolean, ou IRI pour les références `Participation.event`/`.user`) — un champ obligatoire
|
||||
manquant ou mal typé fait que la lecture **jette l'entité** (elle ne fait jamais
|
||||
l'aller-retour). Le **sujet** de l'entité = le **NURI de son document** (une entité = un document),
|
||||
ce qui donne un `@id` en `did:ng:…`.
|
||||
|
||||
Corollaire d'identité : une `Participation` porte un `fp:user` **obligatoire** — ne jamais
|
||||
l'écrire avec un principal vide (l'entité serait jetée en lecture). Le principal du user courant
|
||||
est **stable et dérivé du username** (`urn:festipod:user:<username-normalisé>`), disponible
|
||||
**immédiatement** après login (pas de dépendance à la lecture du profil protégé, qui peut
|
||||
retarder) et **invariant** (il ne bascule pas d'un fallback vers l'IRI de profil en cours de
|
||||
session, ce qui désynchroniserait une participation écrite sous une valeur d'une vérification
|
||||
sous l'autre). C'est le même principal que l'identité SDK (`setCurrentUser`) et le cap owner
|
||||
dérivent du username ; les connexions bilatérales (`declareConnections`) se déclarent avec ces
|
||||
mêmes clés username (pas des IRIs de profil) pour que « protégé = mes connexions » discrimine.
|
||||
@@ -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,25 +0,0 @@
|
||||
---
|
||||
type: rule
|
||||
summary: Utiliser did:ng:${private_store_id} comme scope useShape ET comme @graph d'écriture ; ne jamais utiliser did:ng:i comme scope (casse toutes les écritures par RepoNotFound)
|
||||
---
|
||||
|
||||
# Règle : scope = `@graph` = private_store_id
|
||||
|
||||
Pour lire **et** écrire via l'ORM NextGraph :
|
||||
|
||||
- **Scope** : `useShape(shapeType, \`did:ng:${session.private_store_id}\`)`
|
||||
- **`@graph`** (cible des écritures) : `did:ng:${session.private_store_id}`
|
||||
|
||||
C'est critique : `orm_start_graph` avec le NURI du private_store **ouvre explicitement le repo** dans la HashMap `self.repos` du verifier. Sans ça, `orm_frontend_update` échoue en `RepoNotFound`.
|
||||
|
||||
## Interdit
|
||||
|
||||
**Ne pas utiliser `did:ng:i` comme scope.** Il s'abonne au site entier de l'utilisateur via un chemin de code spécial (`NuriTargetV0::UserSite`) qui **n'ouvre pas les repos individuels** → casse toutes les écritures.
|
||||
|
||||
## Fichiers porteurs
|
||||
|
||||
- `src/shared/hooks/useShapeWithDefaults.ts` — accepte un `storeNuri`, le passe à `useShape`.
|
||||
- `src/shared/utils/ngGraph.ts` — `ensureGraphNuri()` retourne le `@graph` (entités existantes d'abord, sinon fallback `private_store`).
|
||||
- `src/shared/utils/ngBootstrap.ts` — seede en utilisant `ensureGraphNuri()`.
|
||||
|
||||
> Le *pourquoi* complet et les alternatives écartées : [[decision_2026-03-17_private-store-nuri-scope]]. **Ce scope mono-store est précisément ce que le chantier multi-store viendra remplacer** — voir [[brief_2026-05-17_multi-store-refactor]].
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
type: _overview
|
||||
summary: Modèle produit Festipod — le point de rencontre greffé sur un événement public comme unité de valeur, ses acteurs et ses concepts métier
|
||||
summary: Modèle produit Festipod — le point de rencontre greffé sur un événement public comme unité de valeur, ses acteurs, ses concepts métier, et les périmètres de confidentialité (public/protected/private) par entité
|
||||
triggers:
|
||||
keywords: [point de rencontre, rencontre, greffe, greffer, événement, déclarant, hôte, inscrit, inscription, communauté, connexion, festival, déduplication]
|
||||
keywords: [point de rencontre, rencontre, greffe, greffer, événement, déclarant, hôte, inscrit, inscription, communauté, connexion, festival, déduplication, découverte, périmètre, scope, public, protected, privé]
|
||||
paths: ["src/modules/*/features/**"]
|
||||
---
|
||||
|
||||
@@ -16,13 +16,14 @@ Le **domaine fonctionnel** de Festipod : ce que le produit promet et le vocabula
|
||||
|
||||
Festipod laisse les utilisateurs créer des **points de rencontre** qui se *greffent* sur des **événements publics** existants. L'événement (festival, conférence…) n'est qu'un *prétexte* et un point d'ancrage spatio-temporel ; la valeur produite, c'est le point de rencontre. **On s'inscrit à un point de rencontre, jamais à un événement.**
|
||||
|
||||
## Périmètre & sécurité
|
||||
## Périmètre & confidentialité
|
||||
|
||||
Le modèle d'**autorisations / confidentialité** (qui voit quoi : « données personnelles = réseau seulement », anonymat via inbox, capabilities) n'est pas encore implémenté — il vit aujourd'hui comme incubation dans [[brief_2026-05-18_authorization-matrix]] (concept `nextgraph-platform`). Il graduera en règles/`behavior_` quand le multi-user atterrira. C'est la raison pour laquelle il n'y a pas encore de concept `app-security` distinct.
|
||||
Le modèle produit de **qui voit quoi** — données personnelles réservées au réseau, événements/PdR publics, notification d'inscription identifiée-ou-anonyme — est un fait métier : voir [[knowledge_data-scopes-and-discovery]]. La matrice d'autorisations détaillée (acteur × verbe) et son incubation vivent dans le concept `app-security` ([[brief_2026-05-18_authorization-matrix]]).
|
||||
|
||||
## Liens
|
||||
|
||||
- [[knowledge_business-model]] — l'inversion événement / point de rencontre
|
||||
- [[knowledge_actors-and-concepts]] — référence des acteurs et concepts métier
|
||||
- [[knowledge_data-scopes-and-discovery]] — périmètres public/protected/private par entité + découverte
|
||||
- [[knowledge_roadmap]] — fonctionnalités actuelles vs évolutions à venir
|
||||
- [[brief_2026-06-15_event-deduplication]] — défi ouvert de déduplication des événements en P2P
|
||||
- `nextgraph-platform` — où vit la dérivation de la structure de données cible (authz matrix, multi-store)
|
||||
|
||||
@@ -13,7 +13,7 @@ Référence du vocabulaire. Tous les acteurs sont des spécialisations d'un **ut
|
||||
|---|---|
|
||||
| **Utilisateur** | Toute personne ayant un compte (un wallet NextGraph). Racine de tous les autres. |
|
||||
| **Connexion (« ami »)** | Un autre utilisateur avec qui je suis connecté. Sert à scoper les listes (« mes amis qui participent à… ») et la confiance. Bilatérale (acceptation des deux côtés). |
|
||||
| **Déclarant d'un événement** | L'utilisateur qui a inséré l'événement dans Festipod. *N'est pas forcément l'organisateur réel* : juste celui qui le référence. |
|
||||
| **Déclarant d'un événement** | L'utilisateur qui a inséré l'événement dans Festipod. *N'est pas forcément l'organisateur réel* : juste celui qui le référence. **Il n'y a PAS de notion d'« hôte d'événement »** : l'événement est public, simplement signalé par son déclarant, qui **n'est PAS obligé de participer** — à la création aucune participation n'est écrite, le compteur démarre à 0, et le déclarant peut rejoindre/quitter comme tout le monde (décision produit ; côté données cf. data-layer/[[knowledge_context-internals]] §participantCount). L'« hôte » reste un acteur au niveau du **point de rencontre** (ligne suivante), pas de l'événement. |
|
||||
| **Hôte d'un point de rencontre** | L'utilisateur qui a créé un point de rencontre rattaché à un événement. |
|
||||
| **Inscrit à un point de rencontre** | Un utilisateur inscrit à un point de rencontre ; de fait il devient participant à l'événement parent. |
|
||||
| **Membre d'une communauté d'intérêt** | Un utilisateur abonné à une communauté pour découvrir les événements qu'elle référence. |
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Modèle produit de confidentialité et de découverte — chaque entité vit dans un SCOPE (public / protected / private) selon qui doit la voir ; événements & points de rencontre = public, profil réseau & participations = protected (réseau), settings = private ; connexions bilatérales = scope dialog ; la découverte lit un index global d'événements
|
||||
---
|
||||
|
||||
# Périmètres de données et découverte
|
||||
|
||||
Le modèle **produit** de qui voit quoi, et comment on trouve les événements. C'est du **domaine** : le *comment* technique (documents, capabilities, index) est assuré par le SDK de données `@ng-eventually/client` — l'app décrit seulement **l'intention métier**.
|
||||
|
||||
## Trois périmètres (scopes) par donnée
|
||||
|
||||
Chaque entité est stockée dans le **scope** correspondant à qui doit pouvoir la lire :
|
||||
|
||||
| Entité | Scope | Qui lit |
|
||||
|---|---|---|
|
||||
| Événement (l'ancrage) | **public** | tout le monde |
|
||||
| Point de rencontre (PdR) | **public** | tout le monde |
|
||||
| Profil réseau (nom, avatar, bio, ville, intérêts) | **protected** | le titulaire + ses connexions |
|
||||
| Participation / inscription à un PdR | **protected** | l'inscrit + ses connexions |
|
||||
| Index des connexions | **protected** | le titulaire + ses connexions |
|
||||
| Profil privé (settings, email, préférences) | **private** | le titulaire seul |
|
||||
| Connexion A↔B (lien bilatéral, + messagerie future) | **dialog** | les deux utilisateurs |
|
||||
|
||||
Principe directeur : **le statut « public » (PdR, événement) et « personnel » (profil, participations, connexions) coexistent dans un même utilisateur.** Les informations personnelles sont réservées au **réseau** (connexions bilatérales), jamais visibles d'un utilisateur lambda.
|
||||
|
||||
- **PdR / événement = publics universels.** Tout utilisateur peut lire et s'abonner ; créer un PdR rend hôte, créer un événement rend déclarant (aucun prérequis).
|
||||
- **Hôte = seul détenteur des droits d'écriture** sur son PdR ; le déclarant n'a aucun droit particulier sur les PdR greffés sur son événement.
|
||||
- **Connexion bilatérale** : `DemandeDeConnexion` (unilatérale, transitoire) → `Connexion` (bilatérale, persistante) — cette dernière ouvre l'accès aux données *protected* de l'autre.
|
||||
|
||||
Festipod **place chaque entité dans le store de son scope** ; l'isolation entre scopes est **assurée par le SDK de données**, pas par du code applicatif (cf. concept `app-security`).
|
||||
|
||||
## Découverte des événements
|
||||
|
||||
Un utilisateur découvre les événements qu'il n'a pas créés via un **index global** : le SDK lit cet index, qui donne les références (NURIs) des documents-événements, puis synchronise et interroge en local. La découverte **primaire** passe par cet index ; un **axe secondaire** relationnel s'y superpose (les participations *protected* des connexions : « mes amis participent à… »).
|
||||
|
||||
> **Notification d'inscription (intention produit).** S'inscrire à un PdR notifie son hôte : identifié si l'inscrit fait partie des connexions de l'hôte, **anonyme sinon**. Ce « identifié si connu, anonyme sinon » est une propriété du modèle de données — l'app y compte, le mécanisme est fourni par le SDK.
|
||||
|
||||
## Questions ouvertes (métier)
|
||||
|
||||
- **Modèle d'écriture de l'événement** : propriétaire (déclarant seul) / wiki (tous) / immuable ? Central pour la déduplication ([[brief_2026-06-15_event-deduplication]]).
|
||||
- **Identité de l'hôte vis-à-vis d'un lambda** : un PdR est lisible par tous, mais faut-il que son hôte soit identifiable ? (pseudonyme par défaut, carte de visite par PdR, ou anonymat révélé aux seules connexions.)
|
||||
- **Champs modifiables d'une inscription** ; **découvrabilité « amis d'amis »**.
|
||||
|
||||
> La matrice d'autorisations détaillée par acteur × verbe vit dans le concept `app-security` ([[brief_2026-05-18_authorization-matrix]]).
|
||||
@@ -15,11 +15,13 @@ summary: Ce qui est implémenté aujourd'hui (cycle événement + point de renco
|
||||
- Profil utilisateur, mise à jour, partage de profil
|
||||
- Liste d'amis (connexions), profil d'un autre utilisateur
|
||||
|
||||
> Réserve : certaines actions de données restent des no-ops en l'état (ex. `joinEvent`/`leaveEvent` côté `FestipodDataContext` — détail dans [[brief_2026-05-21_fork-nextgraph-inbox]] §Couche 3). Le router et les écrans existent, mais le branchement données suit le chantier multi-store.
|
||||
> L'inscription/désinscription au point de rencontre est **réellement branchée** côté données : `joinEvent` persiste une Participation, notifie l'hôte du PdR et crée une Notification ; `leaveEvent` supprime la Participation de façon autoritative (cf. concept `data-layer`, [[caveat_participation-deletion]] côté data-layer). La découverte publique — un utilisateur voit un événement public d'un autre — fonctionne aussi.
|
||||
|
||||
> **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é).
|
||||
- **Abonnement à un utilisateur** pour suivre ses déclarations sans être ami.
|
||||
- **Listes curated** — créer/partager des sélections éditorialisées.
|
||||
- **Multi-utilisateurs collaboratif** : aujourd'hui chaque utilisateur a ses données isolées dans son wallet. Le passage collaboratif (un point de rencontre vu par plusieurs) suppose un refactor de la couche données — voir [[brief_2026-05-17_multi-store-refactor]].
|
||||
- **Multi-utilisateurs collaboratif** : le partage effectif d'un point de rencontre vu par plusieurs utilisateurs, appuyé sur les périmètres public/protected/private (cf. [[knowledge_data-scopes-and-discovery]]).
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
---
|
||||
type: _overview
|
||||
summary: NextGraph comme système EXTERNE (stores, permissions, inbox, modèle d'intégration iframe, limites SDK) + les 4 briefs prospectifs qui dérivent la structure de données cible et le chemin multi-user de Festipod
|
||||
triggers:
|
||||
keywords: [nextgraph-rs, store, group store, dialog store, protected_store, public_store, inbox, capability, nuri, fork, ngd, broker, verifier, multi-store, multi-user, sharedWalletShim, storeRegistry, permission, social_query, OpenRepo, wallet, auto-import, textcode, wallet import, AccessGateScreen]
|
||||
paths: ["src/shared/utils/ngGraph.ts", "src/shared/hooks/useShapeWithDefaults.ts", "scripts/build-ng-packages.sh", "src/modules/auth/sharedWallet.ts", "src/modules/auth/screens/AccessGateScreen.tsx"]
|
||||
---
|
||||
|
||||
# NextGraph platform
|
||||
|
||||
Deux choses ici, distinctes du concept `data-layer` (qui décrit l'**usage actuel** de NextGraph par l'app) :
|
||||
|
||||
1. **Référence du système externe NextGraph** — ses primitives de stockage et de permission, son inbox, son modèle d'intégration/déploiement, et ce que son SDK JS expose (ou pas).
|
||||
2. **Briefs prospectifs** — la dérivation de la structure de données *cible* de Festipod et les chemins pour y arriver (stopgap wallet partagé, refactor multi-store, fork moteur pour l'inbox).
|
||||
|
||||
> Le code de l'app touché par ces chantiers : `src/shared/utils/ngGraph.ts`, `useShapeWithDefaults.ts`, `FestipodDataContext.tsx`, `ngBootstrap.ts` — les seams du futur multi-store. Le modèle de **confidentialité/autorisations** (qui peut faire quoi) vit dans le concept `app-security` ([[brief_2026-05-18_authorization-matrix]]) ; ces chantiers data en sont l'infrastructure.
|
||||
|
||||
## Source locale
|
||||
|
||||
Le repo `nextgraph-rs` est cloné en `/home/sylvain/projects/nextgraph/nextgraph-rs` (soit `../../nextgraph/nextgraph-rs` depuis la racine projet). À consulter pour vérifier ce qui est réellement exposé au protocole/SDK plutôt que la doc.
|
||||
|
||||
## Référence (système externe)
|
||||
|
||||
- [[knowledge_stores-permissions]] — 5 types de stores, document/repo, capabilities/Nuri, inbox, exposition SDK JS
|
||||
- [[knowledge_integration-model]] — paquets JS, modèle iframe, où tourne le verifier, broker `ngd`, déploiement, reciblage build-time
|
||||
- [[knowledge_broker-import-constraint]] — le broker hébergé n'autorise pas l'auto-import d'un wallet par une web-app tierce (vérifié 2026-06-17)
|
||||
|
||||
## Briefs (chantiers prospectifs)
|
||||
|
||||
- [[brief_2026-05-17_multi-store-refactor]] — passer du mono-store actuel à une structure par entité
|
||||
- [[brief_2026-06-15_shared-wallet-shim]] — stopgap staging : wallet partagé unique + `storeRegistry`
|
||||
- [[brief_2026-05-21_fork-nextgraph-inbox]] — forker `nextgraph-rs` pour exposer l'inbox au SDK JS
|
||||
|
||||
## Décisions
|
||||
|
||||
- [[decision_2026-06-17_assisted-wallet-import]] — distribution du wallet partagé par import assisté (l'auto-import zéro-touche étant impossible)
|
||||
@@ -1,84 +0,0 @@
|
||||
---
|
||||
type: brief
|
||||
summary: Passer du mono-store actuel (tout dans private_store) à une structure de stores par entité ; hardcoding dans ngGraph.ts + useShapeWithDefaults ; contrainte SDK bloquante (Group stores/inbox non exposés) ; refactor structurel possible avec placeholders en attendant l'API
|
||||
last_updated: 2026-05-17
|
||||
---
|
||||
|
||||
# Refactor multi-store NextGraph
|
||||
|
||||
**Status:** Incubating — aucun travail démarré
|
||||
**Last updated:** 2026-05-17
|
||||
|
||||
## Context
|
||||
|
||||
L'app est aujourd'hui *mono-store* : tout (events, profils, participations, friendships) atterrit dans le `private_store` de l'utilisateur connecté. Héritage de l'exemple expense-tracker-rdf, formalisé dans la décision du 2026-03-17 (concept `data-layer`, [[decision_2026-03-17_private-store-nuri-scope]]).
|
||||
|
||||
Ce choix bloque le multi-utilisateurs : le `private_store` est non partageable (*« not possible to share the documents of your private store »*, cf. [[knowledge_stores-permissions]]). Tant que tout y est, Bob ne verra jamais l'event d'Alice. Le modèle natif NextGraph est *multi-store par utilisateur* — Festipod doit s'y aligner avant de devenir collaboratif.
|
||||
|
||||
**Déclencheur :** discussion du 2026-05-17 — *poser le cap, exécuter plus tard*.
|
||||
|
||||
## What We Know
|
||||
|
||||
### État actuel du code
|
||||
|
||||
Deux fichiers concentrent le hardcoding du store unique :
|
||||
- `src/shared/utils/ngGraph.ts` — `ensureGraphNuri()` retourne `did:ng:${session.private_store_id}` pour TOUTES les entités.
|
||||
- `src/shared/hooks/useShapeWithDefaults.ts` — accepte un `storeNuri` mais l'appelant unique (`FestipodDataContext`) lui passe toujours le NURI du private_store.
|
||||
|
||||
Entités impactées (toutes mélangées) : `FpEvent` (→ store partagé), `FpUserProfile` (→ partie privée/publique), `FpParticipation` (→ avec son event), `FpMeetingPoint` (local-only aujourd'hui), `FpFriendship` (local-only, privée). Cf. concept `data-layer` §entités.
|
||||
|
||||
### Modèle cible proposé
|
||||
|
||||
> **Note (2026-05-19)** : [[brief_2026-05-18_authorization-matrix]] a depuis dérivé, à partir des seuls points validés, une structure différente — 3 stores natifs par utilisateur + Dialog stores, **sans Group store** dans le périmètre actuel. La structure à 4 niveaux ci-dessous reste pertinente pour le périmètre élargi (communautés, collaboration multi-hôte), aujourd'hui hors périmètre. À reconcilier à l'exécution.
|
||||
|
||||
Structure hiérarchique en **4 niveaux de Group stores** : index communautaire ⊃ communauté ⊃ event ⊃ meeting point.
|
||||
|
||||
| Entité | Store cible | Justification |
|
||||
|---|---|---|
|
||||
| Event (métadonnées) | Group « communauté » | La communauté possède l'event → contrôle qui le modifie |
|
||||
| Référence d'event (pointeur) | Group « index communautaire » | Discovery |
|
||||
| Participation | Group « event » | N'a de sens que dans son event |
|
||||
| MeetingPoint (métadonnées) | Group « event » | Le RDV appartient à l'event |
|
||||
| Participation à un MeetingPoint | Group « meeting point » | RSVP scopé au RDV |
|
||||
| UserProfile (partie publique) | public_store de l'utilisateur | Modèle natif |
|
||||
| Friendship | private_store de l'utilisateur | Purement personnelle |
|
||||
|
||||
### Contrainte SDK bloquante
|
||||
|
||||
Primitives présentes au protocole mais **non exposées dans `@ng-org/web`** (vérifié `0.1.2-alpha.13`) : création de Group stores + invitations/permissions ; **dépôt/lecture d'inbox** (mécanisme retenu pour la notif d'inscription, cf. [[brief_2026-05-18_authorization-matrix]]). `app_request_stream` est la méthode générique la plus susceptible de porter ce mécanisme une fois exposée (à confirmer côté Rust). Cf. [[knowledge_stores-permissions]] §Limites SDK.
|
||||
|
||||
**Implication :** le refactor *structurel* peut commencer sans attendre l'API, avec des placeholders (continuer à pointer `private_store_id` pour les Group stores impossibles). L'**aboutissement complet** (vrai multi-user) dépend de l'arrivée de l'API ou d'un contournement (voir [[brief_2026-05-21_fork-nextgraph-inbox]], [[brief_2026-06-15_shared-wallet-shim]]).
|
||||
|
||||
### Implications côté code
|
||||
|
||||
1. **Disparition de `ensureGraphNuri()`** comme helper unique → helpers par entité ou couche `storeRegistry` résolvant le NURI selon `(entité, contexte)`.
|
||||
2. **`useShapeWithDefaults` reste un wrapper** mais l'appelant choisit explicitement le store (N appelants demain).
|
||||
3. **Chaque entité déclare son store cible** (mapping centralisé ou convention shape→store).
|
||||
4. **`bootstrapWallet()`** (`src/shared/utils/ngBootstrap.ts`) revu : seed réparti, ou seed = données de l'utilisateur courant seulement.
|
||||
5. **`FestipodDataContext`** : hooks par entité, chacun avec son store résolu.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Quand crée-t-on un Group store de communauté (API absente) ? Acte explicite vs communauté par défaut ?
|
||||
2. Comment Bob connaît-il l'index communautaire d'Alice ? (possiblement via le public_store d'Alice)
|
||||
3. Faut-il vraiment 4 niveaux ? Le « meeting point = group store » mérite validation.
|
||||
4. Que devient le seed de démo quand les Group stores n'existent pas encore ?
|
||||
5. Migration des wallets de test existants (script / wipe-reseed / ignore) ?
|
||||
6. Bootstrap d'un user vierge : auto-créer un Group store « par défaut » ou attendre ?
|
||||
|
||||
## Possible Approaches
|
||||
|
||||
- **Refactor structurel d'abord, partage ensuite** (placeholders `private_store_id`).
|
||||
- **Registry centralisé** vs **résolution par convention**.
|
||||
- **Big-bang** vs **par entité** (commencer par Event).
|
||||
- **Maintenir un mode mono-store** parallèle pour dev/demo.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
Invitation effective (capability sharing), permissions par rôle, discovery cross-wallet, contournement de l'UI wallet, mode P2P direct sans broker. → second chantier multi-user dont ce refactor est le prérequis structurel.
|
||||
|
||||
## Starting Points
|
||||
|
||||
- Concept `data-layer` → [[decision_2026-03-17_private-store-nuri-scope]] (la décision qu'on viendra modifier), état du pattern d'écriture
|
||||
- `src/shared/utils/ngGraph.ts`, `src/shared/hooks/useShapeWithDefaults.ts`, `src/shared/context/FestipodDataContext.tsx`, `src/shared/utils/ngBootstrap.ts`
|
||||
- NextGraph docs : [Documents et Stores](https://docs.nextgraph.org/en/documents/), [Getting started](https://docs.nextgraph.org/en/getting-started/)
|
||||
@@ -1,96 +0,0 @@
|
||||
---
|
||||
type: brief
|
||||
summary: Forker temporairement nextgraph-rs pour exposer l'inbox au SDK JS (notif d'inscription, anonymat via from optionnel) — 3 couches : patch Rust (4 fichiers), auto-hébergement ngd+ng-app sur Coolify, intégration Festipod ; fork jetable abandonné quand l'upstream livrera sa solution
|
||||
last_updated: 2026-05-21
|
||||
---
|
||||
|
||||
# Forker NextGraph pour exposer l'inbox au SDK JS
|
||||
|
||||
**Status:** Incubating — aucun travail démarré
|
||||
**Last updated:** 2026-05-21
|
||||
|
||||
## Context
|
||||
|
||||
Festipod doit notifier l'hôte d'un PdR quand quelqu'un s'inscrit, avec **identification si connexion / anonyme sinon** (cf. décision cadre inbox dans [[brief_2026-05-18_authorization-matrix]]). L'**inbox** NextGraph est idéale (le `from` optionnel donne l'anonymat) **mais n'est pas exposée au SDK JS** (cf. [[knowledge_stores-permissions]] §Inbox). Ce brief évalue **forker/patcher `nextgraph-rs`** pour l'exposer.
|
||||
|
||||
### Posture stratégique (cadrée par l'utilisateur)
|
||||
|
||||
Le fork est **explicitement temporaire, non destiné à l'upstream**. Hypothèse : NextGraph finira par exposer sa **propre** solution d'inbox au SDK JS, **possiblement différente**. Quand elle arrivera, on **abandonne le fork et on adapte Festipod**. Tant que leur solution n'est pas là : maintenir le fork à jour (rebase sur `upstream/main`, qui bouge vite en `0.1.2-alpha`) ; **déployer broker + ng-app depuis le fork** ; surveiller l'upstream pour basculer dès que possible. On ne vise **pas** une PR.
|
||||
|
||||
## What We Know
|
||||
|
||||
Trois couches.
|
||||
|
||||
### Couche 1 — Le patch Rust : 4 fichiers (broker vanilla)
|
||||
|
||||
1. **`engine/net/src/types.rs`** — `InboxMsgContent::Link` est une variante **unit** (stub) ; lui donner un payload (ou variante `Notification`) portant le NURI du PdR + lien vers l'`Inscription`. Ajouter un builder `InboxPost::new_link(...)` calqué sur `new_contact_details`. `from = None` → anonymat.
|
||||
2. **`engine/verifier/src/request_processor.rs`** — ajouter le bras de commande manquant (pas de bras `InboxPost`). Idéalement une commande haut-niveau (`NotifyInbox`) construisant le post côté Rust (garde le scellement crypto en Rust). Calquer sur `SocialQueryStart`.
|
||||
3. **`sdk/js/lib-wasm/src/lib.rs`** — exposer `pub async fn inbox_post_link(session_id, to_inbox_nuri, to_profile_nuri, link, anonymous)`, calqué sur `social_query_start`.
|
||||
4. **`engine/verifier/src/inbox_processor.rs`** (`process_inbox`) — bras de réception qui **matérialise** le message en document dans le store de l'hôte (calquer sur le handler `ContactDetails`). L'app lit ensuite via ORM/SPARQL — pas de nouvelle API de lecture d'inbox.
|
||||
|
||||
**Résolution d'identité** (connu/anonyme) : gratuite via SPARQL côté app (JOIN du NURI d'inbox émetteur contre les docs `social:contact`). **Découverte de l'inbox de l'hôte** : embarquer le NURI d'inbox du `public_store` de l'hôte dans le doc PdR ou le profil public (le flux QR-code de partage de profil le porte déjà).
|
||||
|
||||
### Couche 2 — Déploiement (depuis le fork)
|
||||
|
||||
Détail dans [[knowledge_integration-model]]. Le verifier patché tourne **dans l'iframe ng-app** → **construire et auto-héberger le `ngd` + le ng-app** depuis le fork, puis rebuilder le `@ng-org/web` de Festipod avec `NG_REDIR_SERVER`/`NG_DEV*` pointant sur ce ng-app. **Aucune réécriture de l'intégration Festipod** (reste iframe). Le routage inbox du broker est déjà natif, mais comme on auto-héberge le ng-app patché, **on déploie toute la stack depuis le fork** (un seul arbre source).
|
||||
|
||||
- **Local** : `ngd` + ng-app du fork ; Festipod buildé avec `NG_DEV`/`NG_DEV_LOCAL_BROKER`.
|
||||
- **Serveur de test** : `ngd` + ng-app du fork sur notre domaine ; Festipod buildé avec `NG_REDIR_SERVER=notre-domaine`.
|
||||
|
||||
#### Hébergement sur Coolify — 3 pièces web
|
||||
|
||||
1. **`ngd`** — démon WebSocket **stateful** : conteneur avec **volume persistant** pour `--base-path` (RocksDB + clés + PeerId, jamais wipé), mode `--domain` derrière le Traefik de Coolify. Build : Dockerfiles officiels cassés → **écrire notre Dockerfile multi-stage Rust** (RocksDB exige llvm/clang). Premier démarrage **interactif** (lien d'invitation wallet admin) → scripter via `ngcli` ou faire une fois à la main puis persister dans le volume.
|
||||
2. **ng-app** (frontend iframe, wasm patché) — **build statique** (`pnpm webfilebuild`). Servi en statique (buildpack ou nginx).
|
||||
3. **Routage** : un même domaine sert le statique du ng-app ET proxifie le WebSocket vers ngd.
|
||||
|
||||
Plus **Festipod** lui-même (app Bun → skill `coolify-hosting` pour CELLE-CI, pas pour le `ngd` Rust). Drivers de complexité : build Rust+RocksDB sans Dockerfile prêt, conteneur stateful à volume critique, premier-run interactif, double-service (statique + WS).
|
||||
|
||||
### Couche 1 (libs JS) — paquets npm clients patchés
|
||||
|
||||
**On maintient des versions patchées des paquets clients, pas seulement le wasm.** Le forwarding générique permet *techniquement* d'atteindre une méthode wasm sans toucher le JS, mais c'est un **hack** (non typé, fragile) — test rapide seulement. À modifier réellement :
|
||||
|
||||
- **`@ng-org/web`** — modifié de toute façon (URL broker) → y ajouter `inbox_post_link` dans la **surface d'API typée + `.d.ts`**.
|
||||
- **Méthodes streamées** (si lecture inbox en *flux* un jour) — entrée des deux côtés (`E` + `streamed_api`). Pour la seule **écriture** (requête/réponse), inutile.
|
||||
- **`@ng-org/orm`** — à modifier **si** on intègre l'écriture inbox au flux ORM. Sinon (appel `ng.inbox_post_link` à côté), inutile.
|
||||
- **`@ng-org/alien-deepsignals`, `@ng-org/shex-orm`** — a priori inchangés.
|
||||
|
||||
#### Outillage existant : `scripts/build-ng-packages.sh`
|
||||
|
||||
`bun run build:ng` build les 4 paquets depuis `$NEXTGRAPH_RS/sdk/js/*` (défaut `../../nextgraph/nextgraph-rs`) → `pnpm pack` → `.tgz` dans `.ng-tarballs/` → `bun add` réécrit `package.json` vers les tarballs locaux. **Pattern d'origine du projet** : le commit `fd6d408` l'a abandonné quand les alphas ont été publiées sur npm. Pour repasser au custom : **réactiver `bun run build:ng`**. Nuances : `@ng-org/web` est TS pur (le script crée un *stub* `lib-wasm` ; le tarball porte l'API inbox typée + l'URL broker bakée, **pas** le wasm) ; pointer le script sur la **branche patchée** (retirer le `git pull --ff-only`) ; option recommandée : patcher `@ng-org/web` pour lire l'URL broker au **runtime** (évite de rebuilder par domaine).
|
||||
|
||||
### Couche 3 — Intégration dans Festipod
|
||||
|
||||
Exposer la méthode ne suffit pas. Chantiers (certains préexistent à l'inbox) :
|
||||
|
||||
- **Modéliser le PdR.** Les SHEX (`src/shared/shapes/shex/festipodShapes.shex`) ne définissent qu'`Event`/`UserProfile`/`Participation` — **pas de `MeetingPoint`** (local-only), ni d'entité notification. Ajouter les shapes + `bun run build:orm`.
|
||||
- **Implémenter l'inscription (aujourd'hui no-op).** Dans `FestipodDataContext.tsx`, `joinEvent`/`leaveEvent` sont des `console.log`. Le vrai flux : (a) écrire l'`Inscription` dans le `protected_store` de l'inscrit (via multi-store, [[brief_2026-05-17_multi-store-refactor]]), (b) appeler `ng.inbox_post_link(...)` pour notifier l'inbox du PdR.
|
||||
- **Porter le NURI d'inbox de l'hôte** sur le doc PdR (ou lookup profil).
|
||||
- **Lire et résoudre les notifications côté hôte** : lire les docs notification matérialisés (ORM/SPARQL), JOIN identité contre `social:contact`. UI : « N inscrits dont X identifiés ».
|
||||
- **Câblage session** via `src/shared/utils/ngSession.ts`.
|
||||
|
||||
**Dépendances** : présuppose (1) le fork SDK livré, (2) le refactor multi-store. **Surface jetable** : à l'arrivée de l'API officielle, migrer aussi ces points d'appel Festipod.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- `NotifyInbox` haut-niveau vs `InboxPost` brut ? (haut-niveau préféré, garde la crypto en Rust)
|
||||
- Où sourcer le NURI d'inbox de l'hôte (doc PdR vs lookup profil) ?
|
||||
- Forme de la matérialisation côté réception (quels triples) ?
|
||||
- Suppression côté inbox : un déposant peut-il retirer son dépôt ? (résiduelle, cf. [[brief_2026-05-18_authorization-matrix]])
|
||||
- Cadence de rebase du fork ? Critère de bascule vers la solution upstream ?
|
||||
- `@ng-org/web` : patch runtime vs tarball par domaine ?
|
||||
- `ngd` Coolify : automatiser le premier-run vs one-shot manuel persisté ? Un service (reverse-proxy maison) ou deux ?
|
||||
|
||||
## Possible Approaches
|
||||
|
||||
- **A. Fork temporaire + auto-hébergement (retenu comme stopgap)** — patch des 4 fichiers, déploiement depuis le fork. Vrai inbox, anonymat natif. Coût : maintenir le fork + héberger. Jetable.
|
||||
- **B. Contribution upstream — écartée** comme objectif.
|
||||
- **C. Pas de patch, détourner `social_query_start`** — repli, livrable tout de suite mais limité aux **contacts** (pas d'anonyme vers un hôte non-connecté).
|
||||
|
||||
> Voir aussi [[brief_2026-06-15_shared-wallet-shim]] : le vrai multi-user (lecture cross-wallet) suppose en plus un patch `OpenRepo` + capabilities, au-delà de l'inbox.
|
||||
|
||||
## Starting Points
|
||||
|
||||
- [[knowledge_integration-model]], [[knowledge_stores-permissions]]
|
||||
- [[brief_2026-05-18_authorization-matrix]] — la décision cadre inbox que ce patch sert
|
||||
- Repo local `nextgraph-rs` : `sdk/js/lib-wasm/src/lib.rs`, `engine/verifier/src/{request_processor,inbox_processor}.rs`, `engine/net/src/types.rs`
|
||||
- Remotes : `origin` = `git.nextgraph.org/slaivyn/nextgraph-rs` (fork perso), `upstream` = `git.nextgraph.org/NextGraph/nextgraph-rs`
|
||||
@@ -1,183 +0,0 @@
|
||||
---
|
||||
type: brief
|
||||
summary: Stopgap staging multi-user — deux visions cadrées. Lointaine (cible) — multi-wallet, 3 stores natifs par utilisateur + Dialog, 1 document par entité (événement/PdR), partage par capabilities, inbox native du PdR. Adaptée (stopgap) — UN wallet partagé, 1 document par entité dans son private_store, périmètre = métadonnée logique + index, filtre d'isolation applicatif, login simulé. Obstacles NextGraph — pas de lecture cross-wallet (OpenRepo TODO, ReadCap ignoré), capabilities/inbox non exposées au SDK, login non programmable. sharedWalletShim + filtre = jetables à la migration.
|
||||
last_updated: 2026-06-16
|
||||
---
|
||||
|
||||
# Stopgap multi-user : wallet partagé unique (`sharedWalletShim`)
|
||||
|
||||
**Status:** En cours — compte/login + isolation livrés et vérifiés ; **granularité 1 document par entité implémentée** ; primitives + **fan-out multi-documents validés sur broker** (`@data`, 3 scénarios). Reste : reactivity in-app de la création (best-effort) + seeding multi-doc.
|
||||
|
||||
## Objectif & posture
|
||||
|
||||
Mettre Festipod en **staging** avec des **utilisateurs amicaux**, **sans enjeu de sécurité**, branché sur le vrai NextGraph, et **structuré au plus près de l'infra cible** pour qu'une migration soit un simple changement de résolveur, pas une réécriture.
|
||||
|
||||
Trois choses doivent rester nettes pour ne pas dériver, et structurent ce brief :
|
||||
|
||||
1. la **vision lointaine** — ce qu'on aura quand NextGraph offrira lecture cross-wallet, capabilities et inbox ;
|
||||
2. les **obstacles NextGraph** — ce qui, aujourd'hui, empêche cette vision ;
|
||||
3. la **vision adaptée** (stopgap) — au plus proche de la lointaine, compte tenu des obstacles.
|
||||
|
||||
> **Invariant directeur** : tout ce que fait la vision adaptée doit avoir une **correspondance 1:1** explicite avec la vision lointaine (table en fin de section adaptée). Si un choix du stopgap n'a pas d'image claire dans la cible, c'est un signal de dérive.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision lointaine (cible finale)
|
||||
|
||||
Dérivée de [[brief_2026-05-18_authorization-matrix]] et [[knowledge_stores-permissions]]. Périmètre **validé** (hors communautés / listes curées / suivi, encore hors périmètre).
|
||||
|
||||
### Identité & login
|
||||
- **1 utilisateur = 1 wallet NextGraph.** Le wallet **est** l'identité ; pas de compte applicatif séparé.
|
||||
- **Login = ouvrir son propre wallet** (redirect broker). C'est un vrai login par-utilisateur.
|
||||
|
||||
### Stores & granularité documents
|
||||
Modèle natif : `1 document = 1 repo = 1 frontière de permission = 1 inbox`. Un **store** est un document-conteneur qui regroupe et permissionne d'autres documents. Chaque utilisateur a **3 stores natifs** ; les entités sont des **documents individuels** dedans (pas un gros graphe par store).
|
||||
|
||||
| Entité | Store (propriétaire) | Granularité | Notes |
|
||||
|---|---|---|---|
|
||||
| Événement | `public_store` du déclarant | **1 document / événement** | adressable par NURI (utile pour la déduplication) |
|
||||
| Point de rencontre (PdR) | `public_store` de l'hôte | **1 document / PdR** | **possède son inbox native** (reçoit les inscriptions) |
|
||||
| Profil réseau | `protected_store` | 1 document | nom, avatar, bio, ville, intérêts |
|
||||
| Participation / Inscription | `protected_store` de l'inscrit | 1 document / inscription | + dépôt d'un lien dans l'**inbox du PdR** |
|
||||
| Profil privé (settings, email) | `private_store` | 1 document | soi seul |
|
||||
| Connexion A↔B | **Dialog store** A↔B | doc connexion (+ messagerie) | deux écrivains |
|
||||
| Index des connexions | `protected_store` | 1 document | liste les NURIs des Dialog stores |
|
||||
|
||||
**Aucun Group store** sur le périmètre validé (les 3 stores + Dialog + inboxes suffisent).
|
||||
|
||||
### Partage & visibilité
|
||||
- Par **capabilities** : on transmet un Nuri portant un read/write cap. **Public** = lisible par tous sans cap. **Protected** = cap obtenue via la connexion. **Privé** = soi.
|
||||
- Ajout de permission asynchrone ; retrait synchrone (SyncSignature).
|
||||
|
||||
### Inbox & notifications
|
||||
- L'**inbox native du document PdR** reçoit les dépôts d'inscription (lien DID cap). `from` optionnel ⇒ **identifié si connexion de l'hôte, anonyme sinon**, gratuitement.
|
||||
|
||||
### Découverte
|
||||
- **Pas d'annuaire central.** On découvre via les `public_store` et le graphe de connexions (et plus tard `social_query`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Obstacles côté NextGraph (ce qui empêche la vision lointaine aujourd'hui)
|
||||
|
||||
Vérifiés dans `nextgraph-rs` (2026-06-15).
|
||||
|
||||
| Élément de la cible | Obstacle actuel | Preuve |
|
||||
|---|---|---|
|
||||
| Lire le store d'un **autre** utilisateur | `OpenRepo` **non implémenté** ; un NURI étranger lève `RepoNotFound` ; une session ne contient que ses 3 stores dans `self.repos` | `engine/verifier/src/verifier.rs:1423`, `request_processor.rs` `resolve_target` |
|
||||
| Partager une **capability** (Nuri + droits) | non exposé au SDK ; le champ `access`/`ReadCap` du NURI **n'est jamais inspecté** | [[knowledge_stores-permissions]] |
|
||||
| **Inbox** d'un document (notif d'inscription) | pas exposée au SDK JS (nécessite un fork moteur) | [[brief_2026-05-21_fork-nextgraph-inbox]] |
|
||||
| **Login per-utilisateur** fluide | login **non programmable** (redirect web vers le broker) | [[decision_2026-06-15_shared-wallet-login-flow]] |
|
||||
|
||||
**Conséquence centrale** : tant que la lecture cross-wallet n'existe pas, **aucune donnée ne franchit la frontière entre deux wallets**. Bob ne peut pas lire le `public_store` d'Alice. Toute approche « chacun son wallet » est donc bloquée à la racine.
|
||||
|
||||
---
|
||||
|
||||
## 3. Vision adaptée (stopgap) — au plus proche de la cible
|
||||
|
||||
### Principe : UN wallet partagé
|
||||
Tous les utilisateurs amicaux ouvrent **le même** wallet. NextGraph ne voit qu'une identité → **tout est techniquement lisible** (on contourne l'absence de lecture cross-wallet en supprimant la frontière). Le « multi-utilisateur » devient une **fiction applicative**.
|
||||
|
||||
### Granularité documents — **identique à la cible** : 1 document par entité
|
||||
Pour rester fidèle, on reproduit les **deux niveaux** de la cible (conteneur → documents) :
|
||||
|
||||
- chaque **événement** et chaque **PdR** = **son propre document** (`doc_create`), tous physiquement dans le `private_store` de l'unique wallet partagé ;
|
||||
- le **périmètre** (public/protected/private) est une **métadonnée logique** portée par le document, **pas** un store physique ;
|
||||
- un **document-index par (utilisateur × périmètre)** liste les NURIs des entités de ce périmètre — il **joue le rôle du futur store-conteneur** (`docPublic` ≈ futur `public_store`, etc.).
|
||||
|
||||
Garder la granularité « 1 doc par entité » est ce qui rend la migration 1:1 **et** ce qui rendra l'**inbox du PdR** possible plus tard sans refonte (l'inbox est un attribut de document).
|
||||
|
||||
| Entité | Périmètre logique | Document stopgap | Indexé dans |
|
||||
|---|---|---|---|
|
||||
| Événement | public | 1 doc / événement | `docPublic` du déclarant |
|
||||
| PdR | public | 1 doc / PdR | `docPublic` de l'hôte |
|
||||
| Profil réseau | protected | doc profil réseau | `docProtected` |
|
||||
| Participation | protected | 1 doc / participation (ou groupé) | `docProtected` de l'inscrit |
|
||||
| Profil privé | private | doc settings | `docPrivate` |
|
||||
| Connexion A↔B | dialog | doc connexion | index de connexions |
|
||||
|
||||
### `sharedWalletShim` (échafaudage, sans équivalent cible)
|
||||
Index des **comptes** simulés → leurs documents-index : `username → profileId → { docPublic, docProtected, docPrivate }`. Ancré dans le `private_store` du wallet partagé (`session.private_store_id`, toujours connu → ancre de bootstrap). Rend possibles le **login cross-device** et le **picker d'utilisateurs**. **N'a aucun équivalent cible** (la cible n'a pas d'annuaire central) → **jetable**.
|
||||
|
||||
### Partage simulé : filtre d'isolation applicatif
|
||||
Un seul wallet ⇒ tout lisible. Pour **se comporter** comme la cible, la couche données filtre les lectures par `currentAccountId` + connexions : `private` → propriétaire ; `protected` → propriétaire + connexions ; `public` → tous. Remplace les **capabilities** (pas appliqué par la crypto, mais honoré par l'app) → **jetable**.
|
||||
|
||||
### Identité & login simulés
|
||||
- **Couche réelle (technique, invisible)** : le redirect broker du wallet partagé, présenté comme **barrière d'accès à l'environnement** (pas un login). Cf. [[decision_2026-06-15_shared-wallet-login-flow]].
|
||||
- **Couche applicative (le login perçu)** : écran « Connexion » = **username seul** (déclaratif, sans mot de passe) → `localStorage`. « Déconnexion » = efface le username, sans toucher NG. Vrai logout planqué.
|
||||
|
||||
### Inbox / notification d'inscription
|
||||
**Hors périmètre du stopgap** (nécessite le fork). Mais la granularité « 1 doc/PdR » est le **pré-requis** qui la rendra branchable plus tard sans refonte.
|
||||
|
||||
### Correspondance stopgap → cible (l'invariant 1:1)
|
||||
|
||||
| Stopgap | Vision lointaine | Migration |
|
||||
|---|---|---|
|
||||
| doc événement (dans le wallet partagé) | doc événement dans le `public_store` du déclarant | déplacer le doc + appliquer cap publique |
|
||||
| doc PdR | doc PdR dans le `public_store` de l'hôte **+ inbox** | déplacer + brancher l'inbox |
|
||||
| `docPublic`/`docProtected`/`docPrivate` (index) | `public_store`/`protected_store`/`private_store` | l'index devient le store natif |
|
||||
| filtre d'isolation applicatif | capabilities (read caps via connexion) | retirer le filtre, poser les caps |
|
||||
| login applicatif (username) | login = ouvrir son wallet | retirer la couche compte |
|
||||
| `sharedWalletShim` | — (rien) | supprimer |
|
||||
| wallet partagé unique | un wallet par utilisateur | éclater par propriétaire |
|
||||
|
||||
**Migration = swap du résolveur `storeRegistry`** (de « doc dans le wallet partagé » vers « doc dans le vrai store du propriétaire ») + déplacement des documents + pose des capabilities + suppression de l'échafaudage. **Les écrans ne changent pas.**
|
||||
|
||||
---
|
||||
|
||||
## Familles de contournement (pourquoi A)
|
||||
|
||||
| Famille | Idée | Verdict |
|
||||
|---|---|---|
|
||||
| **A — wallet partagé** | un seul wallet, multi-user simulé côté app | **retenue** : livrable vite, zéro travail moteur, local-first préservé |
|
||||
| B — NG comme backend | un backend Bun détient un wallet, clients HTTP | écartée : abandonne le local-first, plus lourd |
|
||||
| C — lecture cross-wallet | chacun son wallet, lecture du public des autres | **infaisable** (obstacle §2) sans fork moteur |
|
||||
| D — fork moteur (`OpenRepo` + capabilities) | rendre la cible réelle | hors stopgap : c'est le chemin cible, lourd (cf. [[brief_2026-05-21_fork-nextgraph-inbox]]) |
|
||||
|
||||
---
|
||||
|
||||
## État d'implémentation (2026-06-16)
|
||||
|
||||
Deux drapeaux de build, **OFF par défaut** (le mono-store validé reste le défaut ; dev/`@ui`/`@e2e` inchangés) :
|
||||
- **`FESTIPOD_STAGING=1`** — flux login option 2 (découplé de `NODE_ENV` pour ne pas bloquer `@e2e`).
|
||||
- **`FESTIPOD_MULTISTORE=1`** — couche multi-document (storeRegistry).
|
||||
|
||||
| Pièce | Fichier | État |
|
||||
|---|---|---|
|
||||
| Couche compte (faux login, localStorage) | `src/shared/context/AccountContext.tsx` | ✅ livré, vérifié (build + `@ui`) |
|
||||
| Gate technique + écran « Connexion » + orchestrateur | `src/modules/auth/screens/{AccessGateScreen,ConnexionScreen}.tsx`, `src/app/AuthGate.tsx` | ✅ livré |
|
||||
| Vrai logout planqué | `ngSession.ts:logoutNg`, `SettingsScreen.tsx` | ✅ livré |
|
||||
| Filtre d'isolation (mode connecté) | `src/shared/utils/isolation.ts` + `FestipodDataContext` | ✅ livré, pur, vérifié |
|
||||
| storeRegistry + sharedWalletShim | `src/shared/utils/storeRegistry.ts` | ✅ **1 doc/entité** (`createEntityDoc`/`listEntityDocs` + index par périmètre) ; primitives **validées broker** |
|
||||
| Câblage multi-document (reads fan-out `{graphs}` + write per-entité `createEntityDoc`) | `FestipodDataContext` (useNgData) derrière `MULTISTORE` | ✅ lecture fan-out validée ; ⚠️ reactivity de la création in-app = **best-effort** (le nouveau doc est ajouté au fan-out, l'`@id` peut être en attente jusqu'au re-subscribe) |
|
||||
| Validation broker (`@data`) | `src/modules/workshop/{features/multistore-stopgap.feature, steps/data/multistore.steps.ts}` | ✅ **3 scénarios verts** (ORM-sur-doc-créé, shim r/w, **fan-out par entité**) |
|
||||
|
||||
**Granularité = 1 document par entité (fait)** : public (événements/PdR) → un `doc_create` **par entité**, NURI ajouté à l'**index** du périmètre (le futur store-conteneur) ; protected (profil, participations) → **groupé** dans l'index protected. Lecture publique = `listEntityDocs('public')` → `useShape({graphs:[…]})`. Mono-store (défaut) inchangé.
|
||||
|
||||
**Validation broker (2026-06-16, `@data` contre `nextgraph.net`, 3 scénarios verts)** :
|
||||
1. ✅ `doc_create("Graph","data:graph","store",undefined)` → NURI utilisable comme `@graph` ORM (write+read d'une `Participation` via `useShape({graphs:[nuri]})`). On n'est **pas** limité au `private_store` comme scope (cf. [[rule_private-store-scope]]).
|
||||
2. ✅ Shim r/w : 3 docs créés + `sparql_update`, rechargés via `sparql_query` (`readBindings` tolérant, OK en pratique).
|
||||
3. ✅ **Fan-out par entité** : 2 comptes × 1 doc-événement (`createEntityDoc` + indexé), un `useShape({graphs:[docA,docB]})` lit **les deux** événements, l'index public liste les deux docs.
|
||||
4. ⏳ **Reste** : reactivity de la création in-app (best-effort, à itérer sur broker) + seeding multi-document (auto-seed neutralisé en `MULTISTORE`).
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **Modèle d'écriture de l'événement** (propriétaire / wiki / immuable) — *ouvert dans la matrice*. Propriétaire/immuable → événement = doc dans le `public_store` du déclarant (granularité par entité exacte). **Wiki** → exigerait un Group store (hors périmètre) et **changerait la cible**.
|
||||
- **Création des documents** : `doc_create` à la création de l'entité (retenu par la granularité par entité) ; création paresseuse des index de périmètre.
|
||||
- **Picker d'utilisateurs** : saisie libre vs liste des comptes du `sharedWalletShim`.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Le **vrai multi-user** (lecture cross-wallet) — fork moteur (`OpenRepo` + capabilities), voir [[brief_2026-05-21_fork-nextgraph-inbox]].
|
||||
- L'**inbox du PdR** (notif d'inscription) — même fork.
|
||||
- L'**auto-hébergement** du broker/ng-app (staging sur `nextgraph.net`).
|
||||
- Toute **sécurité réelle** (credential partagé, pas de mot de passe, pas de chiffrement par utilisateur).
|
||||
|
||||
## Starting Points
|
||||
|
||||
- [[brief_2026-05-18_authorization-matrix]] — périmètres et partition de la vision lointaine
|
||||
- [[brief_2026-05-17_multi-store-refactor]] — l'indirection `storeRegistry`
|
||||
- [[brief_2026-05-21_fork-nextgraph-inbox]] — le chemin cible réel (cross-wallet + inbox)
|
||||
- [[decision_2026-06-15_shared-wallet-login-flow]] — flux login/logout
|
||||
- [[knowledge_stores-permissions]] — stores, capabilities, inbox, limites SDK
|
||||
- `src/shared/utils/storeRegistry.ts`, `src/shared/context/FestipodDataContext.tsx`, `src/app/AuthGate.tsx`, `src/shared/utils/isolation.ts`
|
||||
- Source `nextgraph-rs` : `sdk/rust/src/tests/sparql_regressions.rs:136-200` (multi-document), `engine/verifier/src/verifier.rs:1423` (TODO `OpenRepo`)
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
type: decision
|
||||
summary: Flux login/logout du stopgap wallet partagé — le vrai login NextGraph (redirect broker) apparaît en premier, perçu comme une barrière technique d'accès à l'environnement ; l'écran applicatif « Connexion » (username seul → localStorage) EST le login perçu ; « Déconnexion » efface juste le username sans toucher NG ; vrai logout planqué
|
||||
last_updated: 2026-06-15
|
||||
---
|
||||
|
||||
# Décision 2026-06-15 — Flux de login/logout du stopgap wallet partagé
|
||||
|
||||
Arbitrage du flux d'authentification perçu pour le stopgap [[brief_2026-06-15_shared-wallet-shim]]. Frozen.
|
||||
|
||||
## Contrainte de départ
|
||||
|
||||
Le login NextGraph **n'est pas programmable** : c'est une **redirection web** vers la page du broker (`nextgraph.net`). Impossible d'ouvrir le wallet partagé en silence — il faut au minimum un passage par le redirect broker, au moins une fois par device. La question n'est donc pas *« comment éviter le redirect »* mais *« comment l'ordonner et le présenter »* pour que l'UX reste cohérente.
|
||||
|
||||
## Décision : option 2 — gate technique d'abord, « Connexion » applicative ensuite
|
||||
|
||||
Deux couches d'auth distinctes, présentées dans cet ordre :
|
||||
|
||||
1. **Couche réelle (technique, non perçue comme login)** — le redirect broker apparaît **immédiatement, avant tout rendu de l'app**. Comme il précède l'app, l'utilisateur le lit comme une **barrière technique d'accès à l'environnement de test** (type mur de beta), **pas** comme un login applicatif. Mêmes credentials partagés pour tous (donnés dans l'invitation, façon « code d'accès »). Une fois par device, puis persistant. **Jamais étiqueté « login ».** Un splash Festipod minimal précède le redirect pour donner du contexte.
|
||||
2. **Couche applicative (perçue comme LE login)** — écran **« Connexion »** = saisie du **username** (→ `localStorage`, `currentAccountId`). C'est le login *dans la perception* de l'utilisateur. **Sans mot de passe** (décision username-seul) → connexion **déclarative** : n'importe qui prend n'importe quel username (cohérent zéro-sécurité / amis). **« Déconnexion »** = efface **seulement** le username et revient à l'écran « Connexion » ; **n'appelle aucune fonction NG**.
|
||||
|
||||
Le **vrai logout** (`ng.session_stop` / `user_disconnect` / `wallet_close`) reste **planqué** (réglages/debug), car il force un nouveau redirect.
|
||||
|
||||
Le label **« Connexion »/« Déconnexion »** (et non « Changer de profil ») est un choix explicite : on assume de faire passer le username pour le login applicatif, puisque la barrière technique n'est pas perçue comme tel.
|
||||
|
||||
## Pourquoi (vs option 1 écartée)
|
||||
|
||||
**Option 1 écartée** — faux login d'abord (username), puis page d'avertissement « saisissez tel username/password », puis bouton *Continuer* déclenchant le redirect. Rejetée : workflow étrange, **double-login dissonant** (« je me suis déjà connecté, pourquoi je recommence ailleurs ? »), page d'avertissement qui **ressemble à une arnaque**, et le redirect **ressurgit en plein usage** à chaque expiration de session.
|
||||
|
||||
**Option 2 retenue** parce que :
|
||||
- **Cohérence du modèle mental** : la barrière technique n'étant pas perçue comme un login, la paire **Connexion/Déconnexion** applicative est complète et auto-cohérente — plus aucun mismatch sur le logout (se déconnecter ramène à l'écran de connexion, les deux dans la même couche).
|
||||
- **Dégradation gracieuse** : un re-gate après redémarrage navigateur (perte de `sessionStorage`) se lit comme « reconnexion à l'environnement », pas comme un bug.
|
||||
- **Implémentation plus simple** : `NextGraphContext` fait déjà le flux `connect`/redirect ; l'écran « Connexion » est un écran in-app normal ; pas de page d'avertissement bespoke.
|
||||
- **Similarité avec l'infra cible** (objectif directeur du stopgap) : la forme **« redirect broker → app »** est exactement le flux du vrai multi-wallet. À la migration, on **supprime l'écran « Connexion » username** et la **barrière technique devient le vrai login per-user** — la forme du flux ne change pas.
|
||||
|
||||
## Faits techniques vérifiés (`nextgraph-rs`, 2026-06-15)
|
||||
|
||||
- **Persistance de session : OUI.** Wallet mémorisé côté iframe broker (`localStorage` long-terme + `sessionStorage` pour la session active) ; au rechargement, `init()` retrouve la session **sans re-déclencher le redirect** tant que la session broker existe (`sdk/js/web/src/index.ts`, `sdk/js/api-web/main.ts`). Un **redémarrage complet du navigateur** (perte de `sessionStorage`) peut re-déclencher le gate.
|
||||
- **Logout réel exposé : OUI.** `ng.session_stop()`, `ng.user_disconnect()`, `ng.wallet_close()` (`sdk/js/lib-wasm/src/lib.rs`) ; arrêtent la session / effacent le wallet ; **forcent un nouveau redirect** ensuite → d'où le choix de **ne pas** les appeler dans la « Déconnexion » applicative et de planquer le vrai logout.
|
||||
|
||||
## Conséquences côté code (Festipod)
|
||||
|
||||
- `NextGraphContext` — déclencher le `connect`/redirect **au boot**, avant le rendu de l'app (+ splash pré-redirect).
|
||||
- Un écran applicatif **« Connexion »** (username → `localStorage` / `currentAccountId`), username résolu contre les comptes du `sharedWalletShim`.
|
||||
- Une **« Déconnexion »** qui efface seulement le username (aucun appel NG).
|
||||
- Vrai logout exposé seulement en réglages/debug.
|
||||
|
||||
## See Also
|
||||
|
||||
- [[brief_2026-06-15_shared-wallet-shim]] — le stopgap que cette décision complète
|
||||
- Concept `data-layer` — `NextGraphContext`, auto-init conditionnel, flux redirect broker
|
||||
@@ -1,53 +0,0 @@
|
||||
---
|
||||
type: decision
|
||||
summary: Distribution du wallet partagé par IMPORT ASSISTÉ PAR FICHIER (.ngw) — l'auto-import zéro-touche étant impossible (broker hébergé), Festipod sert le FICHIER du wallet (téléchargement) + le mot de passe et guide un import unique sur nextgraph.eu « Import a Wallet File » depuis l'AccessGateScreen. Le TextCode a d'abord été retenu puis CORRIGÉ (transfert temporaire 5 min, inutilisable à embarquer). Barrière d'accès ON par défaut (ACCESS_GATE_DISABLED=1 pour bypass tests/dev), ancien LoginScreen retiré. Alternatives écartées (auto-import app, lien magique, self-host) ; provisioning de test (storageState) distinct
|
||||
last_updated: 2026-06-29
|
||||
---
|
||||
|
||||
# Décision 2026-06-17 — Distribution du wallet partagé par import assisté
|
||||
|
||||
Comment un utilisateur récupère le wallet partagé sur un nouveau navigateur, dans le stopgap [[brief_2026-06-15_shared-wallet-shim]]. Frozen.
|
||||
|
||||
## Contrainte de départ
|
||||
|
||||
L'auto-import zéro-touche par l'app est **impossible** avec le broker hébergé (fait vérifié : [[knowledge_broker-import-constraint]]). Le wallet doit préexister dans le navigateur, importé sur `nextgraph.eu` (cross-origin, non pilotable par Festipod). La question n'est donc pas « comment auto-importer » mais « comment **minimiser la friction de récupération** » — le problème initial étant que l'utilisateur devait d'abord *se procurer* le wallet.
|
||||
|
||||
## Décision : import assisté par FICHIER
|
||||
|
||||
Festipod **sert le FICHIER `.ngw`** du wallet partagé (téléchargement) et **affiche le mot de passe** dans l'`AccessGateScreen` (la barrière d'accès, cf. [[decision_2026-06-15_shared-wallet-login-flow]]), avec un guide en 3 étapes :
|
||||
|
||||
1. Télécharger le fichier du wallet partagé (bouton de téléchargement).
|
||||
2. Ouvrir `https://nextgraph.eu/#/wallet/login` (nouvel onglet) → « Import a Wallet File » → choisir le fichier → saisir le mot de passe affiché.
|
||||
3. Revenir et cliquer « Entrer » (redirect broker → demande de déverrouiller le wallet → mot de passe → app).
|
||||
|
||||
Festipod **fournit** ainsi le wallet (fin de la friction de récupération) ; l'**import lui-même reste un geste manuel unique par device**, incompressible avec le broker hébergé. Posture **zéro-sécurité, credential partagé** assumée (cf. brief) → embarquer le fichier + le mot de passe est cohérent.
|
||||
|
||||
> **Correction 2026-06-25 (le TextCode était une fausse piste)** : la 1ʳᵉ version embarquait le **TextCode**. Or le TextCode est un **transfert temporaire** (5 min, deux devices en ligne, usage unique — cf. [[knowledge_broker-import-constraint]]), donc **inutilisable embarqué** (un testeur arrivant plus tard aurait un code mort). Le test e2e passait quand même car il génère+importe le code dans la foulée. La primitive correcte est le **FICHIER statique**.
|
||||
|
||||
## Pourquoi (alternatives écartées)
|
||||
|
||||
- **(a) Auto-import embarqué par l'app** — *impossible* : le broker ne laisse aucune fenêtre d'exécution avant son gate wallet ([[knowledge_broker-import-constraint]]).
|
||||
- **(b) TextCode embarqué** — *cassé* : transfert temporaire 5 min, non réutilisable (cf. correction ci-dessus).
|
||||
- **(c) Lien magique pré-rempli** vers le broker — pas de route d'import par URL côté broker hébergé.
|
||||
- **(d) Self-host / fork du ng-app** — seule voie vers le **vrai zéro-touche**, mais lourde ; track séparé ([[brief_2026-05-21_fork-nextgraph-inbox]]). Non retenu pour le stopgap.
|
||||
|
||||
## Conséquences côté code (Festipod)
|
||||
|
||||
- `src/modules/auth/sharedWallet.ts` — `SHARED_WALLET_PASSWORD` lu depuis un **global gravé au build** `globalThis.__FESTIPOD_SHARED_WALLET_PASSWORD__` (`define` dans `build.ts`, depuis `FESTIPOD_SHARED_WALLET_PASSWORD`) ; `SHARED_WALLET_FILE_URL = /shared-wallet.ngw` ; `hasSharedWallet()` (mot de passe non vide) pilote l'affichage. Vide par défaut → la barrière retombe sur le flux simple.
|
||||
- `build.ts` — copie le fichier (`FESTIPOD_SHARED_WALLET_FILE`) dans le bundle en `/shared-wallet.ngw` + grave le mot de passe.
|
||||
- `src/modules/auth/screens/AccessGateScreen.tsx` — section assistée (téléchargement du fichier + mot de passe + guide) affichée si `hasSharedWallet()`.
|
||||
- `src/app/AuthGate.tsx` — la barrière est **ON PAR DÉFAUT** (« Festipod ne fonctionne jamais sans NextGraph »). **Révision 2026-06-29** : drapeau **inversé** — la barrière n'est désactivée que si `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ === true` (gravé par `build.ts` depuis `ACCESS_GATE_DISABLED=1`, ou injecté par le harness via `context.addInitScript` pour `@e2e`). Absent → barrière ON. (Remplace l'ancien `FESTIPOD_STAGING`/`__FESTIPOD_REQUIRE_NG__`, qui était OFF par défaut.)
|
||||
- **Ancien `LoginScreen` retiré** (`/login`, bouton « Se connecter avec NextGraph » + login démo email/mdp) : obsolète puisque l'`AccessGateScreen` précède le routeur. Évite le dead-end « connecter sans wallet ». Route `/login` supprimée.
|
||||
- **Atterrissage post-login** : après le choix du pseudo, `ConnexionScreen` navigue vers `/home` (la redirection vers l'accueil que faisait l'ancien `LoginScreen` avait disparu avec lui → on retombait sur l'onboarding `WelcomeScreen` à `/`). Filet pour les retours : `WelcomeScreen` redirige vers `/home` si déjà connecté. L'e2e `@humain` va désormais jusqu'à l'accueil pour couvrir ça.
|
||||
- L'admin exporte le fichier une fois (nextgraph.eu : menu wallet → Download/Export Wallet File) et l'injecte au build (`FESTIPOD_SHARED_WALLET_FILE=<chemin.ngw> FESTIPOD_SHARED_WALLET_PASSWORD=<mdp> bun run build` ; barrière ON par défaut, pas de drapeau à poser).
|
||||
|
||||
## À distinguer du provisioning de test
|
||||
|
||||
Le harness multi-navigateur provisionne le wallet partagé par **injection storageState** (niveau navigateur, sans la contrainte broker) — concept `bdd-testing` → `knowledge_multibrowser-harness`. Cela **prouve** « wallet partagé → app connectée » mais **court-circuite l'import**. Le mécanisme RÉEL est validé **de bout en bout par la vraie app** (e2e `@humain`) : un navigateur vierge ouvre l'app staging, **télécharge le fichier proposé par l'`AccessGateScreen`** (et vérifie que le mot de passe affiché est celui du wallet), l'importe via le vrai flux `nextgraph.eu` « Import a Wallet File », revient, clique « Entrer » et atteint l'app connectée. Les deux ne se confondent pas.
|
||||
|
||||
## See Also
|
||||
|
||||
- [[knowledge_broker-import-constraint]] — le fait technique qui force cette décision
|
||||
- [[brief_2026-06-15_shared-wallet-shim]] — le stopgap
|
||||
- [[decision_2026-06-15_shared-wallet-login-flow]] — le flux d'accès dont l'AccessGateScreen est la barrière
|
||||
- [[knowledge_stores-permissions]] — wallet, capabilities, limites SDK
|
||||
@@ -1,69 +0,0 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Le broker NextGraph hébergé n'autorise pas l'auto-import d'un wallet par une web-app tierce — init() top-level redirige, toute méthode ng.* exige d'être déjà loggé dans l'iframe, et le broker renvoie un device sans wallet vers nextgraph.eu (cross-origin, non pilotable). Un wallet doit préexister dans le navigateur. Des 4 méthodes d'import nextgraph.eu, seul le FICHIER .ngw est statique/réutilisable ; le TextCode/QR sont des transferts temporaires (5 min, deux devices en ligne) inutilisables à embarquer.
|
||||
last_checked: 2026-06-25
|
||||
---
|
||||
|
||||
# Contrainte : pas d'auto-import de wallet par une web-app tierce
|
||||
|
||||
Fait technique **vérifié empiriquement (2026-06-17)** : avec le broker NextGraph **hébergé** (`nextgraph.net`), une web-app tierce (Festipod) **ne peut pas** provisionner/importer un wallet par programme. Le wallet doit **préexister** dans le navigateur avant que le redirect d'authentification puisse réussir.
|
||||
|
||||
## Pourquoi (mécanisme du proxy `@ng-org/web`)
|
||||
|
||||
Lecture de `ngweb.js` (dist de `@ng-org/web`) :
|
||||
|
||||
- **`init()` top-level REDIRIGE** : si `window.self === window.top`, il fait `window.location.href = https://nextgraph.net/redir/#/?o=<url>`. Le code de l'app ne tourne plus.
|
||||
- **Toute méthode `ng.*` est relayée** par `parent.postMessage` vers `nextgraph.net`, et le handler **lève `"you must call init() first"` tant que la session n'est pas établie** (garde interne `d !== false`). Cela inclut `wallet_import_from_code`, `add_in_memory_wallet`, `session_in_memory_start`.
|
||||
- L'app tierce ne s'exécute **dans l'iframe qu'APRÈS** que le broker a déjà ouvert un wallet et établi la session. **Il n'existe aucune fenêtre** où notre code tourne *avant* le gate wallet du broker → **rien à quoi accrocher un auto-import**.
|
||||
|
||||
> Vérifier : `node_modules/@ng-org/web/dist/ngweb.js` — fonction `init` (redirect / postMessage selon top-vs-iframe) et le handler `apply` du Proxy `ng`. Cohérent avec [[knowledge_integration-model]] (le verifier tourne dans l'iframe du ng-app, le proxy ne fait que relayer).
|
||||
|
||||
## Ce que montre le broker (probe Playwright, navigateur frais sans wallet)
|
||||
|
||||
Sur `https://nextgraph.net/redir/#/?o=...`, le broker affiche **littéralement** :
|
||||
|
||||
> « We could not find a wallet in your browser. For now, creating a new wallet while a Web App is authenticating, is not implemented. Please create or import your wallet in a new tab. »
|
||||
|
||||
…et renvoie vers `https://nextgraph.eu/` (app wallet **standalone**). L'import standalone (`/#/wallet/login`) propose **4 méthodes**, mais elles ne sont **PAS équivalentes** :
|
||||
|
||||
| Méthode | Nature | Embarquable / réutilisable ? |
|
||||
|---|---|---|
|
||||
| **Import a Wallet File** (`.ngw`) | **fichier statique** (export portable, hors-ligne) | ✅ **oui** — statique, sans expiration, sans device source |
|
||||
| Import with TextCode | **transfert temporaire** device↔device via leurs serveurs : **5 min**, **les deux appareils en ligne**, usage unique | ❌ non |
|
||||
| Import with QR-Code | transfert live (même famille que TextCode) | ❌ non |
|
||||
| Import via Username | récupération liée à un compte | à étudier |
|
||||
|
||||
> **Piège vérifié (2026-06-25)** : le **TextCode n'est PAS un export statique** — l'écran nextgraph.eu le dit (« temporarily stored on our servers for up to 5 minutes », « both devices need to be online »). L'embarquer dans l'app est **inutilisable** : il expire / est à usage unique. Un test automatisé qui génère ET importe le code dans la foulée **passe** (live), masquant le problème — d'où une fausse piste initiale. **La primitive correcte pour un wallet partagé embarqué = le FICHIER `.ngw`.**
|
||||
|
||||
## Logique du redirect nextgraph.net (broker discovery) + ORDRE critique
|
||||
|
||||
`init()` redirige vers `nextgraph.net/redir`. Là, nextgraph.net regarde dans le **localStorage** s'il connaît un **broker** (le domaine du broker, posé lors d'un import/login wallet antérieur) :
|
||||
|
||||
- **trouvé** → redirige vers le broker (`nextgraph.eu/auth/#/wallet/login` → « Click here to login with your wallet <nom> » → mot de passe) → app. **Cas qui marche.**
|
||||
- **absent** → message « We could not find a wallet in your browser… Please create or import your wallet in a new tab by clicking here ». Le lien ouvre `nextgraph.eu` (page **Welcome**) → **Login** → **Import a Wallet File** → on rejoint l'import.
|
||||
|
||||
> **Ordre critique** : il faut **importer le wallet AVANT** d'atteindre nextgraph.net. Le guide de l'`AccessGateScreen` impose cet ordre (télécharger + importer, *puis* « Entrer »).
|
||||
|
||||
**Deux routes vers l'import** : **A** = lien direct `nextgraph.eu/#/wallet/login` (celui de Festipod + des tests — saute la page Welcome/Login) ; **B** = fallback nextgraph.net « no wallet → clicking here » → Welcome → **Login** → Import (si on atteint nextgraph.net sans wallet).
|
||||
|
||||
**Pourquoi l'e2e `@humain` ne bute pas sur le « no wallet »** : il importe le fichier (route A) **avant** le « Entrer », donc nextgraph.net trouve déjà le broker. Le test **ne couvre pas** la route B (message « no wallet » + page Welcome/Login de nextgraph.eu) — ce sont des comportements nextgraph, pas Festipod, mais un humain cliquant « Entrer » en premier y tombe.
|
||||
|
||||
> **Atténuation côté Festipod (2026-06-29)** : la barrière `AccessGateScreen` (qui **fournit** le fichier wallet + le mot de passe + le guide) est désormais l'écran d'entrée **par défaut** (cf. [[decision_2026-06-17_assisted-wallet-import]]) — l'ancien `LoginScreen` « Se connecter avec NextGraph » (qui menait directement au redirect sans fournir le wallet) a été retiré. Un utilisateur ne peut donc plus atteindre nextgraph.net **sans** que Festipod lui ait d'abord proposé le wallet. La route B reste possible s'il clique « Entrer » avant d'importer, mais il a le wallet sous les yeux pour le faire.
|
||||
|
||||
> **Contrainte UX irréductible + dé-piégeage** : « Entrer » fait une **redirection pleine-page**. Si on clique AVANT d'importer → message « no wallet » → l'import se fait dans un **autre onglet** sans retour auto vers Festipod (le broker hébergé ne sait pas revenir). Il faut donc **importer d'abord, PUIS Entrer** (guide de l'`AccessGateScreen`, ordre du `@humain`). Piège corrigé (2026-06-29) : au retour (back) après un « Entrer » prématuré, la page standalone était restaurée du bfcache avec l'état figé sur `connecting` → bouton « Accès en cours » bloqué ; `NextGraphContext` écoute `pageshow.persisted` (sans session) et réinitialise sur `disconnected` pour permettre de réessayer.
|
||||
|
||||
## Pistes d'élimination du va-et-vient — testées, ÉCARTÉES (2026-06-30)
|
||||
|
||||
Deux idées pour éviter le 2ᵉ onglet ; les deux **infaisables** sans fork :
|
||||
|
||||
1. **Embarquer `nextgraph.eu` en iframe** dans Festipod pour guider l'import sur le même écran. nextgraph.eu **n'a pas** d'en-tête anti-framing (embed possible, import OK dans l'iframe), **MAIS** le wallet importé atterrit dans le stockage **partitionné** `(top: festipod, frame: nextgraph.eu)` — invisible du login top-level → « no wallet ». **Confirmé en vrai navigateur (Chrome).** ⚠️ **Le Chromium de Playwright N'applique PAS ce partitioning → faux positif** : un probe Playwright montrait le wallet « transmis », alors que le vrai Chrome bloque. Ne jamais valider une question d'**isolation de stockage** via Playwright ; tester en vrai navigateur.
|
||||
2. **Déclencher l'écriture cross-origin sur nextgraph.net** : le mécanisme existe (pont `/auth` iframe+postMessage qui écrit `ng_bootstrap` sur nextgraph.net pendant le login nextgraph.eu) mais ce sont **les pages de NextGraph** qui l'orchestrent ; un tiers ne peut pas écrire le localStorage d'une autre origine (same-origin policy), et ça ne couvrirait que la découverte du broker, pas le wallet.
|
||||
|
||||
→ Seule élimination réelle = self-host/fork du ng-app ([[brief_2026-05-21_fork-nextgraph-inbox]]). Le flow stopgap reste l'import **en onglet séparé** (top-level `nextgraph.eu`, première-partie → pas de partitioning → fonctionne).
|
||||
|
||||
## Conséquences
|
||||
|
||||
- Le wallet doit être importé **sur `nextgraph.eu` (cross-origin)** — Festipod ne peut **pas** piloter ce flux ni pré-remplir l'import (pas de route d'import par URL côté broker hébergé).
|
||||
- Le **vrai zéro-touche** exigerait de **self-host/forker le ng-app** (territoire de [[brief_2026-05-21_fork-nextgraph-inbox]]).
|
||||
- Distribution produit retenue, vu cette contrainte : **import assisté par FICHIER** — Festipod fournit le `.ngw` (téléchargement) + le mot de passe et guide l'import — voir [[decision_2026-06-17_assisted-wallet-import]].
|
||||
- À ne pas confondre avec le **provisioning de TEST** (injection storageState dans le harness), qui n'a pas cette contrainte car il agit au niveau navigateur — concept `bdd-testing` → `knowledge_multibrowser-harness`.
|
||||
@@ -1,51 +0,0 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Modèle d'intégration NextGraph — @ng-org/web est un proxy iframe (verifier tourne dans l'iframe ng-app, pas dans le broker), reciblable au build via NG_REDIR_SERVER/NG_DEV*, broker ngd stateful WebSocket ; modifier le verifier = rebuilder le ng-app, pas le broker
|
||||
last_checked: 2026-05-21
|
||||
---
|
||||
|
||||
# Modèle d'intégration et de déploiement NextGraph
|
||||
|
||||
Comment une app web tierce s'intègre à NextGraph, et **où tourne le moteur (verifier)**. Vérifié dans `nextgraph-rs` le 2026-05-21.
|
||||
|
||||
NextGraph s'utilise via un **proxy iframe** (`@ng-org/web`) : l'app tierce ne contient pas le moteur, elle délègue à un ng-app hébergé (défaut `nextgraph.net`) qui exécute le moteur dans une iframe.
|
||||
|
||||
## Les paquets JS
|
||||
|
||||
- **`@ng-org/web`** — paquet **publié**. Proxy postMessage léger (aucun wasm embarqué). **Le** chemin d'intégration tierce ; `@ng-org/orm` et tous les exemples en dépendent. **Festipod l'utilise.**
|
||||
- **`@ng-org/api-web`** — **privé** (non publié). Moteur navigateur complet (charge `@ng-org/lib-wasm` dans un Web Worker). Consommé uniquement par `app/nextgraph` (frontend ng-app) — **pas** une cible d'intégration tierce.
|
||||
- **`@ng-org/lib-wasm`** — moteur compilé wasm (contient le verifier). Source `sdk/js/lib-wasm/`.
|
||||
- **`nextgraph`** (npm) — API NodeJS (build `pkg-node`).
|
||||
- **`@ng-org/orm`** — ORM réactif (`useShape`…), bâti sur `@ng-org/web`.
|
||||
|
||||
## Où tourne le verifier
|
||||
|
||||
Dans le modèle web standard (iframe), le verifier tourne **dans l'iframe** : `app/nextgraph` charge `api-web` → `lib-wasm` dans un Web Worker, côté navigateur. Le broker (`ngd`) ne fait que **transport et stockage**.
|
||||
|
||||
**Conséquence** : modifier la logique du verifier (`request_processor`, `inbox_processor`) = reconstruire le **ng-app**, pas le broker.
|
||||
|
||||
## Le modèle iframe & reciblage build-time
|
||||
|
||||
`@ng-org/web` redirige vers le ng-app hébergé, qui recharge l'app tierce en iframe après auth, puis relaie par `postMessage`. **Reciblable au build** (`sdk/js/web/src/index.ts`, `import.meta.env`) :
|
||||
|
||||
| Variable | Cible |
|
||||
|---|---|
|
||||
| `NG_REDIR_SERVER` | défaut `nextgraph.net` |
|
||||
| `NG_DEV3` | `127.0.0.1:3033` |
|
||||
| `NG_DEV` | `localhost:14402`/`14404` |
|
||||
| `NG_DEV_LOCAL_BROKER` | `localhost:1421` |
|
||||
|
||||
**Pas d'override runtime** — `init()` ne prend pas d'URL broker. Pour pointer vers un ng-app auto-hébergé : **rebuilder `@ng-org/web`** (TS pur, sans wasm → build trivial).
|
||||
|
||||
## Plomberie proxy ↔ iframe ↔ worker (générique)
|
||||
|
||||
Le chemin d'appel d'une méthode est **entièrement générique** (aucune allowlist) : `@ng-org/web` est un `Proxy` JS qui relaie *n'importe quel* nom de méthode par `postMessage` ; `app/nextgraph` dispatch via `Reflect.apply(ng[method], …)`. **Conséquence** : une nouvelle fonction wasm en requête/réponse simple est *atteignable* sans toucher le JS — mais c'est un **hack** non typé (test rapide, pas un plan ; cf. [[brief_2026-05-21_fork-nextgraph-inbox]]). Cas **streamé** : exige une entrée des deux côtés (`E` dans `@ng-org/web` + `streamed_api` dans api-web ; méthodes streamées actuelles : `doc_subscribe`, `orm_start_graph`, `orm_start_discrete`, `file_get`, `app_request_stream`).
|
||||
|
||||
## Le broker (ngd)
|
||||
|
||||
- Supporte déjà nativement l'inbox (`inbox_post`, `inbox_register`, `inbox_pop_for_user` dans `engine/net/src/server_broker.rs`) — un `ngd` standard routerait l'inbox, **aucun patch broker nécessaire**.
|
||||
- Démon **WebSocket** (`async-tungstenite`), **stateful** : RocksDB sous `--base-path`, PeerId persisté (volume critique).
|
||||
- CLI : `--local PORT`, `--domain DOMAIN:PORT,LOCAL_PORT` (mode derrière reverse-proxy TLS-terminé type Traefik/Coolify).
|
||||
- **Ne sert pas de statique** : le ng-app frontend est un déploiement statique séparé (`pnpm webfilebuild`). Premier démarrage **interactif** (lien d'invitation wallet admin). Dockerfiles officiels **cassés**.
|
||||
|
||||
> Détail du déploiement depuis un fork : [[brief_2026-05-21_fork-nextgraph-inbox]] §Couche 2.
|
||||
@@ -1,60 +0,0 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Référence des 5 types de stores NextGraph et leurs droits, document=repo, granularité des permissions, capability/Nuri, inbox native (anonymat via from optionnel), et ce que le SDK @ng-org/web n'expose PAS
|
||||
last_checked: 2026-05-21
|
||||
---
|
||||
|
||||
# Stores NextGraph et droits d'accès
|
||||
|
||||
Référence des primitives de stockage et permission de NextGraph (**système externe**, pas le code de Festipod). Socle des briefs [[brief_2026-05-17_multi-store-refactor]] et [[brief_2026-05-18_authorization-matrix]].
|
||||
|
||||
Source : doc NextGraph officielle ([Documents & Stores](https://docs.nextgraph.org/en/documents/), [Getting started](https://docs.nextgraph.org/en/getting-started/)) vérifiée le 2026-05-21.
|
||||
|
||||
## Points d'entrée du code source local
|
||||
|
||||
Repo cloné en `../../nextgraph/nextgraph-rs` (cf. `_overview`) :
|
||||
- `sdk/js/lib-wasm/src/lib.rs` — API wasm effectivement exposée au JS.
|
||||
- `engine/net/src/app_protocol.rs` — enum `AppRequestCommandV0`, formats `NuriV0`.
|
||||
- `engine/verifier/src/request_processor.rs` — dispatch effectif des `app_request` (la vérité sur ce qui est *traité*).
|
||||
- `engine/net/src/types.rs` — types inbox (`InboxPost`, `InboxMsg`, `InboxMsgContent`).
|
||||
- `engine/verifier/src/inbox_processor.rs` — traitement des messages d'inbox.
|
||||
|
||||
## Les 5 types de stores
|
||||
|
||||
| Store | Lecture | Écriture | Création |
|
||||
|---|---|---|---|
|
||||
| **Private** | Titulaire seul | Titulaire seul | Par défaut |
|
||||
| **Protected** | Titulaire + détenteurs d'un lien + permission | Titulaire + collaborateurs permissionnés | Par défaut |
|
||||
| **Public** | Tout le monde, sans capability | Titulaire seul | Par défaut |
|
||||
| **Group** | Membres du groupe | Membres du groupe (collaboratif) | À la demande |
|
||||
| **Dialog** | Les deux utilisateurs uniquement | Les deux utilisateurs uniquement | À la demande |
|
||||
|
||||
Citations doc (verbatim) : Private — *« only you have access to … not possible to share »* ; Protected — *« share … but they will need a special link and permission »*, *« protected social profile »* ; Public — *« equivalent to your website … without the need for special permissions »* ; Group — *« each Group is a separate Store … documents inherit the permissions of the store »* ; Dialog — *« hold all the data you exchange with another user (and only with that other user) … You cannot add more users »*.
|
||||
|
||||
Tout wallet a d'office les **3 stores** private/protected/public (session : `private_store_id`, `protected_store_id`, `public_store_id`). Group et Dialog se créent à la demande.
|
||||
|
||||
## Concepts transverses
|
||||
|
||||
**Document vs Repo.** *« A Repo is the equivalent of an E2EE group for one and only one Document. »* **1 document = 1 repo** (commits + permissions). Identifiant : `did:ng:o:<RepoID>`. Un **store** est lui-même un document spécial qui regroupe et permissionne d'autres documents.
|
||||
|
||||
**Granularité.** Écriture gérée au niveau **Document (repo)**, pas branche/bloc. Lecture plus fine possible (par bloc/branche). Héritage : un Group store peut faire hériter ses permissions à ses documents.
|
||||
|
||||
**Capability / Nuri.** Le partage transmet un **Nuri** embarquant la capability crypto (lecture et/ou écriture). Pas d'ACL centralisée : posséder le Nuri = le droit. *« adding permissions can be done offline »* ; *« removing permissions … requires a SyncSignature »* (synchrone).
|
||||
|
||||
## Inbox
|
||||
|
||||
**Chaque document a une inbox native.** Un non-éditeur peut y **déposer un lien (DID cap)** sans être invité éditeur ; le propriétaire **modère**. NURI : `did:ng:d:<inbox_id>`. Contenu : enum `InboxMsgContent` (`ContactDetails`, `DialogRequest`, **`Link`**, `Patch`, `ServiceRequest`, `ExtRequest`, `RemoteQuery`, `SocialQuery`…). Message **scellé** (`crypto_box::seal`) vers la pubkey de l'inbox → seul le titulaire déchiffre. Champ `from` **optionnel** → expéditeur **anonyme** possible. C'est le « identifié si connu, anonyme sinon » voulu par Festipod, **natif au protocole** (mécanisme retenu pour la notification d'inscription, cf. [[brief_2026-05-18_authorization-matrix]]).
|
||||
|
||||
### L'inbox n'est PAS utilisable directement depuis le SDK JS
|
||||
|
||||
- `app_request(request)` est exposé, et `AppRequestCommandV0::InboxPost` + `AppRequest::inbox_post()` existent. **MAIS** le `request_processor` du verifier **n'a aucun bras `InboxPost`** (commandes traitées : `OrmStart(Discrete)`, `Fetch`, `FileGet`, `OrmUpdate`, `OrmDiscreteUpdate`, `SocialQueryStart`, `QrCodeProfile(Import)`, `Header`, `Create`, `FilePut`). Envoyer un `InboxPost` ne déclenche rien.
|
||||
- Construire un `InboxPost` exige le scellement crypto côté Rust ; **aucun helper wasm** ne l'expose.
|
||||
- Le dépôt en inbox n'est déclenché qu'**en interne** par `QrCodeProfileImport` (`post_to_inbox(new_contact_details)`) et `social_query_start` (propagation via inbox des **contacts**).
|
||||
|
||||
**Conséquence** : pas de moyen propre de « drop a Link » arbitraire dans l'inbox d'un PdR depuis le SDK JS aujourd'hui. → chantier [[brief_2026-05-21_fork-nextgraph-inbox]]. Piste connexe : `social_query_start` EST exposé (requête fédérée via inbox jusqu'à `degree` sauts) mais limité aux **contacts** (ne couvre pas la notif anonyme vers un hôte non-connecté).
|
||||
|
||||
## Limites du SDK JS
|
||||
|
||||
`@ng-org/web` (vérifié `0.1.2-alpha.13` = `upstream/main` au 2026-05-21, version installée) **n'expose pas** : création de Group/Dialog store ; partage de capability (Nuri avec droits) ; manipulation de permissions ; dépôt/lecture d'inbox.
|
||||
|
||||
Méthodes JS disponibles : `doc_create`, `doc_subscribe`, `sparql_query`, `sparql_update`, `orm_start_graph`, `orm_start_discrete`, `graph_orm_update`, `discrete_orm_update`, `file_get`, `app_request_stream`. La doc annonce *« An API will be provided for permission manipulation »* (sans date).
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: Firefox 151+ bloque (Local Network Access) le broker hébergé qui embarque l'app de dev locale dans son iframe → iframe blanche, zéro log app, aucune erreur. Ce n'est PAS un bug de code. Fix navigateur — about:config network.lna.enabled=false.
|
||||
last_checked: 2026-07-13
|
||||
---
|
||||
|
||||
# Firefox LNA bloque l'iframe app du broker en dev local
|
||||
|
||||
## Symptôme
|
||||
|
||||
En dev local, l'app tourne DANS l'iframe du broker hébergé (`nextgraph.eu`/`nextgraph.net`
|
||||
en HTTPS embarque `festipod.localhost` → `127.0.0.1`). Sur **Firefox 151+**, l'iframe reste
|
||||
**blanche** : **aucun log `[FestipodData]`/`[NG]`** (l'app JS n'est jamais exécutée), et
|
||||
**aucune erreur** rouge (le blocage est une décision de politique réseau, pas un throw). Facile
|
||||
à prendre pour un crash de rendu Festipod — ce n'en est PAS un.
|
||||
|
||||
## Cause
|
||||
|
||||
**Local Network Access (LNA)** : Firefox 151+ (activé par défaut, cf. rollout 149→151) interdit
|
||||
à un **site public** (le broker HTTPS) d'atteindre une **ressource du réseau local**
|
||||
(`127.0.0.1`) — y compris l'embarquer en iframe. Le log révélateur (console) :
|
||||
`Local Network Access detected: ... accessing target "…festipod.localhost…" (127.0.0.1) … prompt action: auto_deny`.
|
||||
|
||||
Deux corollaires qui trompent :
|
||||
- **Le top-level charge très bien** : ta navigation directe vers `https://festipod.localhost:1355`
|
||||
(la barrière AccessGateScreen) n'est PAS soumise à LNA. Seul l'**embarquement iframe** par le
|
||||
broker l'est. Donc « le cert est déjà accepté / l'app se lance » avant l'iframe ≠ l'iframe passera.
|
||||
- **HTTPS n'y change rien** : LNA vise l'**adresse locale cible**, pas le protocole. Passer
|
||||
`portless proxy start --https` (app en `https://festipod.localhost`) ne débloque pas.
|
||||
|
||||
## Fix (navigateur, pas code)
|
||||
|
||||
`about:config` → **`network.lna.enabled` = `false`** (drapeau maître : désactive tous les
|
||||
contrôles LNA). Alternative ciblée : **`network.lna.skip-domains`** avec `nextgraph.eu`,
|
||||
`nextgraph.net` (garde la protection ailleurs). Autres prefs LNA : `network.lna.blocking`,
|
||||
`network.lna.block_trackers`.
|
||||
|
||||
Ne PAS chasser un bug de rendu Festipod tant qu'il n'y a **aucun log `[FestipodData]`** dans la
|
||||
console : sans logs app, l'app n'a pas tourné → c'est l'environnement (LNA, cert non approuvé,
|
||||
serveur dev éteint), pas le code. Le smoke `@e2e` ne peut PAS attraper ça : Playwright n'applique
|
||||
pas LNA comme un vrai Firefox.
|
||||
@@ -14,7 +14,19 @@ Le serveur sert `src/index.html`, qui charge `src/app/frontend.tsx` (voir `app-a
|
||||
## Détails de `build.ts` et du serveur
|
||||
|
||||
- `build.ts` scanne `src/**/*.html` comme entrypoints (aujourd'hui un seul : `src/index.html`), `target: 'browser'`, minify + sourcemap linked, plugin `bun-plugin-tailwind`. Ajouter un 2e `.html` créerait un 2e bundle.
|
||||
- `src/index.ts` (`Bun.serve`) sert : `/reports/cucumber` (rapport HTML), des stubs `/api/hello*`, et un **catch-all `/*` → `src/index.html`** (routing SPA, doit rester en dernier). HMR si `NODE_ENV !== 'production'`, port via `PORT`.
|
||||
- `src/index.ts` (`Bun.serve`) sert : `/reports/cucumber` (rapport HTML), des stubs `/api/hello*`, `/festipod-config.json` + `/shared-wallet.ngw` (config runtime, voir ci-dessous), et un **catch-all `/*` → `src/index.html`** (routing SPA, doit rester en dernier). HMR si `NODE_ENV !== 'production'`, port via `PORT`.
|
||||
|
||||
## Globals de build vs config runtime (piège du wallet partagé)
|
||||
|
||||
`build.ts` injecte des **globals à la compilation** via `define` (p. ex. `__FESTIPOD_SHARED_WALLET_PASSWORD__` depuis `FESTIPOD_SHARED_WALLET_PASSWORD`, `__FESTIPOD_ACCESS_GATE_DISABLED__`, et `__FESTIPOD_AUTO_SEED__` depuis `FESTIPOD_AUTO_SEED` — l'auto-seed de dev, OFF si absent). **Piège** : le serveur `src/index.ts` (utilisé par `bun run dev` ET `bun run start`) bundle `index.html` via l'import HTML de Bun, qui **n'applique aucun `define`** — ni `bun --define` ni `process.env` ne s'y propagent (vérifié). Donc une variable d'env passée à `bun run dev` n'atteint pas le bundle frontend par ce chemin.
|
||||
|
||||
Pour ces chemins servis depuis `src/`, la config passe donc au **runtime** : `src/index.ts` expose `/festipod-config.json` (lu depuis l'env), et l'entrée `src/app/frontend.tsx` la **fetch d'abord**, pose le global, **puis importe l'app dynamiquement** (`await import('./App')`) — ainsi `sharedWallet.ts` lit la valeur à son évaluation. Dans un bundle `build.ts` la valeur est déjà inline par `define`, donc le fetch est court-circuité (`NODE_ENV === 'production'`). Conséquence pratique : pour exercer le flux « portefeuille partagé » en dev **de bout en bout** (téléchargement + import qui fonctionne), passer le VRAI mot de passe du wallet e2e **et** le fichier — le mot de passe affiché à l'écran doit correspondre au `.ngw` importé, sinon l'import échoue (une valeur factice comme `1` fait juste apparaître l'écran) :
|
||||
|
||||
```
|
||||
FESTIPOD_SHARED_WALLET_PASSWORD=festipod-e2e-tests \
|
||||
FESTIPOD_SHARED_WALLET_FILE=./festipod-e2e-tests.ngw \
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Le harness de test est buildé à part
|
||||
|
||||
|
||||
@@ -1,22 +1,24 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Déploiement — Dockerfile multi-stage Bun Alpine qui lance `bun run start` depuis src/ (pas dist/), EXPOSE 3000, env PORT/NODE_ENV ; aucun CI/CD committé ; dev passe par le wrapper portless
|
||||
last_checked: 2026-06-15
|
||||
summary: Déploiement — Dockerfile multi-stage Bun Alpine ; install via pnpm (git+node dans l'image) mais runtime bun ; lance `bun run start` depuis src/ (pas dist/), EXPOSE 3000, env PORT/NODE_ENV ; aucun CI/CD committé ; dev passe par le wrapper portless
|
||||
last_checked: 2026-07-14
|
||||
---
|
||||
|
||||
# Déploiement & infra
|
||||
|
||||
## Dockerfile
|
||||
|
||||
Un `Dockerfile` existe (multi-stage Bun Alpine) :
|
||||
- `FROM oven/bun:1-alpine`, stages `install` (`bun install --frozen-lockfile` depuis `package.json` + `bun.lock`) puis `release` (copie `node_modules` + source).
|
||||
Un `Dockerfile` existe (multi-stage Bun Alpine). **L'installation passe par pnpm, mais le runtime/build/test restent bun** (cf. [[knowledge_stack-and-commands]]) :
|
||||
- `FROM oven/bun:1-alpine`, stage `install` : `apk add --no-cache git nodejs npm` puis `npm install -g pnpm@10.26.0` (l'image bun n'a ni Node ni pnpm ; l'`apk nodejs` d'Alpine n'embarque pas corepack), `COPY package.json pnpm-lock.yaml`, puis `pnpm install --frozen-lockfile`. `git` est requis car `@ng-eventually/client` est une dépendance **git+https** publique (Gitea, sans auth). Stage `release` : copie `node_modules` + source.
|
||||
- `ENV NODE_ENV=production`, `USER bun`, `EXPOSE 3000/tcp`, `ENTRYPOINT ["bun","run","start"]`.
|
||||
|
||||
**Piège `bun` peer** : `bun-plugin-tailwind` déclare `bun` en peerDependency → pnpm matérialise le paquet npm `bun` et **crée un shim `node_modules/.bin/bun`** qui shadow le `bun` du PATH sous `bun run`/`pnpm run`. Son postinstall est ignoré par défaut → shim cassé → `bun run start` échoue. Corrigé en approuvant le build : `pnpm.onlyBuiltDependencies: ["bun"]` dans `package.json` (le postinstall télécharge le vrai binaire). Sans ça, toute la migration pnpm casse le démarrage.
|
||||
|
||||
**Quirk** : `start` = `NODE_ENV=production bun src/index.ts` → le conteneur **exécute la source TypeScript directement** (Bun transpile à la volée), il **n'utilise pas `dist/`**. Le `bun run build` (→ `dist/`) n'est donc **pas** sur le chemin de prod par défaut. Si on veut servir le build, il faut changer l'entrypoint.
|
||||
|
||||
## CI/CD
|
||||
|
||||
**Aucun** pipeline committé (`.github/workflows/` absent, pas de config Coolify dans le repo). Angle mort assumé. Pour héberger l'app Bun, le skill `coolify-hosting` s'applique (mentionné aussi dans concept `nextgraph-platform` pour distinguer Festipod du `ngd` Rust).
|
||||
**Aucun** pipeline committé (`.github/workflows/` absent, pas de config Coolify dans le repo). Angle mort assumé. Pour héberger l'app Bun, le skill `coolify-hosting` s'applique.
|
||||
|
||||
## Variables d'environnement
|
||||
|
||||
@@ -26,3 +28,5 @@ Un `Dockerfile` existe (multi-stage Bun Alpine) :
|
||||
## Dev
|
||||
|
||||
`bun run dev` = **`portless festipod bun --hot src/index.ts`** — passe par le wrapper **`portless`** (outil externe de gestion de port), pas un `bun --hot` nu. HMR actif hors production.
|
||||
|
||||
**Lien local réactif du polyfill** : en prod la dépendance `@ng-eventually/client` vient de Gitea (git+https, figée par `pnpm-lock.yaml`). Pour éditer le polyfill localement et voir les changements en direct, `pnpm run link:polyfill` (script `scripts/link-polyfill.ts`, stratégie S2) remplace `node_modules/@ng-eventually/client` par une **copie réelle** de la source locale (`…/ng-eventually-js/packages/client`) — **sans** son propre `node_modules/@ng-org` — et resynchronise `src/` à chaque édition. C'est ce qui garantit **une seule instance `@ng-org/web`** (un seul verifier) : un symlink vers le checkout monorepo, lui, embarque son `@ng-org` → 2ᵉ instance → SDK cassé. Revenir à l'état committé : `pnpm install`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Composants de la stack (Bun, React, NextGraph, Storybook, Cucumber, Tailwind-dans-le-build) et liste réelle des scripts package.json, dont les quirks (cucumber via node+tsx, build:orm au chemin périmé, build:ng pour le fork local)
|
||||
summary: Composants de la stack (Bun runtime/build/test, install via pnpm, React, NextGraph, Storybook, Cucumber, Tailwind-dans-le-build) et liste réelle des scripts package.json, dont les quirks (cucumber via node+tsx, build:orm au chemin périmé, build:ng pour le fork local, link:polyfill pour le lien local réactif)
|
||||
---
|
||||
|
||||
# Stack & commandes
|
||||
@@ -10,6 +10,7 @@ summary: Composants de la stack (Bun, React, NextGraph, Storybook, Cucumber, Tai
|
||||
| Couche | Techno |
|
||||
|---|---|
|
||||
| Runtime / bundler / test | **Bun** (cf. [[rule_bun-first]]) |
|
||||
| **Installation des deps** | **pnpm** (`pnpm install`, `pnpm-lock.yaml`) — **seule** l'install passe à pnpm ; runtime/build/test restent bun. Motif : `@ng-eventually/client` est résolu depuis Gitea en **git+https** (pnpm gère proprement `git+…#main&path:/packages/client` + le dédoublonnage des peers `@ng-org`). Ne pas rebasculer l'install vers bun/npm. |
|
||||
| UI | **React** (mobile-first, largeur max 768px — style dans concept `app-architecture`) |
|
||||
| Données | **NextGraph** P2P local-first (concept `data-layer`) |
|
||||
| Build CSS | **Tailwind** (`tailwindcss` + `bun-plugin-tailwind`) — présent dans le build, mais les écrans stylent via `app-*`/inline, pas d'utilitaires Tailwind (cf. concept `app-architecture`) |
|
||||
@@ -24,17 +25,19 @@ summary: Composants de la stack (Bun, React, NextGraph, Storybook, Cucumber, Tai
|
||||
| `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 |
|
||||
| `features:parse` | `bun scripts/parse-features.ts` → `features.ts` |
|
||||
| `steps:extract` | `bun scripts/extract-step-definitions.ts` |
|
||||
| `build:orm` | `rdf-orm build --input ./src/shapes/shex --output ./src/shapes/orm` |
|
||||
| `build:ng` | `bash scripts/build-ng-packages.sh` — rebuild des `@ng-org/*` depuis le fork local (concept `nextgraph-platform`) |
|
||||
| `build:ng` | `bash scripts/build-ng-packages.sh` — (re)build des paquets NextGraph depuis une source locale (outil optionnel) |
|
||||
| `link:polyfill` | `bun scripts/link-polyfill.ts` — lien local **réactif** du polyfill `@ng-eventually/client` (stratégie S2 : copie-overlay + watcher), préserve l'instance `@ng-org` unique. Détails dans [[knowledge_deployment]]. |
|
||||
| `storybook` / `build-storybook` | Storybook dev (6006) / build statique |
|
||||
|
||||
## Pièges
|
||||
|
||||
- **`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).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
type: rule
|
||||
summary: Par défaut utiliser Bun et ses APIs natives, jamais les équivalents Node — bun au lieu de node/ts-node, bun install/test/build, bunx, et pas d'express/ws/pg/dotenv
|
||||
summary: Par défaut utiliser Bun et ses APIs natives, jamais les équivalents Node — bun au lieu de node/ts-node, bun test/build, bunx, et pas d'express/ws/pg/dotenv. EXCEPTION : l'installation des paquets passe par pnpm (les deux repos), pas bun install
|
||||
---
|
||||
|
||||
# Règle : Bun-first
|
||||
@@ -11,7 +11,7 @@ Par défaut, utiliser **Bun** et ses APIs natives plutôt que les équivalents N
|
||||
|---|---|
|
||||
| `node <file>`, `ts-node` | `bun <file>` |
|
||||
| `jest`, `vitest` | `bun test` |
|
||||
| `npm/yarn/pnpm install` | `bun install` |
|
||||
| `npm/yarn install`, `bun install` | **`pnpm install`** (voir exception ci-dessous) |
|
||||
| `npm run <script>` | `bun run <script>` |
|
||||
| `npx <pkg>` | `bunx <pkg>` |
|
||||
| `webpack`, `esbuild`, `vite` | `bun build` / bundler Bun (HTML imports) |
|
||||
@@ -26,6 +26,14 @@ Par défaut, utiliser **Bun** et ses APIs natives plutôt que les équivalents N
|
||||
|
||||
Détail des APIs : [[knowledge_bun-apis]].
|
||||
|
||||
## Pourquoi
|
||||
## Exception : l'installation des paquets passe par pnpm
|
||||
|
||||
Le projet est tout-Bun (runtime, bundler, test, serveur). Réintroduire un outil Node redondant ajoute une dépendance, divergerait des conventions du repo, et casse l'intégration native (HMR, transpilation TS automatique, chargement `.env`). C'est un choix de cohérence, pas une préférence cosmétique.
|
||||
**L'installation des dépendances se fait avec `pnpm install` — pas `bun install` — dans les DEUX repos** (Festipod *et* le polyfill `@ng-eventually/client`). Tout le reste reste Bun : **runtime, build, test, scripts** (`bun run dev`, `bun build`, `bun test`, `bunx`). Seule l'étape d'installation change de gestionnaire.
|
||||
|
||||
**Pourquoi.** Le polyfill est installé en prod depuis un dépôt Gitea comme dépendance git à **sous-répertoire** : `git+https://…/ng-eventually.git#main&path:/packages/client`. pnpm (≥ 10.26) résout ce format `#<ref>&path:/…` et garantit une **seule** instance de `@ng-org/*` (un seul verifier) ; `bun install` ne couvre pas ce workflow proprement. Le lockfile de référence est donc `pnpm-lock.yaml`, et le lien local réactif du polyfill passe par `pnpm run link:polyfill` (voir [[knowledge_deployment]]).
|
||||
|
||||
**Conséquence pratique.** Les scripts npm qui reposaient sur `node_modules/.bin/*` peuvent casser (pnpm y place des shims shell, pas des entrées JS) — appeler l'entrée JS réelle du paquet (ex. `node_modules/@cucumber/cucumber/bin/cucumber.js`) plutôt que le shim `.bin/`.
|
||||
|
||||
## Pourquoi (Bun pour tout le reste)
|
||||
|
||||
Le projet est tout-Bun (runtime, bundler, test, serveur). Réintroduire un outil Node redondant ajoute une dépendance, divergerait des conventions du repo, et casse l'intégration native (HMR, transpilation TS automatique, chargement `.env`). C'est un choix de cohérence, pas une préférence cosmétique. L'exception d'installation ci-dessus est le seul écart, et il est motivé par la dépendance git à sous-répertoire.
|
||||
|
||||
@@ -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
|
||||
@@ -14,18 +14,21 @@ Web app mobile-first où les utilisateurs créent des **points de rencontre** qu
|
||||
- **Un module n'importe QUE depuis `shared/` — jamais d'un autre module.** C'est l'invariant qui rend l'archi réelle.
|
||||
- **Bun-first** : `bun` / `bun install` / `bun test` / `bun build`, jamais node/npm/vite/jest. `bun run dev` (port 3000).
|
||||
|
||||
## Frontière SDK NextGraph
|
||||
|
||||
Le SDK de données de Festipod est **`@ng-eventually/client`** — traité comme un **SDK NextGraph fini et mature** (documents par entité placés par scope public/protected/private, capabilities, inboxes). Il est injecté une seule fois via `ngSession.configure(...)`. **Ne jamais documenter dans ce repo l'état courant de NextGraph** (contraintes du SDK sous-jacent, contournements, internes broker/verifier) : cela vit dans le repo `@ng-eventually/client`. La doctrine Festipod décrit uniquement *comment Festipod utilise ce SDK* + le domaine + l'architecture + le contrat BDD.
|
||||
|
||||
## Doctrine du projet — concepts (livrée automatiquement)
|
||||
|
||||
La connaissance détaillée vit dans `.project/concepts/` (système *concept*) : fiches courtes, typées, **livrées par un hook quand tu touches leur territoire** — tu n'as pas à les charger d'avance. Les 6 concepts :
|
||||
|
||||
| Concept | Couvre |
|
||||
|---|---|
|
||||
| `functional-domain` | Modèle produit : point de rencontre, acteurs, concepts métier, défi déduplication |
|
||||
| `functional-domain` | Modèle produit : point de rencontre, acteurs, concepts métier, périmètres public/protected/private par entité, découverte, défi déduplication |
|
||||
| `app-architecture` | Modules, invariant d'imports, app shell, routing path-based, écrans |
|
||||
| `tech-stack` | Bun-first, APIs Bun, build pipeline, commandes |
|
||||
| `data-layer` | NextGraph actuel (mono-store), shapes, modes connected/demo, règles + pièges (suppression, champs perdus, internals) |
|
||||
| `data-layer` | Persistance via le SDK `@ng-eventually/client` : entités-documents par scope, shapes SHEX/ORM, modes connected/demo, pièges |
|
||||
| `bdd-testing` | Cucumber multi-couches FR, contrat `@ui`/`@data`/`@e2e`, harness broker, cookbook |
|
||||
| `app-security` | Posture de sécurité actuelle (mono-store, confiance broker), auth wallet, modèle d'autorisations cible |
|
||||
| `nextgraph-platform` | NextGraph système externe (stores, inbox, SDK) + briefs prospectifs (multi-store, fork, shim) |
|
||||
| `app-security` | Isolation déléguée au SDK (pas de contrôle d'accès dans les écrans), auth wallet, matrice d'autorisations cible |
|
||||
|
||||
Pour **documenter** un fait projet : `/concept document <sujet>` (ne pas écrire en libre dans `.project/`).
|
||||
|
||||
+12
-4
@@ -1,11 +1,19 @@
|
||||
# Use the official Bun image
|
||||
# Use the official Bun image (runtime stays Bun; only install moves to pnpm)
|
||||
FROM oven/bun:1-alpine AS base
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies
|
||||
# Install dependencies with pnpm.
|
||||
# - git: the @ng-eventually/client polyfill is a git+https (public Gitea) dependency → no auth.
|
||||
# - nodejs + npm: pnpm is a Node CLI; we pin the exact pnpm version via `npm i -g`
|
||||
# (Alpine's nodejs package does not bundle corepack).
|
||||
# The `bun` npm peer (pulled by bun-plugin-tailwind) is approved to build in package.json
|
||||
# (pnpm.onlyBuiltDependencies) so node_modules/.bin/bun is a real binary — required because
|
||||
# `bun run start` puts node_modules/.bin ahead of PATH.
|
||||
FROM base AS install
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
RUN apk add --no-cache git nodejs npm \
|
||||
&& npm install -g pnpm@10.26.0
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Copy source code and build assets
|
||||
FROM base AS release
|
||||
|
||||
@@ -142,6 +142,11 @@ const result = await Bun.build({
|
||||
"globalThis.__FESTIPOD_SHARED_WALLET_PASSWORD__": JSON.stringify(
|
||||
process.env.FESTIPOD_SHARED_WALLET_PASSWORD ?? "",
|
||||
),
|
||||
// Auto-seed gate (OFF by default): only seed a genuinely-empty wallet with
|
||||
// demo data when FESTIPOD_AUTO_SEED is set (see src/shared/utils/autoSeed.ts).
|
||||
"globalThis.__FESTIPOD_AUTO_SEED__": JSON.stringify(
|
||||
process.env.FESTIPOD_AUTO_SEED ?? "",
|
||||
),
|
||||
},
|
||||
...cliConfig,
|
||||
});
|
||||
|
||||
+1
-1
@@ -6,13 +6,13 @@
|
||||
"src/modules/*/steps/**/*.ts"
|
||||
],
|
||||
"paths": ["src/modules/*/features/**/*.feature"],
|
||||
"tags": "not @wip and not @humain",
|
||||
"format": [
|
||||
"progress-bar",
|
||||
"json:reports/cucumber-report.json",
|
||||
"html:reports/cucumber-report.html"
|
||||
],
|
||||
"language": "fr",
|
||||
"tags": "not @wip",
|
||||
"formatOptions": {
|
||||
"snippetInterface": "async-await"
|
||||
},
|
||||
|
||||
+12
-3
@@ -8,18 +8,21 @@
|
||||
"start": "NODE_ENV=production bun src/index.ts",
|
||||
"build": "bun run build.ts",
|
||||
"test:cucumber": "bun run cucumber:run && bun run cucumber:report && bun run features:parse && bun run steps:extract",
|
||||
"cucumber:run": "node --import tsx/esm node_modules/.bin/cucumber-js --config cucumber.json",
|
||||
"test:data": "node --import tsx/esm node_modules/.bin/cucumber-js --config cucumber.json --tags @data",
|
||||
"cucumber:run": "node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json",
|
||||
"test:data": "node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js --config cucumber.json --tags @data",
|
||||
"test:auth-setup": "bun scripts/setup-test-auth.ts",
|
||||
"cucumber:report": "bun scripts/parse-test-results.ts",
|
||||
"features:parse": "bun scripts/parse-features.ts",
|
||||
"steps:extract": "bun scripts/extract-step-definitions.ts",
|
||||
"build:orm": "rdf-orm build --input ./src/shapes/shex --output ./src/shapes/orm",
|
||||
"validate": "bun scripts/validate.ts",
|
||||
"build:ng": "bash scripts/build-ng-packages.sh",
|
||||
"link:polyfill": "bun scripts/link-polyfill.ts",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ng-eventually/client": "git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#main&path:/packages/client",
|
||||
"@ng-org/alien-deepsignals": "0.1.2-alpha.11",
|
||||
"@ng-org/orm": "0.1.2-alpha.18",
|
||||
"@ng-org/shex-orm": "0.1.2-alpha.8",
|
||||
@@ -56,5 +59,11 @@
|
||||
"@storybook/addon-a11y": "^10.3.5",
|
||||
"@storybook/addon-docs": "^10.3.5",
|
||||
"@storybook/addon-onboarding": "^10.3.5"
|
||||
}
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"bun"
|
||||
]
|
||||
},
|
||||
"packageManager": "pnpm@10.26.0+sha512.3b3f6c725ebe712506c0ab1ad4133cf86b1f4b687effce62a9b38b4d72e3954242e643190fc51fa1642949c735f403debd44f5cb0edd657abe63a8b6a7e1e402"
|
||||
}
|
||||
|
||||
Generated
+6408
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* link-polyfill.ts — Reactive local link for the @ng-eventually/client polyfill (S2).
|
||||
*
|
||||
* WHY S2 (copy-overlay) and not a symlink (S1):
|
||||
* The committed prod dependency installs @ng-eventually/client from Gitea (git+https)
|
||||
* into pnpm's store WITHOUT its own node_modules/@ng-org → @ng-org/web resolves up to
|
||||
* Festipod → ONE @ng-org instance (one verifier). The local polyfill CHECKOUT, however,
|
||||
* carries its own node_modules/@ng-org/* (symlinks into the ng-eventually-js monorepo
|
||||
* store). Symlinking node_modules/@ng-eventually/client to that checkout puts the
|
||||
* checkout's @ng-org in the resolution path → a SECOND @ng-org instance → broken SDK
|
||||
* (two verifiers). So we overlay a real directory that contains ONLY the polyfill's
|
||||
* source (no node_modules) and keep it in sync by copying — @ng-org still resolves to
|
||||
* Festipod, single instance preserved.
|
||||
*
|
||||
* WHAT IT DOES:
|
||||
* 1. Replaces node_modules/@ng-eventually/client (the pnpm store symlink) with a real
|
||||
* directory holding the local polyfill's package.json + src (NO node_modules).
|
||||
* 2. Asserts the single-instance invariant (same @ng-org/web realpath from Festipod and
|
||||
* from the overlay) — aborts if it would break.
|
||||
* 3. Watches the local polyfill src and copies each change into the overlay, so
|
||||
* `bun --hot` (bun run dev) reloads the edited file live.
|
||||
*
|
||||
* USAGE (reactive dev):
|
||||
* Terminal 1: pnpm run link:polyfill # overlays local source, then watches
|
||||
* Terminal 2: bun run dev # portless festipod bun --hot src/index.ts
|
||||
* Edit files under packages/client/src → they land in node_modules → bun --hot reloads.
|
||||
*
|
||||
* pnpm run link:polyfill --once # overlay + verify, no watch (CI / one-shot)
|
||||
* Return to the committed git-installed dependency: pnpm install
|
||||
*
|
||||
* Override the local checkout path with NG_EVENTUALLY_LOCAL=/path/to/packages/client.
|
||||
*/
|
||||
import { existsSync, lstatSync, mkdirSync, rmSync, cpSync, copyFileSync, realpathSync } from "node:fs";
|
||||
import { watch } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
|
||||
const FESTIPOD = realpathSync(join(import.meta.dir, ".."));
|
||||
const LOCAL =
|
||||
process.env.NG_EVENTUALLY_LOCAL ??
|
||||
"/home/sylvain/projects/nextgraph/ng-eventually-js/packages/client";
|
||||
const TARGET = join(FESTIPOD, "node_modules", "@ng-eventually", "client");
|
||||
const SRC_LOCAL = join(LOCAL, "src");
|
||||
const SRC_TARGET = join(TARGET, "src");
|
||||
const ONCE = process.argv.includes("--once");
|
||||
|
||||
function fail(msg: string): never {
|
||||
console.error(`✖ link:polyfill — ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!existsSync(join(LOCAL, "package.json"))) {
|
||||
fail(`local polyfill not found at ${LOCAL} (set NG_EVENTUALLY_LOCAL to override)`);
|
||||
}
|
||||
|
||||
// 1. Replace the pnpm store symlink with a real overlay dir (metadata + src, NO node_modules).
|
||||
console.log(`→ overlaying local polyfill: ${LOCAL}`);
|
||||
if (existsSync(TARGET) || lstatSync(TARGET, { throwIfNoEntry: false })) {
|
||||
rmSync(TARGET, { recursive: true, force: true });
|
||||
}
|
||||
mkdirSync(TARGET, { recursive: true });
|
||||
for (const meta of ["package.json", "tsconfig.json", "README.md"]) {
|
||||
const from = join(LOCAL, meta);
|
||||
if (existsSync(from)) copyFileSync(from, join(TARGET, meta));
|
||||
}
|
||||
// Copy src fresh (NEVER a node_modules dir — that is what guarantees single @ng-org instance).
|
||||
cpSync(SRC_LOCAL, SRC_TARGET, { recursive: true });
|
||||
|
||||
// 2. Assert the single-instance invariant.
|
||||
const fromFestipod = realpathSync(Bun.resolveSync("@ng-org/web", FESTIPOD));
|
||||
const overlayReal = realpathSync(TARGET);
|
||||
const fromPolyfill = realpathSync(Bun.resolveSync("@ng-org/web", overlayReal));
|
||||
console.log(` @ng-org/web (Festipod): ${fromFestipod}`);
|
||||
console.log(` @ng-org/web (overlay) : ${fromPolyfill}`);
|
||||
if (fromFestipod !== fromPolyfill) {
|
||||
fail(
|
||||
"single-instance invariant BROKEN — @ng-org/web resolves to two different realpaths.\n" +
|
||||
" The overlay must not contain its own node_modules/@ng-org. Aborting.",
|
||||
);
|
||||
}
|
||||
console.log("✓ single @ng-org/web instance preserved");
|
||||
|
||||
if (ONCE) {
|
||||
console.log("✓ overlay ready (--once, not watching)");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 3. Watch and copy on change so `bun --hot` sees live edits.
|
||||
console.log(`👀 watching ${SRC_LOCAL} → ${SRC_TARGET} (Ctrl-C to stop)`);
|
||||
watch(SRC_LOCAL, { recursive: true }, (_event, filename) => {
|
||||
if (!filename) return;
|
||||
const from = join(SRC_LOCAL, filename);
|
||||
const to = join(SRC_TARGET, filename);
|
||||
try {
|
||||
if (existsSync(from)) {
|
||||
mkdirSync(dirname(to), { recursive: true });
|
||||
copyFileSync(from, to);
|
||||
console.log(` ↻ ${filename}`);
|
||||
} else if (existsSync(to)) {
|
||||
rmSync(to, { force: true });
|
||||
console.log(` ✗ ${filename} (removed)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(` ! failed to sync ${filename}:`, err);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,490 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* validate.ts — Validation matrix (broker-level tests only, no @ui/@e2e).
|
||||
*
|
||||
* Default run (no flags) — key subset only, fast:
|
||||
* (a) Polyfill unit tests (@ng-eventually/client — bun test)
|
||||
* (b) Polyfill e2e real-broker (@ng-eventually/client — bun run e2e/run.ts)
|
||||
* (c) Festipod @data KEY SUBSET (cucumber --name regex covering terrain bugs)
|
||||
* (d) Festipod @multibrowser (cucumber --tags @multibrowser)
|
||||
* (e) Festipod @smoke (cucumber --tags @smoke — boot connecté rend)
|
||||
* (f) Festipod @wip [informational only, non-blocking]
|
||||
*
|
||||
* With --full flag:
|
||||
* (c) becomes full @data suite (cucumber --tags @data)
|
||||
*
|
||||
* Each step runs even if the previous one failed (--bail mode is OFF).
|
||||
* Exit code is non-zero if any non-informational step has failures.
|
||||
*
|
||||
* Profile rotation: both Playwright profiles are rotated before @data and
|
||||
* @multibrowser when their size exceeds BLOAT_THRESHOLD_MB (default 50 MB),
|
||||
* to avoid the sparql_query hang described in caveat_wallet-bloat-hang.
|
||||
*/
|
||||
|
||||
import { spawnSync } from "child_process";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
// ─── Config ────────────────────────────────────────────────────────────────
|
||||
|
||||
const FESTIPOD_DIR = "/home/sylvain/projects/festipod/festipod";
|
||||
const POLYFILL_DIR =
|
||||
"/home/sylvain/projects/nextgraph/ng-eventually-js/packages/client";
|
||||
|
||||
const FESTIPOD_PROFILE = path.join(FESTIPOD_DIR, ".playwright-profile");
|
||||
const POLYFILL_PROFILE = path.join(
|
||||
POLYFILL_DIR,
|
||||
"e2e",
|
||||
".playwright-profile-lib",
|
||||
);
|
||||
|
||||
/** Rotate profile when it exceeds this many MB (caveat_wallet-bloat-hang). */
|
||||
const BLOAT_THRESHOLD_MB = 50;
|
||||
|
||||
/**
|
||||
* Per-step timeouts:
|
||||
* - polyfill unit/e2e: short steps, keep 10 min
|
||||
* - @data key subset: generous — BeforeAll + 8 scenarios, ~15 min margin
|
||||
* - @data full: full suite, ~35 min margin
|
||||
* - @multibrowser: 7 scenarios, ~10 min margin
|
||||
* - @wip: informational, 10 min
|
||||
*/
|
||||
const TIMEOUT_POLYFILL_UNIT_MS = 10 * 60 * 1000; // 10 min
|
||||
const TIMEOUT_POLYFILL_E2E_MS = 10 * 60 * 1000; // 10 min
|
||||
const TIMEOUT_DATA_KEY_MS = 15 * 60 * 1000; // 15 min (key subset)
|
||||
const TIMEOUT_DATA_FULL_MS = 35 * 60 * 1000; // 35 min (--full)
|
||||
const TIMEOUT_MULTIBROWSER_MS = 10 * 60 * 1000; // 10 min
|
||||
const TIMEOUT_SMOKE_MS = 10 * 60 * 1000; // 10 min (1 @e2e boot scenario)
|
||||
const TIMEOUT_WIP_MS = 10 * 60 * 1000; // 10 min
|
||||
|
||||
/**
|
||||
* Key-subset --name regex: matches exactly the 8 scenarios that cover the
|
||||
* known terrain bugs (inscription, désinscription, isolation, reconnexion,
|
||||
* compteur dérivé, créateur ne participe pas, auth vide, auth distinctes).
|
||||
*
|
||||
* Uses a single cucumber invocation so BeforeAll (broker login) runs once.
|
||||
*
|
||||
* French accent chars must be URL-safe in the regex — cucumber uses JS
|
||||
* RegExp, which handles unicode natively; we pass the literal string.
|
||||
*/
|
||||
const DATA_KEY_NAME_REGEX = [
|
||||
"S'inscrire à un événement",
|
||||
"Se désinscrire d'un événement$",
|
||||
"Une identité fraîche ne voit pas la participation d'une autre",
|
||||
"Une page fraîche pour la même identité relit ses propres données",
|
||||
"Le créateur ne participe pas automatiquement à son événement",
|
||||
"L'inscription fait converger le compteur dérivé du propriétaire",
|
||||
"Un portefeuille connecté est vide par défaut",
|
||||
"Les données du portefeuille sont distinctes des données par défaut",
|
||||
].join("|");
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function dirSizeMB(dir: string): number {
|
||||
if (!fs.existsSync(dir)) return 0;
|
||||
try {
|
||||
const result = spawnSync("du", ["-sm", dir], { encoding: "utf-8" });
|
||||
const line = result.stdout.trim().split("\n")[0] ?? "";
|
||||
return parseInt(line.split("\t")[0] ?? "0", 10);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any stale Chromium singleton files from `profilePath`. Chromium refuses
|
||||
* to launch (ProcessSingleton error) if SingletonLock, SingletonCookie, or
|
||||
* SingletonSocket are left over from a previous crashed run. Idempotent — safe to
|
||||
* call even when the profile does not exist yet.
|
||||
*/
|
||||
function cleanSingletons(profilePath: string, label: string): void {
|
||||
if (!fs.existsSync(profilePath)) return;
|
||||
const singletons = ["SingletonLock", "SingletonCookie", "SingletonSocket"];
|
||||
for (const name of singletons) {
|
||||
const p = path.join(profilePath, name);
|
||||
if (fs.existsSync(p)) {
|
||||
try {
|
||||
fs.rmSync(p, { force: true });
|
||||
console.log(`[rotate] ${label}: removed stale ${name}.`);
|
||||
} catch {
|
||||
// Non-fatal: if we can't remove it, launch will fail with a clear error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rotateProfile(profilePath: string, label: string): void {
|
||||
const sizeMB = dirSizeMB(profilePath);
|
||||
if (sizeMB > BLOAT_THRESHOLD_MB) {
|
||||
console.log(
|
||||
`[rotate] ${label}: ${sizeMB}MB > ${BLOAT_THRESHOLD_MB}MB — rotating profile...`,
|
||||
);
|
||||
try {
|
||||
fs.rmSync(profilePath, { recursive: true, force: true });
|
||||
console.log(`[rotate] ${label}: profile removed. Will be recreated.`);
|
||||
} catch (e) {
|
||||
console.warn(`[rotate] ${label}: failed to remove profile: ${e}`);
|
||||
}
|
||||
} else {
|
||||
// Even if we keep the profile, remove any stale Chromium singleton files left
|
||||
// by a previous crashed run — Chromium refuses to launch if they exist.
|
||||
cleanSingletons(profilePath, label);
|
||||
console.log(
|
||||
`[rotate] ${label}: ${sizeMB}MB — below threshold, keeping profile.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface StepResult {
|
||||
label: string;
|
||||
status: "passed" | "failed" | "error";
|
||||
/** Lines to show in the summary (failed scenario names, FAIL lines, etc.) */
|
||||
failures: string[];
|
||||
/** Raw exit code */
|
||||
exitCode: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command and capture its output. Returns the result with parsed
|
||||
* pass/fail summary. Never throws — all errors are captured in StepResult.
|
||||
*/
|
||||
function runStep(
|
||||
label: string,
|
||||
cmd: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
timeoutMs: number,
|
||||
extraEnv: Record<string, string> = {},
|
||||
): StepResult {
|
||||
const t0 = Date.now();
|
||||
console.log(`\n${"═".repeat(60)}`);
|
||||
console.log(`▶ ${label}`);
|
||||
console.log(` ${cmd} ${args.join(" ")} (cwd: ${cwd})`);
|
||||
console.log(` timeout: ${Math.round(timeoutMs / 60000)}min`);
|
||||
console.log(`${"═".repeat(60)}`);
|
||||
|
||||
const env = { ...process.env, ...extraEnv };
|
||||
|
||||
const result = spawnSync(cmd, args, {
|
||||
cwd,
|
||||
env,
|
||||
encoding: "utf-8",
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 20 * 1024 * 1024, // 20MB
|
||||
});
|
||||
|
||||
const durationMs = Date.now() - t0;
|
||||
const stdout = result.stdout ?? "";
|
||||
const stderr = result.stderr ?? "";
|
||||
const combined = stdout + "\n" + stderr;
|
||||
|
||||
// Print output in real-time equivalent (post-hoc since spawnSync)
|
||||
if (stdout) process.stdout.write(stdout);
|
||||
if (stderr) process.stderr.write(stderr);
|
||||
|
||||
if (result.error) {
|
||||
console.error(`[${label}] process error:`, result.error.message);
|
||||
return {
|
||||
label,
|
||||
status: "error",
|
||||
failures: [`Process error: ${result.error.message}`],
|
||||
exitCode: result.status ?? 1,
|
||||
durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
const exitCode = result.status ?? 1;
|
||||
const failures = extractFailures(combined, label);
|
||||
const status = exitCode === 0 ? "passed" : "failed";
|
||||
|
||||
return { label, status, failures, exitCode, durationMs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract meaningful failure lines from combined stdout+stderr.
|
||||
* Heuristics per step type (cucumber scenario names, FAIL lines, etc.).
|
||||
*/
|
||||
function extractFailures(output: string, label: string): string[] {
|
||||
const lines = output.split("\n");
|
||||
const failures: string[] = [];
|
||||
|
||||
if (label.includes("polyfill:unit")) {
|
||||
// bun test output: lines starting with "✗" or "FAIL" or "fail"
|
||||
for (const line of lines) {
|
||||
const l = line.trim();
|
||||
if (/^(✗|✕|FAIL|fail)\s/.test(l) || l.includes("tests failed")) {
|
||||
failures.push(l);
|
||||
}
|
||||
}
|
||||
// Also capture summary line "N passed, M failed"
|
||||
const summary = lines.find(
|
||||
(l) => l.includes("passed") && l.includes("failed"),
|
||||
);
|
||||
if (summary) failures.push(summary.trim());
|
||||
} else if (label.includes("polyfill:e2e")) {
|
||||
// e2e/run.ts output: lines starting with " [FAIL]"
|
||||
for (const line of lines) {
|
||||
const l = line.trim();
|
||||
if (l.startsWith("[FAIL]")) failures.push(l);
|
||||
}
|
||||
// Summary: "N passed / M failed" style
|
||||
const summary = lines.find(
|
||||
(l) => l.includes("passed") || l.includes("failed"),
|
||||
);
|
||||
if (summary && !failures.includes(summary.trim()))
|
||||
failures.push(summary.trim());
|
||||
} else {
|
||||
// Cucumber steps: look for "✗" scenario lines, "FAILED" scenario names,
|
||||
// or lines beginning with "✖" / "×" / "Scenario:" after a failure tag
|
||||
for (const line of lines) {
|
||||
const l = line.trim();
|
||||
if (
|
||||
/^(✗|✕|×|✖)\s/.test(l) ||
|
||||
l.startsWith("✘") ||
|
||||
l.includes("# Scénario:") ||
|
||||
l.includes("# Scenario:") ||
|
||||
(l.startsWith("F") && l.length === 1) // progress-bar failure tick
|
||||
) {
|
||||
if (l.length > 1) failures.push(l);
|
||||
}
|
||||
}
|
||||
// Cucumber "N scenarios (M failed)" summary
|
||||
const summary = lines.find((l) =>
|
||||
/\d+ sc[eé]narios?.*(failed|undefined)/.test(l),
|
||||
);
|
||||
if (summary) failures.push(summary.trim());
|
||||
// Individual scenario fail lines: "✗ Scenario name (features/...)"
|
||||
for (const line of lines) {
|
||||
const l = line.trim();
|
||||
if (l.startsWith("✗") || l.startsWith("✕")) {
|
||||
if (!failures.includes(l)) failures.push(l);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return failures.filter(Boolean);
|
||||
}
|
||||
|
||||
function fmtDuration(ms: number): string {
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
const m = Math.floor(ms / 60_000);
|
||||
const s = ((ms % 60_000) / 1000).toFixed(0);
|
||||
return `${m}m${s}s`;
|
||||
}
|
||||
|
||||
function printMatrix(
|
||||
steps: StepResult[],
|
||||
wipResult: StepResult | null,
|
||||
fullMode: boolean,
|
||||
): void {
|
||||
console.log("\n");
|
||||
console.log("╔══════════════════════════════════════════════════════════╗");
|
||||
console.log("║ VALIDATION MATRIX ║");
|
||||
if (fullMode) {
|
||||
console.log("║ (mode: --full, @data complet) ║");
|
||||
} else {
|
||||
console.log("║ (mode: défaut, sous-ensemble clé) ║");
|
||||
}
|
||||
console.log("╚══════════════════════════════════════════════════════════╝");
|
||||
console.log("");
|
||||
|
||||
const maxLabel = Math.max(...steps.map((s) => s.label.length));
|
||||
|
||||
for (const step of steps) {
|
||||
const icon = step.status === "passed" ? "✅" : step.status === "failed" ? "❌" : "⚠️ ";
|
||||
const pad = step.label.padEnd(maxLabel + 2);
|
||||
console.log(` ${icon} ${pad} [${fmtDuration(step.durationMs)}]`);
|
||||
for (const f of step.failures) {
|
||||
console.log(` ↳ ${f}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (wipResult) {
|
||||
console.log("");
|
||||
console.log(" ── @wip (informational, non-blocking) ──────────────────");
|
||||
const icon =
|
||||
wipResult.status === "passed"
|
||||
? "✅"
|
||||
: wipResult.status === "failed"
|
||||
? "❌"
|
||||
: "⚠️ ";
|
||||
const pad = wipResult.label.padEnd(maxLabel + 2);
|
||||
console.log(` ${icon} ${pad} [${fmtDuration(wipResult.durationMs)}]`);
|
||||
for (const f of wipResult.failures) {
|
||||
console.log(` ↳ ${f}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
const allPassed = steps.every((s) => s.status === "passed");
|
||||
const totalMs = steps.reduce((sum, s) => sum + s.durationMs, 0) +
|
||||
(wipResult?.durationMs ?? 0);
|
||||
if (allPassed) {
|
||||
console.log(" 🟢 ALL STEPS PASSED");
|
||||
} else {
|
||||
const failed = steps.filter((s) => s.status !== "passed");
|
||||
console.log(` 🔴 ${failed.length} STEP(S) FAILED: ${failed.map((s) => s.label).join(", ")}`);
|
||||
}
|
||||
console.log(` ⏱ Total: ${fmtDuration(totalMs)}`);
|
||||
if (!fullMode) {
|
||||
console.log(" ℹ️ Pour @data complet : bun run validate -- --full");
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// ─── Cucumber command builder ───────────────────────────────────────────────
|
||||
|
||||
function cucumberArgsByTags(tags: string): string[] {
|
||||
return [
|
||||
"--import",
|
||||
"tsx/esm",
|
||||
"node_modules/.bin/cucumber-js",
|
||||
"--config",
|
||||
"cucumber.json",
|
||||
"--tags",
|
||||
tags,
|
||||
];
|
||||
}
|
||||
|
||||
function cucumberArgsByName(nameRegex: string): string[] {
|
||||
return [
|
||||
"--import",
|
||||
"tsx/esm",
|
||||
"node_modules/.bin/cucumber-js",
|
||||
"--config",
|
||||
"cucumber.json",
|
||||
"--tags",
|
||||
"@data",
|
||||
"--name",
|
||||
nameRegex,
|
||||
];
|
||||
}
|
||||
|
||||
// ─── Main ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const fullMode = args.includes("--full");
|
||||
|
||||
if (fullMode) {
|
||||
console.log("🔍 Festipod — Full Validation Run (--full : @data complet)");
|
||||
} else {
|
||||
console.log("🔍 Festipod — Validation Run (sous-ensemble clé)");
|
||||
console.log(" Pour @data complet : bun run validate -- --full");
|
||||
}
|
||||
console.log(` Festipod: ${FESTIPOD_DIR}`);
|
||||
console.log(` Polyfill: ${POLYFILL_DIR}`);
|
||||
console.log("");
|
||||
|
||||
// ── Profile rotation AVANT les étapes broker ──────────────────────────────
|
||||
console.log("── Profile rotation check (avant @data et @multibrowser) ────");
|
||||
rotateProfile(FESTIPOD_PROFILE, "festipod");
|
||||
rotateProfile(POLYFILL_PROFILE, "polyfill-lib");
|
||||
|
||||
const steps: StepResult[] = [];
|
||||
|
||||
// ── (a) Polyfill unit tests ───────────────────────────────────────────────
|
||||
steps.push(
|
||||
runStep(
|
||||
"polyfill:unit",
|
||||
"bun",
|
||||
["test"],
|
||||
POLYFILL_DIR,
|
||||
TIMEOUT_POLYFILL_UNIT_MS,
|
||||
),
|
||||
);
|
||||
|
||||
// ── (b) Polyfill e2e real broker ──────────────────────────────────────────
|
||||
// Clean singleton files immediately before launching Chromium — guards against
|
||||
// any file left by polyfill:unit (unlikely but defensive) or by a previous
|
||||
// interrupted run that the initial rotateProfile call ran before.
|
||||
cleanSingletons(POLYFILL_PROFILE, "polyfill-lib (pre-e2e)");
|
||||
steps.push(
|
||||
runStep(
|
||||
"polyfill:e2e",
|
||||
"bun",
|
||||
["run", "e2e/run.ts"],
|
||||
POLYFILL_DIR,
|
||||
TIMEOUT_POLYFILL_E2E_MS,
|
||||
),
|
||||
);
|
||||
|
||||
// ── (c) Festipod @data ────────────────────────────────────────────────────
|
||||
if (fullMode) {
|
||||
// --full : lance tout @data
|
||||
steps.push(
|
||||
runStep(
|
||||
"festipod:@data (complet)",
|
||||
"node",
|
||||
cucumberArgsByTags("@data"),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_DATA_FULL_MS,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// défaut : sous-ensemble clé en UNE invocation (BeforeAll partagé)
|
||||
steps.push(
|
||||
runStep(
|
||||
"festipod:@data (clé)",
|
||||
"node",
|
||||
cucumberArgsByName(DATA_KEY_NAME_REGEX),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_DATA_KEY_MS,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── (d) Festipod @multibrowser ────────────────────────────────────────────
|
||||
// Exclude @wip: a scenario tagged @wip @multibrowser (e.g. the reactive
|
||||
// cross-session scenario, blocked by the shared-wallet structural limit) must
|
||||
// not gate the baseline — it flows into the informational @wip pass below.
|
||||
steps.push(
|
||||
runStep(
|
||||
"festipod:@multibrowser",
|
||||
"node",
|
||||
cucumberArgsByTags("@multibrowser and not @wip"),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_MULTIBROWSER_MS,
|
||||
),
|
||||
);
|
||||
|
||||
// ── (e) Festipod @smoke — boot connecté rend / page blanche ───────────────
|
||||
// Un seul scénario @e2e : boote le VRAI App, se connecte au broker, et vérifie
|
||||
// que l'accueil connecté rend du contenu d'app réel SANS erreur runtime. Garde
|
||||
// la CLASSE « crash de rendu une fois connecté » (page blanche). On ne lance
|
||||
// QUE @smoke (pas tout @e2e) pour garder le run par défaut rapide.
|
||||
// Nettoie les singletons Chromium juste avant, comme les autres étapes broker.
|
||||
cleanSingletons(FESTIPOD_PROFILE, "festipod (pre-@smoke)");
|
||||
steps.push(
|
||||
runStep(
|
||||
"festipod:@smoke",
|
||||
"node",
|
||||
cucumberArgsByTags("@smoke and not @wip"),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_SMOKE_MS,
|
||||
),
|
||||
);
|
||||
|
||||
// ── (f) Festipod @wip [informational] ────────────────────────────────────
|
||||
console.log("\n── @wip informational pass (non-blocking) ──────────────────");
|
||||
const wipResult = runStep(
|
||||
"festipod:@wip",
|
||||
"node",
|
||||
cucumberArgsByTags("@wip"),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_WIP_MS,
|
||||
);
|
||||
|
||||
// ── Matrix ────────────────────────────────────────────────────────────────
|
||||
printMatrix(steps, wipResult, fullMode);
|
||||
|
||||
// ── Exit code ─────────────────────────────────────────────────────────────
|
||||
const anyFailed = steps.some((s) => s.status !== "passed");
|
||||
process.exit(anyFailed ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("validate.ts: unhandled error:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
+60
-17
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* AuthGate — the stopgap access flow (see decision_2026-06-15_shared-wallet-login-flow):
|
||||
* 1. Technical access barrier (AccessGateScreen) → opens the SHARED wallet via
|
||||
* the broker redirect (with the wallet file + guide it hands the user).
|
||||
* 2. Perceived app login (ConnexionScreen) → pick a username.
|
||||
* 3. The app.
|
||||
* 1. Access barrier + identifier (AccessGateScreen) → the user names their
|
||||
* virtual space (an identifier) and opens the SHARED wallet via the broker
|
||||
* redirect (with the wallet file + guide it hands the user). Naming the
|
||||
* space and opening it are ONE act.
|
||||
* 2. The app.
|
||||
*
|
||||
* The gate is ON BY DEFAULT (Festipod never functions without NextGraph). It is
|
||||
* disabled only when `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ === true` —
|
||||
@@ -12,11 +13,11 @@
|
||||
* screens, not the auth flow). Absent → gate ON.
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { useNextGraph } from '../shared/context/NextGraphContext';
|
||||
import { useAccount } from '../shared/context/AccountContext';
|
||||
import { useAccount, normalizeIdentifier } from '../shared/context/AccountContext';
|
||||
import { AccessGateScreen } from '../modules/auth/screens/AccessGateScreen';
|
||||
import { ConnexionScreen } from '../modules/auth/screens/ConnexionScreen';
|
||||
import { useRouter, useNavigate } from './router';
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
@@ -26,23 +27,65 @@ const GATE_DISABLED = globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ === true;
|
||||
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
const { status, error, connect } = useNextGraph();
|
||||
const { username } = useAccount();
|
||||
const { identifier, login } = useAccount();
|
||||
const { route } = useRouter();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Once connected AND identified, leave the disconnected welcome screen for the
|
||||
// app home. The identifier is now set at the barrier (before the broker
|
||||
// round-trip), so on return the app can land on '/' with a session already
|
||||
// open; the removed ConnexionScreen used to do this navigate on login.
|
||||
useEffect(() => {
|
||||
if (!GATE_DISABLED && status === 'connected' && identifier && route.page === 'welcome') {
|
||||
navigate('/home');
|
||||
}
|
||||
}, [status, identifier, route.page, navigate]);
|
||||
|
||||
// Gate explicitly disabled (no-gate build / @e2e harness) → straight to app.
|
||||
if (GATE_DISABLED) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// 1. Technical access barrier (real NG login) — until the shared wallet opens.
|
||||
if (status !== 'connected') {
|
||||
return <AccessGateScreen status={status} error={error} onEnter={connect} />;
|
||||
// Access barrier — shown until BOTH the wallet is open AND the space is named.
|
||||
// "Entrer" records the identifier (persisted to localStorage AND written into
|
||||
// the `?id=` URL param, which is what actually survives the broker redirect
|
||||
// across the partitioned frontier) and, if the wallet isn't open yet, triggers
|
||||
// the connect.
|
||||
//
|
||||
// On return (reload / broker round-trip) the identifier is already stored, so
|
||||
// we PREFILL the field with it (`initialIdentifier`) — the user never sees a
|
||||
// bare empty prompt they must re-type. It is captured ONCE, at first access.
|
||||
if (status !== 'connected' || !identifier) {
|
||||
const onEnter = (entered: string) => {
|
||||
login(entered);
|
||||
// Carry the identifier across the broker frontier via the URL. localStorage
|
||||
// is partitioned by top-level site, so the value written here (127.0.0.1)
|
||||
// is NOT what the app reads inside the broker iframe (nextgraph.net). The
|
||||
// `@ng-org/web` redirect embeds the FULL app URL (query included) in the
|
||||
// broker `o=`, which is reloaded in the iframe — so writing the normalized
|
||||
// id into `?id=` BEFORE connect() makes it travel. `history.replaceState`
|
||||
// (not push) keeps a single history entry. See AccountContext resolution.
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('id', normalizeIdentifier(entered));
|
||||
window.history.replaceState(window.history.state, '', url.toString());
|
||||
} catch {
|
||||
/* URL construction can't fail for a real page URL; ignore defensively */
|
||||
}
|
||||
}
|
||||
if (status !== 'connected') connect();
|
||||
};
|
||||
return (
|
||||
<AccessGateScreen
|
||||
status={status}
|
||||
error={error}
|
||||
initialIdentifier={identifier ?? ''}
|
||||
onEnter={onEnter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Perceived app login — until a username is chosen.
|
||||
if (!username) {
|
||||
return <ConnexionScreen />;
|
||||
}
|
||||
|
||||
// 3. The app.
|
||||
// The app.
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
+50
-15
@@ -3,24 +3,59 @@
|
||||
* element and renders the App component to the DOM.
|
||||
*
|
||||
* It is included in `src/index.html`.
|
||||
*
|
||||
* Before loading the app tree it pulls the RUNTIME shared-wallet config (dev
|
||||
* server + `bun run start`, which serve from src/ and so miss build.ts's
|
||||
* compile-time `define`), sets the global, then dynamically imports `App` so
|
||||
* `sharedWallet.ts` reads the value on evaluation. In a build.ts bundle the
|
||||
* password is already inlined via `define`, so this step is skipped (NODE_ENV).
|
||||
*/
|
||||
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
|
||||
const elem = document.getElementById("root")!;
|
||||
const app = (
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
if (import.meta.hot) {
|
||||
// With hot module reloading, `import.meta.hot.data` is persisted.
|
||||
const root = (import.meta.hot.data.root ??= createRoot(elem));
|
||||
root.render(app);
|
||||
} else {
|
||||
// The hot module reloading API is not available in production.
|
||||
createRoot(elem).render(app);
|
||||
/** Fetch the runtime shared-wallet config and set the global (dev/start only). */
|
||||
async function loadRuntimeConfig(): Promise<void> {
|
||||
if (process.env.NODE_ENV === "production") return; // build.ts define provides it
|
||||
try {
|
||||
const res = await fetch("/festipod-config.json");
|
||||
if (!res.ok) return;
|
||||
const cfg = (await res.json()) as { sharedWalletPassword?: string; autoSeed?: string };
|
||||
// Bracket access so build.ts's `define` (which matches the dotted global)
|
||||
// never rewrites this assignment. Only set when the env actually carries one.
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
if (cfg.sharedWalletPassword && g["__FESTIPOD_SHARED_WALLET_PASSWORD__"] == null) {
|
||||
g["__FESTIPOD_SHARED_WALLET_PASSWORD__"] = cfg.sharedWalletPassword;
|
||||
}
|
||||
// Auto-seed gate (see src/shared/utils/autoSeed.ts): only set the global when
|
||||
// the env var carries a truthy value; absent → stays undefined → seed OFF.
|
||||
if (cfg.autoSeed && g["__FESTIPOD_AUTO_SEED__"] == null) {
|
||||
g["__FESTIPOD_AUTO_SEED__"] = cfg.autoSeed;
|
||||
}
|
||||
} catch {
|
||||
// No runtime config endpoint (static build) → rely on the compile-time define.
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await loadRuntimeConfig();
|
||||
// Dynamic import AFTER the global is set, so sharedWallet.ts reads it on eval.
|
||||
const { App } = await import("./App");
|
||||
const elem = document.getElementById("root")!;
|
||||
const app = (
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
if (import.meta.hot) {
|
||||
// With hot module reloading, `import.meta.hot.data` is persisted.
|
||||
const root = (import.meta.hot.data.root ??= createRoot(elem));
|
||||
root.render(app);
|
||||
} else {
|
||||
// The hot module reloading API is not available in production.
|
||||
createRoot(elem).render(app);
|
||||
}
|
||||
}
|
||||
|
||||
void main();
|
||||
|
||||
@@ -333,6 +333,17 @@ body {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Global data-query spinner (near the "Festipod" title) */
|
||||
@keyframes app-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.app-spinner {
|
||||
animation: app-spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Online indicator on avatar */
|
||||
.app-avatar .online-dot {
|
||||
position: absolute;
|
||||
|
||||
@@ -41,6 +41,28 @@ const server = serve({
|
||||
});
|
||||
},
|
||||
|
||||
// Shared-wallet config, exposed at RUNTIME for the dev server + `bun run start`
|
||||
// (both serve from src/, so they miss build.ts's compile-time `define`). The app
|
||||
// entry (frontend.tsx) fetches this before it loads the app tree, so
|
||||
// `sharedWallet.ts` sees the password. Empty env → '' → no shared wallet.
|
||||
"/festipod-config.json": () =>
|
||||
Response.json({
|
||||
sharedWalletPassword: process.env.FESTIPOD_SHARED_WALLET_PASSWORD ?? "",
|
||||
// Auto-seed gate (OFF by default): only set when the env var is present, so
|
||||
// the front seeds an empty wallet only on explicit opt-in (see autoSeed.ts).
|
||||
autoSeed: process.env.FESTIPOD_AUTO_SEED ?? "",
|
||||
}),
|
||||
|
||||
// The shared wallet file (download target of the access barrier), when configured.
|
||||
"/shared-wallet.ngw": async () => {
|
||||
const p = process.env.FESTIPOD_SHARED_WALLET_FILE;
|
||||
if (p) {
|
||||
const file = Bun.file(p);
|
||||
if (await file.exists()) return new Response(file);
|
||||
}
|
||||
return new Response("No shared wallet file configured.", { status: 404 });
|
||||
},
|
||||
|
||||
// Serve index.html for all unmatched routes (must be last)
|
||||
"/*": index,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# language: fr
|
||||
@AUTH @priority-1
|
||||
Fonctionnalité: Barrière d'accès — l'identifiant se saisit une seule fois
|
||||
En tant qu'utilisateur qui revient dans Festipod
|
||||
Je veux retrouver l'identifiant que j'ai déjà choisi, pré-rempli
|
||||
Afin de ne jamais avoir à le retaper à l'arrivée
|
||||
|
||||
# Garde-fou contre la régression rapportée : au retour (rechargement / round-trip
|
||||
# broker) la barrière re-demandait un identifiant NU et VIDE alors qu'il était
|
||||
# déjà stocké. L'identifiant est capturé UNE FOIS au premier accès, persisté,
|
||||
# puis pré-rempli. Voir AuthGate + AccessGateScreen.
|
||||
|
||||
@ui
|
||||
Scénario: Le champ identifiant est pré-rempli avec la valeur déjà stockée
|
||||
Étant donné que la barrière d'accès s'affiche avec l'identifiant stocké "alice"
|
||||
Alors le champ identifiant contient "alice"
|
||||
|
||||
@ui
|
||||
Scénario: Un premier accès sans identifiant stocké affiche un champ vide
|
||||
Étant donné que la barrière d'accès s'affiche sans identifiant stocké
|
||||
Alors le champ identifiant est vide
|
||||
|
||||
@ui
|
||||
Scénario: Entrer remonte l'identifiant saisi
|
||||
Étant donné que la barrière d'accès s'affiche avec l'identifiant stocké "alice"
|
||||
Quand je clique sur "Entrer" dans la barrière
|
||||
Alors l'identifiant remonté à l'application est "alice"
|
||||
@@ -0,0 +1,42 @@
|
||||
# language: fr
|
||||
@AUTH @priority-1
|
||||
Fonctionnalité: Résolution de l'identifiant — le param d'URL prime sur localStorage
|
||||
En tant qu'application relancée dans l'iframe du broker après le round-trip
|
||||
Je veux résoudre l'identifiant depuis le param d'URL "?id="
|
||||
Afin qu'il traverse la frontière top-level↔iframe (que localStorage ne franchit pas)
|
||||
|
||||
# Le flux wallet-partagé fait tourner l'app dans DEUX contextes avec DEUX
|
||||
# partitions localStorage distinctes (top-level 127.0.0.1 vs iframe
|
||||
# nextgraph.net). localStorage ne traverse pas la frontière ; le param "?id="
|
||||
# embarqué dans le redirect broker (o=) la traverse. AccountContext résout donc
|
||||
# dans l'ordre : (1) param d'URL "?id=" (source de vérité) ; (2) sinon
|
||||
# localStorage (préremplissage même-partition). Voir AccountContext + AuthGate.
|
||||
|
||||
@ui
|
||||
Scénario: Le param d'URL est la source de vérité quand il est présent
|
||||
Étant donné que localStorage contient l'identifiant "alice"
|
||||
Et que l'URL porte le param id "bob"
|
||||
Quand le contexte de compte résout l'identifiant
|
||||
Alors l'identifiant résolu est "bob"
|
||||
|
||||
@ui
|
||||
Scénario: Le param d'URL prime même sur une valeur localStorage différente et est persisté
|
||||
Étant donné que localStorage contient l'identifiant "alice"
|
||||
Et que l'URL porte le param id "carol"
|
||||
Quand le contexte de compte résout l'identifiant
|
||||
Alors l'identifiant résolu est "carol"
|
||||
Et localStorage contient désormais l'identifiant "carol"
|
||||
|
||||
@ui
|
||||
Scénario: Sans param d'URL, localStorage sert de repli
|
||||
Étant donné que localStorage contient l'identifiant "dave"
|
||||
Et que l'URL ne porte aucun param id
|
||||
Quand le contexte de compte résout l'identifiant
|
||||
Alors l'identifiant résolu est "dave"
|
||||
|
||||
@ui
|
||||
Scénario: Le param d'URL est normalisé (minuscules, @ retiré)
|
||||
Étant donné que localStorage ne contient aucun identifiant
|
||||
Et que l'URL porte le param id "@Erin"
|
||||
Quand le contexte de compte résout l'identifiant
|
||||
Alors l'identifiant résolu est "erin"
|
||||
@@ -1,31 +1,48 @@
|
||||
/**
|
||||
* 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. Clicking "Entrer" triggers `connect()`, which redirects to the broker
|
||||
* to open the SHARED wallet. After return (inside the broker iframe) NG
|
||||
* auto-connects and the app shows the perceived login (ConnexionScreen).
|
||||
* login. The user also types an IDENTIFIER here — the id that names their
|
||||
* virtual space (a technical id, a pseudo in practice, NOT a Festipod 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 "choose a handle" screen.
|
||||
*
|
||||
* ASSISTED IMPORT (see the broker-import constraint, concept nextgraph-platform
|
||||
* + decision_2026-06-17). The hosted broker can't import a wallet inline during
|
||||
* ASSISTED IMPORT (see decision_2026-06-17). The hosted broker can't import a
|
||||
* wallet inline during
|
||||
* web-app auth: a first-time device has no wallet, so the broker redirect would
|
||||
* dead-end. We therefore HAND the user the shared wallet FILE (download) + the
|
||||
* shared password and guide a one-time import on nextgraph.eu ("Import a Wallet
|
||||
* 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';
|
||||
import { Button, Title, Text } from '../../../shared/components/sketchy';
|
||||
import { Button, Input, Title, Text } from '../../../shared/components/sketchy';
|
||||
import { SHARED_WALLET_PASSWORD, SHARED_WALLET_FILE_URL, WALLET_IMPORT_URL, hasSharedWallet } from '../sharedWallet';
|
||||
|
||||
interface AccessGateScreenProps {
|
||||
status: 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
error?: string;
|
||||
onEnter: () => void;
|
||||
/**
|
||||
* The identifier already stored for this space (the persisted one), used to
|
||||
* PREFILL the field so a returning user never re-types it. Empty on a truly
|
||||
* first access. Normalized upstream; shown verbatim.
|
||||
*/
|
||||
initialIdentifier?: string;
|
||||
/** Enter the space: the raw identifier the user typed (normalized upstream). */
|
||||
onEnter: (identifier: string) => void;
|
||||
}
|
||||
|
||||
// One numbered step: a badge + a title + the action for that step.
|
||||
@@ -44,9 +61,15 @@ function Step({ n, title, children }: { n: number; title: string; children: Reac
|
||||
);
|
||||
}
|
||||
|
||||
export function AccessGateScreen({ status, error, onEnter }: AccessGateScreenProps) {
|
||||
export function AccessGateScreen({ status, error, initialIdentifier, onEnter }: AccessGateScreenProps) {
|
||||
const connecting = status === 'connecting';
|
||||
const [copied, setCopied] = useState(false);
|
||||
// The identifier that names this virtual space (a technical id — a pseudo in
|
||||
// practice, but not a Festipod 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.
|
||||
const [identifier, setIdentifier] = useState(initialIdentifier ?? '');
|
||||
|
||||
const copyPassword = async () => {
|
||||
try {
|
||||
@@ -58,15 +81,31 @@ export function AccessGateScreen({ status, error, onEnter }: AccessGateScreenPro
|
||||
}
|
||||
};
|
||||
|
||||
const canEnter = !connecting && identifier.trim().length > 0;
|
||||
const enter = () => { if (canEnter) onEnter(identifier); };
|
||||
|
||||
// Identifier field + Entrer: naming the space and opening it are one act.
|
||||
const entrer = (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onEnter}
|
||||
disabled={connecting}
|
||||
style={{ width: '100%', opacity: connecting ? 0.6 : 1 }}
|
||||
>
|
||||
{connecting ? 'Accès en cours…' : 'Entrer'}
|
||||
</Button>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<Input
|
||||
data-testid="identifier-input"
|
||||
placeholder="votre identifiant"
|
||||
value={identifier}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setIdentifier(e.target.value)}
|
||||
onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter') enter(); }}
|
||||
/>
|
||||
<Text style={{ margin: 0, fontSize: 12, color: '#999' }}>
|
||||
Il identifie votre espace (mis en minuscules).
|
||||
</Text>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={enter}
|
||||
disabled={!canEnter}
|
||||
style={{ width: '100%', opacity: canEnter ? 1 : 0.6 }}
|
||||
>
|
||||
{connecting ? 'Accès en cours…' : 'Entrer'}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -75,7 +114,14 @@ export function AccessGateScreen({ status, error, onEnter }: AccessGateScreenPro
|
||||
<Title style={{ textAlign: 'center', fontSize: 30, marginBottom: 4 }}>Festipod</Title>
|
||||
<Text style={{ textAlign: 'center', marginBottom: 24, color: '#888' }}>Espace de test</Text>
|
||||
|
||||
{hasSharedWallet() ? (
|
||||
{!hasSharedWallet() ? (
|
||||
// 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.
|
||||
@@ -114,7 +160,7 @@ export function AccessGateScreen({ status, error, onEnter }: AccessGateScreenPro
|
||||
</div>
|
||||
</Step>
|
||||
|
||||
<Step n={3} title="Revenez sur cet onglet et entrez">
|
||||
<Step n={3} title="Revenez ici, choisissez un identifiant et entrez">
|
||||
{entrer}
|
||||
</Step>
|
||||
</>
|
||||
|
||||
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* ConnexionScreen — the *perceived* login of the stopgap.
|
||||
*
|
||||
* STOPGAP (see decision_2026-06-15_shared-wallet-login-flow). The real NG
|
||||
* login (AccessGateScreen) already happened and is not perceived as a login;
|
||||
* THIS screen is what the user experiences as "logging in": they pick a
|
||||
* username (no password — declarative). The username is persisted by
|
||||
* AccountContext (localStorage) and resolved against the accounts living in
|
||||
* the shared wallet.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button, Input, Title, Text, Avatar } from '../../../shared/components/sketchy';
|
||||
import { useAccount } from '../../../shared/context/AccountContext';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate } from '../../../app/router';
|
||||
|
||||
export function ConnexionScreen() {
|
||||
const { login } = useAccount();
|
||||
const { users } = useFestipodData();
|
||||
const navigate = useNavigate();
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
// Choosing a username completes the login → land on the app (not the '/'
|
||||
// welcome/onboarding screen, which the access gate has replaced upstream).
|
||||
const doLogin = (name: string) => {
|
||||
login(name);
|
||||
navigate('/home');
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (value.trim()) doLogin(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<Title style={{ textAlign: 'center', fontSize: 32, marginBottom: 8 }}>Connexion</Title>
|
||||
<Text style={{ textAlign: 'center', marginBottom: 32, color: '#888' }}>
|
||||
Choisissez votre nom d'utilisateur
|
||||
</Text>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 24 }}>
|
||||
<Input
|
||||
placeholder="@votrepseudo"
|
||||
value={value}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setValue(e.target.value)}
|
||||
onKeyDown={(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') submit();
|
||||
}}
|
||||
/>
|
||||
<Button variant="primary" onClick={submit} disabled={!value.trim()} style={{ width: '100%' }}>
|
||||
Se connecter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{users.length > 0 && (
|
||||
<>
|
||||
<Text style={{ textAlign: 'center', fontSize: 13, color: '#888', margin: '8px 0 12px' }}>
|
||||
ou reprenez un compte existant
|
||||
</Text>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{users.map(u => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => doLogin(u.username)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: 10,
|
||||
border: '1px solid #eee',
|
||||
borderRadius: 12,
|
||||
background: '#fff',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<Avatar initials={u.initials} color="#E8590C" size="sm" />
|
||||
<span>
|
||||
<Text style={{ margin: 0, fontWeight: 600 }}>{u.name}</Text>
|
||||
<Text style={{ margin: 0, fontSize: 12, color: '#888' }}>{u.username}</Text>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
/**
|
||||
* Shared wallet material for the staging stopgap.
|
||||
*
|
||||
* STOPGAP (see brief_2026-06-15_shared-wallet-shim + the broker-import
|
||||
* constraint, concept nextgraph-platform): the hosted broker can't auto-import
|
||||
* a wallet, so Festipod HANDS the user the shared wallet and guides a one-time
|
||||
* import on nextgraph.eu.
|
||||
* STOPGAP: the hosted broker can't auto-import a wallet, so Festipod HANDS the
|
||||
* user the shared wallet and guides a one-time import on nextgraph.eu.
|
||||
*
|
||||
* The correct primitive is the **wallet FILE** (.ngw), NOT a TextCode: a
|
||||
* TextCode is a transient device-to-device transfer (5 min, source device
|
||||
|
||||
@@ -2,31 +2,21 @@ import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
// Seed data matching what bootstrapWallet uses
|
||||
import { seedEvents, seedUsers } from '../../../../shared/data/seedData';
|
||||
|
||||
// --- Setup ---
|
||||
|
||||
Given('le portefeuille est vide', async function (this: FestipodWorld) {
|
||||
// Verify starting state: the harness graph should have its own seeded data.
|
||||
// We clear events/users/participations to simulate a truly empty wallet.
|
||||
await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
// Delete all events
|
||||
for (const e of [...td.events]) td.events.delete(e);
|
||||
// Delete all users
|
||||
for (const u of [...td.users]) td.users.delete(u);
|
||||
// Delete all participations
|
||||
for (const p of [...td.participations]) td.participations.delete(p);
|
||||
});
|
||||
|
||||
// Verify empty
|
||||
// Each @data scenario runs under a UNIQUE 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
|
||||
// was itself slow). No mutation, no polling: a fresh wallet has no docs to scan.
|
||||
const counts = await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
return { events: td.events.size, users: td.users.size, participations: td.participations.size };
|
||||
return { events: td.events.size, users: td.users.size };
|
||||
});
|
||||
expect(counts.events, 'Events should be empty').to.equal(0);
|
||||
expect(counts.users, 'Users should be empty').to.equal(0);
|
||||
expect(counts.events, 'Fresh virtual wallet should have no events').to.equal(0);
|
||||
expect(counts.users, 'Fresh virtual wallet should have no users').to.equal(0);
|
||||
});
|
||||
|
||||
Given('le portefeuille contient déjà des événements', async function (this: FestipodWorld) {
|
||||
@@ -43,7 +33,7 @@ Given('le portefeuille contient déjà des événements', async function (this:
|
||||
// Wait for data to propagate
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => (window as any).__testData.events.size > 0,
|
||||
{ timeout: 10000 },
|
||||
{ timeout: 75000 },
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -58,22 +48,36 @@ When('je charge les données de test', async function (this: FestipodWorld) {
|
||||
});
|
||||
(this as any)._eventCountBefore = countBefore;
|
||||
|
||||
await this.appFrame!.evaluate(() => {
|
||||
// AWAIT the seed's own promise (loadTestData returns a BootstrapResult promise)
|
||||
// and record whether it actually seeded — so the propagation wait below can tell
|
||||
// a genuinely-populated wallet (nothing to appear) from an empty one that must
|
||||
// seed. Fire-and-forget here would let the assertions race the async seed.
|
||||
const seededResult = await this.appFrame!.evaluate(async () => {
|
||||
const td = (window as any).__testData;
|
||||
td.loadTestData();
|
||||
const r = await td.loadTestData();
|
||||
return { seeded: r?.seeded ?? false };
|
||||
});
|
||||
(this as any)._loadSeeded = seededResult.seeded;
|
||||
|
||||
// Wait for data to propagate (if wallet was empty, data should appear)
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => {
|
||||
const td = (window as any).__testData;
|
||||
// Either data was already there, or it should appear after loading
|
||||
return td.events.size > 0 || td._loadResult?.seeded === false;
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
).catch(() => {
|
||||
// Timeout is OK if wallet was already populated (idempotent case)
|
||||
});
|
||||
// Wait for data to propagate. Events reach the read via the discovery index (a
|
||||
// fast, independent path); the seeded PROTECTED user docs reach it only through
|
||||
// the by-need re-list, which can lag the public read under load. So wait for BOTH
|
||||
// events AND users to settle (not just events) — otherwise `contient des
|
||||
// utilisateurs` asserts before the protected read lands and flakes to users:0.
|
||||
// On a wallet that already had data (seeded === false) there is nothing to wait
|
||||
// for. The assertions still verify the real counts; this only synchronizes.
|
||||
if (seededResult.seeded) {
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => {
|
||||
const td = (window as any).__testData;
|
||||
return td.events.size > 0 && td.users.size > 0;
|
||||
},
|
||||
{ timeout: 75000 },
|
||||
).catch(() => {
|
||||
// Timeout tolerated — the assertions below surface the real failure with a
|
||||
// clearer message than a raw waitForFunction timeout.
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// --- Assertions ---
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @ui steps for the access barrier (AccessGateScreen).
|
||||
*
|
||||
* These render the prop-driven AccessGateScreen directly (via renderElement) —
|
||||
* it is NOT a registry/route screen, its state comes from props (status,
|
||||
* initialIdentifier, onEnter). We assert on the rendered DOM: the identifier
|
||||
* field is PREFILLED from the stored value, and "Entrer" reports the identifier.
|
||||
*
|
||||
* Guards the reported regression: on return the barrier used to re-ask for a
|
||||
* bare, empty identifier despite one being stored. See AuthGate.tsx.
|
||||
*
|
||||
* 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 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;
|
||||
entered: string | null;
|
||||
}
|
||||
const gateStates = new WeakMap<object, GateState>();
|
||||
function stateFor(world: object): GateState {
|
||||
let s = gateStates.get(world);
|
||||
if (!s) {
|
||||
s = { doc: null, entered: null };
|
||||
gateStates.set(world, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
async function renderGate(world: object, initialIdentifier?: string): Promise<void> {
|
||||
const s = stateFor(world);
|
||||
s.entered = null;
|
||||
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(
|
||||
React.createElement(AccessGateScreen, {
|
||||
status: 'disconnected',
|
||||
initialIdentifier,
|
||||
onEnter: (id: string) => {
|
||||
s.entered = id;
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Given(
|
||||
'la barrière d\'accès s\'affiche avec l\'identifiant stocké {string}',
|
||||
async function (this: FestipodWorld, identifier: string) {
|
||||
await renderGate(this, identifier);
|
||||
},
|
||||
);
|
||||
|
||||
Given(
|
||||
'la barrière d\'accès s\'affiche sans identifiant stocké',
|
||||
async function (this: FestipodWorld) {
|
||||
await renderGate(this, '');
|
||||
},
|
||||
);
|
||||
|
||||
function identifierField(world: object): HTMLInputElement {
|
||||
const s = stateFor(world);
|
||||
expect(s.doc, 'The access barrier should be rendered').to.not.be.null;
|
||||
const input = s.doc!.querySelector('[data-testid="identifier-input"]') as HTMLInputElement | null;
|
||||
expect(input, 'The identifier field should be present').to.not.be.null;
|
||||
return input!;
|
||||
}
|
||||
|
||||
Then(
|
||||
'le champ identifiant contient {string}',
|
||||
function (this: FestipodWorld, expected: string) {
|
||||
expect(identifierField(this).value).to.equal(expected);
|
||||
},
|
||||
);
|
||||
|
||||
Then('le champ identifiant est vide', function (this: FestipodWorld) {
|
||||
expect(identifierField(this).value).to.equal('');
|
||||
});
|
||||
|
||||
When('je clique sur {string} dans la barrière', function (this: FestipodWorld, _label: string) {
|
||||
const s = stateFor(this);
|
||||
const input = identifierField(this);
|
||||
// Submit via Enter on the field (canEnter is satisfied by the prefilled value).
|
||||
const KeyboardEventCtor = (globalThis as { KeyboardEvent?: typeof KeyboardEvent }).KeyboardEvent;
|
||||
const evt = KeyboardEventCtor
|
||||
? new KeyboardEventCtor('keydown', { key: 'Enter', bubbles: true })
|
||||
: Object.assign(new (globalThis as { Event: typeof Event }).Event('keydown', { bubbles: true }), { key: 'Enter' });
|
||||
input.dispatchEvent(evt);
|
||||
expect(s.doc, 'The access barrier should be rendered').to.not.be.null;
|
||||
});
|
||||
|
||||
Then(
|
||||
'l\'identifiant remonté à l\'application est {string}',
|
||||
function (this: FestipodWorld, expected: string) {
|
||||
const s = stateFor(this);
|
||||
expect(s.entered, 'onEnter should have been called with the identifier').to.equal(expected);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* @ui steps for AccountContext identifier resolution.
|
||||
*
|
||||
* Guards the cross-frontier fix: the shared-wallet flow runs the app in TWO
|
||||
* localStorage partitions (top-level 127.0.0.1 vs broker iframe nextgraph.net),
|
||||
* so localStorage does NOT cross. The `?id=` URL param — embedded in the broker
|
||||
* redirect `o=` — DOES cross. AccountContext resolution therefore PRIORITIZES the
|
||||
* URL param over localStorage, and (when present) persists it to localStorage for
|
||||
* same-partition convenience. Normalization (trim, `@`-strip, lowercase) applies.
|
||||
*
|
||||
* These render a tiny probe inside a real AccountProvider (via renderElement),
|
||||
* having first seeded window.location.search and window.localStorage through the
|
||||
* happy-dom harness — so the resolution logic runs for real, not mocked.
|
||||
*/
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import React from 'react';
|
||||
import {
|
||||
renderElement,
|
||||
setRenderUrl,
|
||||
setRenderLocalStorage,
|
||||
getRenderLocalStorage,
|
||||
} from '../../../../shared/test-harness/renderHelper';
|
||||
import { AccountProvider, useAccount } from '../../../../shared/context/AccountContext';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
const STORAGE_KEY = 'festipod.account.identifier';
|
||||
|
||||
// Per-scenario intent (kept off the World type via a WeakMap).
|
||||
interface ResolveState {
|
||||
storageSeed: string | null;
|
||||
url: string;
|
||||
doc: Document | null;
|
||||
}
|
||||
const states = new WeakMap<object, ResolveState>();
|
||||
function stateFor(world: object): ResolveState {
|
||||
let s = states.get(world);
|
||||
if (!s) {
|
||||
s = { storageSeed: null, url: 'http://localhost/', doc: null };
|
||||
states.set(world, s);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
// Probe: renders the resolved identifier so the DOM can be asserted.
|
||||
function IdentifierProbe(): React.ReactElement {
|
||||
const { identifier } = useAccount();
|
||||
return React.createElement('div', { 'data-testid': 'resolved-identifier' }, identifier ?? '');
|
||||
}
|
||||
|
||||
Given(
|
||||
'localStorage contient l\'identifiant {string}',
|
||||
function (this: FestipodWorld, value: string) {
|
||||
stateFor(this).storageSeed = value;
|
||||
},
|
||||
);
|
||||
|
||||
Given('localStorage ne contient aucun identifiant', function (this: FestipodWorld) {
|
||||
stateFor(this).storageSeed = null;
|
||||
});
|
||||
|
||||
Given('l\'URL porte le param id {string}', function (this: FestipodWorld, id: string) {
|
||||
const s = stateFor(this);
|
||||
const url = new URL('http://localhost/');
|
||||
url.searchParams.set('id', id);
|
||||
s.url = url.toString();
|
||||
});
|
||||
|
||||
Given('l\'URL ne porte aucun param id', function (this: FestipodWorld) {
|
||||
stateFor(this).url = 'http://localhost/';
|
||||
});
|
||||
|
||||
When('le contexte de compte résout l\'identifiant', async function (this: FestipodWorld) {
|
||||
const s = stateFor(this);
|
||||
// Seed the happy-dom window (URL + localStorage) BEFORE mounting the provider,
|
||||
// so the provider's init-time resolution reads exactly this state.
|
||||
await setRenderUrl(s.url);
|
||||
await setRenderLocalStorage(STORAGE_KEY, s.storageSeed);
|
||||
s.doc = await renderElement(
|
||||
React.createElement(AccountProvider, null, React.createElement(IdentifierProbe)),
|
||||
);
|
||||
});
|
||||
|
||||
function resolved(world: object): string {
|
||||
const s = stateFor(world);
|
||||
expect(s.doc, 'The probe should be rendered').to.not.be.null;
|
||||
const el = s.doc!.querySelector('[data-testid="resolved-identifier"]');
|
||||
expect(el, 'The resolved-identifier probe should be present').to.not.be.null;
|
||||
return el!.textContent ?? '';
|
||||
}
|
||||
|
||||
Then('l\'identifiant résolu est {string}', function (this: FestipodWorld, expected: string) {
|
||||
expect(resolved(this)).to.equal(expected);
|
||||
});
|
||||
|
||||
Then(
|
||||
'localStorage contient désormais l\'identifiant {string}',
|
||||
async function (this: FestipodWorld, expected: string) {
|
||||
// The URL-param → localStorage persistence runs in a mount useEffect, which
|
||||
// React flushes AFTER the render's first microtask. Yield a few macrotask
|
||||
// ticks (bounded, no polling of any live resource) so the effect has run
|
||||
// before asserting — otherwise the read races the effect and flakes.
|
||||
let stored: string | null = null;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
stored = await getRenderLocalStorage(STORAGE_KEY);
|
||||
if (stored === expected) break;
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
}
|
||||
expect(stored).to.equal(expected);
|
||||
},
|
||||
);
|
||||
@@ -47,20 +47,24 @@ Fonctionnalité: Cycle de vie d'un événement
|
||||
Et l'utilisateur clique sur le bouton "J'y serai" si visible
|
||||
Alors l'écran contient le texte "Je participe"
|
||||
|
||||
# ngSet.delete() updates UI but doesn't persist — NG ORM limitation.
|
||||
@e2e @wip
|
||||
# Auto-suffisant : on s'inscrit d'abord (précondition), puis on se désinscrit,
|
||||
# le tout dans la même session — ne dépend ni de l'état inter-scénarios ni de
|
||||
# la persistance après reconnexion.
|
||||
# FIX T02.c (2026-07-03) : le @wip est LEVÉ. La désinscription se reflète
|
||||
# désormais dans l'UI et est DURABLE — leaveEvent supprime la Participation
|
||||
# côté données via SPARQL DELETE-WHERE (docs.sparqlUpdate, le ng injecté), donc
|
||||
# l'item ne ressuscite plus via la sync broker (l'ancien bug CRDT décrit dans
|
||||
# caveat_participation-deletion). Prouvé : ce scénario @e2e passe + le scénario
|
||||
# @data « désinscription persistante » de inscription-inbox.feature. (La
|
||||
# validation multi-navigateur complète reste T02.f.)
|
||||
@e2e
|
||||
Scénario: Se désinscrire d'un événement
|
||||
Quand l'utilisateur navigue vers l'écran "events"
|
||||
Et l'utilisateur clique sur un événement de la liste
|
||||
Et l'utilisateur attend que l'écran "event-detail" soit affiché
|
||||
Et l'utilisateur clique sur le bouton "Je participe"
|
||||
Alors l'écran contient le texte "J'y serai"
|
||||
|
||||
@e2e @wip
|
||||
Scénario: La désinscription persiste après reconnexion
|
||||
Quand l'utilisateur navigue vers l'écran "events"
|
||||
Et l'utilisateur clique sur un événement de la liste
|
||||
Et l'utilisateur attend que l'écran "event-detail" soit affiché
|
||||
Et l'utilisateur clique sur le bouton "J'y serai" si visible
|
||||
Alors l'écran contient le texte "Je participe"
|
||||
Quand l'utilisateur clique sur le bouton "Je participe"
|
||||
Alors l'écran contient le texte "J'y serai"
|
||||
|
||||
# --- Modification ---
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
# language: fr
|
||||
@EVENT @priority-1
|
||||
Fonctionnalité: Découverte publique via l'index global
|
||||
En tant qu'utilisateur
|
||||
Je veux découvrir les événements publics des autres comptes sans être connecté
|
||||
à eux, afin de trouver des points de rencontre à rejoindre au-delà de mon
|
||||
propre réseau.
|
||||
|
||||
# Découverte = lire l'INDEX GLOBAL. Publier un événement public, c'est soumettre
|
||||
# sa référence à l'index (submitEventToIndex) ; découvrir, c'est lire l'index
|
||||
# (readDiscoveredEvents) puis s'abonner aux documents référencés. L'app ne fait
|
||||
# AUCUN fan-out cross-comptes et ne connaît ni l'hôte de l'index ni sa
|
||||
# mécanique — le SDK possède l'index de bout en bout.
|
||||
|
||||
@data
|
||||
Scénario: Un compte découvre l'événement public d'un autre compte non connecté
|
||||
Étant donné le compte "@bob-public" publie un événement public "Concert au parc"
|
||||
Et le compte "@alice-public" n'est pas connecté à "@bob-public"
|
||||
Quand "@alice-public" découvre les événements publics
|
||||
Alors "@alice-public" voit l'événement public "Concert au parc"
|
||||
Et l'index global liste le document de l'événement
|
||||
@@ -0,0 +1,109 @@
|
||||
# language: fr
|
||||
@EVENT @priority-1 @multibrowser @shared-wallet
|
||||
Fonctionnalité: Validation e2e multi-navigateurs des nouvelles features (T02.f)
|
||||
En tant que développeur validant les fonctionnalités T02
|
||||
Je pilote DEUX navigateurs isolés (contextes Playwright distincts, sessions
|
||||
NextGraph indépendantes) portant le wallet partagé, chacun chargé dans l'app
|
||||
via le broker réel (nextgraph.net), afin de prouver que les comportements
|
||||
démontrables round-trippent réellement à travers le broker d'un navigateur à
|
||||
l'autre — pas seulement dans le set réactif d'une seule page.
|
||||
|
||||
# NB périmètre : l'isolation cryptographique réelle (ReadCap/write-guard actifs,
|
||||
# mono-document + setCurrentUser) est DIFFÉRÉE (mécanisme validé mais dormant,
|
||||
# cf. T02.d/T02.g). Ces scénarios ne prouvent donc PAS l'isolation crypto entre
|
||||
# navigateurs ; ils valident les COMPORTEMENTS démontrables cross-navigateur.
|
||||
|
||||
# --- Inscription + notification hôte (T02.c) ---
|
||||
# Navigateur A = l'hôte (crée l'événement/PdR) ; navigateur B = un second
|
||||
# compte qui s'inscrit. A reçoit la notification, matérialisée depuis l'inbox.
|
||||
|
||||
Scénario: Inscription dans un navigateur, notification hôte dans l'autre
|
||||
Étant donné un navigateur "A" avec le wallet partagé
|
||||
Et un navigateur "B" avec le wallet partagé
|
||||
Et le navigateur "A" charge l'application via le broker
|
||||
Et le navigateur "B" charge l'application via le broker
|
||||
Et le navigateur "A" est connecté à NextGraph
|
||||
Et le navigateur "B" est connecté à NextGraph
|
||||
Quand le navigateur "A" crée l'événement "Rencontre au kiosque"
|
||||
Et le navigateur "B" s'inscrit à l'événement "Rencontre au kiosque"
|
||||
Alors le navigateur "B" devient participant de l'événement "Rencontre au kiosque"
|
||||
Et l'inbox de l'événement "Rencontre au kiosque" reçoit un dépôt "new-participant"
|
||||
|
||||
# --- Désinscription persistante (T02.c, fix @wip) ---
|
||||
# B se désinscrit → la participation disparaît côté broker (SELECT autoritatif)
|
||||
# ET ne ressuscite pas après une re-sync du broker.
|
||||
|
||||
Scénario: La désinscription dans un navigateur ne ressuscite pas après re-sync
|
||||
Étant donné un navigateur "A" avec le wallet partagé
|
||||
Et un navigateur "B" avec le wallet partagé
|
||||
Et le navigateur "A" charge l'application via le broker
|
||||
Et le navigateur "B" charge l'application via le broker
|
||||
Et le navigateur "A" est connecté à NextGraph
|
||||
Et le navigateur "B" est connecté à NextGraph
|
||||
Et le navigateur "A" crée l'événement "Café des curieux"
|
||||
Et le navigateur "B" s'inscrit à l'événement "Café des curieux"
|
||||
Et le navigateur "B" devient participant de l'événement "Café des curieux"
|
||||
Quand le navigateur "B" se désinscrit de l'événement "Café des curieux"
|
||||
Alors le broker ne contient plus aucune participation à l'événement "Café des curieux" pour le navigateur "B"
|
||||
Et l'inscription de l'événement "Café des curieux" ne ressuscite pas dans le navigateur "B" après re-sync
|
||||
|
||||
# --- Découverte publique cross-comptes (T02.e) ---
|
||||
# Bob (navigateur B) publie un événement PUBLIC ; Alice (navigateur A) le
|
||||
# découvre SANS être connectée/amie avec Bob, via le fan-out public.
|
||||
|
||||
# --- Compteur fiable à la connexion du propriétaire (Option B, brief §D.2) ---
|
||||
# Cadrage (recadré 2026-07-13) : ce n'est PAS une exigence de push temps-réel
|
||||
# cross-session. Le compteur est STOCKÉ dans l'événement, SEUL le propriétaire
|
||||
# l'écrit ; l'inscrit DÉPOSE dans l'inbox de l'événement ; le propriétaire traite
|
||||
# ces dépôts à sa PROCHAINE CONNEXION (matérialisation fiable, barrière de sync de
|
||||
# l'inbox). Une reconnexion / re-matérialisation de A est donc ACCEPTÉE — on ne
|
||||
# vise pas « sans reload ».
|
||||
#
|
||||
# FIX (appliqué) : la matérialisation du propriétaire lit l'inbox APRÈS sa barrière
|
||||
# de sync (`inbox.readSynced` → `ensureRepoOpen` + `read`, lib
|
||||
# `@ng-eventually/client`), de sorte qu'un dépôt de l'inscrit déjà synchronisé au
|
||||
# broker EST vu (plus de « 0 prématuré » mémoïsé) ; le materializer est lancé
|
||||
# directement à la connexion ([ready, ownedKey]), pas seulement sur un push
|
||||
# d'inbox. Source unique du NOMBRE : `event.participantCount` (le littéral local
|
||||
# `participantCount: 1` du CreateEventScreen est retiré → départ à 0).
|
||||
Scénario: Le compteur converge chez le propriétaire à sa prochaine connexion
|
||||
Étant donné un navigateur "A" avec le wallet partagé
|
||||
Et un navigateur "B" avec le wallet partagé
|
||||
Et le navigateur "A" charge l'application via le broker
|
||||
Et le navigateur "B" charge l'application via le broker
|
||||
Et le navigateur "A" est connecté à NextGraph
|
||||
Et le navigateur "B" est connecté à NextGraph
|
||||
Et le navigateur "A" crée l'événement "Apéro réactif"
|
||||
Et le navigateur "A" ouvre le détail de l'événement "Apéro réactif"
|
||||
# Le créateur ne participe PAS automatiquement (pas de notion d'hôte) : à la
|
||||
# création le compteur démarre à 0 (|inscriptions actives| = 0).
|
||||
Et le compteur de participants réactif dans "A" pour "Apéro réactif" vaut 0
|
||||
Quand le navigateur "B" s'inscrit à l'événement "Apéro réactif"
|
||||
# À sa PROCHAINE CONNEXION, A traite l'inbox (barrière de sync) et matérialise le
|
||||
# dépôt de B sur son propre doc → participantCount passe à 1. Symétrie avec B :
|
||||
# après reconnexion, B lit le doc public mis à jour par A et voit 1 aussi.
|
||||
Et le navigateur "A" se reconnecte et matérialise l'inbox
|
||||
Alors le compteur de participants réactif dans "A" pour "Apéro réactif" vaut 1
|
||||
Et le navigateur "A" affiche un participant "inconnu" pour "Apéro réactif"
|
||||
Et le navigateur "B" se reconnecte et matérialise l'inbox
|
||||
Et le compteur de participants réactif dans "B" pour "Apéro réactif" vaut 1
|
||||
# Option B symétrique : B se désinscrit → à la prochaine connexion de A, A
|
||||
# matérialise le marqueur "leave" depuis l'inbox et RECALCULE participantCount
|
||||
# sur son propre doc → le compteur repasse à 0.
|
||||
Quand le navigateur "B" se désinscrit de l'événement "Apéro réactif"
|
||||
Et le navigateur "A" se reconnecte et matérialise l'inbox
|
||||
Alors le compteur de participants réactif dans "A" pour "Apéro réactif" vaut 0
|
||||
# Symétrie côté inscrit après désinscription : B voit aussi 0 après reconnexion.
|
||||
Et le navigateur "B" se reconnecte et matérialise l'inbox
|
||||
Et le compteur de participants réactif dans "B" pour "Apéro réactif" vaut 0
|
||||
|
||||
Scénario: Un navigateur découvre l'événement public publié dans l'autre
|
||||
Étant donné un navigateur "A" avec le wallet partagé
|
||||
Et un navigateur "B" avec le wallet partagé
|
||||
Et le navigateur "A" charge l'application via le broker
|
||||
Et le navigateur "B" charge l'application via le broker
|
||||
Et le navigateur "A" est connecté à NextGraph
|
||||
Et le navigateur "B" est connecté à NextGraph
|
||||
Quand le compte "@bob-mb" publie un événement public "Concert au kiosque" dans le navigateur "B"
|
||||
Et le compte "@alice-mb" découvre les événements publics dans le navigateur "A" sans être connecté à "@bob-mb"
|
||||
Alors le navigateur "A" voit l'événement public "Concert au kiosque"
|
||||
@@ -0,0 +1,27 @@
|
||||
# language: fr
|
||||
@EVENT @priority-1
|
||||
Fonctionnalité: Inscription réelle au point de rencontre via inbox (T02.c)
|
||||
En tant qu'utilisateur qui s'inscrit à un point de rencontre
|
||||
Mon inscription est persistée, l'hôte reçoit une notification par son inbox,
|
||||
Et ma désinscription est durable (elle ne ressuscite pas via la sync broker)
|
||||
|
||||
Contexte:
|
||||
Étant donné que je suis connecté en tant qu'utilisateur
|
||||
|
||||
@data
|
||||
Scénario: S'inscrire dépose dans l'inbox de l'hôte et crée une notification
|
||||
Étant donné un événement "Formation CNV" existe
|
||||
Et l'utilisateur n'est pas inscrit à l'événement "Formation CNV" via l'app
|
||||
Quand l'utilisateur s'inscrit à l'événement "Formation CNV" via l'app
|
||||
Alors l'utilisateur devient participant de l'événement "Formation CNV"
|
||||
Et l'inbox de l'événement "Formation CNV" contient au moins un dépôt
|
||||
Et une notification "new-participant" est créée pour l'événement "Formation CNV"
|
||||
|
||||
@data
|
||||
Scénario: La désinscription est persistante (ne ressuscite pas)
|
||||
Étant donné un événement "Résidence Reconnexion" existe
|
||||
Et l'utilisateur est inscrit à l'événement "Résidence Reconnexion" via l'app
|
||||
Quand l'utilisateur se désinscrit de l'événement "Résidence Reconnexion" via l'app
|
||||
Alors l'utilisateur n'est plus participant de l'événement "Résidence Reconnexion"
|
||||
Et le broker ne contient plus aucune participation à l'événement "Résidence Reconnexion"
|
||||
Et l'utilisateur reste non-inscrit à l'événement "Résidence Reconnexion" après re-sync
|
||||
@@ -0,0 +1,26 @@
|
||||
# language: fr
|
||||
@EVENT @priority-1 @data
|
||||
Fonctionnalité: Isolation entre deux identités sur le wallet partagé
|
||||
En tant qu'utilisateur qui nomme son espace virtuel par un identifiant à la
|
||||
barrière d'accès, sur le MÊME wallet physique partagé,
|
||||
Je ne dois voir NI l'inscription NI l'accueil d'une autre identité
|
||||
Afin que les participations restent privées à leur propriétaire.
|
||||
|
||||
# Régression du leak d'isolation : une identité A crée un événement et le
|
||||
# rejoint ; une identité fraîche B arrive sur le même wallet (faux-logout +
|
||||
# re-login sous un autre identifiant, sans reload — le stopgap wallet-partagé).
|
||||
# B ne doit PAS voir la participation de A : ni sur son accueil
|
||||
# (getUserEvents(B)), ni via isParticipating(E, B), ni dans son set de
|
||||
# participations réactif. Le mécanisme : le changement d'identifiant est traité
|
||||
# comme une session fraîche (reset du jeu de lecture + caps + registre), sinon
|
||||
# les docs PROTECTED de A survivent dans le jeu de lecture de B et fuient par la
|
||||
# lecture union. Voir data-layer/knowledge_context-internals § « Changement
|
||||
# d'identité = session fraîche ».
|
||||
|
||||
@data
|
||||
Scénario: Une identité fraîche ne voit pas la participation d'une autre
|
||||
Étant donné que l'identité A crée l'événement "Événement privé de A" et s'y inscrit
|
||||
Quand une identité fraîche B arrive sur le même wallet partagé
|
||||
Alors l'événement "Événement privé de A" n'est pas sur l'accueil de B
|
||||
Et B n'est pas participant de l'événement "Événement privé de A"
|
||||
Et B ne lit aucune participation de A
|
||||
@@ -0,0 +1,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
|
||||
@@ -0,0 +1,52 @@
|
||||
# language: fr
|
||||
@EVENT @priority-1 @data
|
||||
Fonctionnalité: Reconnexion d'une même identité sur le wallet persistant
|
||||
En tant qu'utilisateur qui, sur le MÊME wallet physique, ouvre une PAGE FRAÎCHE
|
||||
(nouveau login broker, session fraîche) sous le MÊME identifiant qu'avant,
|
||||
Je dois relire MES PROPRES données (mon événement, ma participation)
|
||||
Afin que rien ne disparaisse à la reconnexion.
|
||||
|
||||
# Régression de RECONNEXION (distincte de l'isolation deux-identités). CORRIGÉ :
|
||||
# ce scénario passe désormais sous broker réel (2/2 vérifié).
|
||||
#
|
||||
# SYMPTÔME (broker réel) : une identité A crée E + s'y inscrit sur la page
|
||||
# principale ; une PAGE FRAÎCHE pour la MÊME identité A (même wallet, nouveau
|
||||
# login → session verifier fraîche) relisait VIDE (home=[], isParticipating=false,
|
||||
# count=0). La page fraîche retrouve maintenant E, sa participation, et un count
|
||||
# autoritatif de 1.
|
||||
#
|
||||
# CAUSE : à froid, plusieurs lecteurs (watchShape public/protected + effet owned-
|
||||
# events) appelaient `ensureAccount(A)` en parallèle AVANT sync du shim → chacun
|
||||
# lisait 0 ligne et PROVISIONNAIT un nouveau jeu de docs de scope (fork de compte
|
||||
# par lecteur). `canonicalDoc` faisait alors choisir au lecteur frais le docProtected
|
||||
# lexicographiquement-min ≠ celui où l'écrivain avait joué joinEvent → readScopeIndex
|
||||
# vide. FIX (lib) : `ensureAccount` dé-doublonne les provisions concurrentes (map
|
||||
# in-flight par clé de compte) → une seule provision, écrivain et lecteur convergent
|
||||
# sur le même docProtected canonique. Complété par le heal cold-start de
|
||||
# `discovery.readIndex` (ouverture du repo d'index avant lecture ancrée).
|
||||
# Mesuré (fresh page, broker réel) : partProt converge isSuccess n=1, pub isSuccess
|
||||
# n≥1 ; userProt reste isSuccess n=0 (aucun profil écrit dans ce scénario — attendu).
|
||||
|
||||
@data
|
||||
Scénario: Une page fraîche pour la même identité relit ses propres données
|
||||
Étant donné que l'identité A crée l'événement "Événement de reconnexion de A" et s'y inscrit
|
||||
Quand une page fraîche pour la MÊME identité A recharge sur le même wallet
|
||||
Alors l'événement "Événement de reconnexion de A" est sur l'accueil de la page fraîche A
|
||||
Et la page fraîche A est participante de l'événement "Événement de reconnexion de A"
|
||||
Et le compte autoritatif de participation de A à l'événement "Événement de reconnexion de A" est 1
|
||||
|
||||
# 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
|
||||
@@ -28,6 +28,22 @@ Fonctionnalité: US-13 Relayer/Modifier/Supprimer un événement
|
||||
Étant donné que je suis sur la page "relayer un événement"
|
||||
Alors je peux annuler et revenir à l'écran précédent
|
||||
|
||||
# --- Data : le créateur ne participe pas automatiquement (décision produit) ---
|
||||
#
|
||||
# Il n'y a PAS de notion d'hôte : l'événement est public, simplement signalé par
|
||||
# le créateur, qui n'est PAS obligé de participer. À la création, aucune
|
||||
# participation n'est écrite et le compteur démarre à 0. Le créateur voit « J'y
|
||||
# serai » et peut rejoindre/quitter son propre événement comme tout le monde.
|
||||
@data
|
||||
Scénario: Le créateur ne participe pas automatiquement à son événement
|
||||
Étant donné que le créateur relaie l'événement "Signalé par le créateur"
|
||||
Alors le créateur n'est pas participant de l'événement "Signalé par le créateur"
|
||||
Et le compteur de participants de l'événement "Signalé par le créateur" vaut 0
|
||||
Quand le créateur rejoint son événement "Signalé par le créateur"
|
||||
Alors le créateur est participant de l'événement "Signalé par le créateur"
|
||||
Quand le créateur quitte son événement "Signalé par le créateur"
|
||||
Alors le créateur n'est pas participant de l'événement "Signalé par le créateur"
|
||||
|
||||
Scénario: Modifier un événement
|
||||
* Scénario non implémenté
|
||||
|
||||
|
||||
@@ -24,41 +24,65 @@ Fonctionnalité: US-7 M'inscrire/me désinscrire à un événement
|
||||
Alors je peux voir la liste des événements
|
||||
|
||||
# --- Data ---
|
||||
#
|
||||
# Option B (participantCount dérivé et possédé par le propriétaire) : le compteur
|
||||
# n'est plus incrémenté par l'inscrit. L'inscrit écrit sa propre participation
|
||||
# (protected) + dépose un marqueur dans l'inbox de l'événement ; la session du
|
||||
# PROPRIÉTAIRE matérialise l'inbox et recalcule participantCount =
|
||||
# |inscriptions actives distinctes| sur SON propre doc (PAS de base « hôte » : le
|
||||
# créateur ne participe pas automatiquement), de façon RÉACTIVE et cross-session.
|
||||
# Ce que le @data mono-session prouve ici : la participation elle-même
|
||||
# (persistance, idempotence, désinscription AUTORITATIVE). La CONVERGENCE du
|
||||
# compteur dérivé (0→1 sans reload) est validée là où elle a du sens — le
|
||||
# scénario @multibrowser réactif (e2e-multibrowser.feature « Un participant apparaît
|
||||
# réactivement… »), avec un vrai propriétaire (A) et un vrai inscrit (B).
|
||||
|
||||
@data
|
||||
Scénario: S'inscrire à un événement
|
||||
Étant donné un événement "Formation CNV" existe
|
||||
Et l'utilisateur n'est pas inscrit à l'événement "Formation CNV"
|
||||
Et l'événement "Formation CNV" a 8 participants au départ
|
||||
Quand l'utilisateur s'inscrit à l'événement "Formation CNV"
|
||||
Alors l'utilisateur est participant de l'événement "Formation CNV"
|
||||
Et l'utilisateur apparaît dans la liste des participants de l'événement "Formation CNV"
|
||||
Et l'événement "Formation CNV" compte 9 participants
|
||||
|
||||
@data
|
||||
Scénario: Se désinscrire d'un événement
|
||||
Étant donné un événement "Résidence Reconnexion" existe
|
||||
Et l'utilisateur est inscrit à l'événement "Résidence Reconnexion"
|
||||
Et l'événement "Résidence Reconnexion" a 12 participants au départ
|
||||
Quand l'utilisateur se désinscrit de l'événement "Résidence Reconnexion"
|
||||
Alors l'utilisateur n'est plus participant de l'événement "Résidence Reconnexion"
|
||||
Et l'utilisateur n'apparaît plus dans la liste des participants de l'événement "Résidence Reconnexion"
|
||||
Et l'événement "Résidence Reconnexion" compte 11 participants
|
||||
|
||||
@data
|
||||
Scénario: L'inscription est idempotente
|
||||
Étant donné un événement "Résidence Reconnexion" existe
|
||||
Et l'utilisateur est inscrit à l'événement "Résidence Reconnexion"
|
||||
Et l'événement "Résidence Reconnexion" a 12 participants au départ
|
||||
Quand l'utilisateur essaie de s'inscrire une seconde fois à l'événement "Résidence Reconnexion"
|
||||
Alors l'inscription est idempotente pour l'événement "Résidence Reconnexion"
|
||||
Et l'événement "Résidence Reconnexion" compte 12 participants
|
||||
|
||||
@data
|
||||
Scénario: Se désinscrire d'un événement auquel on n'est pas inscrit
|
||||
Étant donné un événement "Formation CNV" existe
|
||||
Et l'utilisateur n'est pas inscrit à l'événement "Formation CNV"
|
||||
Et l'événement "Formation CNV" a 8 participants au départ
|
||||
Quand l'utilisateur se désinscrit de l'événement "Formation CNV"
|
||||
Alors l'utilisateur n'est plus participant de l'événement "Formation CNV"
|
||||
Et l'événement "Formation CNV" compte 8 participants
|
||||
|
||||
# --- Data : compteur dérivé (Option B) ---
|
||||
#
|
||||
# La matérialisation par le PROPRIÉTAIRE dérive l'ensemble actif de l'inbox de
|
||||
# l'événement. L'id d'événement est apparié sur sa forme CANONIQUE (id de repo de
|
||||
# base, en retirant tout suffixe `:v:<overlay>`) à travers ownedEventIds / la clé
|
||||
# de dépôt / le filtre du matérialiseur — donc le propriétaire matérialise les
|
||||
# dépôts d'un événement qu'il possède quelle que soit la voie d'id (create OU
|
||||
# listMyEntityDocs OU après reload). Ce scénario assère le DELTA : l'inscrit qui
|
||||
# vient de rejoindre EST dans l'ensemble actif dérivé du propriétaire (déterministe
|
||||
# même si l'inbox partagée accumule des dépôts, rendant le compteur ABSOLU
|
||||
# non-borné au scénario). La convergence 1→2 réactive absolue reste prouvée par le
|
||||
# scénario @multibrowser réactif (propriétaire A + inscrit B, e2e-multibrowser).
|
||||
# Voir data-layer/knowledge_context-internals § participantCount + le caveat id-form.
|
||||
@data
|
||||
Scénario: L'inscription fait converger le compteur dérivé du propriétaire
|
||||
Étant donné un événement "Formation CNV" existe
|
||||
Et l'utilisateur n'est pas inscrit à l'événement "Formation CNV"
|
||||
Quand l'utilisateur s'inscrit à l'événement "Formation CNV"
|
||||
Alors le compteur dérivé de l'événement "Formation CNV" reflète l'inscription
|
||||
|
||||
@@ -88,7 +88,12 @@ export function CreateEventScreen() {
|
||||
endTime,
|
||||
location: location || 'Lieu à définir',
|
||||
description,
|
||||
participantCount: 1,
|
||||
// Option B: the creator does NOT auto-participate (no host notion). The count
|
||||
// starts at 0 and is DERIVED by the owner-materializer from real inbox
|
||||
// deposits (|active registrations|) — never a local literal. Passing 1 here was
|
||||
// the "local number" bug: the creator's own view showed 1 while the derived
|
||||
// truth (and every other viewer) was 0.
|
||||
participantCount: 0,
|
||||
themes: ['Social'],
|
||||
hostName: 'Moi',
|
||||
hostInitials: 'MD',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -12,7 +12,12 @@ export function ParticipantsListScreen() {
|
||||
const event = eventId ? getEvent(eventId) : undefined;
|
||||
const participants = eventId ? getEventParticipants(eventId) : [];
|
||||
|
||||
const totalCount = event?.participantCount ?? participants.length;
|
||||
// The displayed NUMBER of participants comes ONLY from `event.participantCount`
|
||||
// (the owner-derived, single source of truth — Option B), never from a local
|
||||
// `participants.length` count. `participants` (named, cap-readable) only ever
|
||||
// REDUCES the "unknown" placeholder count; it never sets the total. When there is
|
||||
// no event at all, there is nothing to count → 0.
|
||||
const totalCount = event ? event.participantCount : 0;
|
||||
const unknownCount = Math.max(0, totalCount - participants.length);
|
||||
|
||||
const rows = [
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
// Creator does NOT auto-participate (@data). After createEvent, the creator's
|
||||
// isParticipating(E) === false and participantCount === 0; the creator can then
|
||||
// join (→ true) and leave (→ false) their own event like anyone else.
|
||||
|
||||
Given('le créateur relaie l\'événement {string}', { timeout: 180000 }, async function (this: FestipodWorld, title: string) {
|
||||
const out = await this.appFrame!.evaluate(async (title) => {
|
||||
const td = (window as any).__testData;
|
||||
await td.ensureCurrentUser();
|
||||
const created = await td.createEventReal(title);
|
||||
return { eventId: created.id };
|
||||
}, title);
|
||||
(this as any).creatorEventId = out.eventId;
|
||||
(this as any).creatorEventTitle = title;
|
||||
});
|
||||
|
||||
Then('le créateur n\'est pas participant de l\'événement {string}', { timeout: 60000 }, async function (this: FestipodWorld, title: string) {
|
||||
const eventId = (this as any).creatorEventId;
|
||||
// Wait for the REACTIVE state to settle to "not participating" (observes AD().*
|
||||
// fed by the subscription push — NOT a broker re-read), the sign the sync has
|
||||
// passed. Then do ONE authoritative broker read to prove the 0 is durable.
|
||||
await this.appFrame!.waitForFunction(
|
||||
(eventId) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = td.currentUserId;
|
||||
return !!uid && !td.isParticipating(eventId, uid);
|
||||
},
|
||||
eventId,
|
||||
{ timeout: 30000 },
|
||||
).catch(() => { /* fall through to the single authoritative read */ });
|
||||
const n = await this.appFrame!.evaluate(async ({ eventId }: { eventId: string }) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = await td.ensureCurrentUser();
|
||||
return td.authParticipationCount(eventId, uid);
|
||||
}, { eventId });
|
||||
expect(n, `creator must NOT be participating in "${title}" (broker count)`).to.equal(0);
|
||||
});
|
||||
|
||||
Then('le compteur de participants de l\'événement {string} vaut {int}', { timeout: 60000 }, async function (this: FestipodWorld, title: string, expected: number) {
|
||||
const eventId = (this as any).creatorEventId;
|
||||
// The count is DERIVED by the owner materializing the inbox. On a freshly
|
||||
// created event with no joins, no deposit exists → the reactive count reflects
|
||||
// the create-time value (0). Read the reactive event state.
|
||||
const count = await this.appFrame!.evaluate((eventId: string) => {
|
||||
const td = (window as any).__testData;
|
||||
const ev = td.getEvent(eventId);
|
||||
return ev ? (ev.participantCount ?? -1) : -2;
|
||||
}, eventId);
|
||||
expect(count, `participantCount of "${title}" must be ${expected}`).to.equal(expected);
|
||||
});
|
||||
|
||||
When('le créateur rejoint son événement {string}', { timeout: 120000 }, async function (this: FestipodWorld, _title: string) {
|
||||
const eventId = (this as any).creatorEventId;
|
||||
await this.appFrame!.evaluate(async ({ eventId }: { eventId: string }) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = await td.ensureCurrentUser();
|
||||
await td.appJoinEvent(eventId, uid);
|
||||
}, { eventId });
|
||||
});
|
||||
|
||||
When('le créateur quitte son événement {string}', { timeout: 120000 }, async function (this: FestipodWorld, _title: string) {
|
||||
const eventId = (this as any).creatorEventId;
|
||||
await this.appFrame!.evaluate(async ({ eventId }: { eventId: string }) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = await td.ensureCurrentUser();
|
||||
await td.appLeaveEvent(eventId, uid);
|
||||
}, { eventId });
|
||||
});
|
||||
|
||||
Then('le créateur est participant de l\'événement {string}', { timeout: 60000 }, async function (this: FestipodWorld, title: string) {
|
||||
const eventId = (this as any).creatorEventId;
|
||||
// Wait for the REACTIVE join to settle (observes AD().isParticipating fed by the
|
||||
// subscription push — NOT a broker re-read), then take ONE authoritative broker
|
||||
// count to prove the participation is durable at the data level.
|
||||
await this.appFrame!.waitForFunction(
|
||||
(eventId) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = td.currentUserId;
|
||||
return !!uid && td.isParticipating(eventId, uid);
|
||||
},
|
||||
eventId,
|
||||
{ timeout: 45000 },
|
||||
).catch(() => { /* fall through to the single authoritative read */ });
|
||||
const n = await this.appFrame!.evaluate(async ({ eventId }: { eventId: string }) => {
|
||||
const td = (window as any).__testData;
|
||||
const uid = await td.ensureCurrentUser();
|
||||
return td.authParticipationCount(eventId, uid);
|
||||
}, { eventId });
|
||||
expect(n, `creator must be participating in "${title}" after joining`).to.equal(1);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user