Isolation deux-identités: test permanent + le créateur ne participe plus
Deux corrections produit/tests demandées, empiriquement validées au broker réel.
1. Créateur ≠ hôte (décision produit). Il n'y a PAS de notion d'hôte : un
événement est public, simplement signalé par le créateur, qui n'est PAS
obligé de participer. `createEvent` n'écrit plus de participation-hôte et
`participantCount` démarre à 0 ; le matérialiseur du propriétaire dérive
`participantCount = |inscriptions actives|` (plus de base « +1 hôte »).
2. Isolation deux-identités : le trou réel était l'ABSENCE d'un test de
régression, pas un bug de code actif. Reproduction empirique (DIAG instrumenté,
retiré) : la fuite n'apparaît QUE si le reset `useEffect([username])` est
désactivé ET les caps vides (docs persistés d'une session antérieure sur wallet
gonflé) — le reset en place la neutralise. La sighting live venait d'un état
wallet pré-fix + identifiant réutilisé. Ajout du test permanent manquant :
- isolation-deux-identites.feature (@data) : A crée+rejoint E, une identité
fraîche B sur le même wallet ne voit E ni sur son accueil, ni via
isParticipating(E,B), et ne lit aucune participation portant le principal de A.
- us-13 : « Le créateur ne participe pas automatiquement » (count 0,
isParticipating false autoritatif, puis join→1, leave→0).
Harness: 4 helpers permanents (switchIdentity, currentIdentifier, homeEventTitles,
currentParticipations) pour piloter/observer l'identité en test.
Scénarios @multibrowser/us-7 réalignés (compteur 0→1 au lieu de 1→2).
Doctrine mise à jour (context-internals, actors-and-concepts).
Gates: build OK, tsc propre, @data verts (inscription, désinscription,
idempotence, compteur dérivé, auth ×4), lib @ng-eventually/client non touchée.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user