Ng eventually #1

Open
Sylvain wants to merge 110 commits from ng-eventually into main
11 changed files with 282 additions and 30 deletions
Showing only changes of commit 65bd67cc20 - Show all commits
@@ -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.
@@ -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. |
@@ -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é
@@ -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
@@ -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é
@@ -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).
@@ -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);
});
@@ -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<string, unknown>).__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);
});
+11 -13
View File
@@ -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
+3 -3
View File
@@ -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,
+36
View File
@@ -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,