Compare commits

...

5 Commits

Author SHA1 Message Date
Sylvain Duchesne cebd54c978 The doctrine says what the code does again
Eighteen leaves had drifted behind today's changes, and several taught the exact
mistakes that were just removed.

Corrected, among others: the identity and the profile were conflated, and
`knowledge_context-internals` still described the impersonation fallback and the
principal-to-username join as current mechanisms. `caveat_identity-ids-in-screens`
and `knowledge_data-modes` still had `joinEvent` logging and returning where it
now throws. The shape listings still carried the event host. And
`knowledge_screen-pattern`'s canonical sample taught a toast written beside the
call rather than after the write -- the very bug fixed this afternoon, sitting in
the file a new screen is copied from.

New leaves for what had no home: write rights read from the owned-document
listing, with its three states and its deliberate residual; the owner's ruling
that no "may I write this?" call is coming, so the list is the answer for good;
and the `@data` suite losing its fixtures now that the seed writes nothing into a
connected wallet.

Four doc-debt files settled, including one the hook opened mid-pass.

Worth recording how one leaf died: a caveat was written for the unguarded edit
screen exactly as briefed, then deleted on finding the fix had landed while the
pass ran. Doctrine tracks the tree, not the instructions it was given.
2026-08-16 15:25:34 +02:00
Sylvain Duchesne 13eb2c4a15 Waits that say ten seconds now wait ten seconds, and editing checks you may
Two things that announced what they had not verified.

Seventeen `waitForFunction` calls passed their timeout in Playwright's ARGUMENT
slot instead of its options slot, so every one of them silently used the 30 s
default while the code read 5, 10, 15 or 60. The inventory said sixteen: one was
a false positive and two more were found that it never listed.

All seventeen are corrected, including the nine whose written value is SHORTER
than the default. Honouring the author's number is the point: a wait that is too
short fails loudly and names its step, where thirty seconds obtained by accident
hides a real slowness and reads as a lie in the source. Which of them need
raising is a question for the day the suite can run again -- it will be answered
on an honest number.

The event edit screen awaited nothing: the success toast fired and the screen
navigated away whether or not the write resolved. It now confirms after the
write, keeps the user on their edits when it fails, and says so.

That route was also unguarded -- anyone reaching the URL got the form, for any
event. It is now decided by ownership, read from the list of my own documents,
with the same three-state answer the pencil icon uses. UNKNOWN renders neither
the form nor a bounce: both would present a guess as a fact, and the guess that
matters here is telling a genuine owner their event is not theirs.
2026-08-16 15:16:46 +02:00
Sylvain Duchesne db3dbba294 No fixture seed, no event host, and the edit affordance stops lying
Three changes the product model asked for.

The fixture seed no longer writes anything into a connected wallet, by any
route. `bootstrapWallet` is the single enforcement point -- both call sites
funnel through it -- so the switch cannot be walked around by a screen or a
bridge. The fixtures, the seeding code, the demo path and the rendering tests
are untouched; a unit test now fails if a document is created after all.

An event has no host. The domain says so -- the meeting point has a host, the
event is only the anchor -- while the shape carried `hostName`/`hostInitials`
and every created event was written with the fabricated `'Moi'` / `'MD'`. Gone
from the shape, the ORM bindings, the type, the adapters, the writes and the
screens. `fp:MeetingPoint.host` stays: that one is real.

Regenerating the ORM revealed the committed bindings had drifted from what the
generator emits -- stylistic, verified predicate by predicate, plus the loss of
the `Fp` prefix. The prefix cannot be restored at the generator: the name comes
from the shape IRI, and those IRIs are the persisted RDF classes. Aliased at the
three import sites instead, so nothing downstream moved and the DOM `Event` and
`Notification` types are never shadowed.

Write rights are ownership, read from the list
The contract leaves no other reading -- only an owner writes, and no call adds a
writer -- so `listMyEntityDocs('public')` is what says which events are mine.
The hard-coded `isOwner = true` is replaced by a three-state answer, and the
UNKNOWN state renders neither a pencil nor a greyed one: a disabled look-alike
invites a dead click.

Two adversarial passes refuted the first attempt and both defects are fixed. A
latched boolean denied an owner their own event forever once a listing had
missed it; the ruling is now rebuilt rather than accumulated, so a later listing
overturns an earlier one.

Residual, deliberate and commented: "not mine" is inferred from absence, and the
reactive read and the listing are separate mechanisms, so a freshly arrived
event is ruled out for the window between them. Closing it needs a timer, which
the doctrine forbids.
2026-08-16 14:50:34 +02:00
Sylvain Duchesne 9740841820 A demo wallet keeps working: pick one of my own profiles, and say it is arbitrary
Resolving "my profile" by ownership left one case refusing: several profile
documents are mine and none was created by this session, which is exactly a
reloaded wallet carrying the fixture seed. Sign-up then rejected — in the very
flow being built.

The first by document reference is now used, stable across reloads and openly
arbitrary. While the profile is not a built feature, "which of my fixtures am
I" has no true answer and does not need one.

This is not the impersonation that was removed. That one reached for a profile
by NAME and could land on a document belonging to somebody else; every candidate
here is a document I own. The invariant that matters holds: the app never
presents another person's profile as mine.

Downgraded to a warning, and reworded: the log now says the name shown as yours
is demo data rather than announcing a refusal that no longer happens.
2026-08-16 13:53:00 +02:00
Sylvain Duchesne df971df135 Who I am comes from signing in; my profile is the document I own
The app derived its identity from a profile lookup and, when nothing matched,
picked somebody else. That is backwards: signing in returns who I am, and the
profile is looked up by it.

- Identity and profile are now two things. The identity is what
  `ensureIdentity()` returns: opaque, never rendered, never written, never
  passed to a data-layer call. The profile is Festipod's own object -- pseudo,
  name, initials -- in a document we create and write.
- "My profile" is the profile document I own, resolved through
  `listMyEntityDocs('protected')`. No username matching, no positional pick. A
  failed listing leaves the answer UNKNOWN rather than collapsing to "none".
- Having no profile now resolves to having no profile. Two impersonation
  fallbacks are gone, including one in `updateProfile` that would have written
  your pseudo into a stranger's document.
- A profile is created at sign-in when none exists. The shape makes name,
  initials and username mandatory, so it is written with placeholders that read
  as instructions -- never a plausible human name, never anything derived from
  the opaque identity.

Nothing succeeds in silence any more
`joinEvent` used to return without writing and without throwing when it could
not attribute the participation, while the screen announced success. It rejects
now, and the confirmation follows the write. Withdrawal likewise -- the doctrine
requires it to be authoritative. The host notification stops being written into
the joiner's own store, where its recipient could never read it, and the
optimistic notice shown to the wrong person goes with it.

The creator signs up through the common path: no owner branch anywhere, no
special case, the same deposit and the same derived count.
2026-08-16 13:50:50 +02:00
53 changed files with 1134 additions and 371 deletions
@@ -19,7 +19,7 @@ How the app's code is **structured** and **assembled**. *Feature-based* architec
- [[knowledge_routing]] — path-based routing (History API), route table, hooks
- [[knowledge_screens]] — screen inventory, registry, component library
- [[knowledge_screen-pattern]] — canonical anatomy of a screen (no props, flex layout, showToast)
- [[caveat_identity-ids-in-screens]] — `currentUserId` is the profile document's NURI and is **empty until the protected read lands**; empty reads like "no data"
- [[caveat_identity-ids-in-screens]] — `currentUserId` is empty until my profile document resolves, and an ownership answer can be UNKNOWN; neither means "no"
- [[caveat_boot-unverified-outside-broker]] — the unconditional `ensureIdentity()` await is verified inside the broker iframe; standalone/top-level boot is unverified
- [[knowledge_styling-system]] — `src/index.css`, `app-*` classes, vars, pitfalls (Tailwind unused, `user-content` inert)
- [[cookbook_add-screen]] — procedure for wiring up a new screen (registry + router + shell)
@@ -1,24 +1,30 @@
---
type: caveat
summary: currentUserId is now the profile document's NURI — the same value as currentUser.id — so the old two-id-spaces pitfall is gone; the live hazard is that it is EMPTY until the protected profile read lands, and nothing raises when a screen keys on it too early
last_checked: 2026-08-10
summary: Two values a screen must not read as data — currentUserId is EMPTY until my profile document resolves, and an ownership answer can be UNKNOWN; both look like ordinary values, neither means "no"
last_checked: 2026-08-16
---
# Pitfall: the current user arrives late, and empty reads like a value
# Pitfall: "not answered yet" looks exactly like an answer
## What is true now — one id, not two
Two things a screen receives can be *unresolved*, and in both cases the unresolved form reads like an ordinary value. Nothing throws.
`currentUserId` **is** `currentUser?.id`: the **NURI of the profile document** the app reads back in its own protected scope. The two are no longer distinct spaces, because the app no longer derives a principal from anything it was told — it stopped naming its own identity altogether (concept `app-security`, [[decision_2026-08-10_the-barrier-names-no-identity]]). A participation written today carries that same NURI in `fp:user`.
## `currentUserId` is empty until my profile resolves
> The earlier pitfall — a stable principal `urn:festipod:user:<key>` on one side and a profile NURI on the other, never equal in connected mode — **no longer applies to values written today**. The provider still resolves the older principal form on read (`resolveParticipantUser`, concept `data-layer` → [[knowledge_context-internals]]); a screen never sees it.
`currentUserId` **is** `currentUser?.id`: the NURI of **the profile document I own**. It is not derived from the signed-in identity and shares no id space with it — the identity the session signed in as is opaque, is never rendered, and never travels into a data call (`data-layer` → [[knowledge_context-internals]]). A screen never sees it except as an attribution string.
## The live hazard: `''` before the read lands
Until that profile document resolves — the owned-document listing has to land, and a profile may have to be created — `currentUserId` is **`''`**, a perfectly ordinary empty string.
`currentUserId` is **empty** until the protected profile read resolves — and empty is a perfectly ordinary string. Nothing throws.
- A **query** keyed on it (`getUserEvents`, `isParticipating`, `getFriends`, all defaulting to it) returns an **empty result**, which renders as "you have nothing" instead of "not ready yet".
- A **mutation** that needs it now **rejects** rather than writing a malformed entity: `joinEvent` and `leaveEvent` throw, naming the cause. A caller must therefore *await* them and handle the rejection — the confirmation belongs **after** the write, never beside the call. A screen that fires and forgets shows a success it did not get.
- A **query** keyed on it (`getUserEvents`, `isParticipating`, `getFriends` — all defaulting to `currentUserId`) returns an **empty result** rather than an error, which renders as "you have nothing" instead of "not ready yet".
- A **mutation** that needs it refuses rather than writing a malformed entity: `joinEvent` logs `empty user principal — refusing to write a participation with no fp:user` and returns. A screen that assumed the write happened shows a success it did not get.
**The rule**: treat an empty `currentUserId` as *not ready*, never as *no data*.
**The rule**: treat an empty `currentUserId` as *not ready*, never as *no data*. Gate on it before rendering an emptiness verdict or firing a mutation that stores it.
## An ownership answer can be UNKNOWN
The participation→profile join itself is **not** the screen's business: it is done in the provider (`resolveParticipantUser`). Full mechanics and the write/read invariant: concept `data-layer`, [[knowledge_context-internals]].
`getEventOwnership(eventId)` returns `'mine' | 'not-mine' | 'unknown'`, and `unknown` is a **real third answer** — the listing has not landed, or it failed (`data-layer` [[knowledge_write-rights-are-ownership]]).
Rendering it as "not yours" silently denies an owner their own event. Rendering it as a **disabled twin** of the real control is no better: a greyed pencil reads as "edit, broken" and invites a dead click. The slot stays occupied by a distinct *pending* mark, so the layout does not jump and nobody is told a wrong verdict — `app-security` → [[decision_2026-08-16_write-rights-are-the-owned-list]].
Never derive permission from `unknown` either. A screen that opens an editor because the answer "was not a refusal" is editing on a guess; the edit route consults the same three-state answer the control does, and renders `unknown` as its own pending state — [[knowledge_screen-pattern]].
> The participation→profile join is **not** the screen's business — it is done in the provider (`resolveParticipantUser`). Full mechanics: `data-layer` → [[knowledge_context-internals]].
@@ -1,7 +1,7 @@
---
type: knowledge
summary: Canonical anatomy of a screen — named function with no props, reads everything through useFestipodData/useNavigate/useParams, flex column layout (Header / scrollable content / BottomNav), feedback via showToast, hard-coded French labels; zero-prop rule has no exception left
last_checked: 2026-08-10
summary: Canonical anatomy of a screen — named function with no props, everything through useFestipodData/useNavigate/useParams, flex column layout, hard-coded French labels; the confirmation FOLLOWS the write, and a write affordance is decided by the three-state ownership answer
last_checked: 2026-08-16
---
# Canonical screen pattern
@@ -18,9 +18,10 @@ export function MyScreen() { // named function, NEVER any props
const [local, setLocal] = useState(); // screen-local state (steps, selections)
const handleAction = () => {
// …mutate through useFestipodData
showToast('Message', 'success'); // feedback
navigate('/path');
// THE CONFIRMATION FOLLOWS THE WRITE — never beside the call.
void Promise.resolve(mutate())
.then(() => { showToast('Message', 'success'); navigate('/path'); })
.catch((err: unknown) => { console.error(); showToast('Échec…', 'error'); });
};
return (
@@ -36,7 +37,9 @@ export function MyScreen() { // named function, NEVER any props
## Invariants
- **Zero props, no exception left**: every registered screen receives nothing; everything comes from context/hooks (`useFestipodData`, `useNavigate`, `useParams`). `WelcomeScreen` does not use `useFestipodData` (intro) — but still takes no props. (`LoginScreen`/`ConnexionScreen`/`AccessGateScreen` no longer exist — Festipod renders no access screen of its own; see [[knowledge_screens]] and [[knowledge_app-shell]].)
- **Identity: the current user may not be there yet.** `currentUserId` is the profile document the app reads back in its own protected scope, so it is **empty until that read lands** — see [[caveat_identity-ids-in-screens]] before keying anything on it.
- **Identity: the current user may not be there yet.** `currentUserId` is the NURI of the profile document this session **owns**, so it is **empty until that document resolves** — see [[caveat_identity-ids-in-screens]] before keying anything on it.
- **The confirmation FOLLOWS the write.** Mutations reject rather than returning quietly, so a screen must `await` (or `.then`) before announcing anything: success toast and navigation on resolve, an error toast on reject, and the user kept on their edits. A toast fired beside the call announces a write that may never have happened — the pattern that had to be corrected on the event and profile edit screens.
- **A write affordance is decided by ownership, in three states.** Both the control that *offers* the write and the screen that *performs* it consult the same answer (`data-layer` → [[knowledge_write-rights-are-ownership]]). `unknown` is never folded into either side: showing the form lets a non-owner edit on a guess, and bouncing them out tells a genuine owner their thing is not theirs. Render it as its own pending state.
- **Layout**: full-height flex column; `Header` at the top, content at `flex:1; overflow:auto`, `BottomNav` at the bottom **only for hub screens** (Home, Events, Profile, Friends). Flow screens (creation, editing, detail) have no `BottomNav`.
- **Feedback**: `showToast(message, 'success'|'info'|'error')` (`ToastContainer` mechanism exported by `sketchy/`).
- **Labels**: **French, hard-coded** — no i18n, no translation keys anywhere in the project.
@@ -11,6 +11,7 @@ triggers:
Festipod's **security, privacy and authorization** model.
- **Enforced model** — **isolation between scopes** (public / protected / private) is **enforced by the data SDK** (`@ng-eventually/polyfill`), which exposes to each user only what they are entitled to. The app **trusts** the SDK: no screen carries authorization logic. See [[knowledge_trust-model]].
- **Write rights** — only a document's owner writes it, and the owned-document listing is the whole answer; the app reads it to decide what to *offer*, never to enforce. Settled: [[decision_2026-08-16_write-rights-are-the-owned-list]].
- **Target authorization matrix** — the detail of *who may do what* per actor × verb (personal data = network, anonymity through the notification inbox): [[brief_2026-05-18_authorization-matrix]]. **Incubating.** It will graduate into `rule_`/`behavior_` as the product settles.
## Pitfalls
@@ -25,5 +26,6 @@ Festipod's **security, privacy and authorization** model.
- [[knowledge_authentication]] — wallet-based auth, everyone authenticated, no anonymous access, no screen of Festipod's own
- [[decision_2026-08-10_the-barrier-names-no-identity]] — the app names no identity: the barrier takes nothing, signing in is one `ensureIdentity()`
- [[decision_2026-08-10_sdk-renders-the-barrier]] — Festipod renders no access screen of its own; the SDK draws whatever a first-time device needs to see
- [[decision_2026-08-16_write-rights-are-the-owned-list]] — may-I-write is the owned-document listing and nothing else; no capability probe is planned
- [[brief_2026-05-18_authorization-matrix]] — target authorization matrix (incubating)
- Concept `functional-domain` → [[knowledge_data-scopes-and-discovery]] — which scope for which entity (product fact)
@@ -1,6 +1,6 @@
---
type: brief
summary: Target authorization matrix per data type (meeting point, registration, event, profile, connection) expressed as public/protected/private + dialog scopes; settled framing decisions (everyone authenticated, public meeting points, personal data = network, notification through an identified-or-anonymous inbox); open questions on the event write model and on host identity
summary: Target authorization matrix per data type (meeting point, registration, event, profile, connection) mapped onto the public/protected/private/dialog scopes; framing decisions settled and event update now settled as owner-only; host identity and event deletion still open
last_updated: 2026-05-18
---
@@ -68,10 +68,10 @@ Notes: no `C` differentiation (connections are a UI display filter, not a right,
|---|---|---|---|
| create | ✓ (becomes declarer) | — | ✓ (becomes declarer) |
| read / subscribe | ✓ | ✓ | ✓ |
| update | ? **to be decided** | ? **to be decided** | ? **to be decided** |
| update | ✓ (owner, sole writer) | ✗ | ✗ |
| delete | ? **to be decided** | ✗ | ✗ |
**Open questions:** who may **update** a declared event — the declarer alone (owner)? every user (wiki)? nobody (immutable)? Central to deduplication (concept `functional-domain`, [[brief_2026-06-15_event-deduplication]]). Who may **delete** it, and what becomes of the grafted meeting points (orphaned/cascade/marked deleted)?
**Update is settled — owner only**, and forced rather than chosen: only a document's owner writes it and no call adds a writer, so "wiki" is not expressible ([[decision_2026-08-16_write-rights-are-the-owned-list]]). It constrains deduplication (concept `functional-domain`, [[brief_2026-06-15_event-deduplication]]). **Open:** who may **delete** an event, and what becomes of the grafted meeting points (orphaned/cascade/marked deleted)?
### User profile
@@ -0,0 +1,30 @@
---
type: decision
summary: May-I-write is answered by the list of documents this session owns, and by nothing else — no "may I write this?" call is planned, now or later; the residual window this leaves open is accepted rather than closed
---
# Decision (2026-08-16): write rights are the owned list, permanently
## Context
Screens need to know whether this session may **write** an event's document, in order to offer an edit affordance at all. [[contract_polyfill-surface]] leaves exactly one reading of write rights: *"Only a document's owner writes to it. Holding its read key never grants a write"*, and, under non-guarantees, *"No delegated writing. A received key never grants a write, and no call adds a writer to a document."* Owning a document and being able to write it are therefore the same fact, and `storeRegistry.listMyEntityDocs(scope)` is the only call that reports it. No call answers "may I write this?" — the surface publishes none.
## Decision
**Ownership, read from the owned-document listing, IS the write right — and that is the permanent answer.** The project owner has ruled that **no capability probe is planned**: Festipod will not ask the provider for a "may I write this?" call, and no future one is being waited on. `listMyEntityDocs('public')` says which events are this session's, and a screen asks nothing else.
The answer a screen receives is **three-state**`mine` / `not-mine` / `unknown` — never a boolean. A rejected or not-yet-landed listing means **UNKNOWN**, and the contract is explicit that *"a rejection means 'unknown', never 'absent'"*. Collapsing it into "not mine" is how an owner gets silently told their own event is not theirs.
## Consequences accepted with it
- **UNKNOWN renders neither the control nor a greyed twin of it.** A disabled look-alike reads as "edit, broken" and invites a dead click; the slot stays occupied by a distinct pending mark, so an owner is never silently told the event is not theirs. Screen-side rule: `app-architecture` → [[caveat_identity-ids-in-screens]].
- **"Not mine" is inferred from ABSENCE**, and absence is not authoritative. The reactive read and the listing are two separate mechanisms, so an event can be on screen a moment before a listing can see it; ruled out in that window, it is only re-examined when some other unclassified event triggers a fresh listing. This residual is **deliberate and stated**, not an oversight.
- **The window is not closed**, because closing it needs either a timer — polling, forbidden by `bdd-testing` → [[rule_no-broker-polling]] — or the probe call this decision rules out. Accepting a bounded wrong answer is the arbitration; do not "fix" it with a poll.
## Rejected alternative
**Raise the missing probe as a contract gap and wait for it.** Rejected by the project owner: the contract's ownership rule is not an omission, it is the model — a document has one writer, and a list of one's own documents is a complete answer to who that is. Treating it as a gap would keep an affordance permanently provisional against a call that is not coming.
## Scope
Applies to every write-affordance question the app asks, not only the event edit pencil. How the answer is derived and where it lives: concept `data-layer` → [[knowledge_write-rights-are-ownership]].
@@ -20,9 +20,13 @@ last_checked: 2026-08-10
**Signing out.** The only logout left is the **wallet session** one (`logoutNg`, offered as « Quitter l'environnement de test » in the settings screen): it stops the shared-wallet session so the next access goes back through the broker. There is no app-level sign-out, because there is no app-level identity to sign out of.
## Who the current user IS, seen from the app
## Who the current user IS — the identity and the profile are two things
The app does not derive an identity from anything it was told; **what it is, is the profile document it reads back in its own protected scope**. That value is therefore empty until the protected read lands — the mechanics and the hazard that follows live in concept `data-layer`, [[knowledge_context-internals]] and `app-architecture` → [[caveat_identity-ids-in-screens]].
**The identity** is what `ensureIdentity()` returns, and nothing else derives it. The contract calls it **opaque**: do not parse it, split it, or render it as a readable name. Festipod holds it for display attribution and logging only, and **never passes it to a data-layer call** — no call takes one.
**The profile** — pseudo, name, initials — is **Festipod's own object**, not something the SDK knows about. "My profile" is the profile **document I own**, resolved from the owned-document listing; a failed listing leaves the answer UNKNOWN, never "none", and the app never presents somebody else's profile as mine. When a person has no profile, one is created at sign-in with placeholders that read as unset — never a plausible name, never anything derived from the identity.
The two share no id space and there is **no join between them**. The profile value is therefore empty until that document resolves — the mechanics and the hazard that follows live in concept `data-layer`, [[knowledge_context-internals]] and `app-architecture` → [[caveat_identity-ids-in-screens]].
**Vocabulary.** `username` designates the profile handle `UserProfile.username` and nothing else. `normalizeIdentifier` (`src/shared/utils/identifier.ts`) is a **pure string normalization** of that handle, applied only to `UserProfile.username` — the join between a profile and the person it belongs to, and the name given when sharing a document with a neighbour. It is never applied to the identity: normalising an identity belongs to the data layer, which the contract states outright, and no configuration hook takes it from us. It names no space, account or session.
@@ -12,6 +12,8 @@ Principles:
1. **Isolation is delegated to the SDK.** Every entity lives in the store of its **scope** (public / protected / private, see concept `functional-domain` → [[knowledge_data-scopes-and-discovery]]); the SDK **exposes to the current user only what they are entitled to**. The app assumes that whatever it receives is already authorized — privacy rests on the SDK, not on Festipod code.
2. **Screens carry no access rules.** No "is this user allowed to see this data" check in the components, nor in the data context. The public / network / private separation is a property of **placement by scope**, not of an application-level filter.
The one thing the app *does* read is **write rights**, and it reads them to decide what to **offer**, never to enforce: only a document's owner writes it, so the owned-document listing is the whole answer, in three states ([[decision_2026-08-16_write-rights-are-the-owned-list]]). Enforcement stays below — a screen that got the affordance wrong offers a doomed action, it does not open a hole.
3. **The relationship between users ("connections") is an application-level notion.** The contract publishes no connection or friendship primitive: it models reading as **key possession**, and giving someone that key is **one act**`inbox.share(doc, toUser)`, naming the document and the person. The app therefore **owns** its relationship graph (`src/shared/utils/connections.ts`) and, once a link is two-sided, **shares its own protected documents** with that neighbour. It does not delegate the notion of a relationship, only the **enforcement** of the isolation that follows from it.
What the app declares to the SDK is now **only those shares**: it declares **no identity** ([[decision_2026-08-10_the-barrier-names-no-identity]]), and it **never handles a key or an inbox address** — neither exists in app code. Sharing is also **irreversible**: the contract publishes no revocation, so an act of sharing is permanent ([[contract_polyfill-surface]]).
@@ -33,6 +33,7 @@ BDD tests written in **Cucumber/Gherkin in French** (`Etant donné`, `Quand`, `A
- [[knowledge_e2e-layer]] — the `@e2e` layer: the real app inside the iframe
- [[knowledge_multibrowser-harness]] — several isolated browsers on the shared wallet (storageState injection); the only way multi-user is exercised
- [[caveat_data-scenarios-share-one-wallet]] — a scenario cannot choose its identity, so all of them share one wallet that nothing empties: no per-scenario isolation
- [[caveat_data-suite-has-no-fixtures]] — **known, not fixed**: the fixture seed writes nothing into a connected wallet, so `@data` scenarios that assumed seeded data have none
- [[caveat_reconnexion-froide-local-vs-broker]] — a "fresh page" is not a cold start: which setup proves broker durability, and which one just re-reads local
- [[caveat_first-time-entry-untested]] — **open**: no test proves a first-time device can get into Festipod any more; the SDK's replacement barrier publishes nothing to test against
- [[decision_2026-03-12_headless-wallet-creation]] — why the test wallet is created through a headless UI
@@ -0,0 +1,29 @@
---
type: caveat
summary: Known, not fixed — the fixture seed is off for connected wallets, so every @data scenario that assumed seeded events or profiles now runs against whatever the shared wallet happens to hold; "load test data" is a no-op that reports success
last_checked: 2026-08-16
---
# Caveat: the `@data` suite lost its fixtures
## What changed under it
No fixture is written into a connected wallet any more, by any route — a product decision enforced in one place (`concept data-layer`, [[knowledge_seed-data]]). The `@data` layer did not ask for that and was not adapted to it.
## What that does to the suite
The bridge's `loadTestData()` still resolves, and it reports **`seeded: false`** with no documents created. So:
- Scenarios that **load test data and then assert on it** (`auth/connexion-nextgraph.feature`: loading the fixtures, the "not reloaded twice" idempotence check, "the events have NextGraph identifiers") no longer have anything to assert on. The call succeeds; nothing is written.
- Scenarios whose background **assumes a seeded wallet** ("le portefeuille contient des données de test", "un événement {string} existe" — which seeds on demand when the wallet reads empty) now depend entirely on what the shared wallet happens to already hold.
- Nothing raises. A no-op seed reports success, which is the failure mode to expect: a green step followed by an assertion that finds nothing.
## What NOT to do about it
**Do not re-enable the seed for the tests, and do not add a test-only bypass of the enforcement point.** The switch is enforced at `bootstrapWallet` precisely so no caller can walk around it, and a harness is a caller like any other. **Do not weaken the affected scenarios into something that passes** either.
The suite needs scenarios that **create what they need through the app's own path** (the same `createEvent` / `joinEvent` a user drives), rather than a background that assumes a pre-populated wallet. That is the direction; it is not done.
## Related
This compounds [[caveat_data-scenarios-share-one-wallet]] — scenarios already could not choose their identity or start from a clean slate, and now they cannot furnish that slate either. Both are open.
@@ -22,8 +22,15 @@ summary: How to add a BDD scenario/step — a tagged French .feature, steps per
```
Always `await` (forgetting it means asserting before the promise resolves).
6. **If you add a data operation**: expose the helper on `window.__testData` in **both** harnesses (`src/shared/test-harness/harness.tsx` AND `harness-ng.tsx`) — otherwise the mock fallback drifts away from the real broker.
6. **⚠️ `waitForFunction` timeout goes in the THIRD slot, not the second.** Playwright's signature is `waitForFunction(pageFunction, arg, options)`. Passing `{ timeout: N }` where `arg` belongs is **not an error**: it is accepted as the page function's *argument*, no options are supplied, and the wait silently uses the **30 s default** while the source reads 5, 10 or 60. When there is no argument to pass, the slot must be filled explicitly:
```ts
// ❌ await frame.waitForFunction(fn, { timeout: 10000 }) // waits 30 s
// ✅ await frame.waitForFunction(fn, undefined, { timeout: 10000 }) // waits 10 s
```
This had gone unnoticed on **seventeen** calls at once, nine of which meant to wait *less* than the default. It is worth honouring the written number: a wait that is too short fails loudly and names its step, whereas thirty seconds obtained by accident hides a real slowness and makes the source lie. Same family as the pitfall above — both are Playwright argument slots that accept the wrong thing without complaining.
7. **Wire up a screen under test**: if the French screen name does not resolve to its `id`, add an alias in `screenNameMap` (`src/shared/steps/ui/navigation.steps.ts`).
7. **If you add a data operation**: expose the helper on `window.__testData` in **both** harnesses (`src/shared/test-harness/harness.tsx` AND `harness-ng.tsx`) — otherwise the mock fallback drifts away from the real broker.
8. **Run**: `bun run test:cucumber` (everything) or `bun run test:data` (@data). Report: `reports/cucumber-report.html`. `@data`/`@e2e` require the test wallet (`bun run test:auth-setup` on the first go if needed, otherwise it is created automatically — see [[decision_2026-03-12_headless-wallet-creation]]).
8. **Wire up a screen under test**: if the French screen name does not resolve to its `id`, add an alias in `screenNameMap` (`src/shared/steps/ui/navigation.steps.ts`).
9. **Run**: `bun run test:cucumber` (everything) or `bun run test:data` (@data). Report: `reports/cucumber-report.html`. `@data`/`@e2e` require the test wallet (`bun run test:auth-setup` on the first go if needed, otherwise it is created automatically — see [[decision_2026-03-12_headless-wallet-creation]]).
@@ -1,7 +1,7 @@
---
type: knowledge
summary: The @data layer — Playwright drives Chromium (persistent profile) into the real broker, which loads harness-ng.tsx in an iframe; automated wallet lifecycle, window.__testData bridge, mock fallback; the harness signs in exactly as the app does, and per-scenario isolation is currently ABSENT
last_checked: 2026-08-10
summary: The @data layer — Playwright drives Chromium (persistent profile) into the real broker, which loads harness-ng.tsx in an iframe; automated wallet lifecycle, window.__testData bridge, mock fallback; the harness signs in as the app does, per-scenario isolation is ABSENT and the seed now writes nothing
last_checked: 2026-08-16
---
# The `@data` layer (real broker)
@@ -35,8 +35,8 @@ Cucumber → Playwright (Chromium, persistent profile)
- **HTTP server** started in `BeforeAll` (auto-assigned port), serving the HTML plus `/harness.js` (separate files — an inline script breaks because of special characters in the bundle).
- **The bridge is the real app path (per entity).** Since the move to *one document per entity* (concept `data-layer`, [[rule_document-per-entity]]), the `window.__testData` bridge (`events`/`users`/`participations`, `joinEvent`/`leaveEvent`/`isParticipating`/`getEventParticipants`, `loadTestData`) **delegates to the app's data context** (`appData` through `FestipodDataProvider`) — this is the real per-entity path the screens use, not a read at root-store level. It reads `appData` through a **live ref** (a captured snapshot goes stale after a seed re-render).
- **The harness signs in exactly as the app does.** It mounts `NextGraphProvider > FestipodDataProvider`**no identity provider, no default login** — and awaits the single `ensureIdentity()` before exposing the bridge, mirroring the order `AuthGate` imposes (concept `app-security`, [[decision_2026-08-10_the-barrier-names-no-identity]]). Nothing may read before it resolves. The low-level probes that used to reach past the app path are **gone**, along with the scenarios whose subject was the SDK rather than Festipod ([[rule_tests-validate-festipod-not-the-sdk]]).
- **Identity before writing.** A `Participation` has a mandatory `fp:user`; the current user is **the profile document read back in the protected scope**, so it lags behind the public events. Steps wait for `ensureCurrentUser()` before `joinEvent` (otherwise the mutation refuses, or writes a participation with no user → dropped on read) and then wait (`waitForFunction`) for the participation to be read back.
- **Identity before writing.** A `Participation` has a mandatory `fp:user`, and what goes in it is **the profile document this session owns** — resolved from the owned-document listing, and created at sign-in when there is none, so it lags behind the public events. Steps wait for `ensureCurrentUser()` before `joinEvent`, then wait (`waitForFunction`) for the participation to be read back. Waiting is no longer optional politeness: `joinEvent` and `leaveEvent` now **reject** when the profile is unresolved, so a step that fires too early fails loudly instead of passing over a write that never happened.
- **Per-scenario isolation is currently ABSENT — read [[caveat_data-scenarios-share-one-wallet]] before trusting a green run.** The `Before` hook still mints `this.freshIdentifier` and injects it into `localStorage['festipod.account.identifier']`, and several steps re-inject it, but **nothing reads that key any more**: no published call takes an identifier. Every scenario therefore runs as the same identity on one accumulating wallet. That machinery is inert, not load-bearing — do not build new setup on it, and do not "repair" it by making the app honour the key again.
- The old per-scenario reset (`resetDataState()`, a SPARQL DELETE on the anchor graph) was dropped for cost (up to 10 s of the `Before` hook's 60 s budget, already eaten by the broker login) and its helper is gone too.
- The **physical** growth of the shared wallet was never bounded by any of this — see [[caveat_wallet-bloat-hang]] (profile to be moved aside when reads start to hang).
- The connected seed stays **lightweight** (few docs): creating a document is a serial round trip, so the seed's cost is linear in the number of documents it writes.
- **The bridge's `loadTestData` no longer writes anything.** No fixture reaches a connected wallet by any route, and the enforcement point is deliberately un-bypassable — so the call resolves, reports nothing seeded, and every scenario that assumed seeded events or profiles is now running on whatever the shared wallet already holds. Read [[caveat_data-suite-has-no-fixtures]] before diagnosing an empty assertion, and do not re-enable the seed for the tests.
@@ -10,6 +10,7 @@ last_checked: 2026-08-10
- Helper: `src/shared/test-harness/renderHelper.tsx` (installs the happy-dom globals, wraps the screen). Invoked from `world.ts:renderCurrentScreen()` on every `navigateTo(...)`.
- Deterministic fixtures (`src/shared/data/seedData.ts`, see concept `data-layer`): `Marie Dupont`/`@mariedupont` = currentUser, `Jean Durand`/`@jeandurand` exists, 5 events, and so on.
- **`@ui` is untouched by the connected-wallet seed switch.** No fixture may be written into a *wallet* any more, but `@ui` renders the fixtures straight into React state through `LocalDataProvider` and writes to nothing — so these fixtures are unchanged and stay the layer's ground ([[caveat_data-suite-has-no-fixtures]] is a `@data` problem only).
## Good assertion patterns
+4 -3
View File
@@ -17,9 +17,10 @@ How Festipod **persists its data** through NextGraph (P2P, local-first, end-to-e
- [[contract_polyfill-surface]] — **the data contract, PULLED from the provider and version-pinned**: the `@ng-eventually/polyfill` surface the app codes against, what it guarantees and what it refuses to promise. The ONLY reference — never open the provider's own sources.
- [[knowledge_nextgraph-stack]] — the SHEX shapes, the reactive ORM bindings, `build:orm`, injection through `ngSession`
- [[knowledge_data-modes]] — connected (SDK) vs disconnected/demo (seeded local state), how the provider is chosen
- [[knowledge_entities]] — the `Fp*` types and their SHEX shapes
- [[knowledge_seed-data]] — seed data, `CURRENT_USER_ID`
- [[knowledge_context-internals]] — pitfalls of `FestipodDataContext` (who the current user is and when it arrives, the legacy principal space, dev auto-seed, `participantCount`, local no-op)
- [[knowledge_entities]] — the `Fp*` types and their SHEX shapes; the generated ORM names carry no `Fp` prefix and are aliased at the import sites
- [[knowledge_seed-data]] — the fixtures, and the master switch that keeps them out of any connected wallet
- [[knowledge_context-internals]] — pitfalls of `FestipodDataContext` (identity vs profile, which profile is mine and when it arrives, no silent success, the legacy participation id space, `participantCount`, local no-op)
- [[knowledge_write-rights-are-ownership]] — may I write this? is answered by the owned-document listing, in three states
## Write rules
@@ -1,12 +1,14 @@
---
type: caveat
summary: The FpEventData type and the seed carry startDate/endDate/startTime/endTime/themes, but the Event SHEX does not define them — these fields are silently lost in connected mode (NextGraph)
last_checked: 2026-06-15
last_checked: 2026-08-16
---
# Caveat: event fields not persisted in connected mode
The app type `FpEventData` (`src/shared/data/types.ts`) and the seed (`seedData.ts`) carry the fields **`startDate`, `endDate`, `startTime`, `endTime`, `themes`** — but the **SHEX `Event` shape** (`src/shared/shapes/shex/festipodShapes.shex`) does **not** define them. The shape covers exactly (verified 2026-08-10 in the `.shex`): `title, description, date, location, distance, participantCount, coverImage, hostName, hostInitials`, plus an optional `inbox`.
The app type `FpEventData` (`src/shared/data/types.ts`) and the seed (`seedData.ts`) carry the fields **`startDate`, `endDate`, `startTime`, `endTime`, `themes`** — but the **SHEX `Event` shape** (`src/shared/shapes/shex/festipodShapes.shex`) does **not** define them. The shape covers exactly: `title, description, date, location, distance, participantCount, coverImage`, plus an optional `inbox`.
> **No host field, and none is missing.** The shape used to carry `hostName`/`hostInitials`, and every created event was written with a fabricated value. They are gone — from the shape, the bindings, the type, the adapters, the writes and the screens — because **an event has no host**: it is only the anchor, and its declarer is not required to attend (concept `functional-domain`, [[knowledge_actors-and-concepts]]). `fp:MeetingPoint.host` stays; that one is real. Do not "restore" a host on the event.
> That `inbox` field is a **vestige, and it must stay unused**: it was there to publish an event's inbox address so others could deposit into it. The app no longer handles an inbox address anywhere — a deposit **names the document** (`inbox.postToDocument(doc, …)`) and the owner opens its own with `openDocumentInbox(doc)`. Writing an address into the entity would put back exactly what the surface removed ([[rule_document-per-entity]]).
@@ -1,94 +1,91 @@
---
type: knowledge
summary: Internal pitfalls of FestipodDataContext — currentUserId is the profile document read back in the protected scope (empty until it lands), the legacy principal space resolveParticipantUser still resolves on read, OPT-IN auto-seed, owner-derived participantCount, local-mode no-op mutations
last_checked: 2026-08-10
summary: Internal pitfalls of FestipodDataContext — the signed-in identity and the profile are two unrelated things, "my profile" is the profile document I own, mutations reject instead of succeeding silently, participantCount is derived by the event's owner, and local mode is a no-op
last_checked: 2026-08-16
---
# Internals & pitfalls of `FestipodDataContext`
Non-obvious behaviours of `src/shared/context/FestipodDataContext.tsx` to know about before touching the data context.
## Who am I — `currentUserId` is a document you read back, not a value you were given
## Identity and profile are TWO things — never join them
**The app names no identity of its own** (concept `app-security`, [[decision_2026-08-10_the-barrier-names-no-identity]]): `ensureIdentity()` takes nothing, and nothing switches identity afterwards. So the provider cannot *derive* a principal from an input. What the current user **is**, is the **profile document it reads back in its own protected scope**: `currentUserId` = that profile's `@id`, a doc NURI — the very same value as `currentUser?.id`.
**The identity** is what `ensureIdentity()` returns: an opaque value, published to the tree by `src/shared/utils/currentPrincipal.ts` (a module store, not a context — the component that awaits sits *inside* the data provider, so a context it published would be invisible to its own consumer). It is **for display and log attribution only**. It is never parsed, never rendered as a name, never written into an entity, and **never passed to a data-layer call** — placement is named by scope alone, so handing it back would recreate the parameter the surface deliberately removed ([[contract_polyfill-surface]]).
**The pitfall that follows**: it is **empty until the protected read lands**, and empty is an ordinary string that raises nothing. Mutations needing it **refuse** (`joinEvent` logs `empty user principal — refusing to write a participation with no fp:user` rather than writing an entity that would be dropped on read); queries keyed on it return **empty results** that render as "you have nothing". Treat `''` as *not ready*, never as *no data* — see `app-architecture`, [[caveat_identity-ids-in-screens]].
**The profile** — pseudo, name, initials — is **Festipod's own object**, in a document the app creates and writes. `currentUserId` is that document's NURI, the same value as `currentUser?.id`, and the only value a mutation may write into a `Participation`'s `fp:user`.
> The `currentUser` object is picked with a **fallback** (`@mariedupont`, then `users[0]`) — a leftover of the demo seed, and a silent one: on a wallet holding several profiles it can settle on the wrong person. Worth a look whenever "the app thinks I am someone else".
**There is no join between the two, and there must not be one.** The identity says nothing about the profile. Never compare the principal to an entity id, and never match it against a profile field to decide who the current user is.
## The legacy principal space — resolved on READ only
## "My profile" is the profile document I OWN
A `Participation` written **today** carries `currentUserId` in `fp:user`, i.e. a profile doc NURI, so a direct `participation.userId === profile.id` join matches. Participations written under the **earlier** scheme carry a principal of the form `urn:festipod:user:<normalized-handle>`, which matches nothing directly.
`listMyEntityDocs('protected')` answers *which documents are mine*, and the UserProfile among them is mine. **No field of any profile takes part**: no username comparison, no normalization, no positional pick.
**`resolveParticipantUser`** (`FestipodDataContext`) is the single join point, and it tries, in order: (1) a **direct match** `u.id === userId` — today's writes, and the demo seed's bare `user-1` space; (2) failing that, strip `USER_PRINCIPAL_PREFIX` and match the remainder against `normalizeIdentifier(profile.username)` — the legacy space. Never join by direct comparison at a call site: the symptom of getting it wrong is every participant rendering as « participant inconnu », which shipped once already.
A failed listing is **UNKNOWN, never "none"** — the set stays unresolved, no profile is chosen and none is created, and the failure is retried then said loudly. Reading a rejection as "I own nothing" would create a second profile for someone who already has one.
`USER_PRINCIPAL_PREFIX` is now **read-side only** — nothing mints it any more. It is kept so old data still resolves; it is not a shape to write against.
Four outcomes, and *somebody else's profile* is not one of them:
A further id space takes **no** part in this join: the inbox deposit `uid` (`mint…`) — it identifies a **deposit** for the counter, never a user.
> **Horizon.** This paragraph describes **what is implemented**. The target model drops the plaintext `userId` and routes identity resolution through **reading the profile** — see [[brief_2026-07-20_attendance-set-model]], whose implementation is gated. The id-space fix is explicitly noted there as **still valid**: do not undo it in anticipation of the target.
### Which space each query expects (the `buildQueries` contract)
| Query | What it expects / returns |
| Owned profiles | Answer |
|---|---|
| `getUserEvents(userId)`, `isParticipating(eventId, userId?)`, `getFriends(userId?)` | filter on `participation.userId` / `friendship.userId`; their default is `currentUserId`, which is correct |
| `getEventParticipants(eventId)` | **returns profiles** (`FpUserData``id` = NURI), the join being done internally |
| listing unresolved | UNKNOWN — nothing resolved, nothing created |
| none | I have no profile yet → one is created (below) |
| exactly one | that is me |
| several, none created by this session | the **first by document reference** — stable across reloads, openly arbitrary, warned about once |
**Screen-side impact**: `currentUserId` and `currentUser?.id` are now the same value, so filtering yourself out of a participant list works either way. What still bites is passing an id **before it resolves** — see `app-architecture`, [[caveat_identity-ids-in-screens]].
The last row is a reloaded wallet carrying a fixture seed. The pick carries no meaning, so it is logged as demo data rather than presented as you; **every candidate is a document I own**, which is what separates it from the impersonation that was removed — that one reached for a profile by *name* and could land on a stranger's document. Delete the branch the day a profile is really created and known.
## Reads = `watchShape` (the SDK surface), no more bespoke machinery
> **Two impersonation fallbacks are gone**, including one in `updateProfile` that would have written your pseudo into a stranger's document. Having no profile now resolves to *having no profile*. Do not reintroduce a "pick something plausible" fallback anywhere on this path.
**Since 2026-07-10**: `useNgData` reads through `useShapeQuery(shape, scope)` (a `useSyncExternalStore` binding over `watchShape`) — THREE useQuery-shaped reads (events/public, users/protected, participations/protected) + Fp adapters (`shapeAdapters.ts`). Removed: `readEntities`, `subscribeDocs`+`bumpRead`+`readTick`, the manual listing (`publicDocs`/`protectedDocs`/`registerDoc` for reads), and `relist`. `ready` = the combination of the `isSuccess` flags. See [[rule_app-uses-sdk-surface-only]].
## A profile is created at sign-in when there is none
**Immediate visibility of mutations = an OPTIMISTIC overlay** (no `registerDoc`): `createEvent`/`joinEvent`/`leaveEvent` feed `pendingAddEvents`/`pendingAddParticipations`/`pendingRemoveIds`; the exposed state = merge(reactive, adds) minus removes, deduped by id (id = the doc's NURI). Reconciliation happens automatically on push (an add that shows up in the reactive state, or a remove that disappears from it, is dropped) — never a poll ([[rule_no-broker-polling]]).
Gated on **both** the protected read having settled (`isSuccess` — synced-and-empty, not still-syncing) **and** the owned-document set being known, because "I have no profile" is only true when both have answered. Single-shot per session; on failure the guard is released so a later change retries.
## Dev auto-seed
The UserProfile shape makes `name`, `initials` and `username` **mandatory**, so the profile cannot be written empty. The three fields carry **placeholders that read on screen as "not filled in yet"** — never a plausible human name, never a handle, and **never anything derived from the opaque identity**. The user replaces them through `updateProfile`.
**Since 2026-07-13 the auto-seed is OPT-IN and OFF by default**: it only fires if the `FESTIPOD_AUTO_SEED` env var is set (`=1`), no longer off `NODE_ENV`. Var absent → **no automatic seed at all**, even in dev (`autoSeedEnabled()`/`shouldAutoSeed()`, `src/shared/utils/autoSeed.ts`; delivered in dev through the `/festipod-config.json` runtime route + a compile-time `define` in `build.ts`, the same mechanism as the shared wallet — see `tech-stack/knowledge_build-pipeline`). The **explicit** seed (`loadTestData()`, @data tests) is unchanged. Rationale: the repeated auto-seed was bloating the wallet (slow reads, see [[caveat_wallet-bloat-hang]]).
## Nothing succeeds in silence
When it is enabled, the auto-seed fires if events AND users are both empty — **gated on `isSuccess`** (`watchShape`'s readiness), NO LONGER on a 3s `setTimeout`: we only decide "the wallet is empty" once the sync is **confirmed** (`isSuccess`), otherwise a not-yet-finished read was taken for an empty wallet → a re-seed on every reconnection (bug fixed). Remaining pitfalls:
- **One seed at a time**: `loadTestData()` sets `hasTriedAutoSeed`, and the auto-seed re-checks it → an explicit load cancels the pending auto-seed (otherwise two concurrent `bootstrapWallet` calls write everything twice).
- The seed writes under the **connected session**, so the session that seeds **holds** what it seeded and its protected fixtures round-trip. Seeded users are fixtures, not accounts — nobody has signed in as them, which matters because `inbox.share` refuses a recipient nobody has ever been. Only **events** get an inbox opened at seed time (`openDocumentInbox`), because events are what people deposit into.
- **No retry**: if the seed fails, you get an empty screen + a `console.error`.
Mutations on the create/participate path **reject** rather than returning quietly, and the screen's confirmation **follows** the write:
## `participantCount` — derived and owned by the owner
- `joinEvent` refuses when no profile of mine is resolved: a `Participation` needs `fp:user`, and one written without it is dropped on read — a sign-up that wrote nothing, threw nothing, and let the screen congratulate the user. It now throws, naming the cause. `leaveEvent` likewise, because withdrawal must be authoritative ([[caveat_participation-deletion]]).
- Idempotence is checked **authoritatively against the broker**, not against the reactive set, which can lag a just-written participation. A **failed** count is UNKNOWN and is deliberately *not* swallowed — reading it as zero is exactly how a duplicate gets written.
- **The deposit IS the delivery.** A host-facing notification is no longer minted at join time. It used to be written into the *joiner's* own protected scope with `recipient` set to the event — a document the host can never read — and pushed into the joiner's own list, so the joiner saw a "new participant" notice addressed to someone else. Both are gone: `inbox.postToDocument(doc, …)` carries the news, and the owner builds the notification from the deposits it reads on its own event's inbox.
- The creator signs up through the **common path** — no owner branch, no special case, the same deposit and the same derived count.
> ✅ **CORRECTED (2026-07-13).** The requirement is **"reliable at the owner's NEXT CONNECTION"** (the creator processes their inbox when they connect), NOT a live real-time cross-user notification. The bug was: the owner-materializer materialized **too early** (before the participant's deposit had synced) → read `active=0` → wrote 0 → **memoized that 0** → never re-processed. Fix: (1) read through `inbox.readSynced` instead of `inbox.read` — the two differ by contract, and only the former is the synced view ([[contract_polyfill-surface]]); (2) the materializer fires **directly on connection** (`[ready, ownedKey]`), no longer only on a push; (3) `materializedCountRef` no longer locks in a premature 0 (its sole role = loop guard: only write when the derived value changes); (4) **the single source of the NUMBER = `event.participantCount`** (the `participantCount: 1` literal in `CreateEventScreen` is removed → it starts at 0; the display no longer computes a local number). Kept GREEN (on a fresh profile) by `event/e2e-multibrowser.feature` « Le compteur converge chez le propriétaire à sa prochaine connexion » (un-`@wip`'d). No polling ([[rule_no-broker-polling]]).
## The legacy participation id space — resolved on READ only
**Since 2026-07-07**: `participantCount` is no longer mutated in place by the participant. The flow is inbox-deposit → owner-materialization:
- `joinEvent`/`leaveEvent` **no longer** write `participantCount` on the event's doc — **only a document's owner writes to it**, so a participant cannot touch someone else's. The participant only writes their **own** participation doc (protected), then **deposits** a marker into the event's inbox (`depositRegistration` on join, `depositLeave` on leave, `src/shared/data/registration.ts`).
- The event **owner's** session does the materializing: it watches (`inbox.watch`, no polling) the inbox of the events it owns (`ownedEventIds` = `listMyEntityDocs('public')` + freshly created events), and on every deposit it **recomputes** `participantCount` on **its own** event doc (`updateEntityField` on its own doc). It is the counter's only writer.
- **The counter is DERIVED, not incremented**: `materializeAttendance` (registration.ts) reads the inbox and computes the **set** of distinct active sign-ups (`new-participant` deposits deduped by `uid`, MINUS those cancelled by a `leave-participant` — by exact `regUid` or by the `(eventId, userId)` fallback). `participantCount = |active set|`**no host baseline**: the creator does not attend automatically (there is no notion of host, see concept `functional-domain`), so the counter starts at **0** on creation and only moves on real sign-ups. `createEvent` **no longer writes** a participation at creation time (it used to write a host participation and set the counter to 1); the creator sees « J'y serai » and can join/leave their own event like anyone else. Because it is a **pure function of the inbox**, a replay converges — never double-counting nor a phantom decrement (idempotence). The write is guarded (it only writes when the value changes), a loop guard. Covered by the `@data` scenario « Le créateur ne participe pas automatiquement à son événement » (us-13): counter 0 + `isParticipating(E)===false` at creation, then join→true / leave→false.
- **Owner offline = eventual**: only the owner's session materializes; while they are disconnected, the counter does not move for anyone else (the participations and deposits stay persisted — nothing is lost).
- The counter nevertheless remains an **aggregate**, not the list of named participants: `getEventParticipants` (named identity) is still governed by what the protected scope hands back ([[caveat_participation-deletion]] for the authoritative deletion, unchanged).
A `Participation` written **today** carries the profile document's NURI in `fp:user`, so the direct join `u.id === userId` matches. Participations written under the **earlier** scheme carry `urn:festipod:user:<normalized-handle>`, which matches nothing directly.
### Id-form invariant: match on the CANONICAL form of the event id
**`resolveParticipantUser`** is the single join point and tries, in order: (1) the direct id match — today's writes, and the demo seed's bare `user-1` space; (2) failing that, strip `USER_PRINCIPAL_PREFIX` and compare the remainder to `normalizeIdentifier(profile.username)`. Never join by direct comparison at a call site: getting it wrong renders every participant as « participant inconnu », which shipped once. `USER_PRINCIPAL_PREFIX` is **read-side only** — nothing mints it any more; it is not a shape to write against. The inbox deposit `uid` (`mint…`) is a third space that takes **no** part: it identifies a deposit for the counter, never a user.
An event's `@id` **is** its document NURI (`did:ng:o:<repo>[:v:<overlay>]`). The owner's materializer matches the inbox **deposits** to the owned events **by event id**: `ownedEventIds` (what the materializer iterates over), the **deposit key** (`payload.eventId`, what the participant deposits under) and the counter's **write target** must all designate the same event.
> **Horizon.** The target model drops the plaintext `userId` and resolves identity by reading the profile — [[brief_2026-07-20_attendance-set-model]], gated. The id-space fix is noted there as still valid: do not undo it in anticipation.
**Measured finding (2026-07-07)**: on the current tree these three paths carry the **same** NURI (the `:v:<overlay>` suffix included) — create-time, `listMyEntityDocs` and the `@id` read back all coincide, because `readUnion` **pins the subject to the input NURI** (lib `read-model.ts`, `63ecfee`). So matching already works, **including** for an owned event reached through `listMyEntityDocs` (validated by the @data scenario « …fait converger le compteur dérivé »). The canonicalization below is **defensive**, not the fix for an active bug. (The mismatch one investigation thought it had seen was the **seeded-but-not-owned** artifact: on a persistent wallet, the seed belonged to a `test-*` identity from an earlier run → the current session reaches it through discovery, not through `ownedEventIds` — correct behaviour.)
## Reads = `watchShape`, writes = an optimistic overlay
**Rule**: match the event id on its **canonical form** — the base repo id, with any `:v:<overlay>` suffix stripped (`canonicalEventId`, `src/shared/data/registration.ts`). This canonical form is used for **matching** in `materializeAttendance` / `readRegistrationNotifications`, and for **deduplicating** `ownedEventIds` (`ownedKey`, FestipodDataContext) so that one and the same event reached through two paths is not materialized twice. **Careful**: only the **matching** uses the stripped form; the counter is always **written** to the real owned NURI (a live, openable doc) — a stripped id must never serve as a write target or an anchor. This is an **app-side** invariant (not a NextGraph detail): however the lib makes the overlay vary, the app matches on the common base.
Reads go through `useShapeQuery(shape, scope)` — three scoped reads (events/public, profiles/protected, participations/protected) mapped by `shapeAdapters.ts`; `ready` combines their `isSuccess` flags. The app resolves, lists and re-queries nothing ([[rule_document-per-entity]] §Reads).
## There is no identity switch any more
**Immediate visibility of a mutation is a pure optimistic overlay**: `createEvent`/`joinEvent`/`leaveEvent`/profile creation feed `pendingAdd*` / `pendingRemoveIds`; the exposed state is merge(reactive, adds) minus removes, deduped by id. Reconciliation is automatic on push — never a poll ([[rule_no-broker-polling]]).
The app settles its identity **once**, before anything renders (`ensureIdentity()` in `AuthGate`), and offers no way to change it — the surface stopped publishing one (concept `app-security`, [[decision_2026-08-10_the-barrier-names-no-identity]]). So the provider carries **no identity-change reset**: no `useEffect([identifier])`, no cap reset, no registry-cache reset. Those symbols are gone; do not reintroduce a reset for a transition that cannot happen.
## `participantCount` — derived, and written only by the event's owner
> **Why there is nothing left to reset.** An identity-change reset only made sense while a single React tree could outlive a change of identity. It cannot: one page hosts exactly one identity for its whole life, so session state (the read set, the optimistic overlay, the owner-materializer's doc set) has no second identity to leak into.
>
> The **cross-identity isolation** behaviour is still a real Festipod requirement, but proving it needs **two genuinely separate browser contexts**, each signing in for itself. `event/isolation-deux-identites.feature` is `@wip` for exactly that reason (concept `bdd-testing`, [[rule_tests-validate-festipod-not-the-sdk]]).
The counter is **not** incremented by whoever joins: only a document's owner writes to it. The flow is deposit → owner-materialization.
## `useShapeQuery` instrumentation — global spinner + timing
- A participant writes their **own** participation document (protected), then **deposits** a marker into the event's inbox (`depositRegistration` / `depositLeave`, `src/shared/data/registration.ts`).
- The event **owner's** session watches the inboxes of the events it owns (`inbox.watch`, no polling) and **recomputes** `participantCount` on its own event document. It is the counter's only writer, and it reads through `inbox.readSynced` — the synced view — not `inbox.read`.
- **Derived, not incremented**: `materializeAttendance` computes the set of distinct active sign-ups (deposits deduped by `uid`, minus those cancelled). `participantCount = |active set|`. There is **no host baseline** — an event has no host, the declarer is not required to attend, so the counter starts at **0** on creation and moves only on real sign-ups. Because it is a pure function of the inbox, a replay converges: no double count, no phantom decrement. The write is guarded so it only fires on a genuine change.
- **Owner offline = eventual.** While the owner is disconnected the count does not move for anyone else; nothing is lost. The materializer fires directly on connection, not only on a push, and it never locks in a premature 0.
- The counter is an **aggregate**, not the list of named participants — `getEventParticipants` is governed by what the protected scope hands back.
`useShapeQuery` (a `useSyncExternalStore` binding over `watchShape`) instruments **every query cycle**: at the start of a cycle it registers itself in a module-level store `src/shared/data/pendingQueries.ts` (`beginQuery`/`resolveQuery`, a Set of ids — idempotent, safe under StrictMode), and on the first `isPending → isSuccess|isError` transition (the "first result", the readPromise equivalent) it resolves AND logs the delay: `[FestipodData] <shape>/<scope> premier résultat en <N>ms (n=<len>)` (so the delay for Event/public events is visible by name). The `cycleId` is memoized on `[shapeKey, scope]` → an identity/scope switch recreates the observable AND starts a new cycle (a fresh `beginQuery`), and the cleanup resolves on unmount (never stuck). The `usePendingQueries()` hook exposes the number of pending queries; `HomeScreen` renders a `Spinner` (sketchy, `.app-spinner` + `@keyframes app-spin` in `index.css`) next to the « Festipod » title as long as the count is > 0 → it only stops once **all** in-flight queries have received their first result. Any future `useShapeQuery` contributes to it automatically. The measurement lives on the app side (React-perceived delay), **not** in the polyfill.
Which event a deposit belongs to is matched on the **canonical id-form** — see [[knowledge_write-rights-are-ownership]] §Matching, which governs every event-id comparison in this file.
## Logging convention — identity-first prefix, and counter before→after
## Logging convention — identity-first, and the counter before→after
Every DATA log from the provider goes through **`logPrefix`**: `[<currentUserId>][app][data]` when the principal is resolved, `[app][data]` otherwise (a transient connection state). Reason: a run often drives **several sessions at once** (two tabs, a multi-browser scenario) and their lines end up read side by side — an unprefixed line does not say *whose* it is and becomes useless for diagnosing a leak or a stuck counter. **Adding a DATA log = reusing `logPrefix`**, not a bare `console.log`.
Every DATA log goes through **`logPrefix`**: `[<currentUserId or principal>][app][data]`. A run often drives several sessions at once and their lines are read side by side, so a line must say *whose* it is. Adding a DATA log means reusing `logPrefix`, not a bare `console.log`.
Two measurement points are laid down **as a pair** and serve together: the owner's materializer logs `participantCount` **before → after** its write, and the display read logs the value **as exposed to the render**. Comparing them tells a stuck counter apart between a **DATA** problem (never incremented) and a **DISPLAY** problem (incremented but not re-read until the next session). Do not remove one without the other — on their own they diagnose nothing.
Two measurement points are laid down **as a pair**: the owner's materializer logs `participantCount` before → after its write, and the display read logs the value as exposed to the render. Together they separate a **data** problem (never incremented) from a **display** problem (incremented but not re-read). Do not remove one without the other — alone they diagnose nothing.
## There is no identity switch, and nothing to reset
The app settles its identity once, before anything renders, and offers no way to change it (`app-security` → [[decision_2026-08-10_the-barrier-names-no-identity]]). One page hosts exactly one identity for its whole life, so there is no identity-change reset: no `useEffect([identifier])`, no cap reset, no registry-cache reset. Do not reintroduce a reset for a transition that cannot happen. Cross-identity **isolation** is still a real requirement, but proving it needs two genuinely separate browser contexts (`bdd-testing` → [[rule_tests-validate-festipod-not-the-sdk]]).
## Mutations are no-ops in local mode
In local/demo mode (`useLocalData`), `createEvent`/`joinEvent`/`leaveEvent`/`updateEvent` are **no-ops** (a `console.log`, the state does not change) — yet the screens still show a **success toast** (« Tu participes »). Potentially misleading UX: the user believes they signed up when nothing has changed. See [[knowledge_data-modes]] for how the provider is chosen based on status.
In local/demo mode (`useLocalData`), `createEvent`/`joinEvent`/`leaveEvent`/`updateEvent` are **no-ops** (a log, no state change) — yet the screens still show a success toast. Misleading UX, unchanged. See [[knowledge_data-modes]].
@@ -25,4 +25,4 @@ The app has **two modes**, both consumed through the `useFestipodData()` hook:
- `connected``NgDataProvider` (real wallet data)
- `error``LocalDataProvider` with the seed (graceful fallback)
> Mutations are **genuinely persisted** in connected mode (`joinEvent` writes a Participation and notifies the meeting point's host, `leaveEvent` deletes authoritatively see [[caveat_participation-deletion]]). In local/demo mode they are no-ops (see [[knowledge_context-internals]]).
> Mutations are **genuinely persisted** in connected mode: `joinEvent` writes a Participation into its own document and **deposits** into the event's inbox (the deposit is the delivery — no notification is written for the host), `leaveEvent` deletes authoritatively (see [[caveat_participation-deletion]]). Both **reject** rather than returning quietly when they cannot write, and the screen's confirmation follows the write. In local/demo mode they are **no-ops that still show a success toast** — see [[knowledge_context-internals]].
@@ -1,24 +1,34 @@
---
type: knowledge
summary: The Fp* data types — Event, UserProfile, Participation, MeetingPoint and Notification are persisted in NextGraph (SHEX shapes + ORM); only Friendship stays local-only (app-TS)
last_checked: 2026-07-03
summary: The Fp* app types and their SHEX shapes — Event (no host), UserProfile, Participation, MeetingPoint and Notification are persisted, Friendship stays local-only; the generated ORM names carry NO Fp prefix and are aliased at the import sites
last_checked: 2026-08-16
---
# Data entities
`src/shared/data/types.ts`:
`src/shared/data/types.ts` holds the app's own types; `src/shared/shapes/shex/festipodShapes.shex` holds what is actually persisted.
| Type | Persistence | Key fields |
|---|---|---|
| `FpEventData` | SDK (Event shape) | id, title, date, location, distance, themes |
| `FpUserData` | SDK (UserProfile shape) | id, name, username, bio, city, counts |
| `FpParticipationData` | SDK (Participation shape) | eventId + userId + confirmed |
| `FpMeetingPointData` | SDK (MeetingPoint shape) | eventId, location, time, host |
| `FpNotificationData` | SDK (Notification shape) | kind, target, source |
| `FpEventData` | SDK (Event shape) | title, date, location, distance, participantCount, coverImage |
| `FpUserData` | SDK (UserProfile shape) | name, initials, username, role, isPublic |
| `FpParticipationData` | SDK (Participation shape) | event + user + isConfirmed |
| `FpMeetingPointData` | SDK (MeetingPoint shape) | event, host, title, place, time |
| `FpNotificationData` | SDK (Notification shape) | recipient, type, ref, payload, timestamp, isRead |
| `FpFriendshipData` | **local-only** | userId + friendId |
`MeetingPoint` and `Notification` do have real **SHEX shapes** (`src/shared/shapes/shex/festipodShapes.shex`) with generated ORM bindings (`festipodShapes.shapeTypes.ts`: `FpMeetingPointShapeType`, `FpNotificationShapeType`) and **are persisted**. A `Notification` is created in particular when signing up to a meeting point (`joinEvent`).
**An event has no host.** `hostName`/`hostInitials` are gone from the type and the shape alike — the event is only the anchor, and the host lives one level down on the meeting point (`FpMeetingPointData.hostId`, SHEX `fp:MeetingPoint.host`). See concept `functional-domain`, [[knowledge_actors-and-concepts]].
`Friendship` has **no** SHEX shape and no persistence — it stays app-TS-only (see [[knowledge_nextgraph-stack]]).
**A Notification is no longer created when someone signs up.** The joiner deposits into the event's inbox and the **owner** builds the notification from what it reads there — see [[knowledge_context-internals]] §Nothing succeeds in silence.
> Pitfall: even for `FpEvent` (which is persisted), several fields of the app type are **not** in the shape and are lost when connected — see [[caveat_event-fields-not-persisted]].
`Friendship` has **no** SHEX shape and no persistence — it stays app-TS-only ([[knowledge_nextgraph-stack]]).
## The generated ORM names carry no `Fp` prefix
The generator emits `Event`, `UserProfile`, `Participation`, `MeetingPoint`, `Notification` (and `EventShapeType`, `UserProfileShapeType`, …) — **without** the `Fp` prefix earlier bindings had.
**It cannot be restored at the generator.** The emitted name derives from the shape IRI, and those IRIs are the **persisted RDF classes**: renaming them to regain a prefix would rename the data. So the app **aliases at its import sites** (`… as FpEvent`, `… as FpEventShapeType`) — three of them, in the data context and the two test harnesses. That keeps the downstream names unchanged and, just as importantly, stops the DOM's own `Event` and `Notification` from being shadowed.
Alias at the import; never rename in the generated files, which `bun run build:orm` overwrites ([[knowledge_nextgraph-stack]]).
> Pitfall: several fields of `FpEventData` are **not** in the shape and are lost when connected — [[caveat_event-fields-not-persisted]].
@@ -17,14 +17,16 @@ Festipod persists through **`@ng-eventually/polyfill`**. What that surface offer
The reactive ORM (`useShape`) is built on **SHEX shapes**: `src/shared/shapes/shex/festipodShapes.shex` defines:
- **Event** — title, description, dates, location, themes, participants
- **UserProfile** — name, username, bio, city, visibility
- **Event** — title, description, date, location, distance, participantCount, coverImage. **No host**: an event is only the anchor ([[knowledge_entities]]).
- **UserProfile** — name, initials, username, role, isPublic. The first three are **mandatory**, which is why a new profile is written with placeholders rather than empty.
- **Participation** — links an event and a user, confirmation status
- **MeetingPoint** — a meeting point (location, time, host)
- **Notification** — a notification (created in particular when signing up to a meeting point)
- **MeetingPoint** — a meeting point (event, host, title, place, time)
- **Notification** — recipient, type, ref, payload, timestamp, isRead
The ORM bindings are generated in `src/shared/shapes/orm/` (`*.schema.ts`, `*.shapeTypes.ts`, `*.typings.ts`). **Regenerate** with `bun run build:orm` after any `.shex` change.
> **Regenerating is not a no-op, even with an unchanged `.shex`.** The committed bindings had drifted from what the generator emits, so a regeneration produces a diff beyond your own change — read it rather than assuming it is yours. And the emitted names carry **no `Fp` prefix**; the app aliases at its import sites instead, because the name derives from the shape IRI and those IRIs are the persisted RDF classes ([[knowledge_entities]]). Never hand-edit the generated files.
> **The canonical way to read is the reactive hook.** `useShape`/`watchShape`: you subscribe to a shape on a scope, you get the current value, and the component re-renders on every change — subscription/push, never polling; one-shot reads are the exception. The read/reactivity contract is [[contract_polyfill-surface]] and nothing else.
> `Friendship` has **no** SHEX shape and no persistence — it stays app-TS-only (see [[knowledge_entities]]).
@@ -1,17 +1,26 @@
---
type: knowledge
summary: seedData.ts provides deterministic fixtures (10 users, events, participations) with CURRENT_USER_ID = 'user-1' (Marie Dupont); used in demo mode and by the @ui tests
summary: seedData.ts holds deterministic fixtures (14 users with CURRENT_USER_ID = 'user-1', 5 events) used by demo mode and the @ui tests; no fixture reaches a CONNECTED wallet by any route any more — bootstrapWallet is the single enforcement point of that master switch
last_checked: 2026-08-16
---
# Seed data
`src/shared/data/seedData.ts` provides **deterministic** fixtures:
`src/shared/data/seedData.ts` holds **deterministic** fixtures: 14 users (`CURRENT_USER_ID = 'user-1'`, Marie Dupont), 5 events, participations, meeting points and friendships.
- 10 users — **Marie Dupont = the current user**, `user-1`
- Several events (dates, locations, themes)
- Participations, meeting points, friendships
- `CURRENT_USER_ID = 'user-1'`
## Where they are still used
These fixtures serve (a) **demo mode** (`LocalDataProvider`, see [[knowledge_data-modes]]) and (b) the **`@ui`** tests, which render the screens against this predictable data (`Marie Dupont`/`@mariedupont` = currentUser, `Jean Durand`/`@jeandurand` exists, etc. — see concept `bdd-testing`).
- **Demo / disconnected mode** — `LocalDataProvider` reads them straight into React state ([[knowledge_data-modes]]).
- **The `@ui` rendering tests** — they render screens against this predictable data (`Marie Dupont`/`@mariedupont` is the current user, `Jean Durand`/`@jeandurand` exists…). Concept `bdd-testing`.
> `bootstrapWallet()` (`src/shared/utils/ngBootstrap.ts`) seeds this data into the wallet in connected mode — triggered only by an explicit user action (« Charger données de test »).
Neither path writes to a wallet, which is why both are untouched by the switch below.
## No fixture reaches a CONNECTED wallet, by any route
A **master switch**`fixtureSeedEnabled()` in `src/shared/utils/autoSeed.ts` — is **off**, a product decision: no fixture is written into a connected wallet at all, neither by the opt-in automatic seed nor by an explicit "load test data" action.
**`bootstrapWallet` (`src/shared/utils/ngBootstrap.ts`) is the single enforcement point.** Every route into a wallet funnels through that one function, so the switch cannot be walked around by a screen, a bridge or a test harness; a caller simply gets the ordinary "nothing was seeded" answer, which is exactly true. Call sites consult the switch too, but only so they neither log nor await work that will not happen — the enforcement is not theirs. A unit test fails if a document is created after all.
**Off, not deleted.** The fixtures and the seeding code stay, because the two paths above need them and neither writes to a wallet. If the switch is ever turned back on, what follows still applies: the seed is **linear in the number of documents** (one document per entity, each a serial round trip), so the connected seed writes only what is needed — all events, a few profiles, and no participations, which the sign-up scenarios create live. Events are the only entities whose inbox is opened at seed time, because events are what people deposit into.
> **Consequence, live now**: the `@data` suite has lost its fixtures — concept `bdd-testing`, [[caveat_data-suite-has-no-fixtures]].
@@ -0,0 +1,35 @@
---
type: knowledge
summary: getEventOwnership answers mine / not-mine / unknown from listMyEntityDocs('public') — owning a document IS being able to write it, the ruling is rebuilt on every listing rather than accumulated, and UNKNOWN is a real third answer callers must handle
last_checked: 2026-08-16
---
# Write rights are ownership, read from the owned list
The app never asks whether it may write a document; it asks whether it **owns** one, because [[contract_polyfill-surface]] makes those the same fact. Only an owner writes, a read key never grants a write, and no call adds a writer — so `listMyEntityDocs('public')` is the whole answer, and no probe call will be added (`app-security` → [[decision_2026-08-16_write-rights-are-the-owned-list]]).
## The answer is three-state
`getEventOwnership(eventId)` (`FestipodDataContext`) returns `'mine' | 'not-mine' | 'unknown'`:
- **`mine`** — the event is in the owned set, either because a listing returned it or because this session created it and claimed it directly. Checked **first**, so a fresh creation is authoritative before any listing has answered and never loses to a stale miss.
- **`not-mine`** — a listing has *resolved* and did not return this event, so it was genuinely looked past.
- **`unknown`** — everything else: no listing has landed, the listing failed, or the event arrived after the last one. A rejection means UNKNOWN, never "this session owns nothing"; reading it as `not-mine` is how an owner is silently denied their own event.
**Callers must treat `unknown` as its own case.** It is not a polite `not-mine`, and it is not a boolean waiting to settle.
## The ruling is REBUILT, never accumulated
Every listing **re-adjudicates every visible event**: the ruled-out set is recomputed from scratch, so a later listing can overturn an earlier one. An earlier version latched the verdict into a boolean, which denied an owner their own event forever once a single listing had missed it. Do not reintroduce accumulation — add to the owned set, but rebuild the ruled-out set.
Re-listing is driven by **arrivals, not by time**: while some visible event is neither owned nor ruled out, one more listing is taken; the set then empties and the effect falls silent. That is a push-driven retry, not a poll ([[rule_no-broker-polling]] in `bdd-testing`).
## Known residual — accepted, do not paper over
"Not mine" is inferred from **absence**, and the reactive read and the listing are **separate mechanisms**. An event can therefore be on screen a moment before a listing can see it, and it is ruled out for exactly that window; it is re-examined only if some other unclassified event later triggers a listing. Closing the window needs a timer (forbidden) or a capability probe (ruled out). It is left visible and stated on purpose.
## Matching is on the canonical id-form
An event's `@id` is its document NURI, and the same event can be reached under two overlays (`:v:<overlay>`). Every ownership comparison — the owned set, the ruled-out set, the lookup — runs on the **canonical** form (`canonicalEventId`, `src/shared/data/registration.ts`): the base repo id with any overlay suffix stripped. **Matching only.** A stripped id is never a write target nor an anchor; the counter is always written to the real owned NURI.
> Two screen-side consumers, one answer: the control that **offers** the write and the route that **performs** it ask the same question and treat `unknown` the same way — `app-architecture` → [[knowledge_screen-pattern]]. Why the answer is this and will stay this: `app-security` → [[decision_2026-08-16_write-rights-are-the-owned-list]].
@@ -44,6 +44,6 @@ So: **write = direct SPARQL into the entity's document** (immediate, per-documen
The same goes for **mutating an existing field** (e.g. `participantCount`): mutating a value in memory does not hold — the reactive read re-reads the **persisted** value from the broker (reverting to the old value) → persist through SPARQL (`updateEntityField`: DELETE then INSERT of the triple) so that the change sticks and the re-read agrees. Each field is written with the **right RDF term** according to the SHEX shape (xsd:integer / float / boolean, or an IRI for the `Participation.event`/`.user` references) — a missing or mistyped mandatory field makes the read **discard the entity** (it never round-trips). The entity's **subject** = its document's **NURI** (one entity = one document), which yields an `@id` of the form `did:ng:…`.
Identity corollary: a `Participation` carries a **mandatory** `fp:user` — never write it with an empty value (the entity would be discarded on read). What goes in it is `currentUserId`, i.e. the **NURI of the profile document the app read back in its own protected scope**; the app derives it from nothing, because it names no identity ([[decision_2026-08-10_the-barrier-names-no-identity]] in `app-security`). It therefore **arrives late**: a mutation fired before the protected read lands must refuse rather than write, which is what `joinEvent` does. See [[knowledge_context-internals]].
Identity corollary: a `Participation` carries a **mandatory** `fp:user` — never write it with an empty value (the entity would be discarded on read). What goes in it is `currentUserId`, i.e. the NURI of **the profile document this session OWNS** — never the identity it signed in as, which is opaque and never written into an entity ([[decision_2026-08-10_the-barrier-names-no-identity]] in `app-security`). It therefore **arrives late**: a mutation fired before that document resolves must **reject** rather than write, which is what `joinEvent` and `leaveEvent` do — they throw, and the screen's confirmation follows the write. See [[knowledge_context-internals]].
Sharing keys off a different space: `inbox.share(doc, toUser)` names a **person**, so bilateral connections (`declareConnections`) are declared with **normalized profile handles**, not document NURIs — the data context maps each peer IRI to that key before declaring, and skips peers whose profile it cannot read (they cannot be named).
@@ -13,7 +13,7 @@ Vocabulary reference. Every actor is a specialization of an authenticated **user
|---|---|
| **User** | Anyone with an account (a NextGraph wallet). The root of all the others. |
| **Connection ("friend")** | Another user I am connected to. Used to scope lists ("my friends who are attending…") and trust. Bilateral (accepted on both sides). |
| **Declarer of an event** | The user who inserted the event into Festipod. *Not necessarily the real organizer*: just whoever references it. **There is NO notion of "event host"**: the event is public, merely flagged by its declarer, who **is NOT required to attend** at creation no participation is written, the counter starts at 0, and the declarer can join/leave like anyone else (a product decision; on the data side see data-layer/[[knowledge_context-internals]] §participantCount). The "host" remains an actor at the **meeting point** level (next row), not at the event level. |
| **Declarer of an event** | The user who inserted the event into Festipod. *Not necessarily the real organizer*: just whoever references it. **There is NO notion of "event host"** — and this now holds all the way down: the event carries no host field at all, in the shape or in the app type (`data-layer` → [[knowledge_entities]]). The event is public, merely flagged by its declarer, who **is NOT required to attend**: at creation no participation is written, the counter starts at 0, and the declarer signs up and withdraws through the same path as anyone else. The declarer is nonetheless the event document's **owner**, hence its only writer (`app-security` → [[decision_2026-08-16_write-rights-are-the-owned-list]]). The "host" is an actor at the **meeting point** level (next row), never at the event level. |
| **Host of a meeting point** | The user who created a meeting point attached to an event. |
| **Participant in a meeting point** | A user signed up to a meeting point; in effect they become an attendee of the parent event. |
| **Member of an interest community** | A user subscribed to a community in order to discover the events it references. |
@@ -1,6 +1,6 @@
---
type: knowledge
summary: The product model of confidentiality and discovery — every entity lives in a SCOPE (public / protected / private) depending on who must see it; events & meeting points = public, network profile & participations = protected (network), settings = private; bilateral connections = the dialog scope; discovery = reading the public scope
summary: The product model of confidentiality and discovery — every entity lives in the SCOPE matching who must read it (events & meeting points public, network profile & participations protected, settings private, bilateral connections dialog); discovery is reading the public scope
---
# Data scopes and discovery
@@ -35,9 +35,14 @@ A user discovers the events they did not create simply by **reading the `public`
> **Sign-up notification (product intent).** Signing up to a meeting point notifies its host: identified if the participant is one of the host's connections, **unnamed otherwise**. This "identified if known, unnamed otherwise" falls out of scope placement — the host can read the sign-up, but not the *protected* profile it points at unless they are connected. The app states the intent; it implements no filter of its own.
## Settled: the event write model is OWNER-ONLY
Who may update a declared event was long open — owner, wiki, or immutable. It is **owner: the declarer alone**, and not as a free product choice. The data model leaves no other reading: only a document's owner writes it, a read key never grants a write, and no call adds a writer, so "wiki" is not expressible at all. The declarer's own listing of their documents is what says which events are theirs, permanently (`app-security` → [[decision_2026-08-16_write-rights-are-the-owned-list]]).
This constrains **deduplication**: two declarations of the same real-world event cannot be merged by one declarer editing the other's document ([[brief_2026-06-15_event-deduplication]]).
## Open questions (business)
- **Event write model**: owner (the declarer alone) / wiki (everyone) / immutable? Central to deduplication ([[brief_2026-06-15_event-deduplication]]).
- **The host's identity towards an ordinary user**: a meeting point is readable by all, but should its host be identifiable? (pseudonym by default, a business card per meeting point, or anonymity lifted only for connections.)
- **Which fields of a sign-up can be edited**; **"friends of friends" discoverability**.
@@ -15,7 +15,9 @@ summary: What is implemented today (event + meeting point lifecycle, profiles, c
- User profile, profile update, profile sharing
- Friends list (connections), another user's profile
> Signing up to / withdrawing from a meeting point is **genuinely wired** on the data side: `joinEvent` persists a Participation, notifies the meeting point's host and creates a Notification; `leaveEvent` deletes the Participation authoritatively (see concept `data-layer`, [[caveat_participation-deletion]] on the data-layer side). Public discovery — a user seeing another user's public event — works too.
> Signing up to / withdrawing from a meeting point is **genuinely wired** on the data side: `joinEvent` persists a Participation and deposits into the event's inbox, where its owner reads it; `leaveEvent` deletes the Participation authoritatively (concept `data-layer`, [[caveat_participation-deletion]]). Neither succeeds in silence — they reject rather than returning quietly, and the confirmation the user sees follows the write. Public discovery — a user seeing another user's public event — works too.
> **Updating an event is reserved to its declarer**, and the interface says so rather than discovering it late: the edit route is decided by ownership, and the confirmation follows the write instead of preceding it (concept `app-architecture`, [[knowledge_screen-pattern]]). Owner-only is not a policy choice here — it is the only reading the data model allows ([[knowledge_data-scopes-and-discovery]]).
> **The reconnection promise is guarded, not assumed.** "I come back later and my events and sign-ups are still there" is a product promise like any other, and it is the one whose failure would be least visible — nothing on screen distinguishes "you have nothing" from "it did not come back". The `src/modules/event/features/reconnexion-*.feature` scenarios of the `event` module are its non-regression guard; keep them meaningful, and read [[caveat_reconnexion-froide-local-vs-broker]] (concept `bdd-testing`) before trusting one of them green, because the natural setup proves less than it looks.
@@ -136,6 +136,7 @@ When('l\'utilisateur attend la fin du chargement', async function (this: Festipo
const buttons = Array.from(document.querySelectorAll('button'));
return !buttons.some(b => b.textContent?.includes('Chargement...'));
},
undefined,
{ timeout: 60000 },
);
await this.appFrame!.waitForTimeout(2000);
@@ -152,6 +153,7 @@ Then('l\'écran d\'accueil affiche des événements', async function (this: Fest
const appeared = await this.appFrame!.waitForFunction(
() => document.querySelectorAll('.app-card').length > 0,
undefined,
{ timeout: 15000 },
).then(() => true).catch(() => false);
@@ -101,8 +101,6 @@ export function CreateEventScreen() {
// truth (and every other viewer) was 0.
participantCount: 0,
themes: ['Social'],
hostName: 'Moi',
hostInitials: 'MD',
});
} catch (err) {
console.error('[CreateEvent] createEvent failed:', err);
@@ -238,11 +236,6 @@ export function CreateEventScreen() {
<Text style={{ margin: '4px 0', fontSize: 13, color: '#888' }}>
{ev.date} · {ev.location}
</Text>
{ev.hostName && (
<Text style={{ margin: '0 0 10px 0', fontSize: 12, color: '#888' }}>
Relayé par {ev.hostName}
</Text>
)}
<Button
variant="primary"
style={{ width: '100%', padding: 10, fontSize: 13 }}
+40 -10
View File
@@ -14,6 +14,7 @@ export function EventDetailScreen() {
leaveEvent,
getEventParticipants,
getEventMeetingPoints,
getEventOwnership,
} = useFestipodData();
const event = eventId ? getEvent(eventId) : undefined;
@@ -29,7 +30,19 @@ export function EventDetailScreen() {
lieu: mp.location,
}));
const isOwner = true;
// EDITING IS OWNING. Only a document's owner writes to it, and nothing delegates
// a write, so "may I edit this event" is answered entirely by whether this
// session owns the event's document — no permission call, no extra read.
//
// The third answer, `'unknown'`, is rendered as a DISABLED affordance rather
// than resolved either way. Hiding it would be the worst outcome: an owner
// would be told, silently and wrongly, that their own event is not theirs, with
// nothing on screen to suggest otherwise. Showing it live would be the opposite
// lie — a control that promises an edit it may not be able to make, and only
// says so after the person has typed. Visible-but-inert says the true thing:
// the answer is still coming.
const ownership = eventId ? getEventOwnership(eventId) : 'unknown';
const canEdit = ownership === 'mine';
// Preview list shows the OTHER participants (deliberate — the total is in the
// header count; the full list at "Voir tous les participants" shows everyone).
// Compare on the PROFILE id: `currentUser.id` is the resolved profile NURI, the
@@ -40,23 +53,23 @@ export function EventDetailScreen() {
const handleToggleJoin = () => {
if (!eventId) return;
// The optimistic toast stays immediate (the overlay already reflects the
// change), but the write can genuinely FAIL — a participation document that
// cannot be recorded throws instead of reading empty forever, and an
// unconfirmed withdrawal throws too. Surface it rather than leave the user
// with a success message and nothing written.
// THE CONFIRMATION FOLLOWS THE WRITE. It used to be shown on the spot, before
// the call had settled, so a sign-up that wrote nothing still read as
// « Tu participes ». The screen's own list flips immediately anyway (the data
// layer's optimistic overlay), so nothing is lost by waiting for the truth.
const confirmed = (message: string, tone: 'success' | 'info') => () => showToast(message, tone);
const failed = (message: string) => (err: unknown) => {
console.error('[EventDetail] participation write failed:', err);
showToast(message, 'error');
};
if (joined) {
void Promise.resolve(leaveEvent(eventId))
.then(confirmed('Participation annulée', 'info'))
.catch(failed("La désinscription n'a pas pu être enregistrée"));
showToast('Participation annulée', 'info');
} else {
void Promise.resolve(joinEvent(eventId))
.then(confirmed('Tu participes à cet événement', 'success'))
.catch(failed("L'inscription n'a pas pu être enregistrée"));
showToast('Tu participes à cet événement', 'success');
}
};
@@ -85,8 +98,25 @@ export function EventDetailScreen() {
<div style={{ fontSize: 12, color: '#888' }}>{event.distance} km</div>
)}
</div>
{isOwner && (
<span onClick={() => navigate(`/events/${eventId}/edit`)} style={{ cursor: 'pointer', fontSize: 18, color: '#888' }}></span>
{canEdit && (
<span
onClick={() => navigate(`/events/${eventId}/edit`)}
title="Modifier l'événement"
style={{ cursor: 'pointer', fontSize: 18, color: '#888' }}
></span>
)}
{ownership === 'unknown' && (
// DELIBERATELY NOT A ✎. The slot stays occupied, so an owner is never
// silently told the event is not theirs — but a pending control must
// not look like the actionable one it is not: a greyed-out twin of the
// pencil reads as "edit, broken" and invites a click that does nothing.
// A distinct mark reads as "still working it out", which is the truth.
<span
aria-busy="true"
aria-label="Vérification de vos droits de modification"
title="Vérification de vos droits de modification…"
style={{ cursor: 'default', fontSize: 18, color: '#ddd' }}
></span>
)}
</div>
@@ -26,8 +26,6 @@ export function MeetingPointsScreen() {
eventId,
location: title || lieu || 'Point de rencontre',
time: when || duration,
hostName: currentUser?.name?.split(' ')[0] ?? 'Moi',
hostInitials: currentUser?.initials ?? '?',
});
showToast(title ? `Point de rencontre créé : ${title}` : 'Point de rencontre créé', 'success');
navigate(`/events/${eventId}`);
@@ -6,8 +6,16 @@ import { useNavigate, useParams } from '../../../app/router';
export function UpdateEventScreen() {
const navigate = useNavigate();
const { eventId } = useParams();
const { getEvent, updateEvent } = useFestipodData();
const { getEvent, updateEvent, getEventOwnership } = useFestipodData();
const event = eventId ? getEvent(eventId) : undefined;
// THE ROUTE IS GUARDED BY THE SAME ANSWER EventDetailScreen uses for its pencil
// icon — "editing is owning", so there is nothing else to ask. `'unknown'` is a
// real third case (the owned-document listing may not have landed yet), and it
// is rendered as its OWN pending state below rather than folded into either
// side: showing the form would let a non-owner edit on a still-resolving
// guess, and bouncing the user out would tell an actual owner, wrongly, that
// the event is not theirs.
const ownership = eventId ? getEventOwnership(eventId) : 'unknown';
const [title, setTitle] = useState(event?.title ?? '');
const [startDate, setStartDate] = useState(event?.startDate ?? '');
@@ -22,7 +30,11 @@ export function UpdateEventScreen() {
const dateLabel = startDate
? (endDate ? `${startDate} - ${endDate}` : startDate)
: event?.date ?? '';
updateEvent(eventId, {
// THE CONFIRMATION FOLLOWS THE WRITE — same idiom as EventDetailScreen's
// participation toggle. Showing the toast and navigating away before
// `updateEvent` has settled announced success whether or not anything was
// actually written; a rejection must be told as a failure, not swallowed.
void Promise.resolve(updateEvent(eventId, {
title,
date: dateLabel,
startDate,
@@ -31,11 +43,54 @@ export function UpdateEventScreen() {
endTime,
location,
description,
});
showToast('Événement mis à jour', 'success');
navigate(`/events/${eventId}`);
}))
.then(() => {
showToast('Événement mis à jour', 'success');
navigate(`/events/${eventId}`);
})
.catch((err: unknown) => {
console.error('[UpdateEvent] event update failed:', err);
showToast("La modification n'a pas pu être enregistrée", 'error');
});
};
if (ownership === 'not-mine') {
// A resolved, definitive answer — not a guess. Block the form outright
// rather than let a non-owner type into a write that will only ever reject.
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Modifier l'événement"
left={<span onClick={() => navigate(`/events/${eventId}`)} style={{ cursor: 'pointer', fontSize: 18 }}></span>}
/>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
<Text style={{ textAlign: 'center', color: '#888' }}>
Vous ne pouvez pas modifier cet événement.
</Text>
</div>
</div>
);
}
if (ownership === 'unknown') {
// The listing hasn't landed yet — neither "mine" nor "not mine" is true, so
// neither the form nor a bounce-out is shown. Same wording as
// EventDetailScreen's pending pencil affordance.
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
title="Modifier l'événement"
left={<span onClick={() => navigate(`/events/${eventId}`)} style={{ cursor: 'pointer', fontSize: 18 }}></span>}
/>
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}>
<Text aria-busy="true" style={{ textAlign: 'center', color: '#888' }}>
Vérification de vos droits de modification
</Text>
</div>
</div>
);
}
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Header
@@ -61,6 +61,7 @@ When(
const freshFrame = await pool.setupBrokerPage!(freshPage, pool.harnessUrl!);
await freshFrame.waitForFunction(
() => (window as any).__testData?.ready === true,
undefined,
{ timeout: 60000 },
);
// Resolve A's principal (profile read hydrated) before the reactive Then reads.
@@ -82,7 +82,7 @@ Then('l\'événement {string} finit par apparaître sur la page fraîche A en la
console.log(`[LongPoll] t=${elapsed}ms still ABSENT — forcing a full reload (#${reloadIdx}) to re-attempt the barrier…`);
try {
freshFrame = await pool.setupBrokerPage!(freshPage, pool.harnessUrl!);
await freshFrame.waitForFunction(() => (window as any).__testData?.ready === true, { timeout: 60000 });
await freshFrame.waitForFunction(() => (window as any).__testData?.ready === true, undefined, { timeout: 60000 });
await freshFrame.evaluate(async () => { await (window as any).__testData.ensureCurrentUser(); });
(this as any).recoFreshFrame = freshFrame;
} catch (e) {
@@ -113,7 +113,7 @@ When('une page fraîche pour la MÊME identité A recharge sur le même wallet',
freshPage.on('console', (msg) => { console.log(`[FreshApage:${msg.type()}]`, 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 });
await freshFrame.waitForFunction(() => (window as any).__testData?.ready === true, undefined, { 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 () => {
@@ -20,6 +20,7 @@ Given('le portefeuille contient des données de test', async function (this: Fes
// EventsScreen renders Card components (class app-card) when events load.
const hasData = await this.appFrame!.waitForFunction(
() => document.querySelectorAll('.app-card').length > 0,
undefined,
{ timeout: 30000 },
).then(() => true).catch(() => false);
@@ -69,6 +70,7 @@ When('l\'utilisateur remplit le formulaire de création d\'événement:', async
const formReady = await this.appFrame!.waitForFunction(
() => !!document.querySelector('input[placeholder="Donnez un nom à votre événement"]'),
undefined,
{ timeout: 10000 },
).then(() => true).catch(() => false);
@@ -131,6 +133,7 @@ When('l\'utilisateur modifie le champ lieu avec {string}', async function (this:
// adjacent to that label.
await this.appFrame!.waitForFunction(
() => document.getElementById('root')?.textContent?.includes('Lieu') ?? false,
undefined,
{ timeout: 10000 },
);
await this.appFrame!.evaluate((val: string) => {
@@ -164,6 +167,7 @@ When('l\'utilisateur clique sur un événement de l\'accueil', async function (t
});
const homeHasCards = await this.appFrame!.waitForFunction(
() => document.querySelectorAll('.app-card').length > 0,
undefined,
{ timeout: 5000 },
).then(() => true).catch(() => false);
if (!homeHasCards) {
@@ -173,6 +177,7 @@ When('l\'utilisateur clique sur un événement de l\'accueil', async function (t
});
await this.appFrame!.waitForFunction(
() => document.querySelectorAll('.app-card').length > 0,
undefined,
{ timeout: 10000 },
);
}
@@ -211,6 +216,7 @@ When('l\'utilisateur clique sur un événement de la liste', async function (thi
// EventsScreen also uses Card with .app-card class.
await this.appFrame!.waitForFunction(
() => document.querySelectorAll('.app-card').length > 0,
undefined,
{ timeout: 10000 },
);
const clicked = await this.appFrame!.evaluate(() => {
@@ -93,6 +93,7 @@ Given('l\'utilisateur crée un événement {string} via le vrai formulaire', { t
});
const formReady = await frame.waitForFunction(
() => !!document.querySelector('input[placeholder="Donnez un nom à votre événement"]'),
undefined,
{ timeout: 15000 },
).then(() => true).catch(() => false);
if (!formReady) {
@@ -189,6 +190,7 @@ When('l\'utilisateur ferme et rouvre l\'app sous la même identité dans une ses
const root = document.getElementById('root');
return !!root && root.innerHTML.length > 100;
},
undefined,
{ timeout: 60000 },
);
// Let NG connect + the cold-start read path run.
@@ -42,6 +42,7 @@ Then("l'accueil rend un contenu d'application réel", async function (this: Fest
document.querySelector('[aria-label="Relayer un événement"]') !== null;
return hasNavbar && hasRelayer;
},
undefined,
{ timeout: 15000 },
).then(() => true).catch(() => false);
@@ -18,9 +18,18 @@ export function UpdateProfileScreen() {
const handleSave = () => {
const fullName = `${firstName} ${lastName}`.trim();
const initials = `${firstName[0] ?? ''}${lastName[0] ?? ''}`.toUpperCase();
updateProfile({ name: fullName, initials, username, city, bio });
showToast('Profil mis à jour', 'success');
navigate('/profile');
// The edit REJECTS when no profile of mine is resolved — it refuses to write
// into someone else's document. So confirm and leave the screen only once the
// write has settled, never before.
void Promise.resolve(updateProfile({ name: fullName, initials, username, city, bio }))
.then(() => {
showToast('Profil mis à jour', 'success');
navigate('/profile');
})
.catch((err: unknown) => {
console.error('[UpdateProfile] profile write failed:', err);
showToast("Le profil n'a pas pu être enregistré", 'error');
});
};
return (
@@ -27,6 +27,7 @@ Then('le navigateur {string} est connecté à NextGraph', async function (this:
// __testData.ready flips true only once the NG session is connected.
await handle.appFrame!.waitForFunction(
() => (window as any).__testData?.ready === true,
undefined,
{ timeout: 30000 },
);
});
+431 -89
View File
@@ -10,8 +10,6 @@ import type {
import {
depositRegistration,
depositLeave,
buildNotification,
insertNotification,
readRegistrationNotifications,
materializeAttendance,
deleteParticipation,
@@ -41,19 +39,41 @@ import {
import { useCurrentPrincipal } from '../utils/currentPrincipal';
import { useShapeQuery } from '../data/useShapeQuery';
import { adaptEvents, adaptUsers, adaptParticipations } from '../data/shapeAdapters';
// The ORM generator emits BARE shape names (`EventShapeType`, `Event`, …), taken
// from each shape's IRI local name. They are aliased back to the `Fp*` spelling
// HERE, at the import, so nothing downstream depends on the generator's naming:
// `Event` and `Notification` are DOM globals, and the bare names collide with the
// app's own vocabulary (`FpEventData` & co. in `data/types.ts`). The prefix cannot
// be restored at the generator — the emitted name follows the shape IRI, and those
// IRIs are the persisted RDF classes (`ENTITY_TYPE`), not ours to rename.
import {
FpEventShapeType,
FpUserProfileShapeType,
FpParticipationShapeType,
EventShapeType as FpEventShapeType,
UserProfileShapeType as FpUserProfileShapeType,
ParticipationShapeType as FpParticipationShapeType,
} from '../shapes/orm/festipodShapes.shapeTypes';
import { writeEntity, updateEntityField, ENTITY_TYPE, str, int, flt, bool, iri } from '../data/entityWrites';
import { bootstrapWallet, type BootstrapResult } from '../utils/ngBootstrap';
import { autoSeedEnabled, shouldAutoSeed } from '../utils/autoSeed';
import { autoSeedEnabled, fixtureSeedEnabled, shouldAutoSeed } from '../utils/autoSeed';
// ============================================================================
// Context interface
// ============================================================================
/**
* Whether this session may WRITE an event's document — which, by the SDK
* contract, is exactly whether it OWNS that document: *"Only a document's owner
* writes to it. Holding its read key never grants a write"*, and *"No delegated
* writing. A received key never grants a write, and no call adds a writer to a
* document"*. So there is nothing extra to ask: owning it IS being able to write
* it, and the owned-document listing already answers that.
*
* `'unknown'` is a REAL third answer, never a polite `'not-mine'`. The listing
* may not have landed yet, or may have failed — and a rejection means UNKNOWN,
* never "this session owns nothing". Collapsing it into `'not-mine'` is how you
* end up telling an owner, silently and wrongly, that the thing is not theirs.
*/
export type EventOwnership = 'mine' | 'not-mine' | 'unknown';
interface FestipodDataContextValue {
currentUserId: string;
currentUser: FpUserData | undefined;
@@ -80,6 +100,11 @@ interface FestipodDataContextValue {
isParticipating(eventId: string, userId?: string): boolean;
getFriends(userId?: string): FpUserData[];
getEventMeetingPoints(eventId: string): FpMeetingPointData[];
/**
* May this session WRITE this event's document? Callers MUST treat `'unknown'`
* as its own case — see {@link EventOwnership}.
*/
getEventOwnership(eventId: string): EventOwnership;
selectedEventId: string;
setSelectedEventId(id: string): void;
@@ -116,24 +141,44 @@ function nextId(prefix: string): string {
// old form, or those participations render as "participant inconnu".
const USER_PRINCIPAL_PREFIX = 'urn:festipod:user:';
/** Waits between attempts at resolving the owned-event set (see its effect). */
/** Waits between attempts at resolving an owned-document set (see the effects). */
const OWNED_RETRY_BACKOFF_MS = [500, 1500, 4000];
/**
* Resolve a Participation's `fp:user` to its UserProfile across the TWO id spaces
* that meet at this join (the root cause of the "unknown participant" bug):
* • a Participation stores `urn:festipod:user:<normalized-identifier>` (the stable
* principal = `currentUserId`), while
* • a UserProfile's `id` is its `did:ng:` document NURI — never that principal.
* The bridge is the NORMALIZED IDENTIFIER, which equals `normalizeIdentifier(username)`
* for the matching profile (the exact equality `currentUser` resolution already uses).
* So: strip the principal prefix off the participation's userId, and compare the
* remainder to `normalizeIdentifier(profile.username)`. In demo/local mode both sides
* are the bare seed id (`user-1`), matched directly by `u.id === userId` — which is
* why the direct match is tried FIRST (the seed username `@mariedupont` would not
* normalize to `user-1`). A per-deposit materializer uid (`mint...`, e.g.
* `mrktnoke-rzd699dk`) is a THIRD, unrelated space: it identifies an inbox deposit
* for the count, never a user — it does not participate in this join.
* What a BRAND-NEW profile is created with.
*
* A profile is entirely Festipod's own object — the data layer knows nothing of
* pseudos or display names — but the UserProfile SHEX shape makes `fp:name`,
* `fp:initials` and `fp:username` MANDATORY, so a profile cannot be written
* empty. And nothing about the person is known at sign-in: the identity the
* session signed in as is OPAQUE (no display name; it is never parsed, never
* rendered, never written into an entity). So the three required fields carry a
* PLACEHOLDER that reads on screen as "not filled in yet": none of them is a
* person's name or handle, and none is derived from the identity. The user
* replaces them through `updateProfile` (UpdateProfileScreen).
*/
const UNSET_PROFILE = {
name: 'Profil à compléter',
initials: '?',
username: '(pseudo non défini)',
} as const;
/**
* Resolve a Participation's `fp:user` to its UserProfile.
*
* TODAY'S WRITES need no resolving: `fp:user` carries the profile's own document
* NURI, so the DIRECT match `u.id === userId` answers — and it is tried first.
* The demo/local fixtures coincide there too (both sides are the bare `user-1`).
*
* LEGACY ONLY: participations written under the earlier scheme carry
* `urn:festipod:user:<normalized-handle>`, which matches no profile id. For those
* — and ONLY those — the handle is stripped off and compared to
* `normalizeIdentifier(profile.username)`. This username bridge is a READ-side
* survival for old data; it plays NO part in deciding who the current user is
* (my profile is the profile document I own — see the "WHO AM I" block below).
*
* A per-deposit materializer uid (`mint...`, e.g. `mrktnoke-rzd699dk`) is a third,
* unrelated space: it identifies an inbox deposit for the count, never a user.
*/
function resolveParticipantUser(userId: string, users: FpUserData[]): FpUserData | undefined {
// 1) Direct id match — demo/local seed space (`user-1`), or any coincident space.
@@ -265,6 +310,13 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
console.log(`${logPrefix} loadTestData (local, no-op)`);
return { seeded: false, userIdMap: new Map(), eventIdMap: new Map(), createdDocs: { public: [], protected: [] } };
}, []);
// Demo mode owns its whole fixture world. There is no document, no wallet and
// no writing here — every mutation above is a local no-op — so there is no
// ownership to look up and no listing that could be pending: the answer is
// settled, and it is the same one demo mode gives everywhere else, which is
// "yes, the app is yours to drive". `'unknown'` would leave the affordance
// pending forever on a path where nothing is ever going to resolve.
const getEventOwnership = useCallback((): EventOwnership => 'mine', []);
return {
currentUserId, currentUser, currentPrincipal,
@@ -273,6 +325,7 @@ function useLocalData(empty?: boolean): FestipodDataContextValue {
selectedEventId, setSelectedEventId, selectedEvent,
selectedUserId, setSelectedUserId, selectedUser,
...queries,
getEventOwnership,
createEvent, updateEvent, joinEvent, leaveEvent,
addMeetingPoint, addFriend, updateProfile, loadTestData,
};
@@ -304,10 +357,10 @@ function useNgData(): FestipodDataContextValue {
const eventQuery = useShapeQuery(FpEventShapeType, 'public');
const userQuery = useShapeQuery(FpUserProfileShapeType, 'protected');
const partQuery = useShapeQuery(FpParticipationShapeType, 'protected');
const users = React.useMemo(() => adaptUsers(userQuery.data), [userQuery.data]);
// The RAW reactive sets straight from `watchShape` (before the optimistic
// overlay). The exposed `events`/`participations` merge these with the pending
// overlay below — see the "OPTIMISTIC OVERLAY" block.
// overlay). The exposed `events`/`users`/`participations` merge these with the
// pending overlay below — see the "OPTIMISTIC OVERLAY" block.
const reactiveUsers = React.useMemo(() => adaptUsers(userQuery.data), [userQuery.data]);
const reactiveEvents = React.useMemo(() => adaptEvents(eventQuery.data), [eventQuery.data]);
const reactiveParticipations = React.useMemo(
() => adaptParticipations(partQuery.data),
@@ -331,9 +384,17 @@ function useNgData(): FestipodDataContextValue {
// dropped from pendingRemoves. No re-query, no interval — the overlay only reacts
// to `watchShape`'s own pushes.
const [pendingAddEvents, setPendingAddEvents] = useState<FpEventData[]>([]);
const [pendingAddUsers, setPendingAddUsers] = useState<FpUserData[]>([]);
const [pendingAddParticipations, setPendingAddParticipations] = useState<FpParticipationData[]>([]);
const [pendingRemoveIds, setPendingRemoveIds] = useState<Set<string>>(() => new Set());
const users = React.useMemo(() => {
if (pendingAddUsers.length === 0) return reactiveUsers;
const seen = new Set(reactiveUsers.map(u => u.id));
const extra = pendingAddUsers.filter(u => !seen.has(u.id));
return extra.length ? [...reactiveUsers, ...extra] : reactiveUsers;
}, [reactiveUsers, pendingAddUsers]);
const events = React.useMemo(() => {
if (pendingAddEvents.length === 0) return reactiveEvents;
const seen = new Set(reactiveEvents.map(e => e.id));
@@ -365,6 +426,15 @@ function useNgData(): FestipodDataContextValue {
});
}, [reactiveEvents, pendingAddEvents]);
useEffect(() => {
if (pendingAddUsers.length === 0) return;
const live = new Set(reactiveUsers.map(u => u.id));
setPendingAddUsers(prev => {
const next = prev.filter(u => !live.has(u.id));
return next.length === prev.length ? prev : next;
});
}, [reactiveUsers, pendingAddUsers]);
useEffect(() => {
if (pendingAddParticipations.length === 0) return;
const live = new Set(reactiveParticipations.map(p => p.id));
@@ -405,6 +475,35 @@ function useNgData(): FestipodDataContextValue {
// as the subject). The owner-materializer subscribes to each owned event's inbox
// and writes `participantCount` on THAT (owned) doc — never on someone else's.
const [ownedEventIds, setOwnedEventIds] = useState<Nuri[]>([]);
// Events the listing has RULED OUT — canonical ids that were already visible in
// the reactive read when a listing RESOLVED, and were absent from its result.
// ONLY these may be shown as "not yours".
//
// Why a set and not a "the listing answered" boolean: a listing is a SNAPSHOT,
// and it can only ever UNDER-report ownership, because a document created after
// it was taken cannot possibly be in it. So a miss is authoritative for what
// existed at the time and says NOTHING about anything that arrived since. With a
// boolean, a second tab on the same wallet that creates an event has it pushed
// here, misses the stale snapshot, and its owner is told the event is not theirs
// — permanently. That is the exact failure this whole answer exists to avoid.
const [ruledOutEventIds, setRuledOutEventIds] = useState<ReadonlySet<string>>(new Set());
// The owned set in canonical id-form — the form every event-id comparison here
// uses, so an event reached under a different overlay is still the same event.
const ownedCanonicalIds = React.useMemo(
() => new Set(ownedEventIds.map(canonicalEventId)),
[ownedEventIds],
);
// WHICH PROTECTED DOCUMENTS ARE MINE — the ground on which "my profile" rests.
// `null` means NOT ANSWERED YET (the listing has not landed, or it failed):
// "unknown" and "I own nothing" are indistinguishable as an empty array, and
// only one of them may lead to creating a profile. Nothing downstream may read
// `null` as an empty set.
const [myProtectedDocs, setMyProtectedDocs] = useState<Nuri[] | null>(null);
// The profile document THIS session created for the signed-in person. Set once,
// by the creation effect; it settles "which of my profile documents is mine"
// without looking at any field of any profile.
const [myProfileDocId, setMyProfileDocId] = useState<string>('');
/**
* Fold documents THIS SESSION just created into the owned set.
@@ -434,8 +533,30 @@ function useNgData(): FestipodDataContextValue {
// materializer, so every event this session hosts stops converging. So the
// failure is RETRIED, and if it still will not answer, it is said loudly instead
// of leaving a plausible-looking empty set behind.
// Read through refs inside the async listing below, so classification uses what
// is on screen when the listing RESOLVES rather than what was there when it
// started — the two differ by exactly the push that prompted a re-listing.
const eventsRef = useRef(events);
eventsRef.current = events;
const ownedCanonicalIdsRef = useRef(ownedCanonicalIds);
ownedCanonicalIdsRef.current = ownedCanonicalIds;
// Events the snapshot cannot speak about: neither owned nor ruled out. While
// this is non-empty the listing is re-taken — ONCE per new arrival, never on a
// timer ([[rule_no-broker-polling]]): a listing classifies everything visible at
// the moment it resolves, so this empties and the effect falls silent until a
// genuinely new event turns up.
const unclassifiedEventKey = React.useMemo(() => {
const pending = events
.map(e => canonicalEventId(e.id))
.filter(c => !ownedCanonicalIds.has(c) && !ruledOutEventIds.has(c));
return [...new Set(pending)].sort().join('|');
}, [events, ownedCanonicalIds, ruledOutEventIds]);
const listingTaken = useRef(false);
useEffect(() => {
if (!ready) return;
// Nothing left to learn: a listing has been taken and every visible event is
// classified. Without this the effect would re-list on its own state change.
if (listingTaken.current && unclassifiedEventKey === '') return;
let cancelled = false;
(async () => {
for (let attempt = 0; !cancelled; attempt++) {
@@ -443,6 +564,35 @@ function useNgData(): FestipodDataContextValue {
const myPublic = await listMyEntityDocs('public');
if (cancelled) return;
setOwnedEventIds(prev => [...new Set([...prev, ...myPublic])]);
listingTaken.current = true;
// The listing ANSWERED — so every event visible RIGHT NOW that it did
// not return is genuinely not this session's, and may be shown as such.
// Anything arriving after this point stays unclassified and triggers a
// fresh listing rather than inheriting this one's silence. Events already
// claimed as mine (created here, possibly racing this listing) are never
// ruled out.
// REBUILT, never accumulated: every listing RE-ADJUDICATES everything
// visible, so a later listing can overturn an earlier ruling instead of
// being outvoted by it.
//
// KNOWN RESIDUAL, and it needs the provider — not more code here. This
// infers "not yours" from ABSENCE, and absence is not authoritative: the
// reactive read and this listing are two different mechanisms, so an
// event can be on screen a moment before the listing can see it. Ruled
// out in that window, it is only re-examined if some OTHER unclassified
// event later triggers a listing. Self-healing would need either a timer
// (polling — forbidden) or the "may I write this?" call the surface does
// not publish. Per `rule_app-uses-sdk-surface-only` that is a GAP to
// raise, not one to paper over here, so it is left visible and stated.
const ownedNow = new Set(myPublic.map(canonicalEventId));
setRuledOutEventIds(() => {
const next = new Set<string>();
for (const e of eventsRef.current) {
const c = canonicalEventId(e.id);
if (!ownedNow.has(c) && !ownedCanonicalIdsRef.current.has(c)) next.add(c);
}
return next;
});
return;
} catch (err) {
const wait = OWNED_RETRY_BACKOFF_MS[attempt];
@@ -464,7 +614,7 @@ function useNgData(): FestipodDataContextValue {
}
})();
return () => { cancelled = true; };
}, [ready]);
}, [ready, unclassifiedEventKey]);
// Not in SHEX shapes yet
const [meetingPoints, setMeetingPoints] = useState<FpMeetingPointData[]>([]);
@@ -495,13 +645,25 @@ function useNgData(): FestipodDataContextValue {
// (the test-data action + every @data test) is UNAFFECTED (it seeds directly).
// `hasTriedAutoSeed` keeps it single-shot (also suppressed by an explicit
// `loadTestData`).
//
// ALL OF THAT IS CURRENTLY MOOT: the fixture seed is switched OFF outright
// (`fixtureSeedEnabled`), so this effect returns on its first line and no
// fixture reaches the wallet. `bootstrapWallet` enforces the same switch, so
// the check here buys only silence — no misleading "bootstrapping…" log, and
// no work started that would write nothing.
const hasTriedAutoSeed = useRef(false);
useEffect(() => {
if (!fixtureSeedEnabled()) return;
if (!autoSeedEnabled()) return;
if (hasTriedAutoSeed.current) return;
if (!ready) return;
if (!readReady) return; // still syncing — do NOT mistake pending for empty
const walletHasData = events.length > 0 || users.length > 0;
// MY OWN PROFILE IS NOT "DATA IN THE WALLET". It is created at sign-in on a
// brand-new wallet, so counting it here would permanently disable the seed on
// exactly the wallets it exists for. The question the gate asks is "does this
// wallet already hold something worth preserving", and my own empty profile
// does not.
const walletHasData = events.length > 0 || users.some(u => u.id !== myProfileDocId);
if (!shouldAutoSeed(walletHasData)) {
console.log(`${logPrefix} Auto-seed (FESTIPOD_AUTO_SEED): wallet already has data — skip`);
return;
@@ -516,40 +678,63 @@ function useNgData(): FestipodDataContextValue {
// The reactive `watchShape` reads pick the seeded per-entity docs up on their
// own (each createEntityDoc appends to the scope index → the container-index
// subscription re-resolves → the new docs enter the read). No registerDoc/relist.
}, [ready, readReady, events.length, users.length, claimOwnedEventDocs]);
}, [ready, readReady, events.length, users, myProfileDocId, claimOwnedEventDocs]);
// --- Derived ---
// WHO AM I — answered in TWO id spaces that must not be confused.
//
// (1) `currentPrincipal` — what signing in returned. Known as soon as the one
// identity await settles, i.e. before any document has been read. It names
// a PERSON. It is for display and log attribution; no data call takes it,
// and it is never written into an entity.
// (2) `currentUserId` — the app's own entity space: the `@id` of the profile
// DOCUMENT read back in the protected scope (a doc NURI). This is what a
// Participation's `fp:user` carries and what `resolveParticipantUser`
// matches directly, so it is the only value a mutation may write. It stays
// empty until the protected read lands — mutations that need it refuse
// rather than write an entity the read would drop.
// a PERSON, OPAQUELY: it is never parsed, never rendered as a name, never
// written into an entity and never handed to a data call. It exists here
// for display of "am I signed in" and for log attribution.
// (2) `currentUserId` — the app's own entity space: the `@id` of MY PROFILE
// DOCUMENT (a doc NURI). This is what a Participation's `fp:user` carries
// and what `resolveParticipantUser` matches directly, so it is the only
// value a mutation may write.
//
// THE JOIN between the two is explicit and lives HERE, in one place: a profile
// belongs to the signed-in person when its username normalizes to the
// principal — the same bridge `resolveParticipantUser` uses for the legacy
// `urn:festipod:user:` space. Nothing merges the spaces: the principal selects
// a profile, it never stands in for one.
// THERE IS NO JOIN BETWEEN THE TWO, and there must not be one. A profile is
// Festipod's own object; the identity says nothing about it. **My profile is
// the profile document I OWN** — `listMyEntityDocs('protected')` answers "which
// documents are mine", and the UserProfile among them is mine. No field of any
// profile takes part in the answer: no username comparison, no normalization,
// no positional pick.
//
// THREE OUTCOMES, and "somebody else's profile" is not one of them:
// • exactly one owned profile → that is me;
// • none → I have no profile yet, and the creation effect below makes one
// (until it lands, `currentUserId` is '' and the mutations that need it
// REJECT — they never write an entity keyed on nobody);
// • several, none of them created by this session → genuinely AMBIGUOUS (the
// opt-in fixture seed writes its profiles into my own protected scope), so
// the answer is NO PROFILE, said loudly. Picking one would be picking a
// person at random and calling them "you".
const currentPrincipal = useCurrentPrincipal();
const currentUser =
(currentPrincipal
? users.find(u => u.username && normalizeIdentifier(u.username) === currentPrincipal)
: undefined)
// No profile answers to the signed-in person (the wallet holds fixtures, or
// the profile read has not landed): fall back to the demo-seed pick. KNOWN
// HAZARD — this GUESSES a profile, so the app can show the wrong person as
// "you" while the real answer has simply not been read yet. Note what is and
// is not guessed: the identity itself never is (it is exactly what
// `ensureIdentity()` returned); only the profile it selects can be wrong.
|| users.find(u => u.username === '@mariedupont')
|| users[0];
/** The profiles I own: the reactive profiles whose document is one of mine. */
const myOwnedProfiles = React.useMemo<FpUserData[] | null>(() => {
if (myProtectedDocs === null) return null; // UNKNOWN — never "none"
const mine = new Set<string>(myProtectedDocs);
return users.filter(u => mine.has(u.id));
}, [users, myProtectedDocs]);
const currentUser = React.useMemo<FpUserData | undefined>(() => {
// This session created it → no ambiguity possible, whatever else is owned.
if (myProfileDocId) return users.find(u => u.id === myProfileDocId);
if (myOwnedProfiles === null) return undefined; // not answered yet
if (myOwnedProfiles.length === 1) return myOwnedProfiles[0];
if (myOwnedProfiles.length === 0) return undefined; // none — one gets created
// SEVERAL profile documents are mine, and none was created by this session:
// the fixture seed writes its profiles into my own protected scope, so a
// reloaded demo wallet lands here. Pick the first by document reference —
// stable across reloads, and arbitrary, which is honest: while the profile
// is not a built feature, "which of my fixtures am I" has no true answer.
//
// This is NOT the impersonation that was removed. That one reached for a
// profile by NAME and could land on a document belonging to somebody else.
// Every candidate here is a document I own, so the invariant that holds is
// the one that matters: the app never presents another person's profile as
// mine. Delete this branch the day a profile is really created and known.
return [...myOwnedProfiles].sort((a, b) => a.id.localeCompare(b.id))[0];
}, [users, myProfileDocId, myOwnedProfiles]);
const currentUserId = currentUser?.id || '';
// Identity-first log prefix, reused by every DATA log below (including the
// closures defined earlier in this function body — they only execute after
@@ -573,6 +758,107 @@ function useNgData(): FestipodDataContextValue {
}
const selectedUser = users.find(u => u.id === selectedUserId);
// --- MY PROFILE: which documents are mine ----------------------------------
// Resolve the PROTECTED documents this identity owns. That set is the whole
// basis of "which profile is mine", and it is also what decides whether a
// profile has to be CREATED — so a failure here must never look like an answer:
// a rejection means UNKNOWN, and reading it as "I own nothing" would create a
// second profile for someone who already has one. Retried; if it still will not
// answer, the set stays `null` (no profile resolved, no profile created) and it
// is said loudly.
useEffect(() => {
if (!ready) return;
let cancelled = false;
(async () => {
for (let attempt = 0; !cancelled; attempt++) {
try {
const mine = await listMyEntityDocs('protected');
if (cancelled) return;
setMyProtectedDocs(prev => [...new Set([...(prev ?? []), ...mine])]);
return;
} catch (err) {
const wait = OWNED_RETRY_BACKOFF_MS[attempt];
if (wait === undefined) {
console.error(
`${logPrefix} my-protected-documents resolution FAILED after ` +
`${OWNED_RETRY_BACKOFF_MS.length + 1} attempts — which profile is mine is UNKNOWN, ` +
`not absent: no profile will be resolved and none will be created until it is known:`,
err,
);
return;
}
console.warn(
`${logPrefix} my-protected-documents resolution failed (attempt ${attempt + 1}) — ` +
`retrying in ${wait}ms:`,
err,
);
await new Promise(r => setTimeout(r, wait));
}
}
})();
return () => { cancelled = true; };
}, [ready]);
// --- MY PROFILE: create one when I have none -------------------------------
// A profile is Festipod's own object, and signing in produces none — so the
// first time a person signs in, the app makes theirs. Gated on BOTH the
// protected read having settled (`userQuery.isSuccess`: synced-and-empty, not
// still-syncing) and the owned-document set being KNOWN, because "I have no
// profile" is only true when both have answered. Single-shot per session; on
// failure the guard is released, so a later change to the owned set retries.
const hasTriedProfileCreate = useRef(false);
useEffect(() => {
if (!ready) return;
if (hasTriedProfileCreate.current) return;
if (!userQuery.isSuccess) return;
if (myOwnedProfiles === null) return; // UNKNOWN — never read as "none"
if (myOwnedProfiles.length > 0) return; // I already have one (or more)
hasTriedProfileCreate.current = true;
(async () => {
console.log(`${logPrefix} no profile of mine — creating one (fields left visibly unset)`);
const graph = await createEntityDoc('protected');
// The three fields the UserProfile shape makes mandatory, written with the
// "not filled in yet" placeholders — nothing here comes from the identity.
await writeEntity(graph, ENTITY_TYPE.user, {
name: str(UNSET_PROFILE.name),
initials: str(UNSET_PROFILE.initials),
username: str(UNSET_PROFILE.username),
});
// This document is MINE — claimed explicitly, so it is recognized as my
// profile whatever else the protected scope holds (fixtures included).
setMyProfileDocId(graph);
setMyProtectedDocs(prev => [...new Set([...(prev ?? []), graph])]);
// OPTIMISTIC OVERLAY, same pattern as events/participations: surface the
// profile immediately so `currentUserId` resolves without waiting for the
// broker push; the reconciliation effect drops it once the read carries it.
const optimisticProfile: FpUserData = { id: graph, ...UNSET_PROFILE };
setPendingAddUsers(prev => (prev.some(u => u.id === graph) ? prev : [...prev, optimisticProfile]));
})().catch(err => {
hasTriedProfileCreate.current = false;
console.error(`${logPrefix} creating my profile FAILED — this session has no profile:`, err);
});
}, [ready, userQuery.isSuccess, myOwnedProfiles]);
// --- MY PROFILE: say it when the answer is arbitrary ------------------------
// Several profile documents are mine and none was created by this session (the
// opt-in fixture seed writes its profiles into my own protected scope). One is
// picked deterministically so the app stays usable on a demo wallet, but the
// pick carries no meaning — say so once, or a fixture person silently becomes
// "you" and nobody wonders why.
const warnedAmbiguousProfile = useRef(false);
useEffect(() => {
if (myProfileDocId) return;
if (myOwnedProfiles === null || myOwnedProfiles.length <= 1) return;
if (warnedAmbiguousProfile.current) return;
warnedAmbiguousProfile.current = true;
console.warn(
`${logPrefix} ${myOwnedProfiles.length} profile documents are mine and none was created by ` +
`this session (a fixture seed run on this wallet is the usual cause). The first by document ` +
`reference is used as mine — a stable but ARBITRARY pick, so the name shown as yours is ` +
`demo data, not you.`,
);
}, [myOwnedProfiles, myProfileDocId]);
// --- OWNER MATERIALIZER (Option B, brief §B.2 + T02.c notifications) -------
// The event OWNER's session materializes its OWN events' inbox deposits into
// (1) the correct `participantCount` on its OWN event doc, and
@@ -613,6 +899,25 @@ function useNgData(): FestipodDataContextValue {
// Primitive identity of the set above — the effect's dependency (an array is a
// new reference on every render).
const ownedKey = ownedEvents.join('|');
// WHICH EVENTS MAY THIS SESSION WRITE — the same owned set, asked the other way
// round. Nothing new is called: the contract makes writing a document and owning
// it the same thing, so `listMyEntityDocs('public')` (already resolved above for
// the materializer) is the whole answer. Matched on the CANONICAL id-form, like
// every other event-id comparison here, so an event reached under a different
// overlay is still recognized as the same event.
const getEventOwnership = useCallback((eventId: string): EventOwnership => {
const canon = canonicalEventId(eventId);
// A hit is authoritative EVEN BEFORE any listing has answered: an event this
// session just created was claimed directly, and it is mine whatever a
// listing later says. Checked FIRST, so ownership always beats a stale miss.
if (ownedCanonicalIds.has(canon)) return 'mine';
// A miss counts only for events a resolved listing actually looked past.
if (ruledOutEventIds.has(canon)) return 'not-mine';
// Otherwise the answer is genuinely still coming — a listing is pending, has
// failed, or this event arrived after the last one. Never 'not-mine' here.
return 'unknown';
}, [ownedCanonicalIds, ruledOutEventIds]);
// Last count written per owned event, so we only persist a genuine change.
const materializedCountRef = useRef<Map<string, number>>(new Map());
useEffect(() => {
@@ -810,7 +1115,7 @@ function useNgData(): FestipodDataContextValue {
// obliged to participate, so the count starts at 0 (the owner-materializer
// derives it from the active-registration set — |active|, no host baseline).
participantCount: int(event.participantCount || 0),
coverImage: str(event.coverImage), hostName: str(event.hostName), hostInitials: str(event.hostInitials),
coverImage: str(event.coverImage),
});
// OPTION B: this event's doc is MINE (I just created it), so track it as owned
// → the owner-materializer subscribes to its inbox and maintains its count.
@@ -866,14 +1171,19 @@ function useNgData(): FestipodDataContextValue {
const joinEvent = useCallback(async (eventId: string, userId?: string) => {
const uid = userId || currentUserId;
console.log(`${logPrefix} joinEvent (NG):`, eventId, 'user:', uid);
// A Participation MUST carry a user principal (SHEX `fp:user` is mandatory) —
// writing one without it produces an entity the ORM drops on read (the
// participation silently never round-trips). Refuse an empty principal rather
// than persist a broken participation. The caller resolves a real user id (the
// current user's IRI) before joining.
// A Participation MUST carry a user reference (SHEX `fp:user` is mandatory) —
// writing one without it produces an entity the read drops (the participation
// silently never round-trips). REJECT rather than return: returning here made
// the sign-up a no-op that wrote nothing, threw nothing and let the screen
// congratulate the user. The cause is named, because it is actionable: no
// profile of mine is resolved yet.
if (!uid) {
console.error(`${logPrefix} joinEvent: empty user principal — refusing to write a participation with no fp:user.`);
return;
const msg =
`joinEvent refused for event=${canonicalEventId(eventId)}: no profile of mine is resolved, ` +
`so a Participation would carry no fp:user and would never round-trip. ` +
`Wait for the profile to be created/read, or resolve the ambiguity reported above.`;
console.error(`${logPrefix} ${msg}`);
throw new Error(msg);
}
// IDEMPOTENCE — check AUTHORITATIVELY against the broker, not the reactive set.
// The reactive participation set can lag a just-written participation, so a
@@ -921,16 +1231,24 @@ function useNgData(): FestipodDataContextValue {
// The joiner's sole writes are: their OWN participation doc (above) + the
// inbox DEPOSIT (below). While the owner is offline the count doesn't advance
// for others — accepted eventual behaviour (brief §E.2); nothing is lost.
// 2) Notify the host: deposit into the event/host inbox via the GENERIC lib
// inbox (T02.b) + mint the host FpNotification (T02.a). `from` = registrant
// when connected, anonymous (null) otherwise. Best-effort: a failed deposit
// must not roll back a successful registration.
// 2) Tell the host. THE DEPOSIT IS THE DELIVERY: `inbox.postToDocument(doc, …)`
// is what the surface publishes for reaching a document's owner — anyone
// may deposit, only the owner reads. The host's notification is then built
// on the OWNER's side, out of the deposits it reads from its own event's
// inbox (`readRegistrationNotifications`, in the materializer above).
//
// Nothing else is written here. A host FpNotification used to be minted at
// this point into the JOINER's own protected scope with `recipient` set to
// the event — a document the host cannot read, and never will: the joiner
// owns it and the surface has no way to hand it over. It also pushed that
// notification into THIS session's own list, so the joiner saw a
// "new participant" notice addressed to someone else. Both are gone; the
// deposit alone carries the news, and it reaches its reader.
//
// Best-effort: a failed deposit must not roll back a successful
// registration (the participation document is already written).
try {
const registrantId = uid || null; // no current user → anonymous deposit
// Recipient = the event host. The Event shape carries no host IRI yet, so
// we key the host inbox/notification on the eventId (the host of THAT
// event). This is the domain injection the generic lib deliberately omits.
const recipientId = eventId;
// The event's `@id` IS its document NURI, and a deposit NAMES that document
// — the joiner resolves no inbox and holds no address.
// Carry the joiner's participation-doc NURI so the owner (if a connection)
@@ -940,24 +1258,11 @@ function useNgData(): FestipodDataContextValue {
`event=${canonicalEventId(eventId)} user=${uid} (count now moves via the OWNER ` +
`materializing this deposit on its own doc, at its next connection)`,
);
const { ts, uid: depositUid } = await depositRegistration(eventId, registrantId, partGraph);
const { uid: depositUid } = await depositRegistration(eventId, registrantId, partGraph);
// Remember the join uid so a same-session leave can cancel it precisely.
joinUidsRef.current.set(`${eventId}|${uid}`, depositUid);
const notif = buildNotification(recipientId, eventId, registrantId, ts);
// The host FpNotification is its OWN document in the PROTECTED scope (one
// doc per entity). The inbox materialization remains the source of truth;
// this direct write only pre-warms the reactive read — but a FAILED write is
// not swallowed: it used to be dropped silently, and the line below then
// surfaced a notification nothing had recorded. The rejection reaches the
// catch under this block, which names it.
const notifGraph = await createEntityDoc('protected');
await insertNotification(notifGraph, notif);
// Surface immediately in reactive state (materialization also refreshes it).
// Use the stable per-deposit uid for the id (F5 dedup) so it matches the
// notification id from the inbox and same-ms/anon deposits never collide.
setNotifications(prev => [...prev, { ...notif, id: `notif-${depositUid}` }]);
} catch (err) {
console.error(`${logPrefix} joinEvent inbox/notify failed:`, err);
console.error(`${logPrefix} joinEvent inbox deposit failed:`, err);
}
}, [events, currentUserId]);
@@ -968,7 +1273,22 @@ function useNgData(): FestipodDataContextValue {
// document (writeEntity uses the doc NURI as the subject), so `part.id` is BOTH
// the subject IRI AND the graph NURI it lives in.
const part = participations.find(p => p.eventId === eventId && p.userId === uid);
if (!part) return;
// REJECT rather than pretend. Withdrawal is AUTHORITATIVE (see
// caveat_participation-deletion): the caller only reaches here because it
// believes a participation exists, so finding none is a real disagreement
// about the state — either no profile of mine is resolved (`uid` empty), or
// the participation the screen showed is not in the set. Returning silently
// deleted nothing while the screen announced a withdrawal, and the sign-up
// came back on the next read.
if (!part) {
const msg = uid
? `leaveEvent refused for event=${canonicalEventId(eventId)}: no participation of user=${uid} ` +
`in the participation set — nothing was deleted, so the withdrawal must not be announced.`
: `leaveEvent refused for event=${canonicalEventId(eventId)}: no profile of mine is resolved, ` +
`so the participation to withdraw cannot even be named.`;
console.error(`${logPrefix} ${msg}`);
throw new Error(msg);
}
// DÉSINSCRIPTION FIX (caveat_participation-deletion): the AUTHORITATIVE deletion
// is a SPARQL DELETE via the real injected `ng` (docs.sparqlUpdate), which
// removes the Participation server-side so it does NOT resurrect after re-sync.
@@ -1054,9 +1374,19 @@ function useNgData(): FestipodDataContextValue {
const updateProfile = useCallback(async (updates: Partial<FpUserData>) => {
console.log(`${logPrefix} updateProfile (NG):`, updates);
// The current user's profile is its own document (subject IRI = doc NURI).
const target = currentUser ?? users[0];
if (!target) return;
// MY profile is its own document (subject IRI = doc NURI), and it is the ONLY
// document these edits may land in. There used to be a `?? users[0]` fallback
// here: with no profile resolved, a person editing their own pseudo wrote it
// into a stranger's profile document. Refuse instead — and say which of the
// two reasons it is, since only one of them clears up on its own.
const target = currentUser;
if (!target) {
const msg =
`updateProfile refused: no profile of mine is resolved, and these edits must never land in ` +
`someone else's profile document.`;
console.error(`${logPrefix} ${msg}`);
throw new Error(msg);
}
const graph = target.id;
const persists: Promise<void>[] = [];
if (updates.name !== undefined) persists.push(updateEntityField(graph, graph, 'name', str(updates.name)));
@@ -1069,11 +1399,19 @@ function useNgData(): FestipodDataContextValue {
const loadTestData = useCallback(async (): Promise<BootstrapResult> => {
console.log(`${logPrefix} loadTestData (NG)`);
// THIS NO LONGER SEEDS. `bootstrapWallet` refuses while the fixture seed is
// switched off (`fixtureSeedEnabled`), so what comes back is `seeded: false`
// with no documents, and everything below is a no-op on that empty result.
// The call is kept, and kept honest, rather than removed: the switch is what
// decides, in one place, and flipping it back restores this path unchanged.
// An EXPLICIT load is authoritative — SUPPRESS the dev auto-seed so only ONE
// seed runs (marking the guard at the START, before the awaited seed, closes
// the window where the auto-seed effect could also fire on a still-empty read).
hasTriedAutoSeed.current = true;
const walletHasData = events.length > 0 || users.length > 0;
// Same reading as the auto-seed gate: my own profile is not "data in the
// wallet" — it exists on every wallet from the first sign-in, so counting it
// would make an explicit load a permanent no-op.
const walletHasData = events.length > 0 || users.some(u => u.id !== myProfileDocId);
const result = await bootstrapWallet(walletHasData, createEntityDoc);
// The seeded per-entity docs are appended to their scope indices, which
// `watchShape` subscribes → they enter the reactive reads on the push. No
@@ -1083,8 +1421,11 @@ function useNgData(): FestipodDataContextValue {
// them — otherwise the owner-materializer never opens their inboxes and their
// `participantCount` is never derived.
claimOwnedEventDocs(result.createdDocs.public);
// The seeded PROTECTED docs are deliberately NOT claimed as mine: they are
// FIXTURES (nobody signed in as them), and folding them into the owned-profile
// set is exactly what would make "which profile is mine" ambiguous.
return result;
}, [events.length, users.length, claimOwnedEventDocs]);
}, [events.length, users, myProfileDocId, claimOwnedEventDocs]);
return {
currentUserId, currentUser, currentPrincipal,
@@ -1096,6 +1437,7 @@ function useNgData(): FestipodDataContextValue {
selectedEventId, setSelectedEventId, selectedEvent,
selectedUserId, setSelectedUserId, selectedUser,
...queries,
getEventOwnership,
createEvent, updateEvent, joinEvent, leaveEvent,
addMeetingPoint, addFriend, updateProfile, loadTestData,
};
-14
View File
@@ -130,8 +130,6 @@ export const seedEvents: FpEventData[] = [
distance: 142,
participantCount: 24,
description: 'Une semaine collaborative pour se rencontrer, co-créer et faire avancer le projet de Réseau Social Universel. Au programme : sessions plénières en intelligence collective, ateliers en forum ouvert, et randonnée au Cirque de Navacelles. Hébergement sur place au Revel, écolieu à Rogues dans le Gard.',
hostName: 'Reconnexion',
hostInitials: 'RC',
themes: ['Social'],
},
{
@@ -145,8 +143,6 @@ export const seedEvents: FpEventData[] = [
distance: 3,
participantCount: 12,
description: 'Un atelier pratique pour découvrir les low-tech et apprendre à fabriquer des objets du quotidien.',
hostName: 'La Maison du Vélo',
hostInitials: 'MV',
themes: ['Tech', 'Nature'],
},
{
@@ -160,8 +156,6 @@ export const seedEvents: FpEventData[] = [
distance: 89,
participantCount: 45,
description: "Un forum ouvert sur la transition écologique et sociale, dans le tiers-lieu L'Hermitage.",
hostName: "L'Hermitage",
hostInitials: 'LH',
themes: ['Social', 'Nature'],
},
{
@@ -175,8 +169,6 @@ export const seedEvents: FpEventData[] = [
distance: 5,
participantCount: 16,
description: 'Initiation à la Communication Non Violente. Venez découvrir les bases de la CNV pour améliorer vos relations.',
hostName: 'MJC Montplaisir',
hostInitials: 'MJ',
themes: ['Social'],
},
{
@@ -190,8 +182,6 @@ export const seedEvents: FpEventData[] = [
distance: 7,
participantCount: 30,
description: "Rencontre mensuelle du groupe local des Colibris pour échanger sur les projets en cours.",
hostName: 'Les Colibris',
hostInitials: 'LC',
themes: ['Social', 'Nature'],
},
];
@@ -214,16 +204,12 @@ export const seedMeetingPoints: FpMeetingPointData[] = [
eventId: 'event-1',
location: 'Café de la Place',
time: '30 min avant',
hostName: 'Marie',
hostInitials: 'MD',
},
{
id: 'mp-2',
eventId: 'event-1',
location: 'Station de métro Bellecour',
time: '15h30',
hostName: 'Jean',
hostInitials: 'JD',
},
];
-2
View File
@@ -41,8 +41,6 @@ export function adaptEvent(s: UnionSubject): FpEventData {
distance: s.props[`${FP}distance`] ? num(s, 'distance') : undefined,
participantCount: num(s, 'participantCount'),
coverImage: one(s, 'coverImage') || undefined,
hostName: one(s, 'hostName') || undefined,
hostInitials: one(s, 'hostInitials') || undefined,
};
}
+23 -23
View File
@@ -126,7 +126,7 @@ export const stepDefinitions: StepDefinitionInfo[] = [
"pattern": "l'utilisateur attend la fin du chargement",
"keyword": "When",
"file": "connexion.steps.ts",
"sourceCode": "When('l\\'utilisateur attend la fin du chargement', async function (this: FestipodWorld) {\n await this.appFrame!.waitForFunction(\n () => {\n const buttons = Array.from(document.querySelectorAll('button'));\n return !buttons.some(b => b.textContent?.includes('Chargement...'));\n },\n { timeout: 60000 },\n );\n await this.appFrame!.waitForTimeout(2000);\n});",
"sourceCode": "When('l\\'utilisateur attend la fin du chargement', async function (this: FestipodWorld) {\n await this.appFrame!.waitForFunction(\n () => {\n const buttons = Array.from(document.querySelectorAll('button'));\n return !buttons.some(b => b.textContent?.includes('Chargement...'));\n },\n undefined,\n { timeout: 60000 },\n );\n await this.appFrame!.waitForTimeout(2000);\n});",
"lineNumber": 133
},
{
@@ -134,7 +134,7 @@ export const stepDefinitions: StepDefinitionInfo[] = [
"keyword": "Then",
"file": "connexion.steps.ts",
"sourceCode": "Then('l\\'écran d\\'accueil affiche des événements', async function (this: FestipodWorld) {\n // Navigate to the events screen (path-based) and verify cards are rendered.\n // Home shows only events the current user participates in, which depends\n // on participations hydrating from NG — flaky for a basic data check.\n await this.appFrame!.evaluate(() => {\n window.history.pushState(null, '', '/events');\n window.dispatchEvent(new PopStateEvent('popstate'));\n });",
"lineNumber": 144
"lineNumber": 145
},
{
"pattern": "le créateur relaie l'événement {string}",
@@ -399,7 +399,7 @@ export const stepDefinitions: StepDefinitionInfo[] = [
"pattern": "l'événement {string} finit par apparaître sur la page fraîche A en laissant jusqu'à 60 secondes à la barrière avec rechargements",
"keyword": "Then",
"file": "reconnexion.steps.ts",
"sourceCode": "Then('l\\'événement {string} finit par apparaître sur la page fraîche A en laissant jusqu\\'à 60 secondes à la barrière avec rechargements', { timeout: 120000 }, async function (this: FestipodWorld, title: string) {\n const freshPage = (this as any).recoFreshPage as import('playwright').Page;\n let freshFrame = (this as any).recoFreshFrame as import('playwright').Frame;\n const startedAt = Date.now();\n const BUDGET_MS = 60000;\n const reloadAtMs = [20000, 40000]; // force a fresh barrier attempt at these marks\n let reloadIdx = 0;\n let appearedAtMs = -1;\n\n const readHome = async (): Promise<string[]> => {\n try {\n return await freshFrame.evaluate((t: string) => {\n const td = (window as any).__testData;\n return td && td.homeEventTitles ? td.homeEventTitles() : [];\n }, title);\n } catch { return []; }\n };\n\n while (Date.now() - startedAt < BUDGET_MS) {\n const elapsed = Date.now() - startedAt;\n const titles = await readHome();\n if (titles.includes(title)) { appearedAtMs = elapsed; break; }\n // At each reload mark, do a FULL reload → new NgDataProvider mount → new barrier.\n const reloadMark = reloadAtMs[reloadIdx];\n if (reloadMark !== undefined && elapsed >= reloadMark) {\n reloadIdx++;\n console.log(`[LongPoll] t=${elapsed}ms still ABSENT — forcing a full reload (#${reloadIdx}) to re-attempt the barrier…`);\n try {\n freshFrame = await pool.setupBrokerPage!(freshPage, pool.harnessUrl!);\n await freshFrame.waitForFunction(() => (window as any).__testData?.ready === true, { timeout: 60000 });",
"sourceCode": "Then('l\\'événement {string} finit par apparaître sur la page fraîche A en laissant jusqu\\'à 60 secondes à la barrière avec rechargements', { timeout: 120000 }, async function (this: FestipodWorld, title: string) {\n const freshPage = (this as any).recoFreshPage as import('playwright').Page;\n let freshFrame = (this as any).recoFreshFrame as import('playwright').Frame;\n const startedAt = Date.now();\n const BUDGET_MS = 60000;\n const reloadAtMs = [20000, 40000]; // force a fresh barrier attempt at these marks\n let reloadIdx = 0;\n let appearedAtMs = -1;\n\n const readHome = async (): Promise<string[]> => {\n try {\n return await freshFrame.evaluate((t: string) => {\n const td = (window as any).__testData;\n return td && td.homeEventTitles ? td.homeEventTitles() : [];\n }, title);\n } catch { return []; }\n };\n\n while (Date.now() - startedAt < BUDGET_MS) {\n const elapsed = Date.now() - startedAt;\n const titles = await readHome();\n if (titles.includes(title)) { appearedAtMs = elapsed; break; }\n // At each reload mark, do a FULL reload → new NgDataProvider mount → new barrier.\n const reloadMark = reloadAtMs[reloadIdx];\n if (reloadMark !== undefined && elapsed >= reloadMark) {\n reloadIdx++;\n console.log(`[LongPoll] t=${elapsed}ms still ABSENT — forcing a full reload (#${reloadIdx}) to re-attempt the barrier…`);\n try {\n freshFrame = await pool.setupBrokerPage!(freshPage, pool.harnessUrl!);\n await freshFrame.waitForFunction(() => (window as any).__testData?.ready === true, undefined, { timeout: 60000 });",
"lineNumber": 56
},
{
@@ -442,70 +442,70 @@ export const stepDefinitions: StepDefinitionInfo[] = [
"keyword": "When",
"file": "evenement.steps.ts",
"sourceCode": "When('l\\'utilisateur attend que l\\'écran {string} soit affiché', async function (this: FestipodWorld, screenId: string) {\n // We match on pathname prefix to allow for dynamic ids (event-detail etc.).\n const expectedPath = screenId === 'event-detail' ? '/events/' :\n screenId === 'update-event' ? '/edit' :\n screenId === 'create-event' ? '/events/new' :\n screenId === 'home' ? '/home' :\n screenId === 'events' ? '/events' :\n '/' + screenId;\n\n await this.appFrame!.waitForFunction(\n (path: string) => {\n const current = window.location.pathname;\n if (path === '/edit') return current.endsWith('/edit');\n return current.startsWith(path);\n },\n expectedPath,\n { timeout: 10000 },\n );\n await this.appFrame!.waitForTimeout(1000);\n});",
"lineNumber": 37
"lineNumber": 38
},
{
"pattern": "l'utilisateur remplit le formulaire de création d'événement:",
"keyword": "When",
"file": "evenement.steps.ts",
"sourceCode": "When('l\\'utilisateur remplit le formulaire de création d\\'événement:', async function (this: FestipodWorld, dataTable: any) {\n const rows = dataTable.hashes() as { champ: string; valeur: string }[];\n\n // The new CreateEventScreen is a 3-step wizard:\n // Step 1: name + dates\n // Step 2: similar-event warning (skipped if none)\n // Step 3: location + description + times\n //\n // We'll fill Step 1 fields first, click Next, then fill remaining fields.\n\n const formReady = await this.appFrame!.waitForFunction(\n () => !!document.querySelector('input[placeholder=\"Donnez un nom à votre événement\"]'),\n { timeout: 10000 },\n ).then(() => true).catch(() => false);\n\n if (!formReady) {\n const debug = await this.appFrame!.evaluate(() => ({\n pathname: window.location.pathname,\n inputs: Array.from(document.querySelectorAll('input')).map(i => i.placeholder),\n rootText: document.getElementById('root')?.textContent?.substring(0, 300),\n }));\n throw new Error(`Create form not found. Path: ${debug.pathname}, inputs: ${JSON.stringify(debug.inputs)}, content: ${debug.rootText}`);\n }\n\n const byChamp: Record<string, string> = {};\n for (const { champ, valeur } of rows) byChamp[champ] = valeur;\n\n // Step 1: name + start/end date\n if (byChamp['Nom de l\\'événement']) {\n const input = this.appFrame!.locator('input[placeholder=\"Donnez un nom à votre événement\"]');\n await input.fill(byChamp['Nom de l\\'événement']);\n }\n if (byChamp['Date de début']) {\n await this.appFrame!.locator('input[type=\"date\"]').first().fill(byChamp['Date de début']);\n }\n if (byChamp['Date de fin']) {\n await this.appFrame!.locator('input[type=\"date\"]').nth(1).fill(byChamp['Date de fin']);\n }\n\n // Advance to step 3 (may pass through step 2 if a similar event matches)\n let stepBtn = this.appFrame!.locator('button', { hasText: 'Suivant' });",
"lineNumber": 60
"sourceCode": "When('l\\'utilisateur remplit le formulaire de création d\\'événement:', async function (this: FestipodWorld, dataTable: any) {\n const rows = dataTable.hashes() as { champ: string; valeur: string }[];\n\n // The new CreateEventScreen is a 3-step wizard:\n // Step 1: name + dates\n // Step 2: similar-event warning (skipped if none)\n // Step 3: location + description + times\n //\n // We'll fill Step 1 fields first, click Next, then fill remaining fields.\n\n const formReady = await this.appFrame!.waitForFunction(\n () => !!document.querySelector('input[placeholder=\"Donnez un nom à votre événement\"]'),\n undefined,\n { timeout: 10000 },\n ).then(() => true).catch(() => false);\n\n if (!formReady) {\n const debug = await this.appFrame!.evaluate(() => ({\n pathname: window.location.pathname,\n inputs: Array.from(document.querySelectorAll('input')).map(i => i.placeholder),\n rootText: document.getElementById('root')?.textContent?.substring(0, 300),\n }));\n throw new Error(`Create form not found. Path: ${debug.pathname}, inputs: ${JSON.stringify(debug.inputs)}, content: ${debug.rootText}`);\n }\n\n const byChamp: Record<string, string> = {};\n for (const { champ, valeur } of rows) byChamp[champ] = valeur;\n\n // Step 1: name + start/end date\n if (byChamp['Nom de l\\'événement']) {\n const input = this.appFrame!.locator('input[placeholder=\"Donnez un nom à votre événement\"]');\n await input.fill(byChamp['Nom de l\\'événement']);\n }\n if (byChamp['Date de début']) {\n await this.appFrame!.locator('input[type=\"date\"]').first().fill(byChamp['Date de début']);\n }\n if (byChamp['Date de fin']) {\n await this.appFrame!.locator('input[type=\"date\"]').nth(1).fill(byChamp['Date de fin']);\n }\n\n // Advance to step 3 (may pass through step 2 if a similar event matches)\n let stepBtn = this.appFrame!.locator('button', { hasText: 'Suivant' });",
"lineNumber": 61
},
{
"pattern": "l'utilisateur modifie le champ lieu avec {string}",
"keyword": "When",
"file": "evenement.steps.ts",
"sourceCode": "When('l\\'utilisateur modifie le champ lieu avec {string}', async function (this: FestipodWorld, valeur: string) {\n // UpdateEventScreen has a \"Lieu *\" label followed by an Input. Find the input\n // adjacent to that label.\n await this.appFrame!.waitForFunction(\n () => document.getElementById('root')?.textContent?.includes('Lieu') ?? false,\n { timeout: 10000 },\n );\n await this.appFrame!.evaluate((val: string) => {\n const labels = document.querySelectorAll('*');\n for (const el of labels) {\n if (el.textContent?.trim() === 'Lieu *' && el.tagName !== 'DIV') {\n const parent = el.parentElement;\n const input = parent?.querySelector('input');\n if (input) {\n const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;\n nativeInputValueSetter.call(input, val);\n input.dispatchEvent(new Event('input', { bubbles: true }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n return;\n }\n }\n }\n }, valeur);\n await this.appFrame!.waitForTimeout(500);\n});",
"lineNumber": 129
"sourceCode": "When('l\\'utilisateur modifie le champ lieu avec {string}', async function (this: FestipodWorld, valeur: string) {\n // UpdateEventScreen has a \"Lieu *\" label followed by an Input. Find the input\n // adjacent to that label.\n await this.appFrame!.waitForFunction(\n () => document.getElementById('root')?.textContent?.includes('Lieu') ?? false,\n undefined,\n { timeout: 10000 },\n );\n await this.appFrame!.evaluate((val: string) => {\n const labels = document.querySelectorAll('*');\n for (const el of labels) {\n if (el.textContent?.trim() === 'Lieu *' && el.tagName !== 'DIV') {\n const parent = el.parentElement;\n const input = parent?.querySelector('input');\n if (input) {\n const nativeInputValueSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')!.set!;\n nativeInputValueSetter.call(input, val);\n input.dispatchEvent(new Event('input', { bubbles: true }));\n input.dispatchEvent(new Event('change', { bubbles: true }));\n return;\n }\n }\n }\n }, valeur);\n await this.appFrame!.waitForTimeout(500);\n});",
"lineNumber": 131
},
{
"pattern": "l'utilisateur clique sur un événement de l'accueil",
"keyword": "When",
"file": "evenement.steps.ts",
"sourceCode": "When('l\\'utilisateur clique sur un événement de l\\'accueil', async function (this: FestipodWorld) {\n // HomeScreen renders only events the current user participates in. If\n // participations haven't hydrated from NG yet, the screen is empty — fall\n // back to /events (no participation filter).\n await this.appFrame!.evaluate(() => {\n window.history.pushState(null, '', '/home');\n window.dispatchEvent(new PopStateEvent('popstate'));\n });",
"lineNumber": 157
"lineNumber": 160
},
{
"pattern": "l'utilisateur clique sur un événement de la liste",
"keyword": "When",
"file": "evenement.steps.ts",
"sourceCode": "When('l\\'utilisateur clique sur un événement de la liste', async function (this: FestipodWorld) {\n // EventsScreen also uses Card with .app-card class.\n await this.appFrame!.waitForFunction(\n () => document.querySelectorAll('.app-card').length > 0,\n { timeout: 10000 },\n );\n const clicked = await this.appFrame!.evaluate(() => {\n const cards = document.querySelectorAll('.app-card');\n for (const card of cards) {\n const el = card as HTMLElement;\n if (el.style.cursor === 'pointer' || window.getComputedStyle(el).cursor === 'pointer') {\n el.click();\n return true;\n }\n }\n return false;\n });",
"lineNumber": 210
"sourceCode": "When('l\\'utilisateur clique sur un événement de la liste', async function (this: FestipodWorld) {\n // EventsScreen also uses Card with .app-card class.\n await this.appFrame!.waitForFunction(\n () => document.querySelectorAll('.app-card').length > 0,\n undefined,\n { timeout: 10000 },\n );\n const clicked = await this.appFrame!.evaluate(() => {\n const cards = document.querySelectorAll('.app-card');\n for (const card of cards) {\n const el = card as HTMLElement;\n if (el.style.cursor === 'pointer' || window.getComputedStyle(el).cursor === 'pointer') {\n el.click();\n return true;\n }\n }\n return false;\n });",
"lineNumber": 215
},
{
"pattern": "l'utilisateur clique sur le bouton de modification",
"keyword": "When",
"file": "evenement.steps.ts",
"sourceCode": "When('l\\'utilisateur clique sur le bouton de modification', async function (this: FestipodWorld) {\n // The edit button shows \"✎\" in the header — only visible if user is event owner\n const editBtn = this.appFrame!.locator('text=✎').first();\n await editBtn.click();\n await this.appFrame!.waitForTimeout(1500);\n});",
"lineNumber": 233
"lineNumber": 239
},
{
"pattern": "l'utilisateur clique sur le bouton {string} si visible",
"keyword": "When",
"file": "evenement.steps.ts",
"sourceCode": "When('l\\'utilisateur clique sur le bouton {string} si visible', async function (this: FestipodWorld, buttonText: string) {\n const button = this.appFrame!.locator('button', { hasText: buttonText }).first();\n if (await button.isVisible({ timeout: 3000 }).catch(() => false)) {\n await button.click();\n await this.appFrame!.waitForTimeout(1000);\n }\n});",
"lineNumber": 240
"lineNumber": 246
},
{
"pattern": "l'écran contient le texte {string}",
"keyword": "Then",
"file": "evenement.steps.ts",
"sourceCode": "Then('l\\'écran contient le texte {string}', async function (this: FestipodWorld, expectedText: string) {\n const appeared = await this.appFrame!.waitForFunction(\n (text: string) => document.getElementById('root')?.textContent?.includes(text) ?? false,\n expectedText,\n { timeout: 10000 },\n ).then(() => true).catch(() => false);\n\n if (!appeared) {\n const debug = await this.appFrame!.evaluate(() => ({\n pathname: window.location.pathname,\n rootText: document.getElementById('root')?.textContent?.substring(0, 500),\n }));\n expect.fail(\n `Expected text \"${expectedText}\" not found. Path: \"${debug.pathname}\", content: \"${debug.rootText}\"`,\n );\n }\n});",
"lineNumber": 250
"lineNumber": 256
},
{
"pattern": "l'écran ne contient pas le texte {string}",
"keyword": "Then",
"file": "evenement.steps.ts",
"sourceCode": "Then('l\\'écran ne contient pas le texte {string}', async function (this: FestipodWorld, unexpectedText: string) {\n await this.appFrame!.waitForTimeout(500);\n const found = await this.appFrame!.evaluate(\n (text: string) => document.getElementById('root')?.textContent?.includes(text) ?? false,\n unexpectedText,\n );\n expect(found, `Text \"${unexpectedText}\" should NOT be present`).to.be.false;\n});",
"lineNumber": 268
"lineNumber": 274
},
{
"pattern": "l'écran d'accueil contient le texte {string}",
"keyword": "Then",
"file": "evenement.steps.ts",
"sourceCode": "Then('l\\'écran d\\'accueil contient le texte {string}', async function (this: FestipodWorld, expectedText: string) {\n await this.appFrame!.evaluate(() => {\n window.history.pushState(null, '', '/home');\n window.dispatchEvent(new PopStateEvent('popstate'));\n });",
"lineNumber": 277
"lineNumber": 283
},
{
"pattern": "le navigateur {string} crée l'événement {string}",
@@ -624,21 +624,21 @@ export const stepDefinitions: StepDefinitionInfo[] = [
"keyword": "Given",
"file": "reconnexion-persistance.steps.ts",
"sourceCode": "Given('l\\'événement {string} apparaît sur l\\'accueil de l\\'utilisateur', { timeout: 60000 }, async function (this: FestipodWorld, title: string) {\n const frame = this.appFrame!;\n // Navigate home; if home (participation-filtered) is empty, fall back to\n // /events (Découvrir, no participation filter) — the created event is public.\n await frame.evaluate(() => {\n window.history.pushState(null, '', '/home');\n window.dispatchEvent(new PopStateEvent('popstate'));\n });",
"lineNumber": 137
"lineNumber": 138
},
{
"pattern": "l'utilisateur ferme et rouvre l'app sous la même identité dans une session broker fraîche",
"keyword": "When",
"file": "reconnexion-persistance.steps.ts",
"sourceCode": "When('l\\'utilisateur ferme et rouvre l\\'app sous la même identité dans une session broker fraîche', { timeout: 180000 }, async function (this: FestipodWorld) {\n // SAME browser context → same wallet → same person. The reopened app asks\n // `ensureIdentity()` who it is, exactly as the first page did.\n const ctx = this.page!.context();\n\n const freshPage = await ctx.newPage();\n\n const freshLogs: StampedLog[] = [];\n (this as any).recoFreshLogs = freshLogs;\n attachConsoleCapture(freshPage, freshLogs);\n freshPage.on('pageerror', (err) => freshLogs.push({ t: Date.now(), text: `pageerror: ${err.message}` }));\n\n // NEW broker login → fresh verifier session on the SAME persistent wallet.\n const freshFrame = await pool.setupBrokerPage!(freshPage, pool.appUrl!);\n // Wait for the real app to render.\n await freshFrame.waitForFunction(\n () => {\n const root = document.getElementById('root');\n return !!root && root.innerHTML.length > 100;\n },\n { timeout: 60000 },\n );\n // Let NG connect + the cold-start read path run.\n await freshFrame.waitForTimeout(6000);\n (this as any).recoFreshFrame = freshFrame;\n (this as any).recoFreshPage = freshPage;\n});",
"lineNumber": 172
"sourceCode": "When('l\\'utilisateur ferme et rouvre l\\'app sous la même identité dans une session broker fraîche', { timeout: 180000 }, async function (this: FestipodWorld) {\n // SAME browser context → same wallet → same person. The reopened app asks\n // `ensureIdentity()` who it is, exactly as the first page did.\n const ctx = this.page!.context();\n\n const freshPage = await ctx.newPage();\n\n const freshLogs: StampedLog[] = [];\n (this as any).recoFreshLogs = freshLogs;\n attachConsoleCapture(freshPage, freshLogs);\n freshPage.on('pageerror', (err) => freshLogs.push({ t: Date.now(), text: `pageerror: ${err.message}` }));\n\n // NEW broker login → fresh verifier session on the SAME persistent wallet.\n const freshFrame = await pool.setupBrokerPage!(freshPage, pool.appUrl!);\n // Wait for the real app to render.\n await freshFrame.waitForFunction(\n () => {\n const root = document.getElementById('root');\n return !!root && root.innerHTML.length > 100;\n },\n undefined,\n { timeout: 60000 },\n );\n // Let NG connect + the cold-start read path run.\n await freshFrame.waitForTimeout(6000);\n (this as any).recoFreshFrame = freshFrame;\n (this as any).recoFreshPage = freshPage;\n});",
"lineNumber": 173
},
{
"pattern": "l'événement {string} est toujours présent après reconnexion",
"keyword": "Then",
"file": "reconnexion-persistance.steps.ts",
"sourceCode": "Then('l\\'événement {string} est toujours présent après reconnexion', { timeout: 90000 }, async function (this: FestipodWorld, title: string) {\n const freshFrame = (this as any).recoFreshFrame as import('playwright').Frame;\n\n // Poll BOTH home (participation-filtered) and /events (Découvrir, public list),\n // re-navigating each attempt so the cold-start union read has time to converge.\n // This is NOT broker-polling (rule_no-broker-polling): the app is reactive; we\n // re-read the RENDERED DOM until the reactive set settles, bounded by timeout.\n const deadline = Date.now() + 60000;\n let found = false;\n while (Date.now() < deadline && !found) {\n for (const path of ['/home', '/events']) {\n await freshFrame.evaluate((p: string) => {\n window.history.pushState(null, '', p);\n window.dispatchEvent(new PopStateEvent('popstate'));\n }, path);\n found = await freshFrame.waitForFunction(\n (t: string) => document.getElementById('root')?.textContent?.includes(t) ?? false,\n title,\n { timeout: 6000 },\n ).then(() => true).catch(() => false);\n if (found) break;\n }\n }\n\n // --- Report console evidence from BOTH pages regardless of pass/fail ---\n const mainLogs = ((this as any).recoMainLogs ?? []) as StampedLog[];\n const freshLogs = ((this as any).recoFreshLogs ?? []) as StampedLog[];\n const report =\n summarizeLogs('MAIN PAGE (creator)', mainLogs) + '\\n\\n' +\n summarizeLogs('FRESH PAGE (reconnect)', freshLogs) + '\\n\\n' +\n `RESULT: event \"${title}\" ${found ? 'SURVIVED (visible after reconnect)' : 'DISAPPEARED (NOT visible after reconnect)'}`;\n this.attach(report, 'text/plain');\n // Also echo to stdout so it lands in the raw run output.\n console.log('\\n' + report + '\\n');\n\n // Opt-in RAW dump of connection/sync lines (RECO_RAW_DUMP=1) — the evidence\n // that the FRESH page is a genuine cold boot (own WASM worker + own broker\n // handshake), used to argue reconnection FIDELITY. Off by default (noise).\n if (process.env.RECO_RAW_DUMP === '1') {\n const dumpRaw = (label: string, logs: StampedLog[]) => {\n const t0 = logs.length ? logs[0]!.t : Date.now();\n const hits = logs.filter((l) => /peer|CONNECTION|ESTABLISHED|REPLAY|broker|verifier|worker|bootstrap|open_repo|\\bsync\\b/i.test(l.text));\n console.log(`\\n### RAW (${label}) — ${hits.length} connection/sync lines ###`);\n for (const l of hits) console.log(`+${((l.t - t0) / 1000).toFixed(2)}s ${l.text.slice(0, 200)}`);\n };\n dumpRaw('MAIN', mainLogs);\n dumpRaw('FRESH', freshLogs);\n }\n\n if (!found) {\n const debug = await freshFrame.evaluate(() => ({\n pathname: window.location.pathname,\n rootText: document.getElementById('root')?.textContent?.substring(0, 500),\n }));\n expect.fail(`Reconnected fresh page for the SAME identity did NOT show \"${title}\". Path: ${debug.pathname}, content: ${debug.rootText}`);\n }\n});",
"lineNumber": 200
"lineNumber": 202
},
{
"pattern": "je clique sur un événement",
@@ -735,7 +735,7 @@ export const stepDefinitions: StepDefinitionInfo[] = [
"pattern": "l'accueil rend un contenu d'application réel",
"keyword": "Then",
"file": "accueil-connecte-rend.steps.ts",
"sourceCode": "Then(\"l'accueil rend un contenu d'application réel\", async function (this: FestipodWorld) {\n // Marqueurs FORTS et propres à HomeScreen (absents de WelcomeScreen / d'un\n // simple spinner / du bandeau broker) :\n // - .app-navbar : la barre de navigation basse (BottomNav) — rendue par\n // HomeScreen, pas par l'écran d'onboarding ;\n // - le bouton « Relayer » (aria-label=\"Relayer un événement\") propre à\n // l'en-tête de l'accueil.\n // Si un throw dans HomeScreen (ou un provider monté après connexion) blanchit\n // le rendu, React démonte l'arbre (aucun ErrorBoundary) et ces marqueurs\n // disparaissent → l'attente échoue.\n const rendered = await this.appFrame!.waitForFunction(\n () => {\n const root = document.getElementById('root');\n if (!root) return false;\n const hasNavbar = document.querySelector('.app-navbar') !== null;\n const hasRelayer =\n document.querySelector('[aria-label=\"Relayer un événement\"]') !== null;\n return hasNavbar && hasRelayer;\n },\n { timeout: 15000 },\n ).then(() => true).catch(() => false);\n\n if (!rendered) {\n const debug = await this.appFrame!.evaluate(() => ({\n pathname: window.location.pathname,\n hasNavbar: document.querySelector('.app-navbar') !== null,\n hasRelayer: document.querySelector('[aria-label=\"Relayer un événement\"]') !== null,\n rootLen: document.getElementById('root')?.innerHTML.length ?? 0,\n rootText: document.getElementById('root')?.textContent?.substring(0, 400),\n }));\n expect.fail(\n `L'accueil connecté n'a pas rendu de contenu d'app réel (page blanche ?). ` +\n `path=\"${debug.pathname}\", .app-navbar=${debug.hasNavbar}, ` +\n `bouton Relayer=${debug.hasRelayer}, #root length=${debug.rootLen}, ` +\n `texte: \"${debug.rootText}\"`,\n );\n }\n});",
"sourceCode": "Then(\"l'accueil rend un contenu d'application réel\", async function (this: FestipodWorld) {\n // Marqueurs FORTS et propres à HomeScreen (absents de WelcomeScreen / d'un\n // simple spinner / du bandeau broker) :\n // - .app-navbar : la barre de navigation basse (BottomNav) — rendue par\n // HomeScreen, pas par l'écran d'onboarding ;\n // - le bouton « Relayer » (aria-label=\"Relayer un événement\") propre à\n // l'en-tête de l'accueil.\n // Si un throw dans HomeScreen (ou un provider monté après connexion) blanchit\n // le rendu, React démonte l'arbre (aucun ErrorBoundary) et ces marqueurs\n // disparaissent → l'attente échoue.\n const rendered = await this.appFrame!.waitForFunction(\n () => {\n const root = document.getElementById('root');\n if (!root) return false;\n const hasNavbar = document.querySelector('.app-navbar') !== null;\n const hasRelayer =\n document.querySelector('[aria-label=\"Relayer un événement\"]') !== null;\n return hasNavbar && hasRelayer;\n },\n undefined,\n { timeout: 15000 },\n ).then(() => true).catch(() => false);\n\n if (!rendered) {\n const debug = await this.appFrame!.evaluate(() => ({\n pathname: window.location.pathname,\n hasNavbar: document.querySelector('.app-navbar') !== null,\n hasRelayer: document.querySelector('[aria-label=\"Relayer un événement\"]') !== null,\n rootLen: document.getElementById('root')?.innerHTML.length ?? 0,\n rootText: document.getElementById('root')?.textContent?.substring(0, 400),\n }));\n expect.fail(\n `L'accueil connecté n'a pas rendu de contenu d'app réel (page blanche ?). ` +\n `path=\"${debug.pathname}\", .app-navbar=${debug.hasNavbar}, ` +\n `bouton Relayer=${debug.hasRelayer}, #root length=${debug.rootLen}, ` +\n `texte: \"${debug.rootText}\"`,\n );\n }\n});",
"lineNumber": 26
},
{
@@ -743,7 +743,7 @@ export const stepDefinitions: StepDefinitionInfo[] = [
"keyword": "Then",
"file": "accueil-connecte-rend.steps.ts",
"sourceCode": "Then('aucune erreur runtime n\\'a été émise pendant le boot connecté', function (this: FestipodWorld) {\n // this.pageErrors est peuplé par le hook Before (pageerror + console.error de\n // la page app), réinitialisé à chaque scénario. Un crash de rendu connecté\n // (throw non attrapé dans un composant/provider) émet un `pageerror` et\n // atterrit ici → assertion rouge avec la liste exacte.\n expect(\n this.pageErrors,\n `Des erreurs runtime ont été émises pendant le boot connecté :\\n` +\n this.pageErrors.map((e, i) => ` [${i + 1}] ${e}`).join('\\n'),\n ).to.be.empty;\n});",
"lineNumber": 65
"lineNumber": 66
},
{
"pattern": "je peux configurer mes notifications",
@@ -840,7 +840,7 @@ export const stepDefinitions: StepDefinitionInfo[] = [
"pattern": "le navigateur {string} est connecté à NextGraph",
"keyword": "Then",
"file": "multibrowser.steps.ts",
"sourceCode": "Then('le navigateur {string} est connecté à NextGraph', async function (this: FestipodWorld, name: string) {\n const handle = this.browser(name);\n expect(handle.appFrame, `le navigateur ${name} doit avoir chargé l'app`).to.not.equal(null);\n // __testData.ready flips true only once the NG session is connected.\n await handle.appFrame!.waitForFunction(\n () => (window as any).__testData?.ready === true,\n { timeout: 30000 },\n );\n});",
"sourceCode": "Then('le navigateur {string} est connecté à NextGraph', async function (this: FestipodWorld, name: string) {\n const handle = this.browser(name);\n expect(handle.appFrame, `le navigateur ${name} doit avoir chargé l'app`).to.not.equal(null);\n // __testData.ready flips true only once the NG session is connected.\n await handle.appFrame!.waitForFunction(\n () => (window as any).__testData?.ready === true,\n undefined,\n { timeout: 30000 },\n );\n});",
"lineNumber": 24
},
{
+4 -4
View File
@@ -17,8 +17,10 @@ export interface FpEventData {
distance?: number;
participantCount: number;
coverImage?: string;
hostName?: string;
hostInitials?: string;
// NO HOST. An event is only the anchor — a public event someone referenced so
// meeting points can be grafted onto it. Its declarer is not a host, is not
// required to attend, and is not named on it. The host lives one level down, on
// the meeting point (`FpMeetingPointData.hostId` / SHEX `fp:MeetingPoint.host`).
themes?: string[];
}
@@ -54,8 +56,6 @@ export interface FpMeetingPointData {
place?: string;
location: string;
time: string;
hostName: string;
hostInitials: string;
/** NURI of the meeting point's inbox (wired in T02.b/c). */
inbox?: string;
}
+149 -40
View File
@@ -5,7 +5,7 @@ import type { Schema } from "@ng-org/shex-orm";
* festipodShapesSchema: Schema for festipodShapes
* =============================================================================
*/
export const festipodShapesSchema: Schema = {
export const festipodShapesSchema = {
"http://festipod.org/Event": {
iri: "http://festipod.org/Event",
predicates: [
@@ -22,67 +22,92 @@ export const festipodShapesSchema: Schema = {
readablePredicate: "@type",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/title",
readablePredicate: "title",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/description",
readablePredicate: "description",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/date",
readablePredicate: "date",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/location",
readablePredicate: "location",
},
{
dataTypes: [{ valType: "number" }],
dataTypes: [
{
valType: "number",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/distance",
readablePredicate: "distance",
},
{
dataTypes: [{ valType: "number" }],
dataTypes: [
{
valType: "number",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/participantCount",
readablePredicate: "participantCount",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/coverImage",
readablePredicate: "coverImage",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/hostName",
readablePredicate: "hostName",
},
{
dataTypes: [{ valType: "string" }],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/hostInitials",
readablePredicate: "hostInitials",
iri: "http://festipod.org/inbox",
readablePredicate: "inbox",
},
],
},
@@ -102,35 +127,55 @@ export const festipodShapesSchema: Schema = {
readablePredicate: "@type",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/name",
readablePredicate: "name",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/initials",
readablePredicate: "initials",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/username",
readablePredicate: "username",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/role",
readablePredicate: "role",
},
{
dataTypes: [{ valType: "boolean" }],
dataTypes: [
{
valType: "boolean",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/isPublic",
@@ -154,21 +199,33 @@ export const festipodShapesSchema: Schema = {
readablePredicate: "@type",
},
{
dataTypes: [{ valType: "iri" }],
dataTypes: [
{
valType: "iri",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/event",
readablePredicate: "event",
},
{
dataTypes: [{ valType: "iri" }],
dataTypes: [
{
valType: "iri",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/user",
readablePredicate: "user",
},
{
dataTypes: [{ valType: "boolean" }],
dataTypes: [
{
valType: "boolean",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/isConfirmed",
@@ -192,49 +249,77 @@ export const festipodShapesSchema: Schema = {
readablePredicate: "@type",
},
{
dataTypes: [{ valType: "iri" }],
dataTypes: [
{
valType: "iri",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/event",
readablePredicate: "event",
},
{
dataTypes: [{ valType: "iri" }],
dataTypes: [
{
valType: "iri",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/host",
readablePredicate: "host",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/title",
readablePredicate: "title",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/description",
readablePredicate: "description",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/place",
readablePredicate: "place",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/time",
readablePredicate: "time",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/inbox",
@@ -258,42 +343,66 @@ export const festipodShapesSchema: Schema = {
readablePredicate: "@type",
},
{
dataTypes: [{ valType: "iri" }],
dataTypes: [
{
valType: "iri",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/recipient",
readablePredicate: "recipient",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/type",
readablePredicate: "type",
},
{
dataTypes: [{ valType: "iri" }],
dataTypes: [
{
valType: "iri",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/ref",
readablePredicate: "ref",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 0,
iri: "http://festipod.org/payload",
readablePredicate: "payload",
},
{
dataTypes: [{ valType: "string" }],
dataTypes: [
{
valType: "string",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/timestamp",
readablePredicate: "timestamp",
},
{
dataTypes: [{ valType: "boolean" }],
dataTypes: [
{
valType: "boolean",
},
],
maxCardinality: 1,
minCardinality: 1,
iri: "http://festipod.org/isRead",
@@ -301,4 +410,4 @@ export const festipodShapesSchema: Schema = {
},
],
},
};
} as const satisfies Schema;
@@ -1,31 +1,35 @@
import type { ShapeType } from "@ng-org/shex-orm";
import { festipodShapesSchema } from "./festipodShapes.schema";
import { festipodShapesSchema } from "./festipodShapes.schema.ts";
import type {
FpEvent,
FpUserProfile,
FpParticipation,
FpMeetingPoint,
FpNotification,
} from "./festipodShapes.typings";
Event,
UserProfile,
Participation,
MeetingPoint,
Notification,
} from "./festipodShapes.typings.ts";
// ShapeTypes for festipodShapes
export const FpEventShapeType: ShapeType<FpEvent> = {
export const EventShapeType = {
schema: festipodShapesSchema,
shape: "http://festipod.org/Event",
};
export const FpUserProfileShapeType: ShapeType<FpUserProfile> = {
} as const satisfies ShapeType<Event>;
export const UserProfileShapeType = {
schema: festipodShapesSchema,
shape: "http://festipod.org/UserProfile",
};
export const FpParticipationShapeType: ShapeType<FpParticipation> = {
} as const satisfies ShapeType<UserProfile>;
export const ParticipationShapeType = {
schema: festipodShapesSchema,
shape: "http://festipod.org/Participation",
};
export const FpMeetingPointShapeType: ShapeType<FpMeetingPoint> = {
} as const satisfies ShapeType<Participation>;
export const MeetingPointShapeType = {
schema: festipodShapesSchema,
shape: "http://festipod.org/MeetingPoint",
};
export const FpNotificationShapeType: ShapeType<FpNotification> = {
} as const satisfies ShapeType<MeetingPoint>;
export const NotificationShapeType = {
schema: festipodShapesSchema,
shape: "http://festipod.org/Notification",
};
} as const satisfies ShapeType<Notification>;
+13 -19
View File
@@ -9,9 +9,9 @@ export type IRI = string;
/**
* Event Type
*/
export interface FpEvent {
export interface Event {
/**
* The graph IRI.
* The graph NURI.
*/
readonly "@graph": IRI;
/**
@@ -65,25 +65,19 @@ export interface FpEvent {
*/
coverImage?: string;
/**
* Name of the event host or relay
* NURI of this event's own inbox, published so that anyone holding the event can deposit into it (an inbox belongs to someone and its address must be given, never derived)
*
* Original IRI: http://festipod.org/hostName
* Original IRI: http://festipod.org/inbox
*/
hostName?: string;
/**
* Initials of the event host
*
* Original IRI: http://festipod.org/hostInitials
*/
hostInitials?: string;
inbox?: string;
}
/**
* UserProfile Type
*/
export interface FpUserProfile {
export interface UserProfile {
/**
* The graph IRI.
* The graph NURI.
*/
readonly "@graph": IRI;
/**
@@ -129,9 +123,9 @@ export interface FpUserProfile {
/**
* Participation Type
*/
export interface FpParticipation {
export interface Participation {
/**
* The graph IRI.
* The graph NURI.
*/
readonly "@graph": IRI;
/**
@@ -165,9 +159,9 @@ export interface FpParticipation {
/**
* MeetingPoint Type
*/
export interface FpMeetingPoint {
export interface MeetingPoint {
/**
* The graph IRI.
* The graph NURI.
*/
readonly "@graph": IRI;
/**
@@ -225,9 +219,9 @@ export interface FpMeetingPoint {
/**
* Notification Type
*/
export interface FpNotification {
export interface Notification {
/**
* The graph IRI.
* The graph NURI.
*/
readonly "@graph": IRI;
/**
@@ -18,10 +18,6 @@ fp:Event {
// rdfs:comment "Number of participants" ;
fp:coverImage xsd:string ?
// rdfs:comment "URL of the cover image" ;
fp:hostName xsd:string ?
// rdfs:comment "Name of the event host or relay" ;
fp:hostInitials xsd:string ?
// rdfs:comment "Initials of the event host" ;
fp:inbox xsd:string ?
// rdfs:comment "NURI of this event's own inbox, published so that anyone holding the event can deposit into it (an inbox belongs to someone and its address must be given, never derived)" ;
}
+2
View File
@@ -608,6 +608,7 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
this.appFrame = this.page!.mainFrame();
await this.appFrame.waitForFunction(
() => (window as any).__testData?.ready === true,
undefined,
{ timeout: 10000 },
);
}
@@ -627,6 +628,7 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) {
const root = document.getElementById('root');
return root && root.innerHTML.length > 100;
},
undefined,
{ timeout: 30000 },
);
+10 -4
View File
@@ -21,12 +21,18 @@ import { listMyEntityDocs, openDocumentInbox, resolveScopeGraph } from '../utils
import { setCurrentPrincipal } from '../utils/currentPrincipal';
import { materializeAttendance, NOTIF_TYPE_NEW_PARTICIPANT } from '../data/registration';
import type { RegistrationPayload } from '../data/registration';
// Bare generated names aliased back to the `Fp*` spelling at the import — see the
// same note in `shared/context/FestipodDataContext.tsx`.
import {
FpEventShapeType,
FpUserProfileShapeType,
FpParticipationShapeType,
EventShapeType as FpEventShapeType,
UserProfileShapeType as FpUserProfileShapeType,
ParticipationShapeType as FpParticipationShapeType,
} from '../shapes/orm/festipodShapes.shapeTypes';
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
import type {
Event as FpEvent,
UserProfile as FpUserProfile,
Participation as FpParticipation,
} from '../shapes/orm/festipodShapes.typings';
// ============================================================================
// App — uses real providers (same tree as the real app)
+7 -1
View File
@@ -12,7 +12,13 @@ import React, { useEffect, useRef } from 'react';
import { createRoot } from 'react-dom/client';
import { deepSignal } from '@ng-org/alien-deepsignals';
import type { DeepSignalSet } from '@ng-org/alien-deepsignals';
import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings';
// Bare generated names aliased back to the `Fp*` spelling at the import — see the
// same note in `shared/context/FestipodDataContext.tsx`.
import type {
Event as FpEvent,
UserProfile as FpUserProfile,
Participation as FpParticipation,
} from '../shapes/orm/festipodShapes.typings';
// ============================================================================
// Seed data — same events/users as the app's seedData.ts
+22 -3
View File
@@ -13,11 +13,30 @@
* `/festipod-config.json` (read by frontend.tsx, which sets the global before the
* app tree loads). Absent env global undefined seed OFF.
*
* NOTE: this gate only affects the app's AUTOMATIC seed. Explicit seeding via
* `loadTestData()` (the "Load test data" action + every @data test through the
* harness bridge) is UNAFFECTED it calls bootstrapWallet directly.
* NOTE: this gate only governs the app's AUTOMATIC seed. It is subordinate to
* `fixtureSeedEnabled()` below, which currently switches EVERY route off.
*/
/**
* MASTER SWITCH may a fixture seed be written into the connected wallet AT ALL?
*
* Currently OFF, a product decision: no fixture reaches the connected wallet by
* any route neither the opt-in auto-seed (`FESTIPOD_AUTO_SEED`, whose gate
* below is subordinate to this one) nor an explicit `loadTestData()`.
*
* OFF, not deleted. The fixtures (`data/seedData.ts`) and the seeding code stay,
* because two paths still need them and NEITHER writes to a wallet: the
* demo/disconnected provider (`LocalDataProvider`) reads the fixtures straight
* into React state, and the rendering-layer tests read them directly too. Only
* the connected wallet is closed off.
*
* Enforced at the single place fixtures enter a wallet `bootstrapWallet`
* (`utils/ngBootstrap.ts`) so no caller can bypass it by construction; the
* call sites consult it too, only so they neither log nor await work that will
* not happen.
*/
export const fixtureSeedEnabled = (): boolean => false;
// Build-injected global (not `process.env`, absent in the browser); any path that
// doesn't inject it reads `undefined` → false safely (no ReferenceError).
declare global {
+5 -4
View File
@@ -14,10 +14,11 @@
*
* WHAT IT IS NOT it is NOT the id space the app's own entities live in. A
* UserProfile's id is its document NURI, and a Participation's `fp:user` carries
* that NURI; this principal is a third space. The join between the two is
* explicit and lives in one place (`FestipodDataContext`, where the principal
* selects the current user's profile through `normalizeIdentifier(username)`).
* Never compare this value to an entity id directly.
* that NURI; this principal is a third space, and there is NO join between them.
* A profile is Festipod's own object and this identity says nothing about it:
* **my profile is the profile document I own** (`listMyEntityDocs('protected')`,
* resolved in `FestipodDataContext`). Never compare this value to an entity id,
* and never match it against a profile field to decide who the current user is.
*
* WHY A MODULE STORE and not a React context: `AuthGate` the component that
* makes the await is mounted INSIDE `FestipodDataProvider`, so a context it
+44
View File
@@ -0,0 +1,44 @@
import { expect, test } from 'bun:test';
import { bootstrapWallet } from './ngBootstrap';
// THE SEED IS OFF, AND THIS IS WHAT HOLDS IT OFF.
//
// `bootstrapWallet` is the one place fixtures enter a wallet — the opt-in
// auto-seed and an explicit `loadTestData()` both funnel through it — so proving
// it writes nothing proves no route writes. The proof is not "it returned
// seeded: false" (a seed that half-ran could say that too) but that it never
// asked for a document at all: the `createEntityDoc` handed in below FAILS the
// test if it is called even once.
test('the fixture seed is OFF — bootstrapWallet creates no document, on an empty wallet', async () => {
let createCalls = 0;
const createEntityDoc = async () => {
createCalls++;
throw new Error('createEntityDoc must not be called while the fixture seed is disabled');
};
// `walletHasData: false` is the ONE input that used to make the seed run. Even
// there — a genuinely empty wallet, the case the seed existed for — nothing is
// created.
const result = await bootstrapWallet(false, createEntityDoc);
expect(createCalls).toBe(0);
expect(result.seeded).toBe(false);
expect(result.createdDocs.public).toEqual([]);
expect(result.createdDocs.protected).toEqual([]);
expect(result.userIdMap.size).toBe(0);
expect(result.eventIdMap.size).toBe(0);
});
test('the fixture seed is OFF — bootstrapWallet writes nothing on a populated wallet either', async () => {
let createCalls = 0;
const createEntityDoc = async () => {
createCalls++;
throw new Error('createEntityDoc must not be called while the fixture seed is disabled');
};
const result = await bootstrapWallet(true, createEntityDoc);
expect(createCalls).toBe(0);
expect(result.seeded).toBe(false);
});
+11 -1
View File
@@ -18,6 +18,7 @@ import {
seedUsers,
} from '../data/seedData';
import { writeEntity, ENTITY_TYPE, str, int, flt, bool } from '../data/entityWrites';
import { fixtureSeedEnabled } from './autoSeed';
/** Scope of a seed entity + how to create its own document (SDK create). */
export type Scope = 'public' | 'protected' | 'private';
@@ -46,6 +47,15 @@ export async function bootstrapWallet(
createEntityDoc: CreateEntityDoc,
): Promise<BootstrapResult> {
const createdDocs: { public: Nuri[]; protected: Nuri[] } = { public: [], protected: [] };
// THE SEED IS OFF (see `fixtureSeedEnabled`). This is the one place fixtures
// enter a wallet, so the switch is enforced HERE rather than at each caller:
// every route — the opt-in auto-seed, an explicit `loadTestData()` — funnels
// through this function, and none of them can write around it. Callers get the
// ordinary "nothing was seeded" answer, which is exactly true.
if (!fixtureSeedEnabled()) {
console.log('[Bootstrap] Fixture seed is disabled — nothing written to the wallet');
return { seeded: false, userIdMap: new Map(), eventIdMap: new Map(), createdDocs };
}
// Already has data → returning user, nothing to seed
if (walletHasData) {
console.log('[Bootstrap] Wallet already has data — skipping seed');
@@ -110,7 +120,7 @@ export async function bootstrapWallet(
title: str(e.title), description: str(e.description), date: str(e.date),
location: str(e.location), distance: flt(e.distance),
participantCount: int(e.participantCount),
coverImage: str(e.coverImage), hostName: str(e.hostName), hostInitials: str(e.hostInitials),
coverImage: str(e.coverImage),
});
eventIdMap.set(e.id, id);
// The seeded event is NOT announced anywhere: there is no discovery index to