20 Commits

Author SHA1 Message Date
Sylvain Duchesne 412e5d63e7 Observe the ActivityPods providers instead of asking them what they do
The previous commit treated this package like the polyfill: a provider whose
source is off limits, with the unknowns raised as questions. That was the wrong
regime. The no-read rule attaches to a provider that publishes an engagement —
this one publishes nothing at all, and for that case the protocol has a bounded
reconstruction, made once at adoption time and pinned. So the two questions are
answered here rather than asked, and one of them turned out to be about the Pod
rather than about the providers, which is not our concern.

The observation is now the specification: contract_activitypods-providers,
stamped at the commit it was read from. A record's id is the resource URI and it
is the only identifier, so the seam's two collapse into one. update fetches,
merges and PUTs, so a partial write is safe and the seam declares this backend
as patching.

What the reading also surfaced matters more than either question. Every
container resolution runs against the session's own WebID — in getList, in
create, in a list subscription alike. So this backend can only ever show a user
their own records: no other user's event is reachable, and an event's
participant count cannot be computed from participations the app cannot read.
That is the product, not a detail, so it is need 1 rather than a friction, and
it is phrased as what it is: the capability sits one layer down, exposed as a
WebID parameter, just not reachable through the data provider.

Four frictions are recorded with the workarounds, including the auth provider's
timer and listener that construction installs and nothing removes — which the
runtime backend switch would accumulate, so the interval goes off and the app
checks status itself.
2026-10-02 11:38:23 +02:00
Sylvain Duchesne d48297fe94 Declare what Festipod needs from ActivityPods, rather than guess it
The Refine seam asks four things of a backend, and the ActivityPods README
answers two: how its providers are configured, and that a declared shape tree
gets its own Data Registration and container on the Pod. It is silent on the
record shape and on whether update replaces or patches — the two the seam
cannot default, and the two that fail silently when wrong.

So the declaration is written before adoption instead of after: the surface is
marked as declared rather than measured, the domain conditions are stated at
full strength, and the two unknowns sit in Needs as requests addressed to the
provider. Reading their source to settle them is the one thing this project
does not do.

This branch had no contract manifest at all, so it gets one. It carries no
consume entry, because neither targeted backend publishes an engagement to
pull; the declaration anchors on the README's commit identity, resolved with
ls-remote, which names a state without fetching any content.
2026-10-01 20:14:37 +02:00
Sylvain Duchesne a5f6e01562 Persist the meeting point, and absorb the prefix the generator drops
The meeting point is the product's unit of value and had no shape at all: it
lived in React state, lost on reload. The shape comes from the sibling branch
rather than from my own reading of the domain -- event, host, title, optional
description/place/time, and the optional inbox its own backend will want.

Regenerating the bindings does what that branch documented it would: the
emitted names lose the Fp prefix, because they follow each shape's IRI and
those IRIs are the persisted RDF classes, not ours to rename. So the aliasing
happens at the four import sites, which keeps every downstream name unchanged
and stops the DOM's own Event from being shadowed. The generated files are
never edited; build:orm overwrites them.

I had called this blocked, as "a binding with no consumer". That was wrong and
worth correcting: the shape and the bindings are the PREREQUISITE every
NextGraph-flavoured backend needs, and only the RecordBinding waits for one.
The RecordBindings slot stays optional until a backend can fill it.

Still local-only in the data context: persisting a meeting point needs a
backend to write it through, and the registry is empty.
2026-10-01 19:58:21 +02:00
Sylvain Duchesne 33b4588da4 Remove what the migration left unreachable, and record what it left broken
The shared-wallet shim's fan-out in the data context fed the ORM subscriptions
this provider used to open: readGraphs, writeGraphs, protectedScope,
protectedGraph and the effect that filled them from storeRegistry. Every entity
reads through a Refine backend now, which takes no graph list, so none of it was
reachable. Same for findNg, mapUser and mapParticipation -- reading a record
into a domain type belongs to a backend's binding, not here. storeRegistry
itself stays: the @data multistore scenarios drive it directly.

This is debt the migration created, not debt it inherited, which is why it goes
rather than waiting for a tidy-up.

What the removal COSTS is written where it happened: placement by scope no
longer narrows what the app reads. The backend's visibility declaration is
where that belongs and nothing honours it yet; applyIsolation is the only
narrowing left.

And setLike.delete is now marked as the defect it is. It is a no-op lifted from
a branch where the fixture seed is off, so nothing needs deleting before it --
false here, where the step that empties the wallet therefore does nothing.
Fixing it needs a decision the comment states, because the app exposes no
deleteEvent.
2026-10-01 19:25:17 +02:00
Sylvain Duchesne 0c4f6b6650 Derive the participant count instead of incrementing it
Every Participation is readable by everyone — only the link to a person is
protected — so an event's count is obtained by counting its participations. No
privileged view, nothing to compensate for.

The stored counter is gone from the write path: joining writes a participation
and nothing else, leaving writes nothing else. Writing the participation IS
moving the count, so there is no second write to keep in step and no counter to
race. That race was measured, not supposed: the count sat on whatever the seed
had written, with no error anywhere, because each increment derived from a read
of the previous one.

Derived in BOTH providers, so demo and connected modes cannot disagree about
what a count means. fp:participantCount stays in the shape and the fixtures,
kept in step, but nothing reads it.

The fixtures become the source of truth and had to say something true: they
advertised 24, 12, 45, 16, 30 against five participation rows. Seventeen rows
now, counts aligned to them. Marie's three are untouched on purpose — the home
screen lists "my events" from exactly those, and the @ui tests render against
it. Small on purpose too: a plausible demo, not a hundred rows reproducing
figures nobody reads.

us-7-inscription-evenement.feature still sets a starting count and expects it
±1. Those steps encode the superseded counter and will need rewriting around
creating participations; they are @data, which is parked while no backend is
registered.
2026-10-01 18:35:24 +02:00
Sylvain Duchesne b25bafbb90 Correct what the contract says about the participant count
The previous commit wrote a requirement into types.ts that is false: that the
count needs an inbox, and that a CRUD-only backend therefore cannot carry the
model. That came from misreading the brief's "no filtering" as "the readable
participations are not all of them".

Every Participation is readable by everyone. What is protected is the link to a
person — only a connection can read the profile one points at and recognise who
it is. So the count is obtained by counting an event's participations, which is
ordinary CRUD and asks nothing of a backend.

Left standing, because the measurements did establish it: the count must not be
an incremented field. Reading a stored counter out of a list, adding one and
writing it back derives each write from a read of the previous one, which races
itself — and that mutated counter is the one the project's brief supersedes.

The ng-org backend stays withdrawn, on the reason that holds on its own: it is
not the target, the NextGraph+polyfill provider is.
2026-10-01 16:38:16 +02:00
Sylvain Duchesne 3de86c8be2 Withdraw the ng-org backend, keep the seam that will carry the next two
The participant count is the number of participants INCLUDING those this session
cannot see, so it cannot be a count of the participations we are allowed to
read. The project derives it: whoever signs up deposits a marker into the
event's inbox, and the event OWNER recomputes the field from those deposits.
That needs an inbox. @ng-org/refine-providers is CRUD over SHEX shapes and has
none, so it cannot carry the model — and the counter it could carry, incremented
from a value read back out of a list, races itself and is the one the project's
brief declares superseded. Measured, not assumed: optimistic mutations moved one
assertion from the seeded value to the written one and left the rest.

So the wiring goes and the structure stays. The contract, the registry, the
runtime switch and useBackend are untouched; what is gone is the single entry,
its three ng-org-specific files and the dependency. What the next backend must
answer, the derived count included, is written in types.ts, and the withdrawn
implementation stays in this branch's history as the worked example.

Two refusals rather than quiet degradations. With no backend the data context
falls back to demo data and SAYS so, instead of mounting a connected path that
would hand every screen empty lists. And the @data harness refuses to publish
its bridge at all: on fixtures those scenarios would assert happily and report
green while touching no wallet, which is worse than red.
2026-10-01 16:26:11 +02:00
Sylvain Duchesne 4b4dfa6cad Put every persisted entity on Refine, and the test bridge on the app's view
Profiles and participations now read through useList and write through
useCreate/useUpdate/useDelete, so nothing in the data context talks to the ORM
any more. The seed receives injected writers instead of ORM sets, and decides on
the count the caller has already read -- taken before any await, which is what
the previous version got wrong in the other direction.

The test bridge had to follow. It exposed the raw ORM sets, which no longer hold
what the app holds: a step observing one while the app decides on the other
races itself, and an explicit load seeded a second time because the set still
read empty. The bridge now reads the app's own views through a ref, because a
captured snapshot goes stale after a seed -- the sibling branch had met this and
said so. Its mutations delegate to the data context too; a bridge mutating an
ORM set was testing the bridge.

Those mutations are awaitable now, which is right on its own terms but did NOT
fix what I expected: the counts stay at their seeded values with no error
anywhere, identical before and after. The app's own caveat says the count needs
one connection more than the write, and main only passed because it mutated the
set in place, locally. Those three assertions encode an immediacy this path does
not offer, and whether a test waits for the push is not mine to decide.

STILL FAILING, deliberately left: the three counter assertions above, an
unexplained double seed, and the human wallet-import flow that fails on main
too. setLike.delete is a no-op copied from a branch where nothing needs deleting
before the seed -- true there, false here.
2026-10-01 15:45:55 +02:00
Sylvain Duchesne 974d251079 Rotate the bloated test profile before opening the wallet
The @data suite writes into one wallet that nothing empties, so every run
leaves more behind. Past some volume the broker stops answering in time and
scenarios die on their step timeout, SILENTLY -- nothing in the console. That
failure looks exactly like a bug in whatever was last edited and gets diagnosed
as one: it happened twice in this session before anybody measured the profile,
which had reached 94MB.

Ported from the sibling branch's validate.ts, which already had the remedy and
the threshold. Placed just before ensureAuth rather than at the top of
BeforeAll: that is the branch which actually opens the profile, and the mock
fallback would pay a broker sign-up for nothing.

Two things the comment records rather than hides. Here the rotation resets the
DATA, not just browser state -- nothing serves a shared wallet file in the @data
path, so the wallet is the broker-side one reached through this profile; the
sibling branch documents the opposite, correctly for itself. And it is not free:
a new wallet comes from driving the real broker's sign-up, so each rotation
abandons one there. FESTIPOD_PROFILE_BLOAT_MB=0 opts out.

MEASURED AND UNRESOLVED: one run leaves 54MB, so a 50MB threshold fires every
time. Either it rises or systematic rotation is accepted.
2026-10-01 15:45:41 +02:00
Sylvain Duchesne fd02570b8b Widen the backend contract from one entity to the set
The contract carried a single event binding, which only worked while Events was
the one vertical on Refine. It now carries one binding per persisted entity,
and each binding says which resource name it lives under and who must be able
to read it.

Visibility is DECLARED, not yet enforced: Festipod's confidentiality is
per-entity while Refine has no notion of it, and the NextGraph provider reads
across every graph it can see. Naming it marks where each backend has to close
that gap rather than pretending it is closed.

meetingPoint is optional for now because its SHEX shape does not exist on this
branch; adding it means regenerating the ORM bindings, which drops the Fp prefix
and is its own step. Friendship is absent on purpose — it has no shape and no
persistence anywhere in this project.
2026-10-01 12:29:30 +02:00
Sylvain Duchesne efdfcac4c1 Make the Refine backend a choice rather than a wiring
A backend now answers a contract, and each of its four points is there because
the Events vertical ran into it: how its providers are configured (SHEX shape
types here, resources+jsonContext for ActivityPods), what its records look like,
which key Refine addresses a record by versus which id the app references, and
whether its update replaces or patches. Nothing in it is anticipated.

Switching happens at runtime, so nothing resolves at module scope: the current
backend is React state held by RefineProvider, read through useBackend(), and
<Refine> carries its name as key so switching remounts it and drops the cache
of the backend being left. Leaving one loses access to its data, accepted.

Not done, and not guessed: no activitypods backend. Its configuration is
published, its record shape and update semantics are not, and it declares
resources in ActivityStreams while Festipod persists fp: shapes -- as:Event
exists, a meeting point does not. And AuthGate still gates on the NextGraph
session, so a second backend needs its own barrier to be reachable at all.

The @data budgets in connexion.steps.ts came from ng-eventually, which had
already met this: the timeout was in waitForFunction's SECOND slot, where it is
taken as the predicate's argument, so the wait silently ran on the 30s default.
2026-09-28 16:08:13 +02:00
Sylvain Duchesne 750538bd73 Take the sibling branch's answer for the seed step, instead of a rewrite
ng-eventually lives with one document per entity already, so it had met this
cost and settled it in two lines: await the seed, and give the wait a budget
that matches what opening five documents takes. The 10 s was calibrated when
seeding meant adding rows to an already-open graph.

This replaces a rewrite of the step, begun before looking there, that would
have had each scenario create its own event. @data is now 14 passed / 1 failed,
the same as main, and the remaining failure is main's too.
2026-09-21 16:47:37 +02:00
Sylvain Duchesne 20b3ef8712 Seed only what the tests need, and record that it did not speed anything up
Doctrine prescribes a connected seed that writes only what is needed, because
it is linear in documents. Verified safe before applying: no step asserts on a
seeded participation -- a scenario SETS its starting participant count on the
event and reads it back -- and no profile is looked up by name. So three
profiles instead of fourteen, and no participations.

The speed argument for it was REFUTED by measurement: 24 documents took 13.3s,
eight took 20.2s. Main writes 24 in 1.4s on the same accumulated wallet, so
neither document count nor accumulation is what dominates. Main adds into one
already-open graph; this branch opens a document and a fresh subscription per
event, which is what the Refine provider's create does. The cost is in opening
documents, not in writing triples.

Kept anyway, on the justification that survives: it writes less into a wallet
nothing ever empties.
2026-09-21 15:52:31 +02:00
Sylvain Duchesne ff285455d2 Say where the auto-seed flag can actually arrive
The recovered comment claimed a dev server picks the flag up from the
environment it was built with. It does not: dev and start both serve from src/,
so build.ts's define only reaches the staging bundle the @e2e harness builds.
OFF, the default and the reason the gate exists, needs no plumbing; opting IN
is what is out of reach, and closing that gap means making the app bootstrap
async, which nothing here needs yet.
2026-09-21 14:12:03 +02:00
Sylvain Duchesne 35df3e9110 Decide the seed on the caller's view, and take the sibling branch's answers
bootstrapWallet consulted ngUsers.size, which the await for the subscription
lets the broker re-populate — a caller that has just emptied the wallet then
never seeds again. The count now comes from the caller, as the event count
already did: deciding whether to seed reads what the caller sees, writing
needs the resolved set.

Recovered from ng-eventually, which had answers this session went looking for:
autoSeed.ts, whose gate keeps the dev seed from bloating a persistent wallet
over repeated runs (its fixtureSeedEnabled master switch is deliberately NOT
enforced here — adopting it stands down the seeding scenarios and is a product
decision); overlay-local-checkout.ts, which holds the single-@ng-org invariant;
ng: NG in place of typeof NG, and the harness reading the app's own
currentUserId with optional chaining, which together take typecheck from 3
errors to 1.
2026-09-21 14:08:58 +02:00
Sylvain Duchesne db483726af Install with pnpm, as the sibling branch does
bun install does not rewrite what is already in node_modules when a version
goes DOWN, which silently left @ng-org/orm and @ng-org/web on versions that
disagree about orm_start_graph's arity — the callback then lands in the config
slot and the SDK rejects it as unserializable. Every dependency measurement
taken this way was meaningless.

Carried over from ng-eventually with it: cucumber invoked through the package
entry rather than the .bin shim, which node --import tsx/esm cannot load under
pnpm; the build:orm paths, which still pointed at a src/shapes that moved to
src/shared/shapes; and the tsconfig fix that DROPS baseUrl instead of silencing
its deprecation — reported as a config error, it made tsc exit without checking
a single file.
2026-09-21 13:55:32 +02:00
Sylvain Duchesne 46abbf2e64 Wait for the ORM subscription before seeding into it
The placeholder useShapeSet hands back while a subscription loads refuses
writes, and bootstrapWallet writes its users straight into that set — so the
seed threw on its very first add and the wallet reported nothing persisted.
The refusal is right; issuing the write that early was not.

The previous orm version handed back a writable set immediately for a defined
scope, so a caller never had to wait. Since alpha.21 the set does not exist
until the subscription resolves, so loadTestData now awaits the ORM's own
promise and seeds into the set it resolves with — re-reading ngSet after the
await would hand over the placeholder this closure captured. The dev auto-seed
gates on the same readiness.
2026-09-21 13:53:19 +02:00
Sylvain Duchesne 753282c7b1 Absorb the useShape contract change the provider versions require
@ng-org/orm stopped returning the DeepSignalSet from useShape and now returns
a result object carrying it under data. The bump is forced: the providers
import RdfOrmSubscription, which alpha.18 published as OrmSubscription.

useShapeSet unwraps it in one place and hands back a set, so the call sites
keep the contract they were written against. While the subscription is
loading it yields an empty set that refuses writes rather than dropping
them, matching what the previous version documented.
2026-09-21 12:17:40 +02:00
Sylvain Duchesne d237753916 Mount the @data harness inside Refine, and stop the bundle leaking globals
The harness builds its bundles with a classic script tag, so an unwrapped
bundle publishes every top-level declaration as a global. Bundling Refine
brought in @tanstack/query-core, whose exported MutationObserver class then
shadowed the DOM one for Playwright's injected script, and every
waitForFunction died on defaultMutationOptions. --format=iife keeps the
bundle's declarations to itself.

The harness also mounted the data context outside <Refine> and called the
previous bootstrapWallet signature; it now mirrors the app's provider order
and seeds through the context, so tests and app write events by one path.
2026-09-21 12:13:12 +02:00
Sylvain Duchesne 33d54c6e3e WIP: events read and written through Refine
Mounts Refine over @ng-org/refine-providers and routes the Events vertical
through it. Known incomplete: the @data harness still mounts the data context
outside <Refine> and calls the old bootstrapWallet signature.
2026-09-21 11:58:32 +02:00
148 changed files with 5119 additions and 9299 deletions
+3 -1
View File
@@ -43,4 +43,6 @@ playwright/.auth/
storybook-static
dist-staging/
*.ngw
.tasks/
# per-developer contract source access (never committed)
.project/contracts.local.yaml
@@ -1,9 +0,0 @@
# Doc-debt — app-architecture
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED src/shared/context/FestipodDataContext.tsx @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/auth/screens/AccessGateScreen.tsx @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/context/AccountContext.tsx @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
@@ -30,9 +30,9 @@ Utilisé notamment par Storybook (voir concept `tech-stack`) pour parcourir les
- **home/** : `welcome`, `home`, `settings`
- **event/** : `events`, `event-detail`, `create-event`, `update-event`, `invite`, `participants-list`, `meeting-points`
- **user/** : `profile`, `update-profile`, `user-profile`, `friends-list`, `share-profile`
- **auth/** : `AccessGateScreen` — la **barrière d'accès** (login NextGraph + saisie de l'identifiant), rendue par `src/app/AuthGate.tsx`, **hors registre/routing** (ce n'est pas un écran routé). Les anciens `LoginScreen` puis `ConnexionScreen` ont été retirés (cf. concept `app-security`, [[knowledge_authentication]]).
- **auth/** : `login`
> Le mapping path → écran est dans [[knowledge_routing]]. La plupart des écrans consomment `useFestipodData()` (concept `data-layer`) ; exceptions : `WelcomeScreen` et la barrière `AccessGateScreen`.
> Le mapping path → écran est dans [[knowledge_routing]]. La plupart des écrans consomment `useFestipodData()` (concept `data-layer`) ; exceptions : `LoginScreen`/`WelcomeScreen`.
## Piège : registre incomplet
-9
View File
@@ -1,9 +0,0 @@
# Doc-debt — app-security
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED src/modules/auth/screens/AccessGateScreen.tsx @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/auth/steps/ui/barriere-acces.steps.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/auth/steps/data/connexion.steps.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
+11 -9
View File
@@ -1,21 +1,23 @@
---
type: _overview
summary: Sécurité & confidentialité de Festipod — l'isolation entre périmètres est assurée par le SDK de données, l'app lui fait confiance et ne porte aucune logique d'autorisation dans les écrans ; authentification par wallet ; matrice d'autorisations cible en incubation
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
triggers:
keywords: [sécurité, security, confidentialité, privacy, accès, "access control", contrôle d'accès, trust, confiance, authz, autorisation, permission, wallet, auth, authentification, anonyme, identité, login, scope, isolation]
keywords: [sécurité, security, confidentialité, privacy, accès, "access control", contrôle d'accès, trust, confiance, authz, autorisation, permission, wallet, auth, authentification, anonyme, identité, login]
paths: ["src/modules/auth/**", "src/shared/context/NextGraphContext.tsx"]
---
# App security
Le modèle de **sécurité, confidentialité et autorisations** de Festipod.
Le modèle de **sécurité, confidentialité et autorisations** de Festipod. Le pilier se lit en deux temps :
- **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.
- **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`).
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.
## Liens
- [[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)
- [[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
@@ -1,16 +1,19 @@
---
type: brief
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
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
last_updated: 2026-05-18
---
# Matrice d'autorisations et inventaire des requêtes
**Status:** Incubating — modèle cible, non figé en règles.
**Status:** Incubating — analyse en cours
**Last updated:** 2026-05-18
## Context
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]]).
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é.
## Cadre
@@ -30,7 +33,7 @@ Le modèle **cible** de qui-peut-quoi. La confidentialité de Festipod se dériv
- **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 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.
- **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).
- **Adhésion à une communauté / suivi : hors périmètre actuel.**
## Matrice par type de donnée
@@ -60,7 +63,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). **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 ?
**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).
### Événement
@@ -71,7 +74,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 (concept `functional-domain`, [[brief_2026-06-15_event-deduplication]]). 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 (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é) ?
### Profil utilisateur
@@ -86,7 +89,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 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é ?).
**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é ?).
### Connexion (lien d'amitié)
@@ -102,18 +105,31 @@ 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)).
## Périmètres dérivés
## Partitions naturelles dérivées
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 :
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**.
| Périmètre | Écriture | Lecture | Données |
| Périmètre | Écriture | Lecture | Données validées |
|---|---|---|---|
| **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) |
| **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) |
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é.
### 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.
## Inventaire des requêtes par écran
@@ -121,6 +137,7 @@ La **`Connexion` bilatérale** a *deux* écrivains → périmètre **dialog** d
## See Also
- 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
- [[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
@@ -1,45 +0,0 @@
---
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 de [[decision_2026-06-15_shared-wallet-login-flow]] 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 » de [[decision_2026-06-15_shared-wallet-login-flow]]
(l'ouverture du wallet partagé via broker reste inchangée). État courant du flux :
[[knowledge_authentication]].
@@ -1,32 +0,0 @@
---
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,22 +1,20 @@
---
type: knowledge
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
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
---
# 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'authentification est **déléguée au SDK de données** (`@ng-eventually/client`) : ouvrir sa session, c'est ouvrir son wallet.
**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.
## Flux
- La **barrière d'accès** (`AccessGateScreen`, rendue par `src/app/AuthGate.tsx`) est le vrai login NextGraph : elle ouvre le wallet partagé via la redirection broker. **Dans le même acte**, l'utilisateur saisit un **identifiant** qui nomme son espace virtuel (`onEnter`). Il n'y a **plus d'écran « login perçu » séparé** (l'ancien `ConnexionScreen` « choisissez un nom d'utilisateur » a été retiré — cf. [[decision_2026-07-06_identifier-at-access-barrier]] ; supersede le flux à deux écrans de [[decision_2026-06-15_shared-wallet-login-flow]]).
- 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`.
- `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.
## Le wallet de test
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.
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`).
> 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]].
> 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]].
@@ -1,21 +1,21 @@
---
type: knowledge
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
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
---
# Modèle de confiance
# Modèle de confiance actuel
**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`).
**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**.
Principes :
Conséquences (à connaître avant de raisonner sécurité) :
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.
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.
## Le point de vigilance
## Le piège pour la suite
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**.
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.
> À 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.
> À 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.
-20
View File
@@ -1,20 +0,0 @@
# Doc-debt — bdd-testing
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED src/modules/event/features/reconnexion-persistance-e2e.feature @2026-07-13 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/steps/e2e/reconnexion-persistance.steps.ts @2026-07-13 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/test-harness/harness-ng.tsx @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/test-harness/harness.tsx @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/features/reconnexion-socket-mort.feature @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/steps/data/reconnexion-socket-mort.steps.ts @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/features/reconnexion-meme-identite.feature @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/steps/data/reconnexion.steps.ts @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/features/reconnexion-froide-sans-local.feature @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/steps/data/reconnexion-froide-sans-local.steps.ts @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/auth/steps/ui/barriere-acces.steps.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/support/hooks.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/steps/data/isolation.steps.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/auth/steps/data/connexion.steps.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
@@ -1,35 +0,0 @@
---
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 `freshScenarioUsername`), 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 (`freshScenarioUsername`) 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 **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`.
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`.
## Config
`cucumber.json` : `import` de `src/shared/support/**`, `src/shared/steps/**`, `src/modules/*/steps/**` ; `paths` = `src/modules/*/features/**`; `tags: "not @wip"` (exclut les scénarios WIP) ; `language: fr`. **Runner = Node + tsx** (`node --import tsx/esm node_modules/.bin/cucumber-js`), pas Bun — les plugins (Playwright, happy-dom) ne chargent pas en import Bun natif. Ne pas « bunifier » `cucumber:run`/`test:data`.
`cucumber.json` : `import` de `src/shared/support/**`, `src/shared/steps/**`, `src/modules/*/steps/**` ; `paths` = `src/modules/*/features/**`; `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`.
## Le harness de test est buildé à la demande
@@ -1,7 +1,6 @@
---
type: knowledge
summary: Couche @data — Playwright pilote Chromium (profil persistant) qui s'authentifie au broker NextGraph réel chargeant harness-ng.tsx en iframe ; cycle de vie wallet automatisé (création + login bootstrap), bridge window.__testData, fallback mock
last_checked: 2026-07-05
---
# Couche `@data` (broker réel)
@@ -22,7 +21,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 initial est requis pour amorcer la session (sauvé en localStorage) ; sans lui, les écritures ne passeraient pas. 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 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.
- **Runs suivants** : marker trouvé → login automatisé (click Login → wallet → password → submit) → harness en iframe → `window.__testData.ready`.
- Credentials wallet : `festipod-tests` / `festipod-tests`.
@@ -33,34 +32,5 @@ 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).
- **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.
- **Caveat wallet persistant + isolation par scénario (T03.j)** : le wallet partagé **accumule**
le registre de comptes émulé et les docs per-entité à chaque scénario/run. Le fan-out de lecture
(`listEntityDocs` = `allAccounts()` → 1 SELECT/compte) parcourt tous les docs de tous les comptes
→ ralentit et fait *timeouter* les steps quand le wallet est pollué. Ce registre vit **côté
broker** : supprimer `.playwright-profile/` ne le nettoie PAS (re-sync depuis le broker) et force
une re-auth lente — mauvais levier. À la place, le `Before` @data appelle
`window.__testData.resetDataState()` : **UN** SPARQL DELETE sur le graphe ancre (private-store)
qui efface tous les records `urn:ng-eventually:shim:Account` → `allAccounts()` s'effondre à vide →
le fan-out se **borne** à ce que le scénario courant reprovisionne (comptes recréés paresseusement
par `ensureAccount`). O(1) sur UN graphe — **pas** un delete en fan-out (qui saturait le navigateur,
cf. T03.i `authClearParticipation` retiré). Borné à ≤10s (`Promise.race`) pour ne pas disputer le
budget 60s du `Before` (login broker déjà lent). Infra de test uniquement — ne touche ni la lib ni
le modèle produit ni le chemin de lecture applicatif. Le seed connecté reste **allégé** (peu de
docs) car chaque `docCreate` est un aller-retour broker sériel ~2s.
- **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`).
@@ -42,26 +42,6 @@ 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]]).
## 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 + collecte `pageErrors`), `world.ts` (champs `page`/`appFrame`/`pageErrors`), `scripts/debug-browser.ts` (debug headed), `.playwright-profile{,-debug}/` (gitignored).
`src/shared/support/hooks.ts` (lifecycle Playwright), `world.ts` (champs `page`/`appFrame`), `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**. Permet de tester à la fois le modèle « chacun son wallet » (`@private-wallet`) et le modèle « wallet partagé entre navigateurs » (`@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).
## 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** (chacun son wallet) **et en shared** (wallet partagé), 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** (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.
## 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. 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é).
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`).
- **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. 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 (cf. `decision_2026-06-17`). 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,3 +68,4 @@ 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,52 +0,0 @@
---
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.
-8
View File
@@ -1,8 +0,0 @@
# Doc-debt — data-layer
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED src/shared/context/FestipodDataContext.tsx @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/utils/ngSession.ts @2026-07-20 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
+22 -20
View File
@@ -1,33 +1,35 @@
---
type: _overview
summary: Comment Festipod persiste ses données via le SDK @ng-eventually/client — entités stockées comme documents par scope, écriture SPARQL directe + lecture par modèle union, stack SHEX, modes connected/demo, seed
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
triggers:
keywords: [nextgraph, "@ng-eventually", union, readUnion, readEntities, SHEX, shape, scope, "@graph", NURI, sparql, seed, wallet, FestipodData, ngSession, ngGraph, bootstrap, document, entité, déconnexion, reconnexion, durabilité, outbox, SerializationError]
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"]
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"]
---
# Data layer
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**.
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é.
> **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]].
> 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)
## Modèle & données
- [[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_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_seed-data]] — données de seed, `CURRENT_USER_ID`
- [[knowledge_context-internals]] — pièges de `FestipodDataContext` (currentUser, auto-seed dev, `participantCount` cache, no-op local)
## 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
## 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`.
> Sécurité/confidentialité (mono-store, confiance broker) : concept `app-security`.
@@ -0,0 +1,88 @@
---
type: contract
summary: The Refine data, auth and live providers @activitypods/refine-providers exposes to an application over a Solid Pod — what each operation does, what a record looks like, and what the package does not offer
built_from: https://github.com/activitypods/refine-providers.git/src
built_version: 66616484b96fb2227cd8cdf02321aacfb4811365
built_at: 2026-10-02
---
# contract_activitypods-providers — `@activitypods/refine-providers`
**This is a RECONSTRUCTION, not the provider's promise.** The package publishes no engagement and no change policy, so this document is an observation made once, at adoption time, and pinned to the commit named in `built_version`. Nothing here is committed to by its authors: any release may move any of it. Re-observe at each upgrade — the pin is what makes a difference visible.
## Scope
Three Refine providers over a Solid Pod running ActivityPods: a `dataProvider` (CRUD over LDP containers), an `authProvider` (Solid-OIDC sign-in plus the app-registration dance ActivityPods requires), and a `liveProvider` (push over the Solid Notifications Protocol). Resources are not addressed by a path the caller chooses: each configured resource is mapped at runtime to an LDP container discovered in a WebID's Solid TypeIndex.
Two Ant Design components also ship (`antd-auth-page`, `antd-background-checks`) and are outside this scope — `antd` and `@ant-design/icons` are optional peer dependencies, so an app with its own interface consumes the three providers without them. `react-router` is likewise an optional peer dependency.
Package `@activitypods/refine-providers`, version 0.1.5 at the pinned commit. Peer dependency `@refinedev/core ^5.0.12`. Full typed shape: the package's shipped declarations, `./types` (`dist/types.d.ts`) and `./index` (`dist/index.d.ts`).
## Surface
```ts
dataProvider({ resources, authProvider, jsonContext? }): DataProvider
authProvider({ clientId, redirectUri?, allowAnonymous?, appStatusCheckInterval? }): AuthProvider
liveProvider({ resources, authProvider, jsonContext? }): LiveProvider
type ResourceConfig = { shapeTreeUri?: string; types?: string | string[] }
type ResourcesConfig = Record<string, ResourceConfig> // keyed by Refine resource name
type JsonContext = string | string[] | Record<string, any>
type AuthSession = { token: string; webId: string }
```
`AuthProvider` is Refine's `AuthProvider` plus four members: `getSession(): AuthSession | undefined` (synchronous), `handleCallback(): Promise<{ webId, redirect }>`, `registerApp(webId?): Promise<string | undefined>`, `getAppStatus(): Promise<AppStatus>`. Its `login`/`register` take `{ issuer, redirect?, isSignup? }` — the Pod provider's base URL is a caller-supplied parameter, so the app must let the user pick one.
`DataProvider` implements `getList`, `getOne`, `getMany`, `create`, `update`, `deleteOne` and `getApiUrl`. Nothing else.
Utility functions are exported from the package root as well (notably the TypeIndex discovery and container resolution used internally); they are not part of what this document engages.
## Guarantees
Observed at the pinned commit.
**A record is a JSON-LD resource with an `id`.** `id` is the resource's own URI — `id` or `@id` of the fetched document, whichever is present; a resource carrying neither raises. The record also carries `@context` (the container's, on a listed record) and every other predicate verbatim. There is exactly ONE identifier: `getOne`, `update` and `deleteOne` take that same `id` and use it directly as the URL they fetch.
**`getList`** resolves the resource's container, fetches it whole, and maps `ldp:contains`. Refine's filters and sorters are then applied **in memory**, and pagination slices the result; `total` is the count before slicing. The filter set is broad (equality, `in`, substring, prefix/suffix, ordering, `between`, null checks, and `and`/`or` nesting), string comparison being case-insensitive unless the case-sensitive variant is used, and a filter field may be a dotted path.
**`create`** POSTs `{ "@context": jsonContext, "@type": <resolved types>, ...variables }` to the container, requires a `Location` response header, and returns the record re-fetched from it. The `@type` is the resource's configured `types` expanded through `jsonContext`, or, when only `shapeTreeUri` is configured, the `sh:targetClass` of the shape the shape tree points at.
**`update` merges — a partial write is safe.** It fetches the current resource, drops its `id` and `@context`, and PUTs `{ "@context": jsonContext, ...current, ...variables }`. So values the caller omits survive. The merge is **shallow**: a nested object in `variables` replaces its counterpart whole. It returns the record re-fetched after the write.
**`deleteOne`** DELETEs the resource and returns `{ id }` only.
**A configured resource's container is discovered, not configured.** Resolution reads the WebID document, then its `solid:publicTypeIndex`, then best-effort its `pim:preferencesFile` → `solid:privateTypeIndex`, and takes the first type registration whose `solid:forClass` matches the resource's resolved types and which carries a `solid:instanceContainer`. No match raises, naming the WebID and the types. A `shapeTreeUri` is fetched over plain HTTP, as is the shape it points at — so a shape tree the caller hosts itself works, provided it is reachable and carries `st:shape` → `sh:targetClass`.
**`jsonContext`** is sent as `@context` on every create and update, and is what expands configured CURIE types. It defaults to the ActivityStreams 2 context; an application with its own vocabulary passes its own.
**`liveProvider` pushes — the caller never polls.** It opens a `notify:WebSocketChannel2023` channel per topic, discovering the subscription endpoint from the Pod origin's `.well-known/solid`. Watching specific records subscribes to each record and reports `Update` → `updated`, `Delete`/`Remove` → `deleted`. Watching a list subscribes to the **container** and reports `Add` → `created`, `Remove` → `deleted`. Unsubscribing closes the socket and deletes the channel best-effort. A resource absent from `resources` yields a no-op unsubscribe rather than an error.
**Auth is Solid-OIDC with PKCE.** `login` redirects to the issuer's authorization endpoint with scope `openid webid offline_access`; `handleCallback`, called from the redirect route, completes the code grant and stores the ID token. The session's `webId` is the token's `webid` claim, and an expired token reads as no session. `logout` clears local state. `check` reports unauthenticated with `redirectTo: "/login"` unless `allowAnonymous` is set. `getIdentity` returns `{ id: webId, name, avatar }`, name and avatar read from the WebID document and the profile it links — ActivityPods' own profile vocabulary, unrelated to any vocabulary the app defines for its own user records. `registerApp` returns the AppRegistration URI when the app is registered and its grants current, and otherwise redirects the browser to the user's authorization agent and resolves `undefined`.
**Failures throw, and carry the HTTP status.** Every Pod call that returns non-2xx raises an `Error` with `status` and `body` attached, its message taken from the response body's error message when there is one. `onError` maps a 401 to logout.
## Non-guarantees
**Every read and write is scoped to the logged-in user's own Pod.** Container resolution always runs against the session's own WebID — in `getList`, in `create`, and in a list subscription alike. There is no parameter, filter or option through which an operation reaches another user's resources: `getList` returns what the current user holds, and nothing else. (`getOne`/`getMany` fetch a URI directly and so are not bound this way, but they require the caller to already hold that URI.)
**No server-side querying.** Filtering, sorting and pagination are applied after the whole container has been fetched, so cost grows with the container, not with the page.
**`getManyReference` is not implemented**, despite the data provider's own documentation mentioning it. Neither are `createMany`, `updateMany` and `deleteMany`.
**`getMany` drops what it cannot fetch.** Failed fetches are filtered out silently, so the returned array may be shorter than `ids` with no indication of which are missing.
**Caches are module-level and never invalidated** — shape trees by URI, type registrations by WebID, subscription endpoints by origin. A container that becomes available after a first lookup (the user granting access, for instance) is not seen until the page reloads, which is what the registration flow expects.
**`authProvider` installs process-global timers it never removes.** Unless `appStatusCheckInterval: false` is passed, construction registers a `setInterval` and a `visibilitychange` listener, with no teardown; constructing the provider more than once per page accumulates them.
**One session per page.** The token lives at a fixed `localStorage` key, so two concurrent sessions cannot coexist.
**A list subscription does not report field changes.** A container notifies about membership only, so a member's own predicates changing is invisible to a list; the record has to be watched directly.
**`types` wins over `shapeTreeUri` when both are set** — the opposite of what the configuration type's own documentation states. Configure exactly one. Likewise, when several type registrations match a resource's types, the first encountered wins, with no stated ordering.
**Unregistered apps read and write nothing.** An ActivityPods app must hold an AppRegistration before any data operation succeeds, and keeping it current is the app's business, through `registerApp` and `getAppStatus`.
## Change policy
None is published. The package carries no engagement document, no stated versioning commitment and, at the pinned commit, no git tags — its released versions are visible only as `package.json` version bumps. Treat every upgrade as potentially breaking: re-read the surface against the new commit and re-stamp this document. Divergences found between this reconstruction and observed behaviour are defects in this document, to be fixed here.
@@ -0,0 +1,71 @@
---
type: usage
summary: What Festipod consumes from @activitypods/refine-providers as one of its interchangeable Refine backends, and the conditions its own vocabulary and confidentiality model put on that backend
against: 66616484b96fb2227cd8cdf02321aacfb4811365
---
# usage_festipod — Festipod on `@activitypods/refine-providers`
Festipod is a mobile-first web application: users create **meeting points** grafted onto public events, and sign up to them. Its entire persistence goes through Refine, behind a **backend seam** that exists so the app can support several Refine backends and let the user switch between them at runtime. This package is one of the two backends targeted; the other is a Refine provider over NextGraph plus its polyfill. The seam's own contract — what a backend must answer before it can be added — lives in code, in `src/shared/refine/providers/types.ts`.
Nothing is wired yet, so the list below is **declared, not measured**: it is what Festipod will call, derived from the seam's requirements and from the observation of the package pinned beside this declaration. `against:` names that observation's commit. The package publishes no engagement, so there is nothing to pull and nothing that can drift — what is recorded here is read against our own reconstruction, and re-read when it is re-stamped.
## Consumed surface
### Provider construction
- `authProvider({ clientId, redirectUri, appStatusCheckInterval: false })` — one call site, per backend selection. `appStatusCheckInterval` is disabled deliberately, see friction 1; Festipod drives the check itself.
- `dataProvider({ authProvider, resources, jsonContext })` — one call site, handed the `authProvider` above.
- `liveProvider({ authProvider, resources, jsonContext })` — same, so Refine can subscribe behind every query.
All three are built together, **per backend instance**, inside the seam's `build()`.
### Auth members
`login({ issuer, redirect })` and `register(...)` — Festipod supplies its own Pod-provider picker. `handleCallback()` from its own callback route. `logout()`. `check()`. `getSession()` for the `webId` and the token. `registerApp()` and `getAppStatus()`, the second one on Festipod's own schedule since the built-in interval is off.
### Resources and operations
Four resources, one per persisted entity: **events**, **meeting points**, **participations** and **user profiles**. Each is configured with `types` naming Festipod's own class URIs, and `jsonContext` carries the context that expands them.
Through Refine's hooks only, never the provider directly: `useList` → `getList`, `useCreate` → `create`, `useUpdate` → `update`, `useDelete` → `deleteOne`. `getOne` is reached by Refine's cache plumbing rather than an explicit call. The app mounts Refine with `liveMode: 'auto'` and `mutationMode: 'optimistic'`.
### Record handling
A record's `id` — the resource's URI — is the **only** identifier Festipod uses: the seam's `recordKey` and `domainId` both return it. `update` merging the fields it is given means the seam declares this backend as patching, and sends only what changed.
### Not consumed
- `shapeTreeUri` — exactly one of the two resource identifiers must be configured, and `types` names Festipod's classes directly, without a shape tree to host and keep reachable.
- `getIdentity` — the signed-in identity and a Festipod user profile are two unrelated things in this app; the profile is read as an ordinary record, and only `getSession`'s `webId` is needed.
- `getMany`, `getApiUrl`, `allowAnonymous`, and both Ant Design entry points — Festipod has its own interface.
## Constraints
What must keep holding for this backend to serve the app.
1. **Festipod's own vocabulary, not ActivityStreams.** The four entities are Festipod domain types defined by SHEX shapes this repo holds; none is an ActivityStreams type. A caller-supplied `jsonContext` and caller-supplied `types` are what make that possible, and both must stay caller-supplied.
2. **The identifier a record is addressed by must stay the identifier other records reference.** The seam carries the two separately because they differ on the other targeted backend; here they coincide, and the app relies on that. Splitting them later would break every cross-entity reference silently.
3. **A partial update must stay partial.** The app sends only changed fields. A move to replacing the resource would drop every field omitted, with no error.
4. **Every participation must be readable by every user.** The participant count is the number of participations of an event, counted with no filtering. What is confidential is the **link between a participation and a person** — only a connection can read the profile a participation points at and recognise who it is — never the participation record itself. See need 1: this is the constraint the package does not currently let the app satisfy.
5. **The count is never a stored, incremented field.** Reading a counter, adding one and writing it back derives each write from a read of the previous one and races itself through any data provider. This is measured, not theoretical, and it is why the app derives the count from the participation list at read time.
6. **Changes must be pushed, not polled.** Neither the app nor its BDD suites has a polling loop, and adding one is ruled out by project doctrine. `liveMode: 'auto'` is mounted on that basis.
7. **No process-global state that outlives a remount.** Two backends coexist in one page and the user switches between them at runtime; switching remounts Refine to discard the cache of the backend being left. Building this backend's providers must have no side effect that accumulates across switches — see friction 1, which is where this currently bites.
8. **Each entity carries a declared visibility** — public, protected or private, per the product's confidentiality model — and it is the backend that honours it. Refine has no notion of it, so the intent is declared at the seam and enforcement is the backend's.
## Frictions
1. **`authProvider` installs a timer and a listener it never removes.** Construction registers a `setInterval` and a `visibilitychange` listener with no teardown. Festipod builds providers once per backend selection, so every switch to this backend would accumulate another pair — directly against constraint 7. Workaround carried: pass `appStatusCheckInterval: false` and call `getAppStatus()` on the app's own schedule. That works, but it means the package's own staleness handling is unusable here, and the only way to opt out is to opt out entirely.
2. **`getList` fetches the whole container on every call.** Filtering, sorting and pagination run in memory afterwards, so the cost of listing an event's participations grows with the user's total number of participations rather than with the event. No workaround; it is why a filter on a `getList` buys nothing beyond convenience.
3. **A list subscription reports membership only.** A container channel delivers `Add`/`Remove`, not a member's own fields changing, so a list of events does not refresh when an event's title, place or time is edited. Watching each record instead would open one channel per row. The event list is exactly the screen where this is visible.
4. **`types` takes precedence over `shapeTreeUri`, the opposite of what the configuration type documents.** The declared behaviour is that `types` is "only used as a fallback when `shapeTreeUri` is not set"; a resource setting both is in fact resolved from `types`. Establishing which one actually wins cost a read of the resolution path. Configuring exactly one avoids it, but the documentation should say which wins.
## Needs
1. **A read that is not scoped to the logged-in user's own Pod.** Every container resolution — in `getList`, in `create`, and in a list subscription — runs against the session's own WebID. So `getList('events')` returns only the events the current user created, and `getList('participations')` only their own participations.
This blocks the product, not a detail of it. Festipod exists to let a user **discover other people's public events** and see **how many people have signed up** to a meeting point. Through this provider the app can show neither: no other user's event is reachable, and the participant count of constraint 4 cannot be computed from participations it cannot read.
The request is modest in shape, because the capability already sits one layer down: the package's own TypeIndex discovery takes a WebID as a parameter, and notes that the public TypeIndex is readable without any special grant. What is missing is a way to reach it **through the data provider** — a way to scope a `getList` (and the matching list subscription) to a given WebID, or to a set of them. Festipod has no interest in reimplementing container resolution beside the provider; that would put the app back in the business the provider exists to hold.
2. **Releases that can be pinned, and a statement of what changes between them.** The package carries no git tags, so the only thing this repo can pin is a commit on a moving branch, and the only way to learn what an upgrade changed is to re-read the source. Tagged releases plus a note of what moved in each — in whatever form suits the project — would let a consumer upgrade deliberately instead of re-observing from scratch every time.
@@ -1,186 +0,0 @@
---
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.
@@ -10,8 +10,8 @@ Le type app `FpEventData` (`src/shared/data/types.ts`) et le seed (`seedData.ts`
## Conséquence
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.
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.
## Pour corriger (si on veut les persister)
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é**.
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é**.
@@ -1,15 +1,23 @@
---
type: caveat
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
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
---
# Caveat : la désinscription doit être autoritative
# Caveat : suppression de Participation via `ngSet.delete()`
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.
**É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.
## Le piège
## Histoire (important)
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.
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é.
**À 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`).
## 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.
@@ -1,17 +0,0 @@
---
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).
@@ -0,0 +1,35 @@
---
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]].
@@ -0,0 +1,39 @@
---
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]].
@@ -0,0 +1,51 @@
---
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 — currentUserId = principal stable dérivé de l'identifiant, auto-seed OPT-IN (FESTIPOD_AUTO_SEED, OFF par défaut), participantCount dérivé Option-B (fiable à la connexion du propriétaire via lecture inbox gated sur barrière ; source unique = event.participantCount), instrumentation useShapeQuery (spinner+timing), mutations no-op en mode local malgré le toast
last_checked: 2026-07-07
summary: Pièges internes de FestipodDataContext — 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
---
# Internals & pièges de `FestipodDataContext`
@@ -10,71 +10,20 @@ Comportements non évidents de `src/shared/context/FestipodDataContext.tsx` à c
## Résolution du `currentUser` (mode NG)
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.
## 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é.
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.
## Auto-seed de dev
**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]]).
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.
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`.
## `participantCount` muté en place
## `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)
Le jeu de lecture par besoin (`publicDocs`/`protectedDocs`) **accumule** les docs de scope de l'identité courante (pour ne pas perdre un doc juste créé avant la re-liste). Or le stopgap wallet-partagé garde **un seul arbre React** au travers d'un faux-logout + re-login sous un **autre identifiant** (pas de rechargement — `AccountContext.login` ne fait que réécrire l'identifiant en localStorage, `AuthGate` ne remonte rien). Sans réinitialisation, **les docs PROTECTED de l'identité précédente (ses participations) survivent dans le jeu de lecture de la nouvelle identité et fuient** via la lecture union : le cap gate ne peut pas les filtrer quand le registre de caps (en mémoire) ne gouverne pas ce doc *cette* session (doc persisté d'un run antérieur, ou chargement frais où les caps sont vides). Symptôme observé : un utilisateur B voyait la participation de A (et l'événement de A apparaissait sur l'**accueil** de B, car l'accueil = `getUserEvents(currentUserId)`, cf. concept `app-architecture`).
**Règle** : traiter **tout changement d'identifiant** comme une session fraîche — un `useEffect([identifier])` (ref-gardé pour ne pas tirer au premier mount) vide `publicDocs`/`protectedDocs`, appelle `resetCaps()` + `resetRegistryCache()`, puis bump le read tick ; l'effet de listing reconstruit le jeu **borné à la nouvelle identité**. L'isolation reste par-document/émulée (concept `app-security`, [[knowledge_trust-model]]) ; ce reset ne fait que supprimer le report d'état inter-identités.
**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).
## 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.
`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.
## Mutations no-op en mode local
@@ -1,28 +1,29 @@
---
type: knowledge
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()
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()
---
# Modes de données & contextes
L'app a **deux modes**, tous deux consommés via le hook `useFestipodData()` :
1. **Connected** — shapes ORM du SDK `@ng-eventually/client` (P2P, chiffré, local-first)
1. **Connected** — shapes ORM NextGraph (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 (l'utilisateur courant et son accès aux stores par scope).
- 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).
## FestipodDataContext (`src/shared/context/FestipodDataContext.tsx`)
- Enveloppe les shapes via `useShapeWithDefaults()`.
- Expose `useFestipodData()` (consommé par tous les écrans) + CRUD (`createEvent`, `updateEvent`, `joinEvent`, `leaveEvent`, etc.).
- **Provider selon le statut de connexion** :
- Expose `useFestipodData()` (consommé par tous les écrans) + CRUD (`createEvent`, `updateEvent`, etc.).
- **Provider selon le statut NG** :
- `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)
> 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]]).
> 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.
@@ -1,7 +1,6 @@
---
type: knowledge
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
summary: Types de données Fp* (Event, UserProfile, Participation persistés NextGraph ; MeetingPoint et Friendship encore local-only)
---
# Entités de données
@@ -10,15 +9,12 @@ last_checked: 2026-07-03
| Type | Persistance | Champs clés |
|---|---|---|
| `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 |
| `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 |
| `FpFriendshipData` | **local-only** | userId + friendId |
`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]]).
`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`.
> 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,34 +1,26 @@
---
type: knowledge
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
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
---
# 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.
# Stack NextGraph (côté app)
```
@ng-eventually/client # LE SDK de données de l'app (ORM réactif useShape, docs, scopes, inbox)
@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
```
## Frontière SDK (règle d'or)
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).
- 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 :
## Shapes SHEX
`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 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`.
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`.
> **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]]).
> 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]].
@@ -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 en mode connected — déclenché uniquement par action explicite de l'utilisateur (« Charger données de test »).
> `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`.
@@ -1,53 +0,0 @@
---
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,14 @@
---
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]].
@@ -1,112 +0,0 @@
---
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,25 @@
---
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,10 +0,0 @@
# Doc-debt — functional-domain
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED src/modules/event/features/reconnexion-persistance-e2e.feature @2026-07-13 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/features/reconnexion-socket-mort.feature @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/features/reconnexion-meme-identite.feature @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/event/features/reconnexion-froide-sans-local.feature @2026-07-14 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
@@ -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, ses concepts métier, et les périmètres de confidentialité (public/protected/private) par entité
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
triggers:
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é]
keywords: [point de rencontre, rencontre, greffe, greffer, événement, déclarant, hôte, inscrit, inscription, communauté, connexion, festival, déduplication]
paths: ["src/modules/*/features/**"]
---
@@ -16,14 +16,13 @@ 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 & confidentialité
## Périmètre & sécurité
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]]).
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.
## 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. **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. |
| **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. |
| **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. |
@@ -1,44 +0,0 @@
---
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,11 @@ 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
> 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 : 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.
## É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** : 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]]).
- **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]].
@@ -0,0 +1,36 @@
---
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)
@@ -0,0 +1,84 @@
---
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/)
@@ -0,0 +1,96 @@
---
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`
@@ -0,0 +1,183 @@
---
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`)
@@ -0,0 +1,51 @@
---
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
@@ -0,0 +1,53 @@
---
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
@@ -0,0 +1,69 @@
---
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`.
@@ -0,0 +1,51 @@
---
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.
@@ -0,0 +1,60 @@
---
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).
@@ -1,41 +0,0 @@
---
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,19 +14,7 @@ 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*`, `/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
```
- `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`.
## Le harness de test est buildé à part
@@ -1,24 +1,22 @@
---
type: knowledge
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
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
---
# Déploiement & infra
## Dockerfile
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.
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).
- `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.
**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).
## Variables d'environnement
@@ -28,5 +26,3 @@ Un `Dockerfile` existe (multi-stage Bun Alpine). **L'installation passe par pnpm
## 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 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)
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)
---
# Stack & commandes
@@ -10,7 +10,6 @@ summary: Composants de la stack (Bun runtime/build/test, install via pnpm, React
| 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`) |
@@ -32,8 +31,7 @@ summary: Composants de la stack (Bun runtime/build/test, install via pnpm, React
| `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` — (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]]. |
| `build:ng` | `bash scripts/build-ng-packages.sh` — rebuild des `@ng-org/*` depuis le fork local (concept `nextgraph-platform`) |
| `storybook` / `build-storybook` | Storybook dev (6006) / build statique |
## Pièges
+4 -12
View File
@@ -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 test/build, bunx, et pas d'express/ws/pg/dotenv. EXCEPTION : l'installation des paquets passe par pnpm (les deux repos), pas bun install
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
---
# 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 install`, `bun install` | **`pnpm install`** (voir exception ci-dessous) |
| `npm/yarn/pnpm install` | `bun install` |
| `npm run <script>` | `bun run <script>` |
| `npx <pkg>` | `bunx <pkg>` |
| `webpack`, `esbuild`, `vite` | `bun build` / bundler Bun (HTML imports) |
@@ -26,14 +26,6 @@ Par défaut, utiliser **Bun** et ses APIs natives plutôt que les équivalents N
Détail des APIs : [[knowledge_bun-apis]].
## Exception : l'installation des paquets passe par pnpm
## Pourquoi
**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.
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.
+36
View File
@@ -0,0 +1,36 @@
# Inter-repo contracts. Festipod is a CONSUMER only: it publishes no interface of its own,
# and what it lists under `publish:` is its own consumer-side DECLARATIONS — the channel a
# provider pulls to learn what this app calls, what it needs, and what blocks it.
#
# Festipod's persistence sits behind interchangeable Refine backends (the backend contract
# itself is in-code: `src/shared/refine/providers/types.ts`). Each candidate backend is a
# separate interface, with its own folder inside `data-layer` — the concept that owns how
# Festipod uses an external data surface.
#
# `@activitypods/refine-providers` publishes NO engagement: no contract document, no stated
# change policy, not even a git tag. So its entry is `type: reconstruct` — the one bounded
# crossing this protocol allows, made once at adoption time: the leaf under `into:` is OUR
# observation of that package, authored here and pinned to the commit it was read at. It is
# not a promise anyone made us, and every upgrade has to re-observe it.
#
# Once reconstructed, that leaf IS the specification this app codes against. Reading the
# package's sources again at work time is not how a question gets answered — the leaf is
# fixed or the gap goes into our declaration's `## Needs`.
publish:
# Key qualified by interface: a second declaration is coming for the NextGraph+polyfill
# backend, and publish keys must stay unique.
activitypods-providers--festipod: concepts/data-layer/activitypods-providers/usage_festipod.md
consume:
- contract: activitypods-providers
into: concepts/data-layer/activitypods-providers/
type: reconstruct
# Canonical identity: the package's git remote plus the repo-relative folder the
# observation was made from. Per-developer access to a checkout of it lives in
# `.project/contracts.local.yaml`, which is never committed.
reconstructFrom: https://github.com/activitypods/refine-providers.git/src
# The branch the observation was read from. A tag would be the better pin, but this
# repository publishes none — the resolver turns this into the commit actually read,
# which is what `built_version` records.
ref: master
+4 -7
View File
@@ -14,21 +14,18 @@ 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, périmètres public/protected/private par entité, découverte, défi déduplication |
| `functional-domain` | Modèle produit : point de rencontre, acteurs, concepts métier, 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` | Persistance via le SDK `@ng-eventually/client` : entités-documents par scope, shapes SHEX/ORM, modes connected/demo, pièges |
| `data-layer` | NextGraph actuel (mono-store), shapes, modes connected/demo, règles + pièges (suppression, champs perdus, internals) |
| `bdd-testing` | Cucumber multi-couches FR, contrat `@ui`/`@data`/`@e2e`, harness broker, cookbook |
| `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 |
| `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) |
Pour **documenter** un fait projet : `/concept document <sujet>` (ne pas écrire en libre dans `.project/`).
+4 -12
View File
@@ -1,19 +1,11 @@
# Use the official Bun image (runtime stays Bun; only install moves to pnpm)
# Use the official Bun image
FROM oven/bun:1-alpine AS base
WORKDIR /app
# 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.
# Install dependencies
FROM base AS install
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 package.json bun.lock ./
RUN bun install --frozen-lockfile
# Copy source code and build assets
FROM base AS release
+2 -2
View File
@@ -142,8 +142,8 @@ 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).
// The app may bootstrap demo data into an empty connected wallet only when
// FESTIPOD_AUTO_SEED is set (see src/shared/utils/autoSeed.ts). Absent → OFF.
"globalThis.__FESTIPOD_AUTO_SEED__": JSON.stringify(
process.env.FESTIPOD_AUTO_SEED ?? "",
),
+1 -1
View File
@@ -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"
},
+17 -18
View File
@@ -8,29 +8,27 @@
"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:orm": "rdf-orm build --input ./src/shared/shapes/shex --output ./src/shared/shapes/orm",
"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",
"@ng-org/web": "0.1.2-alpha.13",
"@ng-org/alien-deepsignals": "0.1.2-alpha.13",
"@ng-org/orm": "0.1.2-alpha.21",
"@ng-org/shex-orm": "0.1.2-alpha.9",
"@ng-org/web": "0.1.2-alpha.14",
"@radix-ui/react-label": "^2.1.7",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.8",
"@refinedev/core": "^5.0.12",
"bun-plugin-tailwind": "^0.1.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -43,6 +41,11 @@
"@cucumber/cucumber": "^12.5.0",
"@cucumber/gherkin": "^29.0.0",
"@cucumber/messages": "^26.0.1",
"@storybook/addon-a11y": "^10.3.5",
"@storybook/addon-docs": "^10.3.5",
"@storybook/addon-onboarding": "^10.3.5",
"@storybook/addon-webpack5-compiler-swc": "^4.0.3",
"@storybook/react-webpack5": "^10.3.5",
"@types/bun": "latest",
"@types/chai": "^5.2.3",
"@types/react": "^19",
@@ -50,19 +53,15 @@
"chai": "^6.2.2",
"happy-dom": "^16.6.0",
"playwright": "^1.58.2",
"storybook": "^10.3.5",
"tailwindcss": "^4.1.11",
"tsx": "^4.21.0",
"tw-animate-css": "^1.4.0",
"storybook": "^10.3.5",
"@storybook/react-webpack5": "^10.3.5",
"@storybook/addon-webpack5-compiler-swc": "^4.0.3",
"@storybook/addon-a11y": "^10.3.5",
"@storybook/addon-docs": "^10.3.5",
"@storybook/addon-onboarding": "^10.3.5"
"tw-animate-css": "^1.4.0"
},
"pnpm": {
"onlyBuiltDependencies": [
"bun"
]
}
},
"packageManager": "pnpm@10.26.0+sha512.3b3f6c725ebe712506c0ab1ad4133cf86b1f4b687effce62a9b38b4d72e3954242e643190fc51fa1642949c735f403debd44f5cb0edd657abe63a8b6a7e1e402"
}
+1218 -1481
View File
File diff suppressed because it is too large Load Diff
-106
View File
@@ -1,106 +0,0 @@
#!/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);
}
});
+160
View File
@@ -0,0 +1,160 @@
#!/usr/bin/env bun
/**
* overlay-local-checkout.ts — Reactive local overlay of a data-layer PROVIDER's checkout
* into node_modules. One script, one provider per run; only the paths differ between them.
*
* PROVIDERS (first non-flag argument; defaults to `polyfill`):
* polyfill → node_modules/@ng-eventually/polyfill override: NG_EVENTUALLY_LOCAL
* indexing → node_modules/@ng-helpers/indexing override: NG_HELPERS_LOCAL
*
* WHY a copy-overlay and not a symlink (identical for every provider):
* A committed prod dependency installs from Gitea (git+https) into pnpm's store WITHOUT
* its own node_modules/@ng-org, so @ng-org/web resolves UP to Festipod → ONE @ng-org
* instance (one verifier). A local CHECKOUT, however, carries its own node_modules/*
* (links into that provider's own dev tree). Symlinking node_modules/<pkg> to the
* checkout would put the checkout's copies in the resolution path → a SECOND @ng-org
* (and, for `indexing`, a second @ng-eventually/polyfill) → broken SDK, two verifiers.
* So we overlay a real directory containing ONLY the provider's source (no node_modules):
* shared packages still resolve up to Festipod, single instance preserved.
*
* WHAT IT DOES:
* 1. Replaces node_modules/<package> (the pnpm store symlink) with a real directory
* holding the local checkout's package.json + src (NO node_modules).
* 2. Asserts the single-instance invariant — every package this provider SHARES with
* Festipod must resolve to the same realpath from Festipod and from the overlay —
* and aborts if it would break.
* 3. Watches the local checkout's src and copies each change into the overlay.
*
* ⚠️ RESTART `bun run dev` AFTER THIS SCRIPT WRITES — a rebuild is NOT a substitute.
* A running dev server NEVER picks up a package refreshed inside node_modules, not even
* across a genuine rebuild: VERIFIED in a controlled test, an application-source edit
* produced a new bundle hash and the rebuilt bundle STILL carried the stale dependency.
* The server's resolution of that import is pinned at process start and a rebuild does not
* re-resolve it. Only restarting serves the fresh copy, and nothing warns you — a stale
* server looks exactly like a current one. See
* .project/concepts/tech-stack/caveat_polyfill-overlay-needs-a-dev-restart.md, which cost
* an hour to learn. Watching copies the files; it does not make anything reload them.
*
* USAGE (reactive dev):
* Terminal 1: pnpm run overlay:polyfill # or: pnpm run overlay:indexing
* Terminal 2: bun run dev # portless festipod bun --hot src/index.ts
* Edit the checkout's src → it lands in node_modules → RESTART dev to pick it up.
*
* pnpm run overlay:indexing --once # overlay + verify, no watch (CI / one-shot)
* Return to the committed git-installed dependencies: pnpm install
*/
import { existsSync, lstatSync, mkdirSync, rmSync, cpSync, copyFileSync, realpathSync } from "node:fs";
import { watch } from "node:fs";
import { join, dirname } from "node:path";
interface Provider {
/** Package as installed, e.g. "@ng-eventually/polyfill" — also its node_modules path. */
readonly packageName: string;
/** Local checkout used when the env override is unset. */
readonly defaultLocal: string;
/** Env var overriding the local checkout path. */
readonly envOverride: string;
/**
* Packages this provider SHARES with Festipod and that must stay single-instance.
* Each is resolved from Festipod and from the overlay; the realpaths must match.
*/
readonly singletons: readonly string[];
}
const PROVIDERS: Record<string, Provider> = {
polyfill: {
packageName: "@ng-eventually/polyfill",
defaultLocal: "/home/sylvain/projects/nextgraph/ng-eventually-js/packages/polyfill",
envOverride: "NG_EVENTUALLY_LOCAL",
singletons: ["@ng-org/web"],
},
indexing: {
packageName: "@ng-helpers/indexing",
defaultLocal: "/home/sylvain/projects/nextgraph/ng-helpers",
envOverride: "NG_HELPERS_LOCAL",
// Consumes the polyfill, so BOTH it and the verifier underneath must stay single.
singletons: ["@ng-eventually/polyfill", "@ng-org/web"],
},
};
const FESTIPOD = realpathSync(join(import.meta.dir, ".."));
const args = process.argv.slice(2);
const ONCE = args.includes("--once");
const KEY = args.find((a) => !a.startsWith("-")) ?? "polyfill";
function fail(msg: string): never {
console.error(`✖ overlay:${KEY} — ${msg}`);
process.exit(1);
}
const provider = PROVIDERS[KEY];
if (!provider) {
fail(`unknown provider "${KEY}" — expected one of: ${Object.keys(PROVIDERS).join(", ")}`);
}
const LOCAL = process.env[provider.envOverride] ?? provider.defaultLocal;
const TARGET = join(FESTIPOD, "node_modules", ...provider.packageName.split("/"));
const SRC_LOCAL = join(LOCAL, "src");
const SRC_TARGET = join(TARGET, "src");
if (!existsSync(join(LOCAL, "package.json"))) {
fail(`local checkout not found at ${LOCAL} (set ${provider.envOverride} to override)`);
}
if (!existsSync(SRC_LOCAL)) {
fail(`local checkout has no src/ at ${SRC_LOCAL}`);
}
// 1. Replace the pnpm store symlink with a real overlay dir (metadata + src, NO node_modules).
console.log(`→ overlaying local ${provider.packageName}: ${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 instances).
cpSync(SRC_LOCAL, SRC_TARGET, { recursive: true });
// 2. Assert the single-instance invariant for every package shared with Festipod.
const overlayReal = realpathSync(TARGET);
for (const spec of provider.singletons) {
const fromFestipod = realpathSync(Bun.resolveSync(spec, FESTIPOD));
const fromOverlay = realpathSync(Bun.resolveSync(spec, overlayReal));
console.log(` ${spec} (Festipod): ${fromFestipod}`);
console.log(` ${spec} (overlay) : ${fromOverlay}`);
if (fromFestipod !== fromOverlay) {
fail(
`single-instance invariant BROKEN — ${spec} resolves to two different realpaths.\n` +
" The overlay must not contain its own node_modules. Aborting.",
);
}
}
console.log(`✓ single instance preserved for: ${provider.singletons.join(", ")}`);
if (ONCE) {
console.log("✓ overlay ready (--once, not watching)");
process.exit(0);
}
// 3. Watch and copy on change. This keeps the overlay CURRENT; it does NOT make a running
// dev server notice — not even across a rebuild (see the header). Restart it.
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);
}
});
-490
View File
@@ -1,490 +0,0 @@
#!/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);
});
+15 -10
View File
@@ -2,6 +2,7 @@ import { RouterProvider, useRouter } from './router';
import { ThemeProvider } from '../shared/context/ThemeContext';
import { NextGraphProvider } from '../shared/context/NextGraphContext';
import { AccountProvider } from '../shared/context/AccountContext';
import { RefineProvider } from '../shared/refine/RefineProvider';
import { FestipodDataProvider } from '../shared/context/FestipodDataContext';
import { AuthGate } from './AuthGate';
import { ToastContainer } from '../shared/components/sketchy';
@@ -58,16 +59,20 @@ export function App() {
<ThemeProvider>
<NextGraphProvider>
<AccountProvider>
<FestipodDataProvider>
<RouterProvider>
<div className="app-container">
<AuthGate>
<AppContent />
</AuthGate>
<ToastContainer />
</div>
</RouterProvider>
</FestipodDataProvider>
{/* Refine wraps the data context: FestipodDataContext reads and writes
events through Refine's hooks, so it must sit inside <Refine>. */}
<RefineProvider>
<FestipodDataProvider>
<RouterProvider>
<div className="app-container">
<AuthGate>
<AppContent />
</AuthGate>
<ToastContainer />
</div>
</RouterProvider>
</FestipodDataProvider>
</RefineProvider>
</AccountProvider>
</NextGraphProvider>
</ThemeProvider>
+17 -60
View File
@@ -1,10 +1,9 @@
/**
* AuthGate — the stopgap access flow (see decision_2026-06-15_shared-wallet-login-flow):
* 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.
* 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.
*
* The gate is ON BY DEFAULT (Festipod never functions without NextGraph). It is
* disabled only when `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ === true` —
@@ -13,11 +12,11 @@
* screens, not the auth flow). Absent → gate ON.
*/
import { useEffect, type ReactNode } from 'react';
import type { ReactNode } from 'react';
import { useNextGraph } from '../shared/context/NextGraphContext';
import { useAccount, normalizeIdentifier } from '../shared/context/AccountContext';
import { useAccount } from '../shared/context/AccountContext';
import { AccessGateScreen } from '../modules/auth/screens/AccessGateScreen';
import { useRouter, useNavigate } from './router';
import { ConnexionScreen } from '../modules/auth/screens/ConnexionScreen';
declare global {
// eslint-disable-next-line no-var
@@ -27,65 +26,23 @@ const GATE_DISABLED = globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ === true;
export function AuthGate({ children }: { children: ReactNode }) {
const { status, error, connect } = useNextGraph();
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]);
const { username } = useAccount();
// Gate explicitly disabled (no-gate build / @e2e harness) → straight to app.
if (GATE_DISABLED) {
return <>{children}</>;
}
// 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}
/>
);
// 1. Technical access barrier (real NG login) — until the shared wallet opens.
if (status !== 'connected') {
return <AccessGateScreen status={status} error={error} onEnter={connect} />;
}
// The app.
// 2. Perceived app login — until a username is chosen.
if (!username) {
return <ConnexionScreen />;
}
// 3. The app.
return <>{children}</>;
}
+15 -50
View File
@@ -3,59 +3,24 @@
* 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";
/** 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.
}
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);
}
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();
-11
View File
@@ -333,17 +333,6 @@ 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;
-22
View File
@@ -41,28 +41,6 @@ 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,
},
@@ -1,27 +0,0 @@
# 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"
@@ -1,42 +0,0 @@
# 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"
+18 -50
View File
@@ -4,15 +4,12 @@
* STOPGAP (see decision_2026-06-15_shared-wallet-login-flow). This is the
* REAL NextGraph login, shown before the app renders. Because it precedes the
* app, the user reads it as "access to the test environment", not as an app
* login. The user also types an IDENTIFIER here — the id that names their
* virtual space (a technical id, a pseudo in practice, not a Festipod username).
* Clicking "Entrer" records that identifier and triggers `connect()`, which
* redirects to the broker to open the SHARED wallet. After return the identity
* is already set (persisted before the redirect), so NG auto-connects straight
* into the app — there is no separate "pick a username" screen.
* 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).
*
* ASSISTED IMPORT (see decision_2026-06-17). The hosted broker can't import a
* wallet inline during
* ASSISTED IMPORT (see the broker-import constraint, concept nextgraph-platform
* + 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
@@ -22,20 +19,13 @@
*/
import { useState, type ReactNode } from 'react';
import { Button, Input, Title, Text } from '../../../shared/components/sketchy';
import { Button, 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;
/**
* 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;
onEnter: () => void;
}
// One numbered step: a badge + a title + the action for that step.
@@ -54,15 +44,9 @@ function Step({ n, title, children }: { n: number; title: string; children: Reac
);
}
export function AccessGateScreen({ status, error, initialIdentifier, onEnter }: AccessGateScreenProps) {
export function AccessGateScreen({ status, error, 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 username). 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 {
@@ -74,31 +58,15 @@ export function AccessGateScreen({ status, error, initialIdentifier, onEnter }:
}
};
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 = (
<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>
<Button
variant="primary"
onClick={onEnter}
disabled={connecting}
style={{ width: '100%', opacity: connecting ? 0.6 : 1 }}
>
{connecting ? 'Accès en cours…' : 'Entrer'}
</Button>
);
return (
@@ -107,7 +75,7 @@ export function AccessGateScreen({ status, error, initialIdentifier, onEnter }:
<Title style={{ textAlign: 'center', fontSize: 30, marginBottom: 4 }}>Festipod</Title>
<Text style={{ textAlign: 'center', marginBottom: 24, color: '#888' }}>Espace de test</Text>
{hasSharedWallet() && status !== 'connected' ? (
{hasSharedWallet() ? (
<>
<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.
@@ -146,7 +114,7 @@ export function AccessGateScreen({ status, error, initialIdentifier, onEnter }:
</div>
</Step>
<Step n={3} title="Revenez ici, choisissez un identifiant et entrez">
<Step n={3} title="Revenez sur cet onglet et entrez">
{entrer}
</Step>
</>
@@ -0,0 +1,92 @@
/**
* 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>
);
}
+4 -2
View File
@@ -1,8 +1,10 @@
/**
* Shared wallet material for the staging stopgap.
*
* 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.
* 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.
*
* The correct primitive is the **wallet FILE** (.ngw), NOT a TextCode: a
* TextCode is a transient device-to-device transfer (5 min, source device
+31 -17
View File
@@ -2,21 +2,31 @@ 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) {
// Each @data scenario runs under a UNIQUE username (see hooks.ts
// freshScenarioUsername), 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.
// 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
const counts = await this.appFrame!.evaluate(() => {
const td = (window as any).__testData;
return { events: td.events.size, users: td.users.size };
return { events: td.events.size, users: td.users.size, participations: td.participations.size };
});
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);
expect(counts.events, 'Events should be empty').to.equal(0);
expect(counts.users, 'Users should be empty').to.equal(0);
});
Given('le portefeuille contient déjà des événements', async function (this: FestipodWorld) {
@@ -30,9 +40,12 @@ Given('le portefeuille contient déjà des événements', async function (this:
const td = (window as any).__testData;
td.loadTestData();
});
// Wait for data to propagate
// Wait for data to propagate. `waitForFunction(fn, arg, options)` — the
// timeout goes in the THIRD slot; passed second it is silently taken as the
// predicate's argument and the wait runs on the 30s default instead.
await this.appFrame!.waitForFunction(
() => (window as any).__testData.events.size > 0,
undefined,
{ timeout: 75000 },
);
}
@@ -59,19 +72,20 @@ When('je charge les données de test', async function (this: FestipodWorld) {
});
(this as any)._loadSeeded = seededResult.seeded;
// 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.
// Wait for data to propagate, and for BOTH events and users — not just events.
// They arrive by two independent paths here: events through the Refine read,
// the seeded profiles through the ORM subscription on the protected scope. If
// only events are awaited, `contient des utilisateurs` asserts before the
// profile 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;
},
undefined,
{ timeout: 75000 },
).catch(() => {
// Timeout tolerated — the assertions below surface the real failure with a
@@ -1,101 +0,0 @@
/**
* @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.
*/
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import React from 'react';
import { renderElement } from '../../../../shared/test-harness/renderHelper';
import { AccessGateScreen } from '../../screens/AccessGateScreen';
import type { FestipodWorld } from '../../../../shared/support/world';
// 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;
// '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);
},
);
@@ -1,111 +0,0 @@
/**
* @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,24 +47,20 @@ 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"
# 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
# ngSet.delete() updates UI but doesn't persist — NG ORM limitation.
@e2e @wip
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 "J'y serai" si visible
Alors l'écran contient le texte "Je participe"
Quand l'utilisateur clique sur le bouton "Je participe"
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é
Alors l'écran contient le texte "J'y serai"
# --- Modification ---
@@ -1,21 +0,0 @@
# 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
@@ -1,109 +0,0 @@
# 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"
@@ -1,27 +0,0 @@
# 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
@@ -1,26 +0,0 @@
# 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
@@ -1,36 +0,0 @@
# 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
@@ -28,22 +28,6 @@ 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,65 +24,41 @@ 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"
# --- 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
Et l'événement "Formation CNV" compte 8 participants
@@ -88,12 +88,7 @@ export function CreateEventScreen() {
endTime,
location: location || 'Lieu à définir',
description,
// 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,
participantCount: 1,
themes: ['Social'],
hostName: 'Moi',
hostInitials: 'MD',
@@ -12,12 +12,7 @@ export function ParticipantsListScreen() {
const event = eventId ? getEvent(eventId) : undefined;
const participants = eventId ? getEventParticipants(eventId) : [];
// 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 totalCount = event?.participantCount ?? participants.length;
const unknownCount = Math.max(0, totalCount - participants.length);
const rows = [
@@ -1,93 +0,0 @@
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);
});
@@ -1,64 +0,0 @@
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import type { FestipodWorld } from '../../../../shared/support/world';
// Data-layer proof of PUBLIC discovery via the GLOBAL INDEX against the REAL
// broker. A publisher account creates its own public event document AND submits
// its reference to the SDK discovery index (submitEventToIndex → deposit). A
// separate, NON-connected discoverer account READS THE INDEX
// (readDiscoveredEvents → materialize) and subscribes to the referenced document
// via a real useShape({graphs}) — with no friendship/connection ever declared
// between them, and NO cross-account fan-out. Discovery goes through the index
// alone. See decision_2026-06-16_discovery-model (special-account index owner).
Given('le compte {string} publie un événement public {string}', async function (this: FestipodWorld, publisher: string, title: string) {
const res = await this.appFrame!.evaluate(
async (p) => await (window as any).__testData.publishPublicEventAs(p, ''),
publisher,
);
(this as any).discovery = { publisher, title, doc: res.doc };
expect(res.doc, 'publisher public event doc NURI').to.be.a('string');
});
Given('le compte {string} n\'est pas connecté à {string}', function (this: FestipodWorld, discoverer: string, _publisher: string) {
// No connection is ever declared: the discoverer only exists in the shim.
// Recorded so the When step knows which account performs the discovery.
(this as any).discovery = { ...(this as any).discovery, discoverer };
});
When('{string} découvre les événements publics', async function (this: FestipodWorld, discoverer: string) {
const { doc, title } = (this as any).discovery;
// Discoverer READS THE GLOBAL INDEX (materialize) and mounts a multi-graph
// subscription over the referenced docs — no cross-account fan-out.
const res = await this.appFrame!.evaluate(
async (d) => await (window as any).__testData.discoverPublicEventsAs(d),
discoverer,
);
(this as any).discovery.listed = res.listed;
// Wait for <FanoutProbe> to mount the useShape({graphs}) over the listed docs.
await this.appFrame!.waitForFunction(
() => (window as any).__fanout?.ready === true,
null,
{ timeout: 15000 },
);
// Publisher writes its public event into its own doc (now part of the fan-out).
await this.appFrame!.evaluate(
([d, t]: [string, string]) => (window as any).__fanout.addEventTo(d, t),
[doc, title] as [string, string],
);
});
Then('{string} voit l\'événement public {string}', async function (this: FestipodWorld, _discoverer: string, title: string) {
await this.appFrame!.waitForFunction(
(t) => ((window as any).__fanout.titles() as string[]).includes(t),
title,
{ timeout: 15000 },
);
const titles = await this.appFrame!.evaluate(() => (window as any).__fanout.titles());
expect(titles, `discoverer should see the publisher's public event "${title}"`).to.include(title);
});
Then('l\'index global liste le document de l\'événement', function (this: FestipodWorld) {
const { doc, listed } = (this as any).discovery;
expect(listed, 'the global index should list the publisher event doc').to.include(doc);
});
@@ -1,141 +0,0 @@
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import type { FestipodWorld } from '../../../../shared/support/world';
// T02.c @data proof: registration goes through the REAL FestipodDataContext
// mutations (appData via the harness `appJoinEvent`/`appLeaveEvent` bridge), so
// this faces the same inbox-deposit + host-notification + SPARQL-DELETE path as
// the running app. It proves: (1) join persists + deposits into the host inbox +
// mints a notification; (2) leave is DURABLE — the participation does not
// resurrect after a materialization re-read (the caveat_participation-deletion
// bug fixed via docs.sparqlUpdate DELETE-WHERE).
// --- Setup (app path) ---
// NOTE: app-path steps pass the LIVE current user id (resolved at call time via
// await td.ensureCurrentUser(), guaranteed non-empty once users hydrated), so the Participation
// carries a real principal (the ORM rejects an empty user IRI). Assertions read
// `liveIsParticipating` with the SAME live id, so join/leave and the checks agree.
Given('l\'utilisateur n\'est pas inscrit à l\'événement {string} via l\'app', async function (this: FestipodWorld, eventTitle: string) {
await this.appFrame!.evaluate(async (title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (event) await td.appLeaveEvent(event['@id'], await td.ensureCurrentUser());
}, eventTitle);
});
Given('l\'utilisateur est inscrit à l\'événement {string} via l\'app', async function (this: FestipodWorld, eventTitle: string) {
await this.appFrame!.evaluate(async (title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (event) await td.appJoinEvent(event['@id'], await td.ensureCurrentUser());
}, eventTitle);
});
// --- Actions (app path) ---
When('l\'utilisateur s\'inscrit à l\'événement {string} via l\'app', async function (this: FestipodWorld, eventTitle: string) {
await this.appFrame!.evaluate(async (title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (event) await td.appJoinEvent(event['@id'], await td.ensureCurrentUser());
}, eventTitle);
});
When('l\'utilisateur se désinscrit de l\'événement {string} via l\'app', async function (this: FestipodWorld, eventTitle: string) {
await this.appFrame!.evaluate(async (title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (event) await td.appLeaveEvent(event['@id'], await td.ensureCurrentUser());
}, eventTitle);
});
// --- Assertions ---
Then('l\'inbox de l\'événement {string} contient au moins un dépôt', async function (this: FestipodWorld, eventTitle: string) {
const count = await this.appFrame!.evaluate(async (title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (!event) return -1;
const deposits = await td.readInboxDeposits(event['@id']);
return deposits.length;
}, eventTitle);
expect(count, `host inbox of "${eventTitle}" should hold >= 1 deposit`).to.be.greaterThan(0);
});
Then('une notification {string} est créée pour l\'événement {string}', async function (this: FestipodWorld, type: string, eventTitle: string) {
const found = await this.appFrame!.evaluate(async ([type, title]: [string, string]) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (!event) return false;
// The notification surfaces either via the reactive state (immediate on join)
// or via the emulated curator reading the deposit back — accept either.
const reactive = (td.appNotifications() as any[]).some(
(n) => n.type === type && n.ref === event['@id'],
);
if (reactive) return true;
const deposits = await td.readInboxDeposits(event['@id']);
return deposits.some((d: any) => d?.payload?.kind === type);
}, [type, eventTitle] as [string, string]);
expect(found, `a "${type}" notification should exist for "${eventTitle}"`).to.be.true;
});
Then('l\'utilisateur devient participant de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
// The app-path join writes to the FestipodDataContext participation set, which
// converges with the harness's own useShape set via the shared store. Poll
// in-browser (waitForFunction) until it appears, to absorb that sync latency.
// Poll: the participation is written into its own protected doc and read back
// reactively; under a busy wallet that read can lag, so accept the AUTHORITATIVE
// broker count as well (the write is durable regardless of the reactive re-read).
// Wait for the REACTIVE participation to settle (observes AD().liveIsParticipating
// fed by the subscription push — NOT a broker re-read). Generous window: this
// reactive convergence IS the sync-passed signal.
await this.appFrame!.waitForFunction(
(title) => {
const td = (window as any).__testData;
if (!td.currentUserId) return false; // wait for the profile read to hydrate
const event = [...td.events].find((e: any) => e.title === title);
return !!event && td.liveIsParticipating(event['@id']);
},
eventTitle,
{ timeout: 45000 },
).catch(() => { /* fall through to a single authoritative confirmation */ });
// Then ONE authoritative broker read to prove the participation is durable.
const n = await this.appFrame!.evaluate(async (title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (!event) return 0;
const uid = await td.ensureCurrentUser();
return td.authParticipationCount(event['@id'], uid);
}, eventTitle);
expect(n, `participation to "${eventTitle}" must exist on the broker`).to.be.greaterThan(0);
});
Then('le broker ne contient plus aucune participation à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
// AUTHORITATIVE (not reactive): re-query the broker directly for the current
// user's participations to "eventTitle". A durable désinscription leaves 0.
const count = await this.appFrame!.evaluate(async (title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (!event) return -1;
return td.authParticipationCount(event['@id'], await td.ensureCurrentUser());
}, eventTitle);
expect(count, `broker must hold 0 participations to "${eventTitle}" after leave (authoritative re-query)`).to.equal(0);
});
Then('l\'utilisateur reste non-inscrit à l\'événement {string} après re-sync', async function (this: FestipodWorld, eventTitle: string) {
// Give the broker sync time to (attempt to) resurrect the item, then assert it
// stayed deleted. The SPARQL DELETE-WHERE fix means the participation must NOT
// come back (the @wip bug: it resurrected). We poll in-browser: if it EVER
// reappears within the window the waitForFunction below flips it back to
// participating and the final check fails.
await this.appFrame!.waitForTimeout(3000);
const stillGone = await this.appFrame!.evaluate((title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (!event) return false;
return !td.liveIsParticipating(event['@id']);
}, eventTitle);
expect(stillGone, `participation to "${eventTitle}" must NOT resurrect after re-sync`).to.be.true;
});
+51 -130
View File
@@ -8,7 +8,10 @@ import type { FestipodWorld } from '../../../../shared/support/world';
// --- Setup ---
Given('un événement {string} existe', async function (this: FestipodWorld, eventTitle: string) {
// Ensure wallet has data (seed if empty)
// Ensure wallet has data (seed if empty). AWAITED, and given a budget that
// matches what seeding costs once every entity is its own document: the seed
// opens five of them, and opening a document takes seconds. The previous 10 s
// was calibrated when seeding meant adding rows to an already-open graph.
await this.appFrame!.evaluate(async () => {
const td = (window as any).__testData;
if (td.events.size === 0) await td.loadTestData();
@@ -25,17 +28,11 @@ Given('un événement {string} existe', async function (this: FestipodWorld, eve
});
Given('l\'utilisateur n\'est pas inscrit à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
// Clean slate for the current user's participation. The app leave is
// authoritative (SPARQL DELETE on the participation's own doc) but reactive-gated
// (it only acts on a participation present in the reactive set); enough for the
// common case. Kept LIGHT (no full-wallet fan-out — that saturates the browser
// on a busy wallet).
await this.appFrame!.evaluate(
async (title) => {
(title) => {
const td = (window as any).__testData;
const uid = await td.ensureCurrentUser();
const event = [...td.events].find((e: any) => e.title === title);
if (event) await td.leaveEvent(event['@id'], uid);
if (event) td.leaveEvent(event['@id'], td.currentUserId);
},
eventTitle,
);
@@ -43,25 +40,34 @@ Given('l\'utilisateur n\'est pas inscrit à l\'événement {string}', async func
Given('l\'utilisateur est inscrit à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
await this.appFrame!.evaluate(
async (title) => {
(title) => {
const td = (window as any).__testData;
const uid = await td.ensureCurrentUser();
const event = [...td.events].find((e: any) => e.title === title);
if (event) await td.joinEvent(event['@id'], uid);
if (event) td.joinEvent(event['@id'], td.currentUserId);
},
eventTitle,
);
});
Given('l\'événement {string} a {int} participants au départ', async function (this: FestipodWorld, eventTitle: string, count: number) {
await this.appFrame!.evaluate(
([title, c]: [string, number]) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (event) td.updateEvent(event['@id'], { participantCount: c });
},
[eventTitle, count] as [string, number],
);
});
// --- Actions ---
When('l\'utilisateur s\'inscrit à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
await this.appFrame!.evaluate(
async (title) => {
(title) => {
const td = (window as any).__testData;
const uid = await td.ensureCurrentUser();
const event = [...td.events].find((e: any) => e.title === title);
if (event) await td.joinEvent(event['@id'], uid);
if (event) td.joinEvent(event['@id'], td.currentUserId);
},
eventTitle,
);
@@ -69,11 +75,10 @@ When('l\'utilisateur s\'inscrit à l\'événement {string}', async function (thi
When('l\'utilisateur se désinscrit de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
await this.appFrame!.evaluate(
async (title) => {
(title) => {
const td = (window as any).__testData;
const uid = await td.ensureCurrentUser();
const event = [...td.events].find((e: any) => e.title === title);
if (event) await td.leaveEvent(event['@id'], uid);
if (event) td.leaveEvent(event['@id'], td.currentUserId);
},
eventTitle,
);
@@ -81,11 +86,10 @@ When('l\'utilisateur se désinscrit de l\'événement {string}', async function
When('l\'utilisateur essaie de s\'inscrire une seconde fois à l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
await this.appFrame!.evaluate(
async (title) => {
(title) => {
const td = (window as any).__testData;
const uid = await td.ensureCurrentUser();
const event = [...td.events].find((e: any) => e.title === title);
if (event) await td.joinEvent(event['@id'], uid);
if (event) td.joinEvent(event['@id'], td.currentUserId);
},
eventTitle,
);
@@ -94,117 +98,54 @@ When('l\'utilisateur essaie de s\'inscrire une seconde fois à l\'événement {s
// --- Assertions ---
Then('l\'utilisateur est participant de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
// The participation is written into its own protected document and read back
// reactively — poll (the read lags the write against the broker), resolving the
// current user id at call time.
// Wait for the REACTIVE participation to settle (observes AD().isParticipating fed
// by the subscription push — NOT a broker re-read). Generous window: this reactive
// convergence IS the sync-passed signal.
await this.appFrame!.waitForFunction(
const participating = await this.appFrame!.evaluate(
(title) => {
const td = (window as any).__testData;
const uid = td.currentUserId;
if (!uid) return false;
const event = [...td.events].find((e: any) => e.title === title);
return !!event && td.isParticipating(event['@id'], uid);
if (!event) return false;
return td.isParticipating(event['@id'], td.currentUserId);
},
eventTitle,
{ timeout: 45000 },
).catch(() => { /* fall through to a single authoritative confirmation */ });
// Then ONE authoritative broker read (bypasses the reactive set) to prove the
// participation is durable at the data level.
const n = await this.appFrame!.evaluate(async (title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (!event) return 0;
const uid = await td.ensureCurrentUser();
return td.authParticipationCount(event['@id'], uid);
}, eventTitle);
expect(n, `User should be participating in "${eventTitle}"`).to.be.greaterThan(0);
);
expect(participating, `User should be participating in "${eventTitle}"`).to.be.true;
});
Then('l\'utilisateur n\'est plus participant de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
// AUTHORITATIVE: the désinscription must be durable at the DATA level — the
// broker itself must hold 0 participations for (event, user). The reactive read
// can lag or briefly resurrect; the broker count is the source of truth. Poll it
// to 0 (bounded).
await this.appFrame!.waitForFunction(
const participating = await this.appFrame!.evaluate(
(title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
return !!event && !td.isParticipating(event['@id'], td.currentUserId);
if (!event) return false;
return td.isParticipating(event['@id'], td.currentUserId);
},
eventTitle,
{ timeout: 20000 },
).catch(() => { /* fall through to the authoritative broker check */ });
const n = await this.appFrame!.evaluate(async (title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (!event) return -1;
return td.authParticipationCount(event['@id'], await td.ensureCurrentUser());
}, eventTitle);
expect(n, `broker must hold 0 participations to "${eventTitle}" after leave`).to.equal(0);
);
expect(participating, `User should NOT be participating in "${eventTitle}"`).to.be.false;
});
Then('le compteur dérivé de l\'événement {string} reflète l\'inscription', async function (this: FestipodWorld, eventTitle: string) {
// OPTION B — the count is DERIVED by the OWNER materializing the event's inbox
// (see data-layer/knowledge_context-internals §participantCount). The event-id is
// matched on its CANONICAL form (base repo id, stripping any `:v:<overlay>`) across
// ownedEventIds / the deposit key / the materializer filter, so an owner
// materializes deposits for an event it owns regardless of the id-form path (create
// OR listMyEntityDocs OR after reload) — the fix that makes this converge.
//
// ASSERT THE DELTA, not an absolute count: the shared inbox anchor accumulates
// deposits across the wallet's life, so |active| is not bounded to this scenario —
// but "the just-joined user IS in the owner's derived active set" is deterministic.
// (The absolute 1→2 convergence stays proven end-to-end by the @multibrowser
// reactive scenario with a real owner A + joiner B.) Poll (the deposit's index
// append + broker sync lag), bounded.
// First wait for the REACTIVE join to settle (observes AD().isParticipating fed by
// the subscription push — the sign the deposit's write path has run — NOT a broker
// re-read). THEN materialize the owner's derived active set from the broker ONCE.
await this.appFrame!.waitForFunction(
Then('l\'événement {string} compte {int} participants', async function (this: FestipodWorld, eventTitle: string, expectedCount: number) {
const count = await this.appFrame!.evaluate(
(title) => {
const td = (window as any).__testData;
const uid = td.currentUserId;
if (!uid) return false;
const ev = [...td.events].find((e: any) => e.title === title);
return !!ev && td.isParticipating(ev['@id'], uid);
const event = [...td.events].find((e: any) => e.title === title);
return event?.participantCount ?? -1;
},
eventTitle,
{ timeout: 45000 },
).catch(() => { /* fall through to the single authoritative materialization */ });
const inActive = await this.appFrame!.evaluate(async (title) => {
const td = (window as any).__testData;
const ev = [...td.events].find((e: any) => e.title === title);
const uid = await td.ensureCurrentUser();
if (!ev || !uid) return false;
const users: (string | null)[] = await td.activeRegistrationUsers(ev['@id']);
return users.includes(uid);
}, eventTitle);
expect(inActive, `the just-joined user must be in the owner-derived active set for "${eventTitle}"`).to.be.true;
);
expect(count, `Event "${eventTitle}" participant count`).to.equal(expectedCount);
});
Then('l\'utilisateur apparaît dans la liste des participants de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
await this.appFrame!.waitForFunction(
const found = await this.appFrame!.evaluate(
(title) => {
const td = (window as any).__testData;
const uid = td.currentUserId;
if (!uid) return false;
const event = [...td.events].find((e: any) => e.title === title);
return !!event && td.getEventParticipants(event['@id']).some((p: any) => p.user === uid);
if (!event) return false;
return td.getEventParticipants(event['@id']).some((p: any) => p.user === td.currentUserId);
},
eventTitle,
{ timeout: 30000 },
).catch(async () => {
const n = await this.appFrame!.evaluate(async (title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (!event) return 0;
return td.authParticipationCount(event['@id'], await td.ensureCurrentUser());
}, eventTitle);
expect(n, `User should appear in participants of "${eventTitle}"`).to.be.greaterThan(0);
});
);
expect(found, `User should appear in participants of "${eventTitle}"`).to.be.true;
});
Then('l\'utilisateur n\'apparaît plus dans la liste des participants de l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
@@ -221,34 +162,14 @@ Then('l\'utilisateur n\'apparaît plus dans la liste des participants de l\'év
});
Then('l\'inscription est idempotente pour l\'événement {string}', async function (this: FestipodWorld, eventTitle: string) {
// Idempotence at the DATA level: exactly ONE participation on the broker for
// (event, user), no matter how many times the join was attempted. Assert the
// AUTHORITATIVE broker count == 1 (bypasses reactive-read lag/dupes). POLL the
// authoritative count: the participation is written into its own protected doc
// and its index-append propagates async, so a single unpolled read can catch 0
// before the write is queryable (observed flake) — poll until the durable state
// (exactly 1) is visible, which also proves the second join did NOT add a dupe.
// First wait for the REACTIVE participation to settle (observes AD().isParticipating
// fed by the subscription push — the sync-passed signal — NOT a broker re-read).
// THEN read the AUTHORITATIVE broker count ONCE: exactly 1 proves the second join
// added no dupe (bypasses reactive-read lag).
await this.appFrame!.waitForFunction(
const count = await this.appFrame!.evaluate(
(title) => {
const td = (window as any).__testData;
const uid = td.currentUserId;
if (!uid) return false;
const event = [...td.events].find((e: any) => e.title === title);
return !!event && td.isParticipating(event['@id'], uid);
if (!event) return 0;
return td.getEventParticipants(event['@id']).filter((p: any) => p.user === td.currentUserId).length;
},
eventTitle,
{ timeout: 45000 },
).catch(() => { /* fall through to the single authoritative count */ });
const n = await this.appFrame!.evaluate(async (title) => {
const td = (window as any).__testData;
const event = [...td.events].find((e: any) => e.title === title);
if (!event) return -1;
const uid = await td.ensureCurrentUser();
return td.authParticipationCount(event['@id'], uid);
}, eventTitle);
expect(n, 'User should have exactly one participation record on the broker').to.equal(1);
);
expect(count, 'User should have exactly one participation record').to.equal(1);
});
@@ -1,91 +0,0 @@
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);
return { eventId: created.id, aId };
}, title);
// OBSERVE the REACTIVE set until A's own join has settled (isParticipating reads
// AD() fed by the subscription push — NOT a broker re-read). This is the barrier
// that A's participation exists before B arrives; no broker polling.
const aParticipates = await this.appFrame!.waitForFunction(
({ eventId, aId }: { eventId: string; aId: string }) =>
(window as any).__testData.isParticipating(eventId, aId),
{ eventId: out.eventId, aId: out.aId },
{ timeout: 45000 },
).then(() => true).catch(() => false);
expect(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.identifier', 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);
});
@@ -1,116 +0,0 @@
import { When, Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import type { FestipodWorld } from '../../../../shared/support/world';
import { pool } from '../../../../shared/support/browserPool';
// RECONNECTION of the SAME identity on the SAME persistent wallet (@data, real
// broker). Distinct from the two-identity isolation scenario: here the fresh page
// re-enters under the SAME identifier A (same virtual wallet), on a NEW broker
// login (fresh verifier session). The defect under test: the fresh page reads its
// OWN data EMPTY (home=[], isParticipating=false, authCount=0) because the anchored
// listing path (listMyEntityDocs → readScopeIndex, then readUnion/readDoc) queries
// repos not yet in `self.repos` at cold-start and silently returns 0 rows.
//
// Identity A is the scenario's fresh virtual-wallet username (this.freshUser, set
// by the Before hook into localStorage on every origin). A creates E via the REAL
// app path (createEventReal) and joins it (appJoinEvent) on the MAIN page. Then a
// FRESH PAGE is brought up on the SAME persistent wallet context with the SAME
// identifier A in localStorage BEFORE any script — the closest analogue to the real
// app's re-enter-gate / reload path (a brand-new NgDataProvider mount + a fresh
// broker session that must re-open A's own repos). Montage identical to
// isolation.steps.ts, except the fresh page reuses this.freshUser (SAME A) rather
// than minting a new identifier B.
// The Given "l'identité A crée l'événement {string} et s'y inscrit" is REUSED from
// isolation.steps.ts (same wording, same behavior — A creates E and joins on the
// main page). It stores this.isoEventId / this.isoAId, which the steps below read.
When('une page fraîche pour la MÊME identité A recharge sur le même wallet', { timeout: 120000 }, async function (this: FestipodWorld) {
// SAME identity A as the main page: reuse the scenario's fresh virtual-wallet
// username (set by the Before hook). NOT a new identifier — this is a reconnect,
// not an identity switch.
const aIdentifier = (this as any).freshUser as string;
const ctx = this.page!.context();
const freshPage = await ctx.newPage();
await freshPage.addInitScript((u: string) => {
try { window.localStorage.setItem('festipod.account.identifier', u); } catch { /* opaque */ }
}, aIdentifier);
await freshPage.addInitScript(() => {
(globalThis as Record<string, unknown>).__FESTIPOD_ACCESS_GATE_DISABLED__ = true;
});
freshPage.on('console', (msg) => { if (msg.type() === 'error') console.error('[FreshApage console]', msg.text()); });
// New broker login → fresh verifier session on the SAME persistent wallet.
const freshFrame = await pool.setupBrokerPage!(freshPage, pool.harnessUrl!);
await freshFrame.waitForFunction(() => (window as any).__testData?.ready === true, { timeout: 60000 });
// Let A's listing effect + anchored union read run on the fresh session (this is
// exactly the cold-start read path the fix heals).
await freshFrame.evaluate(async () => {
const td = (window as any).__testData;
await td.ensureCurrentUser();
await new Promise(r => setTimeout(r, 6000));
});
(this as any).recoFreshFrame = freshFrame;
});
// The cold-start read is a BOUNDED SYNC-LAG (measured: the just-created public
// doc lists on the first cold attempt, home titles converge at ~1s), and the real
// UI is reactive — so these assertions POLL for convergence (up to ~15s) instead
// of reading once. A single-shot read races the first-open/sync window and would
// flake. This mirrors how the app's own steps already poll participation.
Then('l\'événement {string} est sur l\'accueil de la page fraîche A', { timeout: 60000 }, async function (this: FestipodWorld, title: string) {
const freshFrame = (this as any).recoFreshFrame;
// OBSERVE the REACTIVE home set (homeEventTitles reads AD().getUserEvents fed by
// the subscription push — NOT a broker re-read). waitForFunction re-evaluates the
// reactive getter until the cold-start union read converges; no broker polling.
const onHome = await freshFrame.waitForFunction(
(title: string) => (window as any).__testData.homeEventTitles().includes(title),
title,
{ timeout: 30000 },
).then(() => true).catch(() => false);
expect(onHome, `"${title}" MUST appear on the fresh A page's home (getUserEvents(A) after reconnect)`).to.be.true;
});
Then('la page fraîche A est participante de l\'événement {string}', { timeout: 60000 }, async function (this: FestipodWorld, title: string) {
const freshFrame = (this as any).recoFreshFrame;
const eventId = (this as any).isoEventId;
await freshFrame.evaluate(async () => { await (window as any).__testData.ensureCurrentUser(); });
// OBSERVE the REACTIVE participation set (isParticipating reads AD() fed by the
// subscription push — NOT a broker re-read). waitForFunction re-evaluates the
// reactive getter until the cold-start union read converges; no broker polling.
const isPart = await freshFrame.waitForFunction(
(eventId: string) => {
const td = (window as any).__testData;
const aId = td.currentUserId;
return !!aId && td.isParticipating(eventId, aId);
},
eventId,
{ timeout: 30000 },
).then(() => true).catch(() => false);
expect(isPart, `The fresh A page MUST read its own participation in "${title}" after reconnect`).to.be.true;
});
Then('le compte autoritatif de participation de A à l\'événement {string} est {int}', { timeout: 60000 }, async function (this: FestipodWorld, _title: string, expected: number) {
const freshFrame = (this as any).recoFreshFrame;
const eventId = (this as any).isoEventId;
await freshFrame.evaluate(async () => { await (window as any).__testData.ensureCurrentUser(); });
// First wait for the REACTIVE state to reflect the expectation (the sign the
// cold-start sync has passed — observes AD().isParticipating fed by the push, NOT
// a broker re-read). THEN do ONE authoritative broker read.
await freshFrame.waitForFunction(
({ eventId, expected }: { eventId: string; expected: number }) => {
const td = (window as any).__testData;
const aId = td.currentUserId;
if (!aId) return false;
const isPart = td.isParticipating(eventId, aId);
return expected > 0 ? isPart : !isPart;
},
{ eventId, expected },
{ timeout: 30000 },
).catch(() => { /* fall through to the single authoritative read */ });
const count = await freshFrame.evaluate(async ({ eventId }: { eventId: string }) => {
const td = (window as any).__testData;
const aId = await td.ensureCurrentUser();
return td.authParticipationCount(eventId, aId);
}, { eventId });
expect(count, `Authoritative broker count of A's participation must be ${expected} after reconnect`).to.equal(expected);
});
@@ -1,365 +0,0 @@
import { When, Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import type { FestipodWorld } from '../../../../shared/support/world';
// T02.f — multi-browser validation of the demonstrable T02 features, driven
// across TWO isolated browser contexts (independent NextGraph sessions/verifiers)
// both carrying the shared wallet, each loaded into the real NG harness through
// the broker. Steps that open the browsers / connect to NG are reused from
// workshop/steps/data/multibrowser.steps.ts ("un navigateur … avec le wallet
// partagé", "… charge l'application via le broker", "… est connecté à NextGraph").
//
// SCOPE: real crypto isolation (ReadCap/write-guard + distinct wallets) is
// DEFERRED — both browsers share ONE NG identity. So these scenarios do NOT prove
// crypto isolation; they prove that the demonstrable behaviours ROUND-TRIP through
// the broker from one browser to the other (not just inside one page's reactive
// set). The "second account" that registers is modelled by a DISTINCT participant
// user id passed to the real app join path (a data-level second party — the
// faithful thing available without wallet isolation).
// A stable, distinct "second participant" id for browser B, so its registration
// is a REAL, non-duplicate join (the shared-wallet host identity already holds a
// self-participation from createEvent). Kept in world state per scenario.
function secondParticipant(world: FestipodWorld): string {
const w = world as any;
if (!w.__mbParticipantB) w.__mbParticipantB = `urn:festipod:mb-participant:${Date.now()}`;
return w.__mbParticipantB;
}
// --- Inscription + notification (T02.c) ---
When('le navigateur {string} crée l\'événement {string}', async function (this: FestipodWorld, name: string, title: string) {
const frame = this.browser(name).appFrame!;
const res = await frame.evaluate(
async (t) => await (window as any).__testData.createEventReal(t),
title,
);
expect(res.id, `event "${title}" created in browser ${name} must have an id`).to.be.a('string').and.not.equal('');
(this as any).mbEvents = { ...((this as any).mbEvents || {}), [title]: res.id };
});
When('le navigateur {string} s\'inscrit à l\'événement {string}', async function (this: FestipodWorld, name: string, title: string) {
const frame = this.browser(name).appFrame!;
const uid = secondParticipant(this);
// Resolve the event id from the reactive set (the event created in browser A
// has synced into browser B's independent session), then register via the REAL
// app path (appJoinEvent → inbox deposit + notification + SPARQL persistence).
await frame.waitForFunction(
(t) => [...(window as any).__testData.events].some((e: any) => e.title === t),
title,
{ timeout: 30000 },
);
await frame.evaluate(
async ([t, u]: [string, string]) => {
const td = (window as any).__testData;
const ev = [...td.events].find((e: any) => e.title === t);
await td.appJoinEvent(ev['@id'], u);
},
[title, uid] as [string, string],
);
});
Then('le navigateur {string} devient participant de l\'événement {string}', async function (this: FestipodWorld, name: string, title: string) {
const frame = this.browser(name).appFrame!;
const uid = secondParticipant(this);
await frame.waitForFunction(
([t, u]: [string, string]) => {
const td = (window as any).__testData;
const ev = [...td.events].find((e: any) => e.title === t);
return !!ev && td.isParticipating(ev['@id'], u);
},
[title, uid] as [string, string],
{ timeout: 20000 },
);
});
Then('l\'inbox de l\'événement {string} reçoit un dépôt {string}', async function (this: FestipodWorld, title: string, kind: string) {
// Host-facing: read the event's inbox deposits back (the emulated curator) and
// assert the registration deposit landed. Read from browser A (the host).
const frame = this.browser('A').appFrame!;
const found = await frame.waitForFunction(
async ([t, k]: [string, string]) => {
const td = (window as any).__testData;
const ev = [...td.events].find((e: any) => e.title === t);
if (!ev) return false;
const deposits = await td.readInboxDeposits(ev['@id']);
return deposits.some((d: any) => d?.payload?.kind === k);
},
[title, kind] as [string, string],
{ timeout: 20000 },
).then(() => true).catch(() => false);
expect(found, `host inbox of "${title}" should receive a "${kind}" deposit`).to.be.true;
});
// --- Désinscription persistante (T02.c) ---
When('le navigateur {string} se désinscrit de l\'événement {string}', async function (this: FestipodWorld, name: string, title: string) {
const frame = this.browser(name).appFrame!;
const uid = secondParticipant(this);
// `leaveEvent` finds the participation in the reactive set before it deposits the
// "leave" marker (an absent participation → early return → no deposit → the owner
// never sees the leave). In the same-session leave (désinscription-persistante)
// the optimistic overlay makes it present instantly; in the reconnect flow (owner-
// convergence) B reloaded, so wait for its participation to sync back into the set
// first. Event-driven wait on the reactive state — not a broker re-query loop.
await frame.waitForFunction(
([t, u]: [string, string]) => {
const td = (window as any).__testData;
const ev = [...td.events].find((e: any) => e.title === t);
return !!ev && td.isParticipating(ev['@id'], u);
},
[title, uid] as [string, string],
{ timeout: 30000 },
).catch(() => { /* proceed; leaveEvent will no-op if truly absent */ });
await frame.evaluate(
async ([t, u]: [string, string]) => {
const td = (window as any).__testData;
const ev = [...td.events].find((e: any) => e.title === t);
await td.appLeaveEvent(ev['@id'], u);
},
[title, uid] as [string, string],
);
});
Then('le broker ne contient plus aucune participation à l\'événement {string} pour le navigateur {string}', async function (this: FestipodWorld, title: string, name: string) {
const frame = this.browser(name).appFrame!;
const uid = secondParticipant(this);
// AUTHORITATIVE re-query (not the reactive set): the broker itself must report 0
// for (event, secondParticipant). A durable désinscription leaves 0.
const count = await frame.evaluate(
async ([t, u]: [string, string]) => {
const td = (window as any).__testData;
const ev = [...td.events].find((e: any) => e.title === t);
if (!ev) return -1;
return await td.authParticipationCount(ev['@id'], u);
},
[title, uid] as [string, string],
);
expect(count, `broker must hold 0 participations to "${title}" after leave (authoritative re-query)`).to.equal(0);
});
Then('l\'inscription de l\'événement {string} ne ressuscite pas dans le navigateur {string} après re-sync', async function (this: FestipodWorld, title: string, name: string) {
const frame = this.browser(name).appFrame!;
const uid = secondParticipant(this);
// Give the broker sync time to (attempt to) resurrect the item, then assert it
// stayed deleted — the SPARQL DELETE-WHERE fix means it must NOT come back.
await frame.waitForTimeout(3000);
const count = await frame.evaluate(
async ([t, u]: [string, string]) => {
const td = (window as any).__testData;
const ev = [...td.events].find((e: any) => e.title === t);
if (!ev) return -1;
return await td.authParticipationCount(ev['@id'], u);
},
[title, uid] as [string, string],
);
expect(count, `participation to "${title}" must NOT resurrect after re-sync`).to.equal(0);
});
// --- Lecture réactive cross-session (P3, brief §D.2) ---
// A crée l'événement, en ouvre le détail (le contexte sélectionne l'event), et
// observe son état RÉACTIF passer de 1 à 2 quand B s'inscrit — SANS recharger.
// L'assertion attend un ÉVÉNEMENT (waitForFunction sur l'état réactif poussé par
// doc_subscribe), pas un timeout fixe : le timeout de garde ne fait que borner
// l'attente, il n'est pas la SOURCE de la mise à jour.
When('le navigateur {string} ouvre le détail de l\'événement {string}', async function (this: FestipodWorld, name: string, title: string) {
const frame = this.browser(name).appFrame!;
// Wait for the event to be in this session's reactive set, then select it (what
// EventDetailScreen navigation does). Reads stay reactive via the subscription.
await frame.waitForFunction(
(t) => [...(window as any).__testData.events].some((e: any) => e.title === t),
title,
{ timeout: 30000 },
);
await frame.evaluate(
(t) => {
const td = (window as any).__testData;
const ev = [...td.events].find((e: any) => e.title === t);
if (ev) td.appData.setSelectedEventId(ev['@id']);
},
title,
);
});
// --- Compteur fiable à la connexion (Option B, recadré 2026-07-13) ---
// The requirement is NOT a live cross-session push: the count is stored in the
// event, only the owner writes it, the registrant DEPOSITS into the event inbox,
// and the owner processes those deposits at its NEXT CONNECTION. So this step
// models exactly that: reload the browser's harness (a fresh verifier that PULLS
// the broker state — including the registrant's synced deposit), wait until it is
// ready and has re-materialized (the owner-materializer runs at connection with a
// BARRIER-GATED inbox read — `inbox.readSynced` — so the synced deposit is seen),
// then re-open the event detail. NO broker-polling loop (rule_no-broker-polling):
// the reconnection is the trigger; the subsequent assertion waits on the reactive
// state settling.
When('le navigateur {string} se reconnecte et matérialise l\'inbox', async function (this: FestipodWorld, name: string) {
const events = (this as any).mbEvents || {};
// Reload the harness → fresh verifier re-loads the wallet from the broker,
// pulling the other browser's synced writes (the deposit, or the owner's count).
await this.loadAppInBrowser(name, 'harness');
const frame = this.browser(name).appFrame!;
await frame.waitForFunction(
() => (window as any).__testData?.ready === true,
null,
{ timeout: 30000 },
);
// Re-select every known event so the owner-materializer (keyed on ownedKey,
// resolved from listMyEntityDocs on connect) has run its barrier-gated pass and
// the detail view is back on the event. Best-effort per title.
for (const title of Object.keys(events)) {
await frame.waitForFunction(
(t) => [...(window as any).__testData.events].some((e: any) => e.title === t),
title,
{ timeout: 30000 },
).catch(() => { /* the event may not be in this browser's set yet */ });
await frame.evaluate(
(t) => {
const td = (window as any).__testData;
const ev = [...td.events].find((e: any) => e.title === t);
if (ev) td.appData.setSelectedEventId(ev['@id']);
},
title,
);
}
});
Then('le compteur de participants réactif dans {string} pour {string} vaut {int}', async function (this: FestipodWorld, name: string, title: string, expected: number) {
const frame = this.browser(name).appFrame!;
// Wait (event-driven) for the reactive participantCount to reach `expected` — the
// guard timeout only bounds the wait; the value arrives via the union re-read the
// subscription triggers, never via the timeout itself.
await frame.waitForFunction(
([t, n]: [string, number]) => {
const td = (window as any).__testData;
const ev = [...td.events].find((e: any) => e.title === t);
if (!ev) return false;
const st = td.reactiveEventState(ev['@id']);
return st.found && st.participantCount === n;
},
[title, expected] as [string, number],
// The owner materializes ALL its owned events at connection (each with a
// barrier-gated inbox read) before its own write pushes back into the reactive
// set — so allow a generous window. This is an event-driven wait on the reactive
// state settling (NOT a broker re-query loop, rule_no-broker-polling).
{ timeout: 45000 },
);
const count = await frame.evaluate(
(t) => {
const td = (window as any).__testData;
const ev = [...td.events].find((e: any) => e.title === t);
return ev ? td.reactiveEventState(ev['@id']).participantCount : -1;
},
title,
);
expect(count, `reactive participantCount for "${title}" in browser ${name}`).to.equal(expected);
});
Then('sans recharger, le compteur de participants réactif dans {string} pour {string} passe à {int}', async function (this: FestipodWorld, name: string, title: string, expected: number) {
const frame = this.browser(name).appFrame!;
// NO reload / no local action on A between B's join and this assertion — the
// update MUST arrive through A's `doc_subscribe` on the (public) event doc that B
// wrote. Event-driven wait: waitForFunction polls A's already-live reactive state
// (no loadAppInBrowser here), succeeding only once the subscription push re-read.
const reached = await frame.waitForFunction(
([t, n]: [string, number]) => {
const td = (window as any).__testData;
const ev = [...td.events].find((e: any) => e.title === t);
if (!ev) return false;
const st = td.reactiveEventState(ev['@id']);
return st.found && st.participantCount === n;
},
[title, expected] as [string, number],
{ timeout: 30000 },
).then(() => true).catch(() => false);
expect(reached, `reactive participantCount for "${title}" in browser ${name} must reach ${expected} WITHOUT reload (via doc_subscribe)`).to.be.true;
});
Then('le navigateur {string} affiche un participant {string} pour {string}', async function (this: FestipodWorld, name: string, _kind: string, title: string) {
const frame = this.browser(name).appFrame!;
// B is NOT a connection of A, so its participation doc is unreadable to A → it
// never appears as a NAMED participant; it falls into the "unknown" placeholder
// count (participantCount − knownCount ≥ 1), exactly EventDetailScreen's
// "Voir tous les participants" path. Assert reactively (event-driven).
const unknown = await frame.waitForFunction(
(t) => {
const td = (window as any).__testData;
const ev = [...td.events].find((e: any) => e.title === t);
if (!ev) return false;
const st = td.reactiveEventState(ev['@id']);
return st.found && st.unknownCount >= 1 ? st.unknownCount : false;
},
title,
{ timeout: 20000 },
).then(h => h.jsonValue()).catch(() => 0);
expect(Number(unknown), `browser ${name} must show ≥1 "unknown" participant for "${title}"`).to.be.at.least(1);
});
// --- Découverte publique cross-comptes (T02.e) ---
When('le compte {string} publie un événement public {string} dans le navigateur {string}', async function (this: FestipodWorld, publisher: string, title: string, name: string) {
const frame = this.browser(name).appFrame!;
const res = await frame.evaluate(
async (p) => await (window as any).__testData.publishPublicEventAs(p, ''),
publisher,
);
expect(res.doc, `publisher public event doc for ${publisher}`).to.be.a('string');
(this as any).mbDiscovery = { publisher, title, doc: res.doc };
});
When('le compte {string} découvre les événements publics dans le navigateur {string} sans être connecté à {string}', async function (this: FestipodWorld, discoverer: string, name: string, _publisher: string) {
const { doc, title } = (this as any).mbDiscovery;
// The publisher doc + its account were created in the OTHER browser's verifier.
// For THIS browser's independent verifier to discover them, it must PULL those
// wallet writes from the broker. Two independent NextGraph verifiers on the same
// shared-wallet document converge eventually, but a `sparqlQuery` reads the LOCAL
// verifier copy — it won't pull on its own. Re-bootstrapping this browser's
// harness (fresh page → fresh verifier load from the broker) forces the pull.
// So: poll discovery, and every few attempts reload this browser's harness to
// re-sync from the broker, until the publisher doc surfaces.
let listed: string[] = [];
let frame = this.browser(name).appFrame!;
for (let attempt = 0; attempt < 12 && !listed.includes(doc); attempt++) {
if (attempt > 0 && attempt % 3 === 0) {
// Reload the harness → the verifier re-loads the wallet from the broker,
// pulling the publisher's shim account + index writes.
await this.loadAppInBrowser(name, 'harness');
frame = this.browser(name).appFrame!;
await frame.waitForFunction(
() => (window as any).__testData?.ready === true,
null,
{ timeout: 30000 },
);
}
const res = await frame.evaluate(
async (d) => await (window as any).__testData.discoverPublicEventsAs(d),
discoverer,
);
listed = res.listed;
if (!listed.includes(doc)) await frame.waitForTimeout(1500);
}
(this as any).mbDiscovery.listed = listed;
expect(listed, 'cross-account public index should list the publisher doc (after cross-verifier re-sync)').to.include(doc);
// <FanoutProbe> mounts a useShape({graphs}) over the discovered docs; write the
// publisher's public event into its own doc (now part of the discoverer fan-out).
await frame.waitForFunction(
() => (window as any).__fanout?.ready === true,
null,
{ timeout: 15000 },
);
await frame.evaluate(
([d, t]: [string, string]) => (window as any).__fanout.addEventTo(d, t),
[doc, title] as [string, string],
);
});
Then('le navigateur {string} voit l\'événement public {string}', async function (this: FestipodWorld, name: string, title: string) {
const frame = this.browser(name).appFrame!;
await frame.waitForFunction(
(t) => ((window as any).__fanout.titles() as string[]).includes(t),
title,
{ timeout: 15000 },
);
const titles = await frame.evaluate(() => (window as any).__fanout.titles());
expect(titles, `browser ${name} should see the public event "${title}"`).to.include(title);
});
@@ -1,31 +0,0 @@
# language: fr
#
# SMOKE « crash de rendu connecté / page blanche ».
#
# Régression gardée : après connexion au broker, seul le bandeau de l'iframe
# s'affichait et le vrai App ne rendait rien (page blanche). Aucun test
# n'attrapait cette CLASSE de bug — un crash de rendu qui ne survient QUE une
# fois l'app connectée et montée sur des données réelles.
#
# Ce smoke boote le VRAI App (serveur src/index.ts, pas le harness __testData),
# se connecte au broker (fourni par le hook Before @e2e), navigue vers l'accueil
# connecté (HomeScreen, qui consomme useFestipodData) et vérifie DEUX choses :
# 1. l'accueil rend un élément d'app significatif (barre de navigation + bouton
# « Relayer »), preuve que HomeScreen a monté — pas juste un spinner ou le
# bandeau du broker ;
# 2. AUCUNE erreur runtime (pageerror / console.error) n'a été émise pendant
# le boot connecté — un throw dans un composant/provider monté après
# connexion vire le smoke au rouge.
#
# Tout futur crash de rendu connecté = ce smoke passe au rouge.
@e2e @smoke
Fonctionnalité: L'accueil connecté rend du contenu réel
En tant qu'utilisateur qui vient de se connecter
Je veux que l'écran d'accueil rende réellement l'application
Afin de ne jamais retomber sur une page blanche une fois connecté
Scénario: L'accueil rend du contenu réel sans erreur après connexion
Quand l'application connectée affiche l'accueil
Alors l'accueil rend un contenu d'application réel
Et aucune erreur runtime n'a été émise pendant le boot connecté
+2 -7
View File
@@ -1,7 +1,6 @@
import { useState } from 'react';
import { Title, Card, AvatarStack, BottomNav, EventCover, EventMeetingPoints, Spinner, type MeetingPointData } from '../../../shared/components/sketchy';
import { Title, Card, AvatarStack, BottomNav, EventCover, EventMeetingPoints, type MeetingPointData } from '../../../shared/components/sketchy';
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
import { usePendingQueries } from '../../../shared/data/pendingQueries';
import { useNavigate } from '../../../app/router';
const PEOPLE = [
@@ -66,7 +65,6 @@ function EventCardBody({
export function HomeScreen() {
const navigate = useNavigate();
const { getUserEvents, currentUserId, getEventMeetingPoints } = useFestipodData();
const pendingQueries = usePendingQueries();
const [joinedIds, setJoinedIds] = useState<Set<string>>(new Set());
const myEvents = getUserEvents(currentUserId);
@@ -96,10 +94,7 @@ export function HomeScreen() {
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div style={{ flex: 1, overflow: 'auto' }}>
<div style={{ padding: '12px 16px 8px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<Title style={{ margin: 0 }}>Festipod</Title>
{pendingQueries > 0 && <Spinner />}
</div>
<Title style={{ margin: 0 }}>Festipod</Title>
<button
onClick={() => navigate('/events/new')}
aria-label="Relayer un événement"
+2 -3
View File
@@ -12,9 +12,8 @@ export function SettingsScreen() {
const [darkMode, setDarkMode] = useState(false);
const [location, setLocation] = useState(true);
// Faux logout: clears the current identifier only — the shared wallet stays
// open underneath. In staging this returns to the access barrier (identifier
// prompt), since the gate shows until an identifier is set again.
// Faux logout: clears the perceived login (username) only — the shared
// wallet stays open underneath. In staging this returns to ConnexionScreen.
const handleLogout = () => {
logout();
navigate('/');
@@ -1,75 +0,0 @@
import { When, Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import type { FestipodWorld } from '../../../../shared/support/world';
// --- Smoke « crash de rendu connecté / page blanche » ---
//
// Le hook Before @e2e a déjà : booté le VRAI App via le broker (this.appFrame),
// attendu que #root ait du contenu, et laissé 3s aux providers pour se
// stabiliser. On ne re-boote donc PAS l'app ici — on réutilise this.appFrame.
//
// Ces steps prouvent que l'App a réellement MONTÉ l'accueil connecté (HomeScreen,
// qui consomme useFestipodData), pas juste un spinner ou le bandeau du broker.
When("l'application connectée affiche l'accueil", async function (this: FestipodWorld) {
// Un utilisateur connecté qui atterrit sur '/' est redirigé vers '/home' par
// WelcomeScreen. On navigue explicitement pour rendre le smoke déterministe
// quel que soit l'état de la redirection au moment du boot.
await this.appFrame!.evaluate(() => {
window.history.pushState(null, '', '/home');
window.dispatchEvent(new PopStateEvent('popstate'));
});
// Laisse le routeur et HomeScreen (re)monter.
await this.appFrame!.waitForTimeout(1000);
});
Then("l'accueil rend un contenu d'application réel", async function (this: FestipodWorld) {
// Marqueurs FORTS et propres à HomeScreen (absents de WelcomeScreen / d'un
// simple spinner / du bandeau broker) :
// - .app-navbar : la barre de navigation basse (BottomNav) — rendue par
// HomeScreen, pas par l'écran d'onboarding ;
// - le bouton « Relayer » (aria-label="Relayer un événement") propre à
// l'en-tête de l'accueil.
// Si un throw dans HomeScreen (ou un provider monté après connexion) blanchit
// le rendu, React démonte l'arbre (aucun ErrorBoundary) et ces marqueurs
// disparaissent → l'attente échoue.
const rendered = await this.appFrame!.waitForFunction(
() => {
const root = document.getElementById('root');
if (!root) return false;
const hasNavbar = document.querySelector('.app-navbar') !== null;
const hasRelayer =
document.querySelector('[aria-label="Relayer un événement"]') !== null;
return hasNavbar && hasRelayer;
},
{ timeout: 15000 },
).then(() => true).catch(() => false);
if (!rendered) {
const debug = await this.appFrame!.evaluate(() => ({
pathname: window.location.pathname,
hasNavbar: document.querySelector('.app-navbar') !== null,
hasRelayer: document.querySelector('[aria-label="Relayer un événement"]') !== null,
rootLen: document.getElementById('root')?.innerHTML.length ?? 0,
rootText: document.getElementById('root')?.textContent?.substring(0, 400),
}));
expect.fail(
`L'accueil connecté n'a pas rendu de contenu d'app réel (page blanche ?). ` +
`path="${debug.pathname}", .app-navbar=${debug.hasNavbar}, ` +
`bouton Relayer=${debug.hasRelayer}, #root length=${debug.rootLen}, ` +
`texte: "${debug.rootText}"`,
);
}
});
Then('aucune erreur runtime n\'a été émise pendant le boot connecté', function (this: FestipodWorld) {
// this.pageErrors est peuplé par le hook Before (pageerror + console.error de
// la page app), réinitialisé à chaque scénario. Un crash de rendu connecté
// (throw non attrapé dans un composant/provider) émet un `pageerror` et
// atterrit ici → assertion rouge avec la liste exacte.
expect(
this.pageErrors,
`Des erreurs runtime ont été émises pendant le boot connecté :\n` +
this.pageErrors.map((e, i) => ` [${i + 1}] ${e}`).join('\n'),
).to.be.empty;
});

Some files were not shown because too many files have changed in this diff Show More