diff --git a/.project/concepts/data-layer/knowledge_context-internals.md b/.project/concepts/data-layer/knowledge_context-internals.md index c2e9ed7..63913fa 100644 --- a/.project/concepts/data-layer/knowledge_context-internals.md +++ b/.project/concepts/data-layer/knowledge_context-internals.md @@ -26,7 +26,7 @@ Un auto-seed se déclenche **uniquement hors production** (`process.env.NODE_ENV **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 = 1 (hôte lui-même, base de création) + |ensemble actif|`. 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. +- **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. @@ -44,6 +44,8 @@ Le jeu de lecture par besoin (`publicDocs`/`protectedDocs`) **accumule** les doc **Règle** : traiter **tout changement d'identifiant** comme une session fraîche — un `useEffect([username])` (ref-gardé pour ne pas tirer au premier mount) vide `publicDocs`/`protectedDocs`, appelle `resetCaps()` + `resetRegistryCache()`, puis bump le read tick ; l'effet de listing reconstruit le jeu **borné à la nouvelle identité**. L'isolation reste par-document/émulée (concept `app-security`, [[knowledge_trust-model]]) ; ce reset ne fait que supprimer le report d'état inter-identités. +**Mécanisme confirmé empiriquement (2026-07-07)** : le leak se reproduit UNIQUEMENT quand DEUX conditions coïncident — (a) le jeu de lecture porte encore le doc PROTECTED de A au travers du switch (pas de reset), ET (b) le registre de caps en mémoire ne gouverne pas ce doc (`resetCaps()` déjà tiré / caps vides pour un doc persisté d'une session antérieure au reload). Alors la participation de A traverse la lecture union de B (le filtre par-document n'a aucun cap à vérifier). Avec le reset ci-dessus tiré, `setProtectedDocs([])` retire le doc de A du jeu de lecture de B AVANT que la lecture cap-less ne l'expose → plus de fuite quel que soit l'état des caps. **Régression gardée** par le scénario `@data` « Une identité fraîche ne voit pas la participation d'une autre » (event/isolation-deux-identites.feature) : A crée E + s'y inscrit, B (page fraîche sur le même wallet, identifiant distinct) n'a NI E sur son accueil (`getUserEvents(B)`), NI `isParticipating(E,B)`, ET ne lit AUCUNE participation portant le principal de A. Le symptôme historique « B voit “Je participe” » survenait surtout quand B **réutilisait un identifiant déjà employé par A** (même principal normalisé) sur un wallet **bloaté** (docs persistés d'un run antérieur, caps vides). + ## Mutations no-op en mode local En mode local/demo (`useLocalData`), `createEvent`/`joinEvent`/`leaveEvent`/`updateEvent` sont des **no-ops** (`console.log`, l'état ne change pas) — mais les écrans affichent quand même un **toast de succès** (« Tu participes »). UX potentiellement trompeuse : l'utilisateur croit s'être inscrit alors que rien n'a changé. Voir [[knowledge_data-modes]] pour le choix du provider selon le statut. diff --git a/.project/concepts/functional-domain/knowledge_actors-and-concepts.md b/.project/concepts/functional-domain/knowledge_actors-and-concepts.md index 114be17..d67fd73 100644 --- a/.project/concepts/functional-domain/knowledge_actors-and-concepts.md +++ b/.project/concepts/functional-domain/knowledge_actors-and-concepts.md @@ -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. | diff --git a/src/modules/event/features/e2e-multibrowser.feature b/src/modules/event/features/e2e-multibrowser.feature index 306a5e5..a4fc4f9 100644 --- a/src/modules/event/features/e2e-multibrowser.feature +++ b/src/modules/event/features/e2e-multibrowser.feature @@ -52,9 +52,10 @@ Fonctionnalité: Validation e2e multi-navigateurs des nouvelles features (T02.f) # découvre SANS être connectée/amie avec Bob, via le fan-out public. # --- Lecture réactive cross-session (P3, brief §D.2) --- - # A crée l'événement et en ouvre le détail (compteur = 1). B s'inscrit. SANS que - # A recharge ni n'agisse, l'état réactif de A (poussé par doc_subscribe sur le doc - # public de l'événement) montre participantCount === 2 et un participant "inconnu". + # A crée l'événement et en ouvre le détail (compteur = 0, le créateur ne + # participe pas). B s'inscrit. SANS que A recharge ni n'agisse, l'état réactif de + # A (poussé par doc_subscribe sur le doc public de l'événement) montre + # participantCount === 1 et un participant "inconnu". Scénario: Un participant apparaît réactivement dans l'autre navigateur sans reload Étant donné un navigateur "A" avec le wallet partagé @@ -65,15 +66,17 @@ Fonctionnalité: Validation e2e multi-navigateurs des nouvelles features (T02.f) 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 réactif dans "A" pour "Apéro réactif" vaut 1 + # 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" - Alors sans recharger, le compteur de participants réactif dans "A" pour "Apéro réactif" passe à 2 + Alors sans recharger, le compteur de participants réactif dans "A" pour "Apéro réactif" passe à 1 Et le navigateur "A" affiche un participant "inconnu" pour "Apéro réactif" # Option B symétrique : B se désinscrit → A (propriétaire) matérialise le # marqueur "leave" depuis l'inbox et RECALCULE participantCount sur SON PROPRE - # doc → le compteur repasse à 1 côté A, SANS reload ni action de A. + # doc → le compteur repasse à 0 côté A, SANS reload ni action de A. Quand le navigateur "B" se désinscrit de l'événement "Apéro réactif" - Alors sans recharger, le compteur de participants réactif dans "A" pour "Apéro réactif" passe à 1 + Alors sans recharger, le compteur de participants réactif dans "A" pour "Apéro réactif" passe à 0 Scénario: Un navigateur découvre l'événement public publié dans l'autre Étant donné un navigateur "A" avec le wallet partagé diff --git a/src/modules/event/features/isolation-deux-identites.feature b/src/modules/event/features/isolation-deux-identites.feature new file mode 100644 index 0000000..d1faeb7 --- /dev/null +++ b/src/modules/event/features/isolation-deux-identites.feature @@ -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 diff --git a/src/modules/event/features/us-13-creer-evenement.feature b/src/modules/event/features/us-13-creer-evenement.feature index 63a92d4..ac763d1 100644 --- a/src/modules/event/features/us-13-creer-evenement.feature +++ b/src/modules/event/features/us-13-creer-evenement.feature @@ -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é diff --git a/src/modules/event/features/us-7-inscription-evenement.feature b/src/modules/event/features/us-7-inscription-evenement.feature index f6bd14f..4984c6e 100644 --- a/src/modules/event/features/us-7-inscription-evenement.feature +++ b/src/modules/event/features/us-7-inscription-evenement.feature @@ -28,11 +28,12 @@ Fonctionnalité: US-7 M'inscrire/me désinscrire à un événement # 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 = 1 (hôte) + - # |inscriptions actives distinctes| sur SON propre doc, 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é (1→2 sans reload) est validée là où elle a du sens — le + # 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). diff --git a/src/modules/event/steps/data/createur.steps.ts b/src/modules/event/steps/data/createur.steps.ts new file mode 100644 index 0000000..54558ef --- /dev/null +++ b/src/modules/event/steps/data/createur.steps.ts @@ -0,0 +1,83 @@ +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; + // Authoritative: the broker must hold 0 participations for (E, creator). Poll a + // little to absorb any pending write from a just-run leave. + const n = await this.appFrame!.evaluate(async ({ eventId }: { eventId: string }) => { + const td = (window as any).__testData; + const uid = await td.ensureCurrentUser(); + let last = -1; + for (let i = 0; i < 8; i++) { + last = await td.authParticipationCount(eventId, uid); + if (last === 0) return 0; + await new Promise(r => setTimeout(r, 750)); + } + return last; + }, { 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; + const n = await this.appFrame!.evaluate(async ({ eventId }: { eventId: string }) => { + const td = (window as any).__testData; + const uid = await td.ensureCurrentUser(); + let last = 0; + for (let i = 0; i < 20; i++) { + last = await td.authParticipationCount(eventId, uid); + if (last >= 1) return last; + await new Promise(r => setTimeout(r, 1000)); + } + return last; + }, { eventId }); + expect(n, `creator must be participating in "${title}" after joining`).to.equal(1); +}); diff --git a/src/modules/event/steps/data/isolation.steps.ts b/src/modules/event/steps/data/isolation.steps.ts new file mode 100644 index 0000000..62d2250 --- /dev/null +++ b/src/modules/event/steps/data/isolation.steps.ts @@ -0,0 +1,87 @@ +import { Given, When, Then } from '@cucumber/cucumber'; +import { expect } from 'chai'; +import type { FestipodWorld } from '../../../../shared/support/world'; +import { pool } from '../../../../shared/support/browserPool'; + +// Two-identity isolation (@data, real broker). Identity A (the fresh per-scenario +// username set in localStorage) creates an event E and joins it; a genuinely- +// different identity B is brought up on the SAME wallet; B must read NONE of A's +// protected participation, E must not be on B's home, isParticipating(E,B) false. +// +// B is brought up via a FRESH PAGE on the SAME persistent wallet context with B's +// identifier in localStorage — the closest analogue to the real app's re-enter- +// gate / reload path (a brand-new NgDataProvider mount, username=B, on a wallet +// that already holds A's docs). This exercises the identity-switch reset that +// keeps A's protected docs out of B's read set. + +Given('l\'identité A crée l\'événement {string} et s\'y inscrit', { timeout: 180000 }, async function (this: FestipodWorld, title: string) { + const out = await this.appFrame!.evaluate(async (title) => { + const td = (window as any).__testData; + const aId = await td.ensureCurrentUser(); + const created = await td.createEventReal(title); + await td.appJoinEvent(created.id, aId); + // Wait until A's own participation is in A's reactive set (authoritative-ish). + for (let i = 0; i < 30; i++) { + if (td.isParticipating(created.id, aId)) break; + await new Promise(r => setTimeout(r, 500)); + } + return { eventId: created.id, aId, aParticipates: td.isParticipating(created.id, aId) }; + }, title); + expect(out.aParticipates, 'A must be participating in its own event before B arrives').to.be.true; + (this as any).isoEventId = out.eventId; + (this as any).isoEventTitle = title; + (this as any).isoAId = out.aId; +}); + +When('une identité fraîche B arrive sur le même wallet partagé', { timeout: 120000 }, async function (this: FestipodWorld) { + const bId = `iso-b-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; + (this as any).isoBId = bId; + const ctx = this.page!.context(); + const bPage = await ctx.newPage(); + await bPage.addInitScript((u: string) => { + try { window.localStorage.setItem('festipod.account.username', u); } catch { /* opaque */ } + }, bId); + await bPage.addInitScript(() => { + (globalThis as Record).__FESTIPOD_ACCESS_GATE_DISABLED__ = true; + }); + bPage.on('console', (msg) => { if (msg.type() === 'error') console.error('[Bpage console]', msg.text()); }); + const bFrame = await pool.setupBrokerPage!(bPage, pool.harnessUrl!); + await bFrame.waitForFunction(() => (window as any).__testData?.ready === true, { timeout: 60000 }); + // Let B's listing effect + union read run (rebuilds the read set bounded to B). + await bFrame.evaluate(async () => { + const td = (window as any).__testData; + await td.ensureCurrentUser(); + await new Promise(r => setTimeout(r, 6000)); + }); + (this as any).isoBFrame = bFrame; +}); + +Then('l\'événement {string} n\'est pas sur l\'accueil de B', async function (this: FestipodWorld, title: string) { + const bFrame = (this as any).isoBFrame; + const onHome = await bFrame.evaluate((title: string) => { + const td = (window as any).__testData; + return td.homeEventTitles().includes(title); + }, title); + expect(onHome, `"${title}" must NOT appear on B's home (getUserEvents(B))`).to.be.false; +}); + +Then('B n\'est pas participant de l\'événement {string}', async function (this: FestipodWorld, title: string) { + const bFrame = (this as any).isoBFrame; + const eventId = (this as any).isoEventId; + const isPart = await bFrame.evaluate(async ({ eventId }: { eventId: string }) => { + const td = (window as any).__testData; + const bId = await td.ensureCurrentUser(); + return td.isParticipating(eventId, bId); + }, { eventId }); + expect(isPart, `B must NOT be participating in "${title}"`).to.be.false; +}); + +Then('B ne lit aucune participation de A', async function (this: FestipodWorld) { + const bFrame = (this as any).isoBFrame; + const aId = (this as any).isoAId; + const leaks = await bFrame.evaluate((aId: string) => { + const td = (window as any).__testData; + return td.currentParticipations().filter((p: any) => p.userId === aId); + }, aId); + expect(leaks, `B must read NONE of A's protected participations (found ${JSON.stringify(leaks)})`).to.have.lengthOf(0); +}); diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index f26b5a2..98715b7 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -533,8 +533,8 @@ function useNgData(): FestipodDataContextValue { // IDEMPOTENCE / CONVERGENCE: the count is DERIVED from the SET of distinct // active registrations (`materializeAttendance`: distinct join uids MINUS // cancelled ones), never an unbounded ±1. A broker re-sync replays the same - // deposits → same set → same count. `participantCount = 1 (host self, the - // create-time baseline) + activeRegistrations.size`. The write is GUARDED + // deposits → same set → same count. `participantCount = activeRegistrations.size` + // (no host baseline — the creator does not auto-participate). The write is GUARDED // (write only when the value actually changes) so re-materializing an unchanged // inbox does not thrash the doc / loop the reactive read. // @@ -575,7 +575,7 @@ function useNgData(): FestipodDataContextValue { // (1) COUNT — derive the distinct active-registration set for this event // and write it on MY OWN event doc (only when it changed). const active = await materializeAttendance(targetInbox, evId); - const nextCount = 1 + active.length; // 1 = host self (create baseline) + const nextCount = active.length; // no host baseline (creator not auto-in) if (materializedCountRef.current.get(evId) !== nextCount) { materializedCountRef.current.set(evId, nextCount); await updateEntityField(evId, evId, 'participantCount', int(nextCount)) @@ -682,22 +682,20 @@ function useNgData(): FestipodDataContextValue { const eventId = await writeEntity(eventGraph, ENTITY_TYPE.event, { title: str(event.title), description: str(event.description), date: str(event.date), location: str(event.location), distance: flt(event.distance), - participantCount: int(event.participantCount || 1), + // No host notion: the creator merely SIGNALS a public event and is NOT + // obliged to participate, so the count starts at 0 (the owner-materializer + // derives it from the active-registration set — |active|, no host baseline). + participantCount: int(event.participantCount || 0), coverImage: str(event.coverImage), hostName: str(event.hostName), hostInitials: str(event.hostInitials), }); registerDoc('public', eventGraph); // OPTION B: this event's doc is MINE (I just created it), so track it as owned // → the owner-materializer subscribes to its inbox and maintains its count. setOwnedEventIds(prev => (prev.includes(eventGraph) ? prev : [...prev, eventGraph])); - if (currentUserId) { - // The host's participation is its OWN document in the PROTECTED scope. - const partGraph = await createEntityDoc(owner, 'protected'); - await writeEntity(partGraph, ENTITY_TYPE.participation, { - event: iri(eventId), user: iri(currentUserId), isConfirmed: bool(true), - }); - registerDoc('protected', partGraph); - setSelectedEventId(eventId); - } + // The creator does NOT auto-participate (no host notion — settled product + // decision): NO participation is written on create. The creator sees "J'y + // serai" and may join/leave their own event like anyone else. + if (currentUserId) setSelectedEventId(eventId); const addedEvent = { "@id": eventId, title: event.title }; // Make the PUBLIC event discoverable: submit its reference to the SDK global // discovery index (an SDK act — the app holds no index/store id). The SDK diff --git a/src/shared/data/registration.ts b/src/shared/data/registration.ts index 6dbadec..e0cd81f 100644 --- a/src/shared/data/registration.ts +++ b/src/shared/data/registration.ts @@ -234,9 +234,9 @@ export interface ActiveRegistration { * set — it can never resurrect a phantom count. * * The owner then writes `participantCount` on its OWN event doc as - * `1 (host self, from create) + activeRegistrations.size`. The host's own - * participation is the create-time baseline (never deposited into the inbox), so - * it is added here rather than derived from a deposit. + * `activeRegistrations.size`. There is NO host baseline: the creator merely + * signals a public event and is NOT obliged to participate (no host notion), so + * the count is 0 until someone joins, and the creator may join/leave like anyone. */ export async function materializeAttendance( targetInbox: string, diff --git a/src/shared/test-harness/harness-ng.tsx b/src/shared/test-harness/harness-ng.tsx index 4c58280..c470034 100644 --- a/src/shared/test-harness/harness-ng.tsx +++ b/src/shared/test-harness/harness-ng.tsx @@ -87,6 +87,15 @@ function HarnessRouter() { function ConnectedHarness() { const ngCtx = useNextGraph(); const appData = useFestipodData(); + // Identity switch (two-identity isolation): the app has no page reload on a + // faux-logout+re-login (shared-wallet stopgap), so switching identity here + // means calling AccountContext.login() with a new identifier — which drives the + // `prevOwnerRef` reset effect in FestipodDataContext. Exposed to steps so a @data + // scenario can bring up identity A, then a genuinely-different identity B on the + // SAME wallet and assert B is isolated. + const account = useAccount(); + const accountRef = useRef(account); + accountRef.current = account; // The bridge is built once inside an effect (below) and its getters close over // `appData`. `appData` is a NEW object every render (its `events`/`users` reflect // the latest per-entity reads), so a captured snapshot goes STALE — after @@ -169,6 +178,33 @@ function ConnectedHarness() { get currentUserId() { return AD().currentUserId || currentUserId; }, session, + // --- IDENTITY SWITCH (two-identity isolation) ---------------------- + /** Faux-logout + re-login under a NEW identifier on the SAME wallet (no + * page reload), exactly as the real app's AccessGate/Settings flow does. + * Drives AccountContext.login → setCurrentUser + the FestipodDataContext + * `prevOwnerRef` reset. Returns the normalized id now in effect. */ + switchIdentity(identifier: string) { + accountRef.current.login(identifier); + return normalizeUsername(identifier); + }, + /** The current app-level identifier (localStorage-backed). */ + currentIdentifier() { + return accountRef.current.username; + }, + /** Titles of the events the CURRENT user PARTICIPATES in — exactly what the + * HOME screen shows (`getUserEvents(currentUserId)`). Used by the + * two-identity isolation test to assert a fresh identity's home is empty. */ + homeEventTitles() { + const ad = AD(); + return ad.getUserEvents(ad.currentUserId).map(e => e.title); + }, + /** The current user's participation rows (userId+eventId), the reactive set + * the screens read. Used to assert a fresh identity reads NONE of another + * identity's protected participations. */ + currentParticipations() { + return AD().participations.map(p => ({ userId: p.userId, eventId: p.eventId })); + }, + // --- App-level view (through real providers, same as what screens see) --- appData, ngStatus: ngCtx.status,