Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 412e5d63e7 | |||
| d48297fe94 | |||
| a5f6e01562 | |||
| 33b4588da4 | |||
| 0c4f6b6650 | |||
| b25bafbb90 | |||
| 3de86c8be2 | |||
| 4b4dfa6cad | |||
| 974d251079 | |||
| fd02570b8b | |||
| efdfcac4c1 | |||
| 750538bd73 | |||
| 20b3ef8712 | |||
| ff285455d2 | |||
| 35df3e9110 | |||
| db483726af | |||
| 46abbf2e64 | |||
| 753282c7b1 | |||
| d237753916 | |||
| 33d54c6e3e |
@@ -1,40 +0,0 @@
|
||||
# Festipod — variables d'environnement (exemple)
|
||||
#
|
||||
# Copier en `.env` (chargé automatiquement par Bun) et renseigner les valeurs.
|
||||
# En dev (`bun run dev`) ET en prod (`bun run start`), l'app sert depuis src/ et
|
||||
# lit ces variables au RUNTIME (via l'endpoint /festipod-config.json de src/index.ts).
|
||||
# Sans elles, l'app tombe en mode dégradé : la barrière d'accès n'affiche que le
|
||||
# champ identifiant, sans l'assistance de chargement du portefeuille partagé.
|
||||
|
||||
# ── Portefeuille partagé (stopgap staging) ─────────────────────────────────
|
||||
# Mot de passe du portefeuille partagé.
|
||||
# VIDE => hasSharedWallet() faux => la barrière n'affiche QUE le champ identifiant
|
||||
# (pas les 3 étapes « télécharger + importer le portefeuille »). REQUIS en staging
|
||||
# pour l'onboarding d'un appareil qui n'a pas encore de wallet.
|
||||
FESTIPOD_SHARED_WALLET_PASSWORD=
|
||||
|
||||
# Chemin ABSOLU vers le fichier portefeuille partagé (.ngw). Servi en
|
||||
# téléchargement à /shared-wallet.ngw depuis la barrière d'accès.
|
||||
FESTIPOD_SHARED_WALLET_FILE=/chemin/absolu/vers/festipod-wallet.ngw
|
||||
|
||||
# ── Seed automatique (opt-in) ──────────────────────────────────────────────
|
||||
# Non vide => l'app amorce des données de démo dans un wallet VIDE au 1er login.
|
||||
# OFF par défaut : laisser vide en usage normal.
|
||||
FESTIPOD_AUTO_SEED=
|
||||
|
||||
# ── Serveur ────────────────────────────────────────────────────────────────
|
||||
# Port HTTP du serveur (défaut 3000).
|
||||
PORT=3000
|
||||
|
||||
# NODE_ENV=production bascule `bun run start` (pas de HMR). En dev, laisser vide.
|
||||
NODE_ENV=
|
||||
|
||||
# ── Outillage dev (facultatif) ─────────────────────────────────────────────
|
||||
# Override du chemin local du polyfill @ng-eventually/client pour `pnpm run
|
||||
# link:polyfill` (lien local réactif). Défaut = ../nextgraph/ng-eventually-js/packages/client.
|
||||
NG_EVENTUALLY_LOCAL=
|
||||
|
||||
# ── Build only (build.ts / `bun run build`, PAS le runtime) ────────────────
|
||||
# ACCESS_GATE_DISABLED=1 => build SANS barrière d'accès (l'app démarre directement).
|
||||
# Réservé à un build de démo/no-gate ; ne pas utiliser pour un déploiement réel.
|
||||
ACCESS_GATE_DISABLED=
|
||||
+3
-1
@@ -43,4 +43,6 @@ playwright/.auth/
|
||||
storybook-static
|
||||
dist-staging/
|
||||
*.ngw
|
||||
.tasks/
|
||||
|
||||
# per-developer contract source access (never committed)
|
||||
.project/contracts.local.yaml
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
type: _overview
|
||||
summary: Feature-based architecture of the app — modules per domain, import invariant, provider-stacked app shell, path-based routing, screens and registry
|
||||
summary: Architecture feature-based de l'app — modules par domaine, invariant d'imports, app shell à providers, routing path-based, écrans et registre
|
||||
triggers:
|
||||
keywords: [module, modules, screen, écran, routing, route, navigate, useNavigate, useParams, registry, registre, app shell, shared, import]
|
||||
paths: ["src/app/**", "src/screens/**", "src/modules/*/screens/**", "src/shared/components/**", "src/shared/context/**"]
|
||||
@@ -8,18 +8,17 @@ triggers:
|
||||
|
||||
# App architecture
|
||||
|
||||
How the app's code is **structured** and **assembled**. *Feature-based* architecture: the code is organized by **business domain** (module), not by technical layer.
|
||||
Comment le code de l'app est **structuré** et **assemblé**. Architecture *feature-based* : le code est organisé par **domaine métier** (module), pas par couche technique.
|
||||
|
||||
**Read this first:** [[rule_module-imports]] — the central invariant that keeps modules decoupled.
|
||||
**À lire en premier :** [[rule_module-imports]] — l'invariant central qui garde les modules découplés.
|
||||
|
||||
## Links
|
||||
## Liens
|
||||
|
||||
- [[knowledge_module-structure]] — module tree + `shared/` layer
|
||||
- [[knowledge_app-shell]] — `src/app/`, provider stack, entry points
|
||||
- [[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` (principal) vs `currentUser.id` (profile NURI): two id spaces that are not interchangeable
|
||||
- [[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)
|
||||
- `tech-stack` — build, Bun bundler, commands
|
||||
- [[knowledge_module-structure]] — arborescence modules + couche `shared/`
|
||||
- [[knowledge_app-shell]] — `src/app/`, pile de providers, points d'entrée
|
||||
- [[knowledge_routing]] — routing path-based (History API), table de routes, hooks
|
||||
- [[knowledge_screens]] — inventaire des écrans, registre, lib de composants
|
||||
- [[knowledge_screen-pattern]] — anatomie canonique d'un écran (sans props, layout flex, showToast)
|
||||
- [[knowledge_styling-system]] — `src/index.css`, classes `app-*`, vars, pièges (Tailwind non-utilisé, `user-content` inerte)
|
||||
- [[cookbook_add-screen]] — procédure pour câbler un nouvel écran (registre + router + shell)
|
||||
- `tech-stack` — build, bundler Bun, commandes
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: A screen juggles TWO ids for the current user that are not interchangeable — currentUserId (principal urn:festipod:user:…) for participation/friendship queries, currentUser.id (profile NURI) to compare against rendered profiles; getting it wrong raises no error, it just yields an empty list or counts you as an unknown participant
|
||||
last_checked: 2026-07-27
|
||||
---
|
||||
|
||||
# Pitfall: two ids for the current user inside a screen
|
||||
|
||||
`useFestipodData()` exposes **two** identifiers for the current user. They live in **different spaces** and are **never equal in connected mode**:
|
||||
|
||||
| Value | Space | What it is for |
|
||||
|---|---|---|
|
||||
| `currentUserId` | stable **principal** derived from the login identifier (`urn:festipod:user:<key>`) | this is what **participations** and **friendships** store |
|
||||
| `currentUser.id` | **NURI of the profile document** (`did:ng:…`) | this is what rendered **profiles** carry |
|
||||
|
||||
In seed/demo mode the two coincide (`user-1`) — **the pitfall only shows up when connected**, and never as an error: just a wrong result.
|
||||
|
||||
## The rule
|
||||
|
||||
- Queries that **filter participations/friendships** — `getUserEvents(userId)`, `isParticipating(eventId, userId?)`, `getFriends(userId?)` — expect the **principal**. Their default value (`currentUserId`) is correct; **do not pass them** a profile `user.id`, or the list comes back **empty**.
|
||||
- `getEventParticipants(eventId)` returns **profiles**. Any comparison over its result (typically "remove myself from the list") therefore goes through **`currentUser?.id`**, never `currentUserId`.
|
||||
|
||||
## What the mistake costs (observed)
|
||||
|
||||
- Comparing `participant.id !== currentUserId` to filter yourself out **removes nothing**: you show up in your own list, and since the row is no longer recognized it renders as « participant inconnu ».
|
||||
- Symmetrically, a screen displaying **another user's** events from their **profile id** (`getUserEvents(viewedUser.id)`) yields an empty list when connected — same cause.
|
||||
|
||||
The participation→profile join itself is **not** the screen's business: it is done in the provider (`resolveParticipantUser`), through the normalized identifier. Full mechanics and the write/read invariant: concept `data-layer`, [[knowledge_context-internals]].
|
||||
@@ -1,20 +1,20 @@
|
||||
---
|
||||
type: cookbook
|
||||
summary: Procedure for adding a screen — create the component in the module, register it in src/screens/index.ts, add the route in router.tsx, mount it in App.tsx, plus a screenNameMap alias if it is covered by BDD
|
||||
summary: Procédure pour ajouter un écran — créer le composant dans le module, l'enregistrer dans src/screens/index.ts, ajouter la route dans router.tsx, le monter dans App.tsx, et un alias screenNameMap si testé en BDD
|
||||
---
|
||||
|
||||
# Cookbook: adding a screen
|
||||
# Cookbook : ajouter un écran
|
||||
|
||||
A screen has to be wired up in **several places** — forgetting one produces silent bugs (see the `ConnectScreen` case, [[knowledge_screens]]).
|
||||
Un écran doit être câblé à **plusieurs endroits** — en oublier un produit des bugs silencieux (cf. le cas `ConnectScreen`, [[knowledge_screens]]).
|
||||
|
||||
1. **Create the component**: `src/modules/{module}/screens/MyScreen.tsx`, following [[knowledge_screen-pattern]] (function with no props, `useFestipodData`/`useNavigate`/`useParams`, flex layout, styling via [[knowledge_styling-system]]). Respect [[rule_module-imports]] (import only from `shared/`).
|
||||
1. **Créer le composant** : `src/modules/{module}/screens/MyScreen.tsx`, en suivant [[knowledge_screen-pattern]] (fonction sans props, `useFestipodData`/`useNavigate`/`useParams`, layout flex, style via [[knowledge_styling-system]]). Respecter [[rule_module-imports]] (importer seulement depuis `shared/`).
|
||||
|
||||
2. **Add it to the registry**: `src/screens/index.ts` — add the import plus the entry (`id`, French `name`, `path`, `component`). **The most frequently forgotten step**: a screen missing from the registry is invisible to Storybook and to the registry's consumers, even though it works as a route.
|
||||
2. **Enregistrer dans le registre** : `src/screens/index.ts` — ajouter l'import + l'entrée (`id`, `name` FR, `path`, `component`). **Étape la plus oubliée** : un écran absent du registre est invisible à Storybook et aux consommateurs du registre, même s'il fonctionne en route.
|
||||
|
||||
3. **Add the route**: `src/app/router.tsx` — extend the `Route` type, add the case in `parsePath()` (and the reverse conversion if present).
|
||||
3. **Ajouter la route** : `src/app/router.tsx` — étendre le type `Route`, ajouter le cas dans `parsePath()` (et la conversion inverse si présente).
|
||||
|
||||
4. **Mount it in the shell**: `src/app/App.tsx` — add the case in the switch that maps `route.page` → component.
|
||||
4. **Monter dans le shell** : `src/app/App.tsx` — ajouter le cas dans le switch qui mappe `route.page` → composant.
|
||||
|
||||
5. **(If covered by BDD)**: add an alias in `screenNameMap` (`src/shared/steps/ui/navigation.steps.ts`) if the French name used in the `.feature` does not resolve trivially to the `id`. See concept `bdd-testing`.
|
||||
5. **(Si testé en BDD)** : ajouter un alias dans `screenNameMap` (`src/shared/steps/ui/navigation.steps.ts`) si le nom français du `.feature` ne se résout pas trivialement vers l'`id`. Voir concept `bdd-testing`.
|
||||
|
||||
> Check consistency: the `id` must be identical across the registry, the router and `screenNameMap`. A silent mismatch = an unreachable or unrendered screen.
|
||||
> Vérifier la cohérence : l'`id` doit être identique entre le registre, le router et `screenNameMap`. Un écart silencieux = écran injoignable ou non rendu.
|
||||
|
||||
@@ -1,50 +1,33 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: src/app/ is the app's real shell — App.tsx stacks the providers (Theme > NextGraph > Account > FestipodData > Router), AuthGate keeps every routed screen behind the access barrier, and the shell switches screens according to the route
|
||||
last_checked: 2026-07-27
|
||||
summary: src/app/ est le shell réel de l'app — App.tsx empile les providers (Theme > NextGraph > FestipodData > Router) et bascule l'écran selon la route
|
||||
---
|
||||
|
||||
# App shell
|
||||
|
||||
`src/app/` is the **shell of the real app** (mobile web app), not a prototyping tool.
|
||||
`src/app/` est le **shell de l'app réelle** (mobile web app), pas un outil de prototypage.
|
||||
|
||||
> Migration note: older notes described `src/app/` as a "prototyping tool" using hash routing (`#/`, `#/demo/...`). That is **obsolete** since the restructuring into a real app. The current truth: path-based routing via the History API (see [[knowledge_routing]]).
|
||||
> Note de migration : d'anciennes notes décrivaient `src/app/` comme un « prototyping tool » en routing par hash (`#/`, `#/demo/...`). C'est **périmé** depuis la restructuration en vraie app. La vérité courante : routing path-based via History API (voir [[knowledge_routing]]).
|
||||
|
||||
## Provider stack
|
||||
## Pile de providers
|
||||
|
||||
`App.tsx` stacks the providers, then switches screens according to the current route:
|
||||
`App.tsx` empile les providers puis bascule l'écran selon la route courante :
|
||||
|
||||
```
|
||||
ThemeProvider
|
||||
└ NextGraphProvider (NextGraph connection cycle — concept data-layer)
|
||||
└ AccountProvider (current identity = the identifier — concept app-security)
|
||||
└ FestipodDataProvider (data, connected/demo mode — concept data-layer)
|
||||
└ RouterProvider (current route + navigate)
|
||||
└ div.app-container
|
||||
├ AuthGate (access barrier)
|
||||
│ └ AppContent (switch route.page → screen)
|
||||
└ ToastContainer
|
||||
└ NextGraphProvider (cycle de connexion NextGraph — concept data-layer)
|
||||
└ FestipodDataProvider (données, mode connected/demo — concept data-layer)
|
||||
└ RouterProvider (route courante + navigate)
|
||||
```
|
||||
|
||||
`AppContent` reads `useRouter()` to resolve `route.page` → the screen to render.
|
||||
Le composant racine lit `useRouter()` pour résoudre `route.page` → écran à rendre.
|
||||
|
||||
### Ordering invariants (what breaks if you move a layer)
|
||||
## Points d'entrée
|
||||
|
||||
- **`AccountProvider` sits ABOVE `FestipodDataProvider`.** The data provider calls `useAccount()` to derive its principal (`currentUserId`) *and* to reset its session when the identity changes. Reversing the order breaks the whole identity resolution, silently.
|
||||
- **`AuthGate` sits INSIDE the router**: it reads `useRouter()`/`useNavigate()` to leave the logged-out landing route once connected **and** identified. Moving it out of `RouterProvider` breaks it.
|
||||
- **`AuthGate` wraps EVERY routed screen.** As long as the wallet is not open **or** the identifier is not resolved, `AccessGateScreen` is rendered **instead of** `AppContent`. Consequence: **no screen may assume it is reachable without an identity** — unless the barrier is disabled (see below).
|
||||
- **`ToastContainer` sits OUTSIDE `AuthGate`** (but inside `.app-container`): it is mounted regardless of the barrier's state.
|
||||
|
||||
### Disabling the barrier (two consumers)
|
||||
|
||||
`AuthGate` is **ON by default**; it steps aside only if `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ === true`, set either by `build.ts` (from `ACCESS_GATE_DISABLED=1`, barrier-free build) or by the test harness via `addInitScript` for the `@e2e` tests (which exercise the screens, not the auth flow). **Impact**: the barrier flow is therefore **not** covered by the `@e2e` tests — its guards are `@ui` tests (concept `bdd-testing`).
|
||||
|
||||
## Entry points
|
||||
|
||||
| File | Role |
|
||||
| Fichier | Rôle |
|
||||
|---|---|
|
||||
| `src/index.ts` | `Bun.serve()` — HTTP server, serves `index.html` + the cucumber report |
|
||||
| `src/index.html` | HTML entry point, loads `src/app/frontend.tsx` |
|
||||
| `src/app/frontend.tsx` | React root, renders `<App />` |
|
||||
| `src/index.ts` | `Bun.serve()` — serveur HTTP, sert `index.html` + rapport cucumber |
|
||||
| `src/index.html` | Entrée HTML, charge `src/app/frontend.tsx` |
|
||||
| `src/app/frontend.tsx` | Racine React, rend `<App />` |
|
||||
|
||||
The build and the bundler (Bun + Tailwind, alias `@/* → ./src/*`) are documented in the `tech-stack` concept.
|
||||
Le build et le bundler (Bun + Tailwind, alias `@/* → ./src/*`) sont documentés dans le concept `tech-stack`.
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Feature-based tree — business modules (event, user, home, auth, workshop, meeting, notification) and a shared/ layer importable by all of them
|
||||
summary: Arborescence feature-based — modules métier (event, user, home, auth, workshop, meeting, notification) et couche shared/ importable par tous
|
||||
---
|
||||
|
||||
# Module structure
|
||||
# Structure des modules
|
||||
|
||||
The code is organized by **business domain**, not by technical layer.
|
||||
Le code est organisé par **domaine métier**, pas par couche technique.
|
||||
|
||||
```
|
||||
src/modules/
|
||||
event/ # Events: CRUD, discovery, participants, meeting points
|
||||
user/ # Profiles, connections ("friends"), sharing
|
||||
event/ # Événements : CRUD, discovery, participants, points de rencontre
|
||||
user/ # Profils, connexions (« amis »), partage
|
||||
home/ # Dashboard, settings
|
||||
auth/ # Login, welcome/onboarding
|
||||
workshop/ # Workshop specs (features only, no screens)
|
||||
meeting/ # Meeting point specs (features only)
|
||||
notification/ # Notification specs (features only)
|
||||
workshop/ # Specs atelier (features seulement, pas d'écrans)
|
||||
meeting/ # Specs point de rencontre (features seulement)
|
||||
notification/ # Specs notification (features seulement)
|
||||
```
|
||||
|
||||
Each module may contain:
|
||||
- `screens/` — React screen components
|
||||
- `features/` — Gherkin `.feature` files (BDD specs, see concept `bdd-testing`)
|
||||
- `steps/{ui,data,e2e}/` — Cucumber step definitions, one folder per layer
|
||||
Chaque module peut contenir :
|
||||
- `screens/` — composants d'écran React
|
||||
- `features/` — fichiers Gherkin `.feature` (specs BDD, voir concept `bdd-testing`)
|
||||
- `steps/{ui,data,e2e}/` — step definitions Cucumber par couche
|
||||
|
||||
## The `shared/` layer
|
||||
## Couche `shared/`
|
||||
|
||||
`src/shared/` holds everything reusable across modules:
|
||||
`src/shared/` contient tout le réutilisable inter-modules :
|
||||
|
||||
| Directory | Contents |
|
||||
| Répertoire | Contenu |
|
||||
|---|---|
|
||||
| `components/` | UI component library (see [[knowledge_screens]]) |
|
||||
| `context/` | `ThemeContext`, `NextGraphContext`, `AccountContext` (current identity — concept `app-security`), `FestipodDataContext` (concept `data-layer`); their **stacking order** is constrained, see [[knowledge_app-shell]] |
|
||||
| `data/` | User stories, `features.ts` (auto-generated), `seedData.ts`, `types.ts` |
|
||||
| `components/` | Lib de composants UI (voir [[knowledge_screens]]) |
|
||||
| `context/` | `ThemeContext`, `NextGraphContext`, `FestipodDataContext` (voir concept `data-layer`) |
|
||||
| `data/` | User stories, `features.ts` (auto-généré), `seedData.ts`, `types.ts` |
|
||||
| `hooks/` | `useShapeWithDefaults` (NextGraph) |
|
||||
| `shapes/` | SHEX + ORM bindings (see concept `data-layer`) |
|
||||
| `shapes/` | SHEX + bindings ORM (voir concept `data-layer`) |
|
||||
| `utils/` | `ngSession.ts`, `ngBootstrap.ts`, `ngGraph.ts` |
|
||||
| `steps/`, `support/` | Shared Cucumber step definitions and hooks (concept `bdd-testing`) |
|
||||
| `steps/`, `support/` | Step definitions et hooks Cucumber partagés (concept `bdd-testing`) |
|
||||
| `lib/` | Helpers (`cn`, etc.) |
|
||||
|
||||
The dependency rule between modules and `shared/` lives in [[rule_module-imports]].
|
||||
La règle de dépendance entre modules et `shared/` est dans [[rule_module-imports]].
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Path-based routing via the History API (hand-rolled router in src/app/router.tsx) — route table, useNavigate/useParams hooks, no prop drilling
|
||||
summary: Routing path-based via History API (router maison dans src/app/router.tsx) — table de routes, hooks useNavigate/useParams, pas de prop drilling
|
||||
---
|
||||
|
||||
# Routing
|
||||
|
||||
**Path-based** routing via the History API — hand-rolled router in `src/app/router.tsx` (`window.history.pushState` + `popstate`, `parsePath(pathname)`). No hash routing.
|
||||
Routing **path-based** via l'History API — router maison dans `src/app/router.tsx` (`window.history.pushState` + `popstate`, `parsePath(pathname)`). Pas de routing par hash.
|
||||
|
||||
## Route table
|
||||
## Table de routes
|
||||
|
||||
| Path | Screen |
|
||||
| Path | Écran |
|
||||
|---|---|
|
||||
| `/` | WelcomeScreen |
|
||||
| `/login` | LoginScreen |
|
||||
| `/home` | HomeScreen |
|
||||
| `/events` | EventsScreen |
|
||||
| `/events/new` | CreateEventScreen |
|
||||
@@ -24,12 +25,12 @@ summary: Path-based routing via the History API (hand-rolled router in src/app/r
|
||||
| `/profile/edit` | UpdateProfileScreen |
|
||||
| `/profile/friends` | FriendsListScreen |
|
||||
| `/profile/share` | ShareProfileScreen |
|
||||
| `/profile/connect` | ConnectScreen (`src/modules/user/screens/ConnectScreen.tsx`) — routed and mounted in `App.tsx`, but **absent from the screen registry**, see [[knowledge_screens]] |
|
||||
| `/profile/connect` | (connexion) |
|
||||
| `/users/:id` | UserProfileScreen |
|
||||
| `/settings` | SettingsScreen |
|
||||
|
||||
> This table mirrors `parsePath()` in `router.tsx` — go back to it whenever it changes, that is the source of truth. There is **no** authentication route: the access barrier is not routed, it is rendered *above* the route switch by `AuthGate` (see [[knowledge_app-shell]]).
|
||||
> Cette table reflète `parsePath()` dans `router.tsx` — y revenir si elle évolue, c'est la source de vérité.
|
||||
|
||||
## Hooks
|
||||
|
||||
Screens use the router's `useNavigate()` and `useParams()` — **no prop drilling**. The shell intercepts navigation to switch the displayed screen (see [[knowledge_app-shell]]).
|
||||
Les écrans utilisent `useNavigate()` et `useParams()` du router — **pas de prop drilling**. Le shell intercepte la navigation pour basculer l'écran affiché (voir [[knowledge_app-shell]]).
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
---
|
||||
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 on hub screens), feedback via showToast, hard-coded French labels
|
||||
summary: Anatomie canonique d'un écran — fonction nommée sans props, lit tout via useFestipodData/useNavigate/useParams, layout flex colonne (Header / contenu scrollable / BottomNav pour les écrans hub), feedback via showToast, libellés français en dur
|
||||
---
|
||||
|
||||
# Canonical screen pattern
|
||||
# Pattern canonique d'un écran
|
||||
|
||||
Every screen follows the same shape. Knowing it avoids reinventing or diverging.
|
||||
Tous les écrans suivent la même forme. La connaître évite de réinventer ou de diverger.
|
||||
|
||||
## Shape
|
||||
## Forme
|
||||
|
||||
```tsx
|
||||
export function MyScreen() { // named function, NEVER any props
|
||||
export function MyScreen() { // fonction nommée, JAMAIS de props
|
||||
const navigate = useNavigate();
|
||||
const { eventId, userId } = useParams();
|
||||
const { getEvent, currentUser, … } = useFestipodData();
|
||||
const [local, setLocal] = useState(…); // screen-local state (steps, selections)
|
||||
const [local, setLocal] = useState(…); // état local d'écran (étapes, sélections)
|
||||
|
||||
const handleAction = () => {
|
||||
// …mutate through useFestipodData
|
||||
// …muter via useFestipodData
|
||||
showToast('Message', 'success'); // feedback
|
||||
navigate('/path');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display:'flex', flexDirection:'column', height:'100%' }}>
|
||||
<Header title="…" /* left/right optional */ />
|
||||
<div style={{ flex:1, overflow:'auto' }}>{/* scrollable content */}</div>
|
||||
<BottomNav active="…" /> {/* only on hub screens */}
|
||||
<Header title="…" /* left/right optionnels */ />
|
||||
<div style={{ flex:1, overflow:'auto' }}>{/* contenu scrollable */}</div>
|
||||
<BottomNav active="…" /> {/* seulement sur les écrans hub */}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,13 +34,10 @@ export function MyScreen() { // named function, NEVER any props
|
||||
|
||||
## Invariants
|
||||
|
||||
- **Zero props**: a screen receives nothing; everything comes from context/hooks (`useFestipodData`, `useNavigate`, `useParams`). Two exceptions, of different kinds:
|
||||
- `WelcomeScreen` does not use `useFestipodData` (intro) — but still takes no props. (`LoginScreen`/`ConnexionScreen` no longer exist.)
|
||||
- **`AccessGateScreen` is the only genuine exception to the zero-prop rule**: it is **not a routed screen**, it is rendered by `src/app/AuthGate.tsx`, which passes it `status`/`error`/`initialIdentifier`/`onEnter`. It therefore sits **outside the registry and outside the route table**, and has access to neither the router nor the data. See [[knowledge_screens]] and [[knowledge_app-shell]].
|
||||
- **Identity: two id spaces.** `currentUserId` (principal) and `currentUser.id` (profile NURI) are **not** interchangeable depending on the query — see [[caveat_identity-ids-in-screens]] before comparing an id inside a screen.
|
||||
- **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.
|
||||
- Styling: see [[knowledge_styling-system]]. Navigation/registry: [[knowledge_routing]], [[knowledge_screens]].
|
||||
- **Zéro prop** : l'écran ne reçoit rien ; tout vient du contexte/hooks (`useFestipodData`, `useNavigate`, `useParams`). Exceptions légitimes : `LoginScreen`/`WelcomeScreen` n'utilisent pas `useFestipodData` (auth/intro).
|
||||
- **Layout** : flex colonne pleine hauteur ; `Header` en haut, contenu en `flex:1; overflow:auto`, `BottomNav` en bas **uniquement pour les écrans hub** (Home, Events, Profile, Friends). Les écrans de flux (création, édition, détail) n'ont pas de `BottomNav`.
|
||||
- **Feedback** : `showToast(message, 'success'|'info'|'error')` (mécanisme `ToastContainer` exporté par `sketchy/`).
|
||||
- **Libellés** : **français, en dur** — aucun i18n, aucune clé de traduction dans le projet.
|
||||
- Style : voir [[knowledge_styling-system]]. Navigation/registre : [[knowledge_routing]], [[knowledge_screens]].
|
||||
|
||||
To **create** a screen (the 3+ places to wire up), see [[cookbook_add-screen]].
|
||||
Pour **créer** un écran (les 3+ endroits à câbler), voir [[cookbook_add-screen]].
|
||||
|
||||
@@ -1,42 +1,40 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Inventory of screens per module, central registry src/screens/index.ts, and the component library under shared/components/sketchy/ — whose NAME is kept but which renders a modern theme (not hand-drawn)
|
||||
summary: Inventaire des écrans par module, registre central src/screens/index.ts, et lib de composants sous shared/components/sketchy/ — dont le NOM est conservé mais qui rend un thème moderne (pas hand-drawn)
|
||||
---
|
||||
|
||||
# Screens and components
|
||||
# Écrans et composants
|
||||
|
||||
## Component library: `sketchy/` = modern theme
|
||||
## Lib de composants : `sketchy/` = thème moderne
|
||||
|
||||
⚠️ **Naming pitfall.** The component library lives under `src/shared/components/sketchy/` (path kept, imported by ~17 screens), **but it no longer renders a "hand-drawn" style**: it was ported to a **modern** theme (DM Sans / orange, `app-*` classes). The *import path* is right, the *"sketchy" visual description* is obsolete. Do not reintroduce hand-drawn aesthetics on the strength of the folder name.
|
||||
⚠️ **Piège de nommage.** La lib de composants vit sous `src/shared/components/sketchy/` (chemin conservé, importé par ~17 écrans), **mais elle ne rend plus un style « hand-drawn »** : elle a été portée vers un thème **moderne** (DM Sans / orange, classes `app-*`). Le *chemin d'import* est bon, la *description visuelle « sketchy »* est périmée. Ne pas réintroduire d'esthétique dessinée en se fiant au nom du dossier.
|
||||
|
||||
Typical components: `Header`, `BottomNav`, `Button`, `Card`, `Input`, `Badge`, `Avatar`/`AvatarStack`, `Text`/`Title`, `Toggle`, `ListItem`, `Divider`, `Placeholder`, `BrokerBanner`, `NgStatus`.
|
||||
Composants typiques : `Header`, `BottomNav`, `Button`, `Card`, `Input`, `Badge`, `Avatar`/`AvatarStack`, `Text`/`Title`, `Toggle`, `ListItem`, `Divider`, `Placeholder`, `BrokerBanner`, `NgStatus`.
|
||||
|
||||
## Screen registry
|
||||
## Registre d'écrans
|
||||
|
||||
`src/screens/index.ts` imports every screen from every module and exposes:
|
||||
`src/screens/index.ts` importe tous les écrans de tous les modules et expose :
|
||||
|
||||
```typescript
|
||||
export const screenGroups // grouped by domain (home, events, user, general)
|
||||
export const screens // flat list
|
||||
export const screenGroups // groupés par domaine (home, events, user, general)
|
||||
export const screens // liste à plat
|
||||
export function getScreen(id): Screen | undefined
|
||||
```
|
||||
|
||||
Used notably by Storybook (see concept `tech-stack`) to browse the screens.
|
||||
Utilisé notamment par Storybook (voir concept `tech-stack`) pour parcourir les écrans.
|
||||
|
||||
## Inventory
|
||||
## Inventaire
|
||||
|
||||
Screens per module (IDs = registry keys):
|
||||
Écrans par module (IDs = clés du registre) :
|
||||
|
||||
- **home/**: `welcome`, `home`, `settings`
|
||||
- **event/**: `events`, `event-detail`, `create-event`, `update-event`, `invite`, `participants-list`, `meeting-points`
|
||||
- **user/**: `profile`, `update-profile`, `user-profile`, `friends-list`, `share-profile`
|
||||
- **auth/**: `WelcomeScreen` (intro, routed at `/`) and `AccessGateScreen` — the **access barrier** (NextGraph login + identifier entry), rendered by `src/app/AuthGate.tsx`, **outside the registry and outside routing** (it is not a routed screen) and **driven by props** (`status`/`error`/`initialIdentifier`/`onEnter`), the only exception to the zero-prop rule ([[knowledge_screen-pattern]]). The former `LoginScreen`, then `ConnexionScreen`, have been removed (see concept `app-security`, [[knowledge_authentication]]).
|
||||
- **home/** : `welcome`, `home`, `settings`
|
||||
- **event/** : `events`, `event-detail`, `create-event`, `update-event`, `invite`, `participants-list`, `meeting-points`
|
||||
- **user/** : `profile`, `update-profile`, `user-profile`, `friends-list`, `share-profile`
|
||||
- **auth/** : `login`
|
||||
|
||||
Structurally, this screen does **not** render a standard screen layout but a **choice between three mutually exclusive access branches**, driven by `status` + the presence of a shared wallet. **Impact**: a new access case is added as a branch here, **not** as a route. The content and ordering of the branches are `app-security` doctrine ([[knowledge_authentication]]) — do not redefine them from here.
|
||||
> Le mapping path → écran est dans [[knowledge_routing]]. La plupart des écrans consomment `useFestipodData()` (concept `data-layer`) ; exceptions : `LoginScreen`/`WelcomeScreen`.
|
||||
|
||||
> The path → screen mapping lives in [[knowledge_routing]]. Most screens consume `useFestipodData()` (concept `data-layer`); exceptions: `WelcomeScreen` and the `AccessGateScreen` barrier.
|
||||
## Piège : registre incomplet
|
||||
|
||||
## Pitfall: incomplete registry
|
||||
|
||||
The registry must list **every** screen. Observed case, **still true (verified 2026-07-28)**: `ConnectScreen` (`src/modules/user/screens/ConnectScreen.tsx`) exists, is routed at `/profile/connect` (`router.tsx`) and is mounted in `App.tsx` (`case 'connect'`), yet it is **missing from `src/screens/index.ts`** → invisible to Storybook and to the registry's consumers, even though it works as a route. Always check that the screen is registered (see [[cookbook_add-screen]]).
|
||||
Le registre doit lister **tous** les écrans. Cas observé : `ConnectScreen` (`src/modules/user/screens/`, routé `/profile/connect`, monté dans `App.tsx`) est **absent de `src/screens/index.ts`** → invisible à Storybook et aux consommateurs du registre, bien qu'il fonctionne en route. Toujours vérifier que l'écran est enregistré (cf. [[cookbook_add-screen]]).
|
||||
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: src/index.css is the styling source of truth — --app-* variables (colors, radii, DM Sans font) and app-* classes rendered by the components; screens combine those classes with inline styles; Tailwind is in the build but screens use no Tailwind utilities; the user-content class is inert
|
||||
summary: src/index.css est la source de vérité du style — variables --app-* (couleurs, rayons, police DM Sans) et classes app-* rendues par les composants ; les écrans combinent ces classes avec des styles inline ; Tailwind est dans le build mais les écrans n'utilisent pas d'utilitaires Tailwind ; la classe user-content est inerte
|
||||
last_checked: 2026-06-15
|
||||
---
|
||||
|
||||
# Styling system
|
||||
# Système de style
|
||||
|
||||
**Source of truth: `src/index.css`** ("Modern clean — DM Sans" theme). That is where the CSS variables and the `app-*` classes live. No per-module CSS files.
|
||||
**Source de vérité : `src/index.css`** (thème « Modern clean — DM Sans »). C'est là que vivent les variables CSS et les classes `app-*`. Pas de fichiers CSS par module.
|
||||
|
||||
## Variables (`:root`)
|
||||
|
||||
- Colors: `--app-black #1a1a1a`, `--app-gray #888`, `--app-bg/--app-white #fff`, orange accent `--app-accent #E8590C` (+ `-light #FFF7ED`, `-border`, `-dark #C05621`), green `--app-green #22543D` (+ `-light`, `-border`, `-text`).
|
||||
- Radii: `--app-radius 16px`, `--app-radius-sm 12px`, `--app-radius-xs 8px`.
|
||||
- Font: `--font-app: 'DM Sans', …`.
|
||||
- Couleurs : `--app-black #1a1a1a`, `--app-gray #888`, `--app-bg/--app-white #fff`, accent orange `--app-accent #E8590C` (+ `-light #FFF7ED`, `-border`, `-dark #C05621`), vert `--app-green #22543D` (+ `-light`, `-border`, `-text`).
|
||||
- Rayons : `--app-radius 16px`, `--app-radius-sm 12px`, `--app-radius-xs 8px`.
|
||||
- Police : `--font-app: 'DM Sans', …`.
|
||||
|
||||
## `app-*` classes
|
||||
## Classes `app-*`
|
||||
|
||||
Defined in `index.css`, rendered by the components in `shared/components/sketchy/`: `app-btn` (+ `-primary`/`-green`), `app-input`, `app-card`, `app-title`/`app-subtitle`/`app-text`, `app-badge`, `app-toggle`, `app-checkbox`, `app-header`, `app-navbar`, `app-list-item`, `app-avatar`, `app-placeholder`, `app-divider`, `app-tab`.
|
||||
Définies dans `index.css`, rendues par les composants de `shared/components/sketchy/` : `app-btn` (+ `-primary`/`-green`), `app-input`, `app-card`, `app-title`/`app-subtitle`/`app-text`, `app-badge`, `app-toggle`, `app-checkbox`, `app-header`, `app-navbar`, `app-list-item`, `app-avatar`, `app-placeholder`, `app-divider`, `app-tab`.
|
||||
|
||||
## Conventions for writing a screen
|
||||
## Conventions d'écriture d'un écran
|
||||
|
||||
- Use the **`sketchy/` components** (which carry the `app-*` classes) for buttons/inputs/cards/typography.
|
||||
- For **layout** (flex, gaps, paddings, one-off colors), screens use **inline styles** (`style={{…}}`) — that is the normal pattern, not a deviation.
|
||||
- Icons: **emojis**/Unicode symbols (📅 📍 📝 🎪…), generally no icon imports.
|
||||
- Width: `.app-container` caps at **`max-width: 768px`, `height: 100dvh`** (mobile-first/portrait tablet). No media queries — no desktop responsiveness.
|
||||
- Utiliser les **composants `sketchy/`** (qui portent les classes `app-*`) pour boutons/inputs/cartes/typo.
|
||||
- Pour le **layout** (flex, gaps, paddings, couleurs ponctuelles), les écrans utilisent des **styles inline** (`style={{…}}`) — c'est le pattern normal, pas une déviation.
|
||||
- Icônes : **emojis**/symboles Unicode (📅 📍 📝 🎪…), pas d'imports d'icônes en général.
|
||||
- Largeur : `.app-container` borne à **`max-width: 768px`, `height: 100dvh`** (mobile-first/tablette portrait). Aucune media query — pas de responsive desktop.
|
||||
|
||||
## Pitfalls
|
||||
## Pièges
|
||||
|
||||
- **Tailwind is in the build** (`bun-plugin-tailwind` plugin, `tailwindcss` dependency), but **screens use no Tailwind utility classes** — the actual styling goes through `app-*` + inline. Do not "tailwindify" a screen thinking you are following the convention.
|
||||
- **`user-content` is an INERT class**: used on many titles/names across the screens, **with no CSS definition whatsoever**. It is a legacy marker with no effect — do not rely on it for styling, do not assume it does anything.
|
||||
- No **dark mode**: the "darkMode" toggle in `SettingsScreen` is not wired to anything.
|
||||
- **Tailwind est dans le build** (plugin `bun-plugin-tailwind`, dépendance `tailwindcss`), mais **les écrans n'utilisent pas de classes utilitaires Tailwind** — le style réel passe par `app-*` + inline. Ne pas « tailwindiser » un écran en pensant suivre la convention.
|
||||
- **`user-content` est une classe INERTE** : utilisée sur de nombreux titres/noms dans les écrans, **sans aucune définition CSS**. C'est un marqueur legacy sans effet — ne pas s'appuyer dessus pour styler, ne pas croire qu'elle fait quelque chose.
|
||||
- Pas de **dark mode** : le toggle « darkMode » de `SettingsScreen` n'est branché à rien.
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
---
|
||||
type: rule
|
||||
summary: A module imports ONLY from shared/ (and the screen registry) — never from another module; this is the invariant that keeps the feature-based architecture real
|
||||
summary: Un module n'importe QUE depuis shared/ (et le registre d'écrans) — jamais depuis un autre module ; c'est l'invariant qui garde l'architecture feature-based
|
||||
---
|
||||
|
||||
# Rule: a module never imports from another module
|
||||
# Règle : un module n'importe jamais d'un autre module
|
||||
|
||||
**Modules import only from `shared/` — never from each other.**
|
||||
**Les modules importent uniquement depuis `shared/` — jamais entre eux.**
|
||||
|
||||
```
|
||||
src/modules/event/screens/EventDetailScreen.tsx
|
||||
✅ import from 'shared/components/...'
|
||||
✅ import from 'shared/context/FestipodDataContext'
|
||||
✅ import from 'src/screens' (registry types)
|
||||
❌ import from 'modules/user/screens/...'
|
||||
✅ import depuis 'shared/components/...'
|
||||
✅ import depuis 'shared/context/FestipodDataContext'
|
||||
✅ import depuis 'src/screens' (types du registre)
|
||||
❌ import depuis 'modules/user/screens/...'
|
||||
```
|
||||
|
||||
## Why
|
||||
## Pourquoi
|
||||
|
||||
This is what makes the *feature-based* architecture real rather than cosmetic: each domain stays a self-contained block, movable or removable without breaking the others. Any shared need **moves up into `shared/`**; any cross-domain dependency goes through a `shared/` contract (usually `FestipodDataContext` or the screen registry), never through a direct import.
|
||||
C'est ce qui rend l'architecture *feature-based* réelle et pas cosmétique : chaque domaine reste un bloc autonome, déplaçable/supprimable sans casser les autres. Tout besoin partagé **remonte dans `shared/`** ; toute dépendance inter-domaines passe par un contrat de `shared/` (souvent `FestipodDataContext` ou le registre d'écrans), jamais par un import direct.
|
||||
|
||||
## How to check
|
||||
## Vérifier
|
||||
|
||||
`grep -rE "from '\.\./\.\./(event|user|home|auth|workshop|meeting|notification)/" src/modules/` must not surface anything pointing from one module to *another* module. An import that crosses two different module names is a violation.
|
||||
`grep -rE "from '\.\./\.\./(event|user|home|auth|workshop|meeting|notification)/" src/modules/` ne doit rien remonter d'un module vers un *autre* module. Un import qui croise deux noms de modules différents est une violation.
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
---
|
||||
type: _overview
|
||||
summary: Festipod's security & privacy — isolation between scopes is enforced by the data SDK, the app trusts it and carries no authorization logic in the screens; wallet-based authentication; target authorization matrix still incubating
|
||||
summary: Sécurité & confidentialité de Festipod — posture ACTUELLE (mono-store, confiance broker, aucun contrôle d'accès côté app) et modèle d'autorisations CIBLE (incubation) ; authentification par wallet NextGraph
|
||||
triggers:
|
||||
keywords: [sécurité, security, confidentialité, privacy, accès, "access control", contrôle d'accès, trust, confiance, authz, autorisation, permission, wallet, auth, authentification, anonyme, anonymat, pseudonyme, traçage, corrélation, overlay, cap-less, identité, login, scope, isolation]
|
||||
keywords: [sécurité, security, confidentialité, privacy, accès, "access control", contrôle d'accès, trust, confiance, authz, autorisation, permission, wallet, auth, authentification, anonyme, identité, login]
|
||||
paths: ["src/modules/auth/**", "src/shared/context/NextGraphContext.tsx"]
|
||||
---
|
||||
|
||||
# App security
|
||||
|
||||
Festipod's **security, privacy and authorization** model.
|
||||
Le modèle de **sécurité, confidentialité et autorisations** de Festipod. Le pilier se lit en deux temps :
|
||||
|
||||
- **Enforced model** — **isolation between scopes** (public / protected / private) is **enforced by the data SDK** (`@ng-eventually/client`), 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]].
|
||||
- **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.
|
||||
- **Actuel** — ce que le code applique aujourd'hui : voir [[knowledge_trust-model]]. Résumé brutal : **aucun contrôle d'accès côté app**, l'app affiche le `private_store` de l'utilisateur connecté et fait confiance au broker. Mono-user de fait.
|
||||
- **Cible** — le modèle d'autorisations dérivé (qui peut faire quoi, données personnelles = réseau, anonymat via inbox) : [[brief_2026-05-18_authorization-matrix]]. **Incubation, non implémenté.** Il graduera en `rule_`/`behavior_` quand le multi-user atterrira (chantiers data dans le concept `nextgraph-platform`).
|
||||
|
||||
## Pitfalls (read BEFORE designing anything "anonymous")
|
||||
L'écart entre les deux est volontaire : tant que l'app est mono-store (cf. concept `data-layer`), il n'y a rien à autoriser côté app.
|
||||
|
||||
- [[caveat_stable-overlay-pseudonym]] — a cap-less reference exposes a **permanent pseudonym** of the person; a single cross-reference de-anonymizes their entire history **retroactively**, and no rotation is known
|
||||
- [[caveat_shared-wallet-global-before-gate-import]] — since the shared wallet is the only mode, a password global set **after** the barrier has been imported makes it unusable (config error screen, no field at all)
|
||||
## Liens
|
||||
|
||||
## Links
|
||||
|
||||
- [[knowledge_trust-model]] — the app delegates isolation to the SDK, no access control in the screens
|
||||
- [[knowledge_authentication]] — wallet-based auth, everyone authenticated, no anonymous access
|
||||
- [[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)
|
||||
- [[knowledge_trust-model]] — posture de sécurité actuelle (mono-store, confiance broker, pas d'enforcement app)
|
||||
- [[knowledge_authentication]] — auth par wallet NextGraph, tous authentifiés, pas d'accès anonyme
|
||||
- [[brief_2026-05-18_authorization-matrix]] — modèle d'autorisations cible (incubation)
|
||||
- `nextgraph-platform` — les primitives (stores, capabilities, inbox) et les chantiers data qui porteront la cible
|
||||
|
||||
@@ -1,126 +1,143 @@
|
||||
---
|
||||
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: Matrice d'autorisations par type de donnée (PdR, inscription, événement, profil, connexion) ; dérive que 3 stores natifs par utilisateur + Dialog stores suffisent, aucun Group store sur le périmètre validé ; questions ouvertes sur modèle d'écriture événement et identité de l'hôte
|
||||
last_updated: 2026-05-18
|
||||
---
|
||||
|
||||
# Authorization matrix and query inventory
|
||||
# Matrice d'autorisations et inventaire des requêtes
|
||||
|
||||
**Status:** Incubating — target model, not frozen into rules.
|
||||
**Status:** Incubating — analyse en cours
|
||||
**Last updated:** 2026-05-18
|
||||
|
||||
## Context
|
||||
|
||||
The **target** model of who-may-do-what. Festipod's privacy derives from: (1) an authorization matrix per actor × verb; (2) the inventory of queries per screen; (3) the **scopes** that follow from them — data sharing both an authorization *and* an access pattern. The concrete entity → scope placement is a product fact: concept `functional-domain` → [[knowledge_data-scopes-and-discovery]]. Isolation is **enforced by the data SDK** ([[knowledge_trust-model]]).
|
||||
Préalable au refactor multi-store ([[brief_2026-05-17_multi-store-refactor]]) et à toute évolution multi-user. La structure de stores NextGraph cible doit être *dérivée* de : (1) une matrice d'autorisations ; (2) un inventaire des requêtes par écran ; (3) les partitions naturelles qui en découlent (données partageant autorisations *et* schéma d'accès).
|
||||
|
||||
## Framing
|
||||
C'est aussi le **modèle de confidentialité/sécurité** de Festipod (pilier sécurité), non encore implémenté.
|
||||
|
||||
### Actors (all authenticated)
|
||||
## Cadre
|
||||
|
||||
`Alice` (viewpoint, owner of the data in focus) · `Bob` (second protagonist, bilateral relationships) · `D` (event declarer) · `H` (host of a meeting point) · `I` (registrant) · `C` (connection) · `U` (plain user with no relationship).
|
||||
### Acteurs (tous authentifiés)
|
||||
|
||||
### Verbs
|
||||
`Alice` (point de vue, propriétaire de la donnée en focus) · `Bob` (second protagoniste, relations bilatérales) · `D` (déclarant d'événement) · `H` (hôte d'un PdR) · `I` (inscrit) · `C` (connexion) · `U` (utilisateur lambda sans relation).
|
||||
|
||||
`create` · `read` (one-shot) · `subscribe` (reactive read) · `update` · `delete`. Conventions: `✓` allowed · `✗` forbidden · `cond` conditional · `—` not applicable.
|
||||
### Verbes
|
||||
|
||||
## Framing decisions (settled)
|
||||
`créer` · `lire` (one-shot) · `s'abonner` (lecture réactive) · `modifier` · `supprimer`. Conventions : `✓` autorisé · `✗` interdit · `cond` sous condition · `—` sans objet.
|
||||
|
||||
- **Everyone is authenticated.** No anonymous access.
|
||||
- **Meeting points are universally public.** Any user may read and subscribe.
|
||||
- **Creating a meeting point is open to all.** No prerequisite.
|
||||
- **Host = holder of the write rights** on a meeting point (1 host, the creator; being the host is public).
|
||||
- **Personal information = reserved to the network.** Visible only to the holder and their connections: participations, the profile in full, the connection list, and any declarative state whose disclosure would be a leak. "Public" status (meeting point, event) and "personal" status (profile, participations, connections) coexist within the same user.
|
||||
- **Bilateral connection.** It exists once both sides have accepted. Two objects: `DemandeDeConnexion` (one-sided, transient) and `Connexion` (bilateral, persistent).
|
||||
- **Registration notification through the meeting point's inbox.** The act of registering is composite: (a) writing an `Inscription` object into the registrant's *protected* scope, (b) dropping a link into the **inbox** of the meeting point document. The sender is **identified if they are one of the host's connections, anonymous otherwise** — a property of the data model.
|
||||
- **Joining a community / following: out of current scope.**
|
||||
## Décisions cadre (acquises)
|
||||
|
||||
## Matrix per data type
|
||||
- **Tous authentifiés.** Pas d'accès anonyme.
|
||||
- **Points de rencontre publics universels.** Tout utilisateur peut lire et s'abonner.
|
||||
- **Création de PdR ouverte à tous.** Pas de prérequis.
|
||||
- **Hôte = détenteur des droits d'écriture** sur un PdR (1 hôte, le créateur ; le fait d'être hôte est public).
|
||||
- **Informations personnelles = réservées au réseau.** Visibles seulement au titulaire et à ses connexions : participations, intégralité du profil, liste de connexions, et tout état déclaratif dont la divulgation serait une fuite. Statut « public » (PdR, événement) et « personnel » (profil, participations, connexions) coexistent dans le même utilisateur.
|
||||
- **Connexion bilatérale.** Existe après acceptation des deux côtés. Deux objets : `DemandeDeConnexion` (unilatérale, transitoire) et `Connexion` (bilatérale, persistante).
|
||||
- **Notification d'inscription via l'inbox NextGraph du PdR.** L'acte « s'inscrire » est composite : (a) écriture d'un objet `Inscription` dans le `protected_store` de l'inscrit, (b) dépôt d'un lien (DID cap) dans l'**inbox** du document PdR. Identification du sender par résolution du DID contre le graphe de connexions de l'hôte : connexion → inscription complète visible ; sinon → lien opaque (« quelqu'un (DID…) s'est inscrit »). Anonymat partiel **natif aux capabilities** (cf. [[knowledge_stores-permissions]] §Inbox).
|
||||
- **Adhésion à une communauté / suivi : hors périmètre actuel.**
|
||||
|
||||
### Meeting point
|
||||
## Matrice par type de donnée
|
||||
|
||||
| Verb | Alice (= Host) | I (other registrant) | D (parent declarer) | U (plain user) |
|
||||
### Point de rencontre
|
||||
|
||||
| Verbe | Alice (= Hôte) | I (autre inscrit) | D (déclarant parent) | U (lambda) |
|
||||
|---|---|---|---|---|
|
||||
| create | ✓ (becomes host) | — | ✗ | ✓ (becomes host) |
|
||||
| read | ✓ | ✓ | ✓ | ✓ |
|
||||
| subscribe | ✓ | ✓ | ✓ | ✓ |
|
||||
| update | ✓ | ✗ | ✗ | ✗ |
|
||||
| delete | ✓ | ✗ | ✗ | ✗ |
|
||||
| créer | ✓ (rend hôte) | — | ✗ | ✓ (rend hôte) |
|
||||
| lire | ✓ | ✓ | ✓ | ✓ |
|
||||
| s'abonner | ✓ | ✓ | ✓ | ✓ |
|
||||
| modifier | ✓ | ✗ | ✗ | ✗ |
|
||||
| supprimer | ✓ | ✗ | ✗ | ✗ |
|
||||
|
||||
Notes: no `C` differentiation (connections are a UI display filter, not a right, since everything is public). `D` has no special right over the meeting points grafted onto their event.
|
||||
Notes : pas de différenciation `C` (les connexions sont un filtre d'affichage UI, pas un droit, tout étant public). Le `D` n'a aucun droit particulier sur les PdR greffés sur son événement.
|
||||
|
||||
### Registration to a meeting point
|
||||
### Inscription à un point de rencontre
|
||||
|
||||
`Inscription` links a user and a meeting point. **Personal data** (registrant + their connections). Composite act (a)+(b) above.
|
||||
`Inscription` lie un utilisateur et un PdR. **Donnée personnelle** (inscrit + ses connexions). Acte composite (a)+(b) ci-dessus.
|
||||
|
||||
| Verb | Alice (registrant) | C (connection) | H (host) | I (other registrant) | U |
|
||||
| Verbe | Alice (inscrite) | C (connexion) | H (hôte) | I (autre inscrit) | U |
|
||||
|---|---|---|---|---|---|
|
||||
| create (composite act) | ✓ | — | ✗ | ✗ | ✓ (becomes registrant) |
|
||||
| read the content | ✓ | ✓ | cond: ✓ if H ∈ connections(Alice); opaque link otherwise | cond: ✓ if I ∈ connections(Alice) | ✗ |
|
||||
| subscribe | ✓ | ✓ | cond (same) | cond (same) | ✗ |
|
||||
| read the meeting point's inbox (raw entries) | — | — | ✓ | ✗ | ✗ |
|
||||
| update | ? **to be decided** (depends on the fields) | ✗ | ✗ | ✗ | ✗ |
|
||||
| delete | ✓ (unregister; remove the link from the inbox if possible) | ✗ | cond: inbox moderation only (does not delete the object) | ✗ | ✗ |
|
||||
| créer (acte composite) | ✓ | — | ✗ | ✗ | ✓ (rend inscrite) |
|
||||
| lire le contenu | ✓ | ✓ | cond : ✓ si H ∈ connexions(Alice) ; sinon lien opaque | cond : ✓ si I ∈ connexions(Alice) | ✗ |
|
||||
| s'abonner | ✓ | ✓ | cond (idem) | cond (idem) | ✗ |
|
||||
| lire l'inbox du PdR (entrées brutes) | — | — | ✓ | ✗ | ✗ |
|
||||
| modifier | ? **à trancher** (selon champs) | ✗ | ✗ | ✗ | ✗ |
|
||||
| supprimer | ✓ (se désinscrire ; retirer le lien de l'inbox si possible) | ✗ | cond : modération inbox seule (ne supprime pas l'objet) | ✗ | ✗ |
|
||||
|
||||
**Host visibility: resolved** (identified if a connection, anonymous otherwise). **Open questions:** which fields of a registration are editable (a bare boolean, or + comment/status/companions?); **inbox-side deletion** — can a depositor withdraw their link from a document they do not control?
|
||||
**Visibilité hôte : résolue** (identifiée si connecté, anonyme sinon — natif). **Questions ouvertes :** champs modifiables d'une inscription (booléen seul ou +commentaire/statut/accompagnants ?) ; **suppression côté inbox** — un déposant peut-il retirer son lien d'un doc qu'il ne contrôle pas ? (à vérifier au protocole).
|
||||
|
||||
### Event
|
||||
### Événement
|
||||
|
||||
| Verb | Alice (= D) | H (host of a grafted meeting point) | U |
|
||||
| Verbe | Alice (= D) | H (hôte d'un PdR greffé) | U |
|
||||
|---|---|---|---|
|
||||
| create | ✓ (becomes declarer) | — | ✓ (becomes declarer) |
|
||||
| read / subscribe | ✓ | ✓ | ✓ |
|
||||
| update | ? **to be decided** | ? **to be decided** | ? **to be decided** |
|
||||
| delete | ? **to be decided** | ✗ | ✗ |
|
||||
| créer | ✓ (rend déclarant) | — | ✓ (rend déclarant) |
|
||||
| lire / s'abonner | ✓ | ✓ | ✓ |
|
||||
| modifier | ? **à trancher** | ? **à trancher** | ? **à trancher** |
|
||||
| supprimer | ? **à trancher** | ✗ | ✗ |
|
||||
|
||||
**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)?
|
||||
**Questions ouvertes :** qui peut **modifier** un événement déclaré — déclarant seul (propriétaire) ? tout utilisateur (wiki) ? personne (immuable) ? Central pour la déduplication (cf. concept `functional-domain`, [[brief_2026-06-15_event-deduplication]] côté functional-domain). Qui peut **supprimer**, et que deviennent les PdR greffés (orphelins/cascade/marqué supprimé) ?
|
||||
|
||||
### User profile
|
||||
### Profil utilisateur
|
||||
|
||||
**Nothing in the profile is public.** Two scopes: the **network profile** (Alice + connections: name, avatar, bio, city, interests); the **private profile** (Alice alone: settings, email, preferences).
|
||||
**Rien dans le profil n'est public.** Deux périmètres : **profil réseau** (Alice + connexions : nom, avatar, bio, ville, intérêts) ; **profil privé** (Alice seule : settings, email, préférences).
|
||||
|
||||
| Verb | Alice | C | U |
|
||||
| Verbe | Alice | C | U |
|
||||
|---|---|---|---|
|
||||
| create | ✓ (at sign-up) | — | — |
|
||||
| read — network | ✓ | ✓ | ✗ |
|
||||
| read — private | ✓ | ✗ | ✗ |
|
||||
| subscribe | ✓ | ✓ (network) | ✗ |
|
||||
| update | ✓ | ✗ | ✗ |
|
||||
| delete (account) | ✓ | ✗ | ✗ |
|
||||
| créer | ✓ (à l'inscription) | — | — |
|
||||
| lire — réseau | ✓ | ✓ | ✗ |
|
||||
| lire — privé | ✓ | ✗ | ✗ |
|
||||
| s'abonner | ✓ | ✓ (réseau) | ✗ |
|
||||
| modifier | ✓ | ✗ | ✗ |
|
||||
| supprimer (compte) | ✓ | ✗ | ✗ |
|
||||
|
||||
**Tension to resolve:** a meeting point is readable by everyone, yet its host should not be identifiable by a plain user. Three positions: (i) **pseudonym through identity alone** (name/avatar resolved only for connections); (ii) **identity denormalized into the offer** (the host picks a "business card" per meeting point, living inside the meeting point object, profile closed); (iii) **host anonymity** (identity revealed only to connections). To be decided. Others: field-by-field composition of each scope; status of the `username` (public/network/dropped?).
|
||||
**Tension à résoudre :** un PdR est lisible par tous, mais son hôte ne devrait pas être identifiable par un lambda. Trois positions : (i) **pseudonyme par DID seul** (nom/avatar résolus seulement aux connexions) ; (ii) **identité dénormalisée dans l'offre** (l'hôte choisit une « carte de visite » par PdR, vivant dans l'objet PdR, profil fermé) ; (iii) **anonymat de l'hôte** (identité révélée seulement aux connexions). À trancher. Autres : composition champ-par-champ de chaque périmètre ; statut du `username` (public/réseau/supprimé ?).
|
||||
|
||||
### Connection (friendship link)
|
||||
### Connexion (lien d'amitié)
|
||||
|
||||
Bilateral. `DemandeDeConnexion` (one-sided, pending) → `Connexion` (bilateral, upon acceptance; opens access to personal data). Alice's connection list is **personal** (Alice + her connections).
|
||||
Bilatérale. `DemandeDeConnexion` (unilatérale, en attente) → `Connexion` (bilatérale, à l'acceptation ; ouvre l'accès aux données personnelles). La liste de connexions d'Alice est **personnelle** (Alice + ses connexions).
|
||||
|
||||
| Verb | Alice (initiator) | Bob (other side) | C | U |
|
||||
| Verbe | Alice (initiatrice) | Bob (autre côté) | C | U |
|
||||
|---|---|---|---|---|
|
||||
| create the request | ✓ | — | — | — |
|
||||
| accept | — | ✓ | — | ✗ |
|
||||
| read Alice's list | ✓ | ✓ | ✓ | ✗ |
|
||||
| subscribe | ✓ | ✓ | ✓ | ✗ |
|
||||
| delete (break A↔B) | ✓ | ✓ | ✗ | ✗ |
|
||||
| créer la demande | ✓ | — | — | — |
|
||||
| accepter | — | ✓ | — | ✗ |
|
||||
| lire la liste d'Alice | ✓ | ✓ | ✓ | ✗ |
|
||||
| s'abonner | ✓ | ✓ | ✓ | ✗ |
|
||||
| supprimer (rompre A↔B) | ✓ | ✓ | ✗ | ✗ |
|
||||
|
||||
**Open questions:** granularity on Bob's side (does he see Alice's whole list, or only A↔B? — the principle implies: the whole list); "friends of friends" discoverability (does Alice see Bob↔Carole? — no, unless Carole ∈ connections(Alice)).
|
||||
**Questions ouvertes :** granularité côté Bob (voit-il toute la liste d'Alice ou juste A↔B ? — conséquence du principe : toute la liste) ; découvrabilité « amis d'amis » (Alice voit-elle Bob↔Carole ? — non, sauf si Carole ∈ connexions(Alice)).
|
||||
|
||||
## Derived scopes
|
||||
## Partitions naturelles dérivées
|
||||
|
||||
Heuristic: same scope if (a) same write-authorization cell *and* (b) accessed together. Three **scopes** emerge, plus the bilateral case:
|
||||
Heuristique : même store si (a) même cellule d'autorisation en écriture *et* (b) accédées ensemble. À partir des seuls points validés, **trois périmètres** émergent — qui correspondent **presque parfaitement aux 3 stores natifs**.
|
||||
|
||||
| Scope | Write | Read | Data |
|
||||
| Périmètre | Écriture | Lecture | Données validées |
|
||||
|---|---|---|---|
|
||||
| **public** | Alice alone | Everyone | Meeting points hosted by Alice; declared events *(subject to the write model)* |
|
||||
| **protected** (network) | Alice alone | Alice + connections | Network profile; participations; connection index |
|
||||
| **private** | Alice alone | Alice alone | Private profile (settings, email, preferences) |
|
||||
| **dialog** (A↔B) | Alice and Bob | Alice and Bob | The bilateral `Connexion` (+ groundwork for future messaging) |
|
||||
| **Public** ↔ `public_store` | Alice seule | Tous | PdR hébergés par Alice ; événements déclarés *(sous réserve du modèle d'écriture)* |
|
||||
| **Réseau** ↔ `protected_store` | Alice seule | Alice + connexions | Profil réseau ; participations ; index des connexions |
|
||||
| **Privé** ↔ `private_store` | Alice seule | Alice seule | Profil privé (settings, email, préférences) |
|
||||
|
||||
The bilateral **`Connexion`** has *two* writers → a **dialog** scope dedicated to the pair; the **"all of Alice's connections" index** lives in *protected* (it lists the connection references). The **meeting point's inbox** is an attribute of the public document, not a separate scope.
|
||||
### Cas particulier : la Connexion bilatérale
|
||||
|
||||
## Query inventory per screen
|
||||
Donnée à *deux* écrivains → ne tient dans aucun store individuel. Primitive native : le **Dialog store**. Modèle : **une `Connexion` A↔B = un Dialog store** (contient l'objet + matière à messagerie future) ; l'**index « toutes les connexions d'Alice »** vit dans le `protected_store` d'Alice (liste les NURIs des Dialog stores). La `DemandeDeConnexion` : soit dans un Dialog store provisoire, soit dans le `public_store` du destinataire (à trancher selon le SDK).
|
||||
|
||||
*To be filled in once the matrix has stabilized.* Planned schema: `| Screen | One-shot reads | Subscriptions | Writes | Triggering actor |`. Screens to analyze: see the route table (concept `app-architecture`).
|
||||
### Inbox du document PdR
|
||||
|
||||
Le doc PdR (dans le `public_store` de l'hôte) a une **inbox** native : reçoit les dépôts d'inscription (liens DID cap), plus tard commentaires/signaux. **Pas un store séparé**, attribut du document. Pas d'impact sur les partitions.
|
||||
|
||||
### Ce qui ne demande aucun Group store
|
||||
|
||||
Sur le périmètre validé, **aucune donnée ne demande de Group store**. Tout tient dans : 3 stores natifs par utilisateur + Dialog stores + inboxes natives. Les Group stores ne deviennent nécessaires que si le modèle d'écriture événement est « wiki », ou si communautés/suivi/collaboration multi-hôte reviennent dans le périmètre.
|
||||
|
||||
### Implication pour [[brief_2026-05-17_multi-store-refactor]]
|
||||
|
||||
Ce brief y propose une structure à 4 niveaux de Group stores. **Cette analyse dérive une structure différente** (3 stores natifs + Dialog, sans Group) parce que les concepts qui justifient les Group stores ont été mis hors périmètre. À reconcilier à l'exécution.
|
||||
|
||||
## Inventaire des requêtes par écran
|
||||
|
||||
*À remplir une fois la matrice stabilisée.* Schéma prévu : `| Écran | Lectures one-shot | Abonnements | Écritures | Acteur déclencheur |`. Écrans à analyser : voir la table de routes (concept `app-architecture`).
|
||||
|
||||
## See Also
|
||||
|
||||
- Concept `functional-domain` → [[knowledge_data-scopes-and-discovery]] — entity → scope placement + discovery
|
||||
- [[knowledge_trust-model]] — isolation is enforced by the SDK
|
||||
- `README.md §Modèle fonctionnel` — source of the actors
|
||||
- [[brief_2026-05-17_multi-store-refactor]] — consommateur principal
|
||||
- [[brief_2026-06-15_shared-wallet-shim]] — stopgap reprenant ces périmètres
|
||||
- `README.md §Modèle fonctionnel` / concept `functional-domain` — source des acteurs
|
||||
- Concept `data-layer` — état actuel mono-store
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: The shared wallet password is captured at the EVALUATION of src/modules/auth/sharedWallet.ts; now that the shared wallet is the only mode, a value missing at that instant no longer yields a degraded form but a configuration error screen WITHOUT any identifier field — every entry point that renders AccessGateScreen must set the global BEFORE the module is first imported
|
||||
last_checked: 2026-07-27
|
||||
---
|
||||
|
||||
# Pitfall: set the shared-wallet global BEFORE importing the barrier
|
||||
|
||||
**The invariant.** `src/modules/auth/sharedWallet.ts` reads `globalThis.__FESTIPOD_SHARED_WALLET_PASSWORD__` **exactly once, at module evaluation** (the `SHARED_WALLET_PASSWORD` constant, surfaced by `hasSharedWallet()`). A value set *after* that first import is never re-read.
|
||||
|
||||
**Why it became blocking.** As long as "no shared wallet" was a fallback mode, a missing global degraded into a still-usable form — evaluation order was cosmetic. Since [[decision_2026-07-20_shared-wallet-only-mode]], `hasSharedWallet() === false` is a **configuration error**: `AccessGateScreen` renders an error block **with no identifier field**. The barrier becomes a dead end, not a degraded login.
|
||||
|
||||
## Impact — if I touch X, Y breaks
|
||||
|
||||
- **Static import = trap.** A static `import` of `AccessGateScreen` (or of any module that transitively reaches `sharedWallet.ts`) from an entry point that sets the global itself is **hoisted above the assignment** → empty password → error screen, with no JS error to signal it. The remedy is a **dynamic import** (`await import(...)`) executed after setting the global.
|
||||
- **Entry points concerned today**: the frontend served from `src/` (`src/app/frontend.tsx` fetches `/festipod-config.json`, sets the global, then imports the app dynamically — mechanics detailed in tech-stack → [[knowledge_build-pipeline]]) and the `@ui` harness that renders the barrier (`src/modules/auth/steps/ui/barriere-acces.steps.ts`, same set-then-lazy-import sequence). A bundle produced by `build.ts` is **not** concerned: there the value is inlined by `define`.
|
||||
- **Operations**: a server without `FESTIPOD_SHARED_WALLET_PASSWORD` serves **no** working barrier at all — by design (fail loudly). Treat it as a configuration outage, not as a screen bug.
|
||||
|
||||
**Verified (2026-07-27)**: capture at evaluation time in `sharedWallet.ts`, and the `!hasSharedWallet()` guard as the first branch of `AccessGateScreen`.
|
||||
|
||||
> Caveat: the header of `sharedWallet.ts` still describes the old fallback ("the gate falls back to the plain flow") — an obsolete comment; what `AccessGateScreen` actually renders is authoritative.
|
||||
@@ -1,41 +0,0 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: Any cap-less reference to a person's protected document exposes the `:v:` of their store — a STABLE AND PERMANENT pseudonym, identical everywhere and forever. It does not say who, but a single cross-reference RETROACTIVELY de-anonymizes all of their past and future references. It is the very same bit of information that makes anonymous dedup possible. No known rotation.
|
||||
last_checked: 2026-07-27
|
||||
---
|
||||
|
||||
# Pitfall: the `:v:` of a cap-less reference is a permanent pseudonym
|
||||
|
||||
**Read this before designing anything that circulates cap-less references** (registrations, invitations, mentions, indexes, notifications).
|
||||
|
||||
## The fact
|
||||
|
||||
A NURI is written `did:ng:o:{document}:v:{overlay}`. The `:v:` segment does **not** come from the document but from **its store** — and a person has **exactly one** *protected* store. Therefore:
|
||||
|
||||
> **All** cap-less references to **any** of a person's protected documents carry the **same** `:v:`. Everywhere, and forever.
|
||||
|
||||
VERIFIED in `nextgraph-rs` (the details and the pointers live on the polyfill side, `docs/readcap-and-nuri-model.md`): the value injected when a document is created is the overlay of the containing store; a `Repo` carries no overlay of its own, and every block access of a `Store` goes through **its** `overlay_id` — a per-document overlay is therefore structurally impossible, not merely absent.
|
||||
|
||||
## Why this is a pitfall and not just a limitation
|
||||
|
||||
This `:v:` **does not say who** — it is a non-invertible `BLAKE3` of the store id. The temptation is therefore to treat it as opaque, hence harmless. It is not: it is a **constant handle**.
|
||||
|
||||
- **Correlation** — anyone collecting cap-less references can link together all those belonging to one and the same person, without ever identifying them. Recurring presence, memberships, rhythm.
|
||||
- **Retroactive de-anonymization** — this is the real danger. **One single** cross-reference, **one single time** (a person naming themselves, a channel that leaks, a match against outside data), is enough for `:v:X` to become attached to an identity. At that instant, **all** of the history tied to that `:v:` flips at once — including what was published years earlier in the belief that it was anonymous.
|
||||
- **No way out** — VERIFIED, along four axes: no overlay rotation (the outer one is a pure hash of the store id, with no secret); the store id is generated once at identity creation and never regenerated; there is no migration path for content towards a new store; and no form of reference allows locating a document without exposing its store's overlay. Renewing capabilities would only change the *inner* overlay — the outer one, the only one present in cap-less NURIs, would survive it. **The only way out is to abandon the entire identity**, which carries none of the content along. Reported upstream as a possible design flaw (`orm-tests/INBOX/2026-07-27-outer-overlay-permanent-pseudonym-no-rotation.md`, see [[rule_nextgraph-inbox]]).
|
||||
|
||||
## The coupling you must not hope to break
|
||||
|
||||
That very same `:v:` is what makes it possible to **deduplicate without reading** — two references sharing a `:v:` come from the same person, and that is the basis of the anonymous participant counter ([[brief_2026-07-20_attendance-set-model]] on the `data-layer` side).
|
||||
|
||||
**It is the same bit of information.** Anonymous dedup and untraceability are not two requirements to be reconciled: they are two readings of one and the same piece of data. You cannot obtain one by removing the other. The only real dial is **how the stores are carved up** — which shifts the trade-off without making it disappear.
|
||||
|
||||
And this is **not** an artifact of the polyfill: the property survives into real NextGraph.
|
||||
|
||||
## What to do about it
|
||||
|
||||
- **Never present an action to the user** as "anonymous" without a caveat if it circulates a cap-less reference. It is **pseudonymous**, and the pseudonym is permanent.
|
||||
- **Count** the occurrences of a `:v:` that you expose: every additional context in which it appears widens the cross-referencing surface.
|
||||
- **Recheck** this caveat if NextGraph introduces overlay rotation or an indirect form of reference — it would then become moot, which would be good news.
|
||||
|
||||
Links: [[knowledge_trust-model]], [[brief_2026-05-18_authorization-matrix]], data-layer ([[brief_2026-07-20_attendance-set-model]], [[rule_capture-nextgraph-findings]], [[rule_nextgraph-inbox]]).
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
type: decision
|
||||
summary: The identifier of the virtual space is entered at the access barrier (AccessGateScreen), in the same act that opens the wallet; the separate "perceived login" screen (ConnexionScreen, « choisissez un nom d'utilisateur ») is removed; the identifier is a lowercase-normalized technical id, not a Festipod username
|
||||
---
|
||||
|
||||
# Decision (2026-07-06): identifier entered at the access barrier
|
||||
|
||||
## Context
|
||||
|
||||
The earlier stopgap flow (decision of 2026-06-15, a note that disappeared along with the `nextgraph-platform` concept — see `git log`) chained **two screens**: (1) `AccessGateScreen`, the access barrier (the real NextGraph login, opening the shared wallet); (2) `ConnexionScreen`, a "perceived login" where the user picked a **username**. That application-level identity was in fact the key of the **virtual wallet** (shim account / cap owner key), not a product username — so the "username" framing was misleading (confusing `setUsername` logic).
|
||||
|
||||
## Decision
|
||||
|
||||
The user enters their **identifier** directly in `AccessGateScreen`, **in the same act** that opens the wallet (« Entrer » records the identifier, then triggers `connect()`). `ConnexionScreen` is **deleted**. The identifier:
|
||||
|
||||
- is a **technical id** that names the virtual space (a nickname in practice, **not** a Festipod username);
|
||||
- is **normalized** on entry (trimmed, `@` stripped, **lowercased**) and persisted before the broker redirect (so it survives the round-trip);
|
||||
- **is** the identity id handed to the SDK (`setCurrentUser`), and the key for the caps and the shim account — no more mixed-case handle to reconcile.
|
||||
|
||||
`AuthGate` therefore shows the barrier as long as the wallet is not open **or** the identifier is not set, then the app directly — with no intermediate screen.
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- **Keeping both screens**: the second, "username" screen perpetuated the confusion between product identity and wallet identifier, and added a step with no value.
|
||||
- **Deriving the identifier from the wallet** (no entry at all): impossible here — there is a single shared wallet; the identifier is precisely what distinguishes the virtual spaces inside that wallet (emulation, see concept `data-layer` and the `@ng-eventually/client` SDK).
|
||||
|
||||
## Scope
|
||||
|
||||
Supersedes the "screen 2 / perceived login" part of the 2026-06-15 stopgap flow (opening the shared wallet through the broker is unchanged). Current state of the flow: [[knowledge_authentication]].
|
||||
@@ -1,32 +0,0 @@
|
||||
---
|
||||
type: decision
|
||||
summary: The shared wallet is the ONLY operating mode (the @ng-eventually/client polyfill relies on it as its data backend); the "no shared wallet" fallback is removed — misconfiguration → a blunt error screen, no more bare form. Reaffirms that the barrier's identifier = the wallet/space id, distinct from the profile username.
|
||||
---
|
||||
|
||||
# Decision (2026-07-20) — the shared wallet is the only mode; identifier ≠ profile username
|
||||
|
||||
## Context
|
||||
|
||||
Observed regression: on opening, the app landed on a **bare form asking for an identifier**, without the wallet-loading assistance. Cause: `FESTIPOD_SHARED_WALLET_PASSWORD` undefined in the server environment → `hasSharedWallet()` false → `AccessGateScreen` switched to its fallback mode. But that mode is a **dead end**: a device with no wallet cannot connect once the import assistance is hidden. In parallel, the old notion of "username" was still lingering to designate the **wallet identity**, which conflated it with the real profile username.
|
||||
|
||||
## Decision
|
||||
|
||||
1. **The shared wallet is the only supported mode.** Festipod does not work without it — the `@ng-eventually/client` polyfill uses it as its data backend (see [[knowledge_authentication]], `rule_app-uses-sdk-surface-only`). `hasSharedWallet() === false` is therefore **not a functional mode**: it is a **misconfiguration** → `AccessGateScreen` displays a **blunt error screen** (« Portefeuille partagé non configuré, définir `FESTIPOD_SHARED_WALLET_PASSWORD` »), never the dead-end bare form.
|
||||
|
||||
2. **The barrier's identifier ≠ the profile username.** The identifier entered in `AccessGateScreen` is the **technical id of the wallet/space** (lowercase-normalized, carried by the `?id=` URL param), not a username. The **username** is a distinct concept living in `UserProfile` (`@handle`, predicate `http://festipod.org/username`). Code and tests must no longer label the wallet identity "username/user" (renamed to `identifier`). Reaffirms and extends [[decision_2026-07-06_identifier-at-access-barrier]].
|
||||
|
||||
## Consequences
|
||||
|
||||
- `AccessGateScreen`: three-branch rendering (config error / assisted import flow when not connected / identifier field alone when already connected).
|
||||
- `username → identifier` rename of the wallet identity across the test infrastructure (`freshScenarioIdentifier`, `freshIdentifier`), `registration.ts`, `ngSession`, plus comments; **`UserProfile.username` untouched** (profile, seed, display, SHEX).
|
||||
- `.env.example` added at the root to make the configuration explicit (including `FESTIPOD_SHARED_WALLET_PASSWORD`, `FESTIPOD_SHARED_WALLET_FILE`).
|
||||
|
||||
## Rejected alternative
|
||||
|
||||
Keeping the wallet-less fallback as a future "own-wallet flow": rejected **for now** — no own-wallet flow in the near term, and the silent fallback created a misleading dead end. To be reintroduced **explicitly** the day an own-wallet mode (each user with their own NextGraph wallet) exists, outside the stopgap.
|
||||
|
||||
## Links
|
||||
|
||||
- Shared-wallet stopgap: `decision_2026-06-15_shared-wallet-login-flow` (referenced by `AccessGateScreen`/`AccountContext`).
|
||||
- [[decision_2026-07-06_identifier-at-access-barrier]] — the identifier at the barrier.
|
||||
- [[knowledge_authentication]], [[knowledge_trust-model]].
|
||||
@@ -1,24 +1,20 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: A user's identity = their NextGraph wallet; every user is authenticated (no anonymous access); auth is delegated to the SDK, the app has no application-level accounts or passwords
|
||||
summary: Authentification = possession d'un wallet NextGraph ; tous les utilisateurs sont authentifiés (pas d'accès anonyme) ; l'auth passe par le redirect/iframe broker, et l'app n'auto-connecte que dans l'iframe
|
||||
---
|
||||
|
||||
# Authentication
|
||||
# Authentification
|
||||
|
||||
**A user's identity = their NextGraph wallet.** There is **no anonymous access** to the app: every user is authenticated (see concept `functional-domain`). There is **no application-level account/password system** — authentication is **delegated to the data SDK** (`@ng-eventually/client`): opening your session means opening your wallet.
|
||||
**L'identité d'un utilisateur = son wallet NextGraph.** Il n'y a **pas d'accès anonyme** à l'app : tout utilisateur est authentifié (cf. concept `functional-domain`). Il n'y a pas de système de comptes/mots de passe applicatif — l'auth est déléguée à NextGraph.
|
||||
|
||||
## Flow
|
||||
## Flux
|
||||
|
||||
- The **access barrier** (`AccessGateScreen`, rendered by `src/app/AuthGate.tsx`) is the real NextGraph login: it opens the shared wallet through the broker redirect. **In the same act**, the user enters an **identifier** that names their virtual space (`onEnter`). There is **no separate "perceived login" screen any more** (the former `ConnexionScreen`, « choisissez un nom d'utilisateur », has been removed — see [[decision_2026-07-06_identifier-at-access-barrier]]; supersedes the two-screen flow of the 2026-06-15 stopgap).
|
||||
- **The shared wallet is the ONLY supported mode**: `AccessGateScreen` has **three branches** — (1) *configuration error* if no shared wallet is configured (no more dead-end bare form), (2) the **assisted import** flow as long as the session is not connected, (3) the **identifier field alone** once connected. See [[decision_2026-07-20_shared-wallet-only-mode]], and the evaluation-order pitfall [[caveat_shared-wallet-global-before-gate-import]] (the password global must be set before the screen is first imported, otherwise you land on branch 1).
|
||||
- **Vocabulary in the code**: the wallet identity is called `identifier` everywhere (`registration.ts`, `ngSession`, hooks and test steps) — **never** `username`, which exclusively designates the profile handle `UserProfile.username`. Do not relabel one as the other: they are two distinct identity spaces.
|
||||
- This **identifier is a technical id** (a nickname in practice, **not** a Festipod username): it is **normalized** (trimmed, `@` stripped, **lowercased**) then persisted (`AccountContext` → `IdentityStore`), so a reload — or another device reopening the same shared wallet — lands back on the same space. It is this id that is handed to the SDK (`setCurrentUser`) and on which the caps and the shim account are keyed.
|
||||
- **Carried across the boundary by a URL PARAM `?id=`** (source of truth), NOT by localStorage. The app runs in two contexts — **top-level** (`127.0.0.1:3000` directly, `window.self === window.top`, where the barrier is displayed) and **iframe** (embedded under `nextgraph.net` after the broker round-trip, `window.self !== window.top`). The browser **partitions storage by top-level site**: the top-level's localStorage and the iframe's are **two distinct partitions** → localStorage CANNOT carry the identity from one context to the other (observed symptom: two diverging values depending on the context). The SDK redirects via `location.href = broker + encodeURIComponent(window.location.href)` (embedding the full app URL, query string included, into the `o=` that is reloaded in the iframe), so a **URL param does cross over**. `AuthGate` writes `?id=<identifier>` (`history.replaceState`) **before** `connect()`; `AccountContext` resolves the identifier by priority: **(1) `?id=` from the URL** then **(2) localStorage** (prefill/convenience within the same partition only). localStorage key: `festipod.account.identifier`.
|
||||
- **Entered ONLY ONCE on first access + prefilled on return.** On a top-level reload the NG session is not restored automatically (`NextGraphContext` starts back at `disconnected`): `AuthGate` shows the barrier again as long as `status !== 'connected'`, but the `AccessGateScreen` field is **prefilled** (`initialIdentifier` prop) — never a bare, empty field. Regressions guarded by `src/modules/auth/features/{barriere-acces-identifiant,identifiant-resolution}.feature` (@ui) — all the more useful because the barrier flow is **disabled** in the @e2e tests (`__FESTIPOD_ACCESS_GATE_DISABLED__`), and therefore invisible at that layer.
|
||||
- Once the session is open, the current user and their access to the per-scope stores are provided by `NextGraphContext`.
|
||||
- `LoginScreen` (`src/modules/auth/screens/`) déclenche la connexion via `useNextGraph()` (ne consomme pas `useFestipodData`).
|
||||
- Le flux standard `@ng-org/web` est un **redirect vers le broker** (`nextgraph.net/redir/`) qui recharge l'app dans une **iframe** après authentification (détail dans concept `nextgraph-platform`, [[knowledge_integration-model]] côté nextgraph-platform).
|
||||
- **L'app n'auto-connecte que dans l'iframe broker** (`window.self !== window.top`) — sinon `initNgWeb()` redirigerait toute la page. Cette règle vit côté data-layer ([[rule_conditional-ng-init]]) car elle concerne le cycle `NextGraphContext`, mais elle a une conséquence sécurité directe : **hors iframe, aucune session n'est ouverte sans action explicite** de l'utilisateur.
|
||||
|
||||
## The test wallet
|
||||
## Le wallet de test
|
||||
|
||||
The `@data`/`@e2e` tests open a real wallet (`festipod-tests`, persistent profile) — see concept `bdd-testing`. These are **plaintext test credentials**, with no security stake, dedicated to staging.
|
||||
Les tests `@data`/`@e2e` créent/ouvrent un wallet réel (`festipod-tests`/`festipod-tests`, profil persistant) — voir concept `bdd-testing`. Ce sont des **credentials de test en clair**, sans enjeu de sécurité, dédiés au staging (cohérent avec la posture « utilisateurs amicaux » du stopgap, concept `nextgraph-platform`).
|
||||
|
||||
> The authorization model that will build on this identity (bilateral connections, personal data = network, host anonymity) is incubating: [[brief_2026-05-18_authorization-matrix]].
|
||||
> Le modèle d'autorisations qui s'appuiera sur cette identité (connexions bilatérales, données personnelles = réseau, anonymat hôte) est en incubation : [[brief_2026-05-18_authorization-matrix]].
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Isolation between scopes (public/protected/private) is enforced by the data SDK; the app trusts it and only displays what it returns — no access control in the screens, all privacy rests on the SDK
|
||||
last_checked: 2026-07-06
|
||||
summary: Posture de sécurité actuelle — aucun contrôle d'accès côté app, l'app lit/affiche le private_store de l'utilisateur connecté et fait confiance au broker NextGraph pour ne retourner que des données autorisées ; mono-user de fait
|
||||
last_checked: 2026-06-15
|
||||
---
|
||||
|
||||
# Trust model
|
||||
# Modèle de confiance actuel
|
||||
|
||||
**Stance:** the app reads data through the ORM subscriptions of the `@ng-eventually/client` SDK and displays it **with no app-side authorization logic** (`src/shared/context/FestipodDataContext.tsx`, `useNgData`).
|
||||
**Posture observée dans `src/shared/context/FestipodDataContext.tsx` (`useNgData`) :** l'app lit tout ce que les subscriptions ORM retournent depuis le `private_store` de l'utilisateur connecté et l'affiche **sans aucun filtre d'autorisation côté app**.
|
||||
|
||||
Principles:
|
||||
Conséquences (à connaître avant de raisonner sécurité) :
|
||||
|
||||
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.
|
||||
3. **The relationship between users ("connections") is an application-level notion, not an SDK primitive.** NextGraph has no bilateral connection/friendship primitive; on the SDK side there is only a **directed read grant** towards an identity. The app therefore **owns** its relationship graph (`src/shared/utils/connections.ts`) and **translates** it into per-document directed grants handed to the SDK — it does not delegate the notion of a relationship to the SDK, only the **enforcement** of the isolation that follows from it. What the app declares to the SDK stays minimal: **its identity** (the identifier, see [[knowledge_authentication]]) and **those grants**; it still carries no access logic in the screens.
|
||||
1. **Aucun contrôle d'accès applicatif.** Pas de vérification « l'utilisateur a-t-il le droit de voir cette donnée ». L'app suppose que **le broker/NextGraph ne retourne que ce que l'utilisateur peut voir**. Toute la confidentialité repose sur cette confiance dans la couche NextGraph, pas sur du code Festipod.
|
||||
2. **Mono-store, donc mono-user de fait.** Tout (events, profils, participations) vit dans le `private_store` de l'utilisateur connecté (cf. concept `data-layer`, [[decision_2026-03-17_private-store-nuri-scope]] côté data-layer). Un autre utilisateur ne voit rien — par construction, le `private_store` n'est pas partageable. Il n'y a donc rien à « autoriser » : chacun ne voit que ses propres données.
|
||||
3. **Pas de séparation de périmètres.** Le découpage public / réseau / privé du modèle cible ([[brief_2026-05-18_authorization-matrix]]) **n'existe pas encore** dans le code : aucun `protected_store`/`public_store` n'est utilisé pour le métier.
|
||||
|
||||
## The point to watch
|
||||
## Le piège pour la suite
|
||||
|
||||
Because the app **displays everything it receives**, privacy rests entirely on the SDK exposing only what is legitimate. It is a deliberate choice (the app stays thin), but it means **never reintroducing on the screen side a piece of data that the scope should not have let through**.
|
||||
Le jour où le multi-user arrive (lecture cross-wallet, voir les briefs de `nextgraph-platform`), cette **absence d'enforcement applicatif devient un risque** : si la séparation reste portée seulement par la crypto/capabilities NextGraph et que l'app continue d'afficher « tout ce qu'elle reçoit », une fuite de capability = une fuite de données. Le stopgap `shared-wallet-shim` (concept `nextgraph-platform`) prévoit d'ailleurs un **filtre d'isolation applicatif** explicite parce que, dans ce mode, un seul wallet rend tout physiquement lisible.
|
||||
|
||||
> To check when in doubt: `useNgData` in `FestipodDataContext.tsx` contains no identity-filtering branch — that is intentional, isolation comes from below.
|
||||
> À vérifier si on doute : `useNgData` dans `FestipodDataContext.tsx` ne contient aucune branche de filtrage par identité ; les seuls IDs manipulés sont ceux du wallet courant.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
type: _overview
|
||||
summary: BDD Cucumber/Gherkin in French across 3 layers (@ui, @data, @e2e) — setup, layer contract (what to test where), real broker harness, and the source-grep leftovers pitfall
|
||||
summary: BDD Cucumber/Gherkin en français sur 3 couches (@ui, @data, @e2e) — setup, contrat de couches (quoi tester où), harness broker réel, et le piège des vestiges source-grep
|
||||
triggers:
|
||||
keywords: [cucumber, gherkin, bdd, feature, scenario, scénario, step, steps, "@ui", "@data", "@e2e", playwright, broker, harness, wallet, world, hooks, renderHelper, multibrowser, multi-navigateur, "@multibrowser", "@private-wallet", "@shared-wallet", storageState, "@wip"]
|
||||
paths: ["src/modules/*/features/**", "src/modules/*/steps/**", "src/shared/steps/**", "src/shared/support/**", "src/shared/test-harness/**", "cucumber.json"]
|
||||
@@ -8,30 +8,29 @@ triggers:
|
||||
|
||||
# BDD testing
|
||||
|
||||
BDD tests written in **Cucumber/Gherkin in French** (`Etant donné`, `Quand`, `Alors`) across **3 layers** of increasing cost.
|
||||
Tests BDD **Cucumber/Gherkin en français** (`Etant donné`, `Quand`, `Alors`) sur **3 couches** de coût croissant.
|
||||
|
||||
**Read before writing a test:** [[rule_test-layer-contracts]] — each layer answers a distinct question; mixing them produces brittle tests. That is the rule which decides *where* an assertion belongs.
|
||||
**À lire avant d'écrire un test :** [[rule_test-layer-contracts]] — chaque couche répond à une question distincte ; mélanger produit des tests fragiles. C'est la règle qui décide *où* va une assertion.
|
||||
|
||||
## The 3 layers
|
||||
## Les 3 couches
|
||||
|
||||
```
|
||||
/\ @e2e real app inside the broker iframe — critical journeys
|
||||
/\ @e2e app réelle dans l'iframe broker — parcours critiques
|
||||
/ \
|
||||
/----\ @data mutations & persistence through the real NextGraph broker
|
||||
/----\ @data mutations & persistance via broker NextGraph réel
|
||||
/------\
|
||||
/ @ui \ in-process screen rendering (happy-dom + seed) — the bulk of the volume
|
||||
/ @ui \ rendu d'écran in-process (happy-dom + seed) — le gros du volume
|
||||
/__________\
|
||||
```
|
||||
|
||||
## Links
|
||||
## Liens
|
||||
|
||||
- [[rule_test-layer-contracts]] — what to test at each layer (the contract)
|
||||
- [[knowledge_cucumber-setup]] — config, layout, scripts, auto-generated files
|
||||
- [[knowledge_ui-layer]] — the `@ui` layer: render helper, fixtures, good and bad patterns
|
||||
- [[knowledge_data-layer-broker]] — the `@data` layer: broker harness, wallet lifecycle, bridge
|
||||
- [[knowledge_e2e-layer]] — the `@e2e` layer: the real app inside the iframe
|
||||
- [[knowledge_multibrowser-harness]] — several isolated browsers × wallet model (private/shared), storageState injection
|
||||
- [[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
|
||||
- [[decision_2026-03-12_headless-wallet-creation]] — why the test wallet is created through a headless UI
|
||||
- [[caveat_source-grep-vestiges]] — leftovers from the "source analysis" era in `world.ts`
|
||||
- [[cookbook_add-scenario]] — adding a scenario/step (layers, `evaluate` serialization pitfall, `@wip`)
|
||||
- [[rule_test-layer-contracts]] — quoi tester à chaque couche (le contrat)
|
||||
- [[knowledge_cucumber-setup]] — config, layout, scripts, fichiers auto-générés
|
||||
- [[knowledge_ui-layer]] — couche `@ui` : render helper, fixtures, bons/anti patterns
|
||||
- [[knowledge_data-layer-broker]] — couche `@data` : harness broker, cycle de vie wallet, bridge
|
||||
- [[knowledge_e2e-layer]] — couche `@e2e` : app réelle dans l'iframe
|
||||
- [[knowledge_multibrowser-harness]] — plusieurs navigateurs isolés × modèle de wallet (private/shared), injection storageState
|
||||
- [[decision_2026-03-12_headless-wallet-creation]] — pourquoi le wallet de test est créé en UI headless
|
||||
- [[caveat_source-grep-vestiges]] — vestiges de l'ère « analyse de source » dans `world.ts`
|
||||
- [[cookbook_add-scenario]] — ajouter un scénario/step (couches, piège de sérialisation `evaluate`, `@wip`)
|
||||
|
||||
@@ -1,54 +0,0 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: A "fresh page" opened via ctx.newPage() on the PERSISTENT Chromium context NEVER proves broker durability — it re-reads the local IndexedDB of the very same profile. Only a non-persistent context spawned from freshBrowser, seeded solely by the storageState captured at BeforeAll, settles broker-vs-local.
|
||||
last_checked: 2026-07-27
|
||||
---
|
||||
|
||||
# Pitfall: a "fresh page" is not a cold start (local vs broker)
|
||||
|
||||
**Reconnection** scenarios all ask the same question — *does the user get their own data back after closing and reopening?* — but **depending on the browser context chosen, they do not answer the same question**. That is the pitfall: the most natural setup (`ctx.newPage()`) yields a green that proves nothing about the broker.
|
||||
|
||||
## The two setups, and what each one proves
|
||||
|
||||
| Setup | Where | What it proves | What it does NOT prove |
|
||||
|---|---|---|---|
|
||||
| `this.page!.context().newPage()` — fresh page on the **persistent** context (`.playwright-profile`) | `reconnexion.steps.ts` (@data), `reconnexion-persistance.steps.ts` (@e2e) | a new broker login → **fresh verifier session** (empty memory), full remount of the providers | nothing about **broker durability**: the profile **still holds the local repos** in IndexedDB, so a "fresh" reader may well reopen **from local** |
|
||||
| `spawnContext('shared')` — **non-persistent** context spawned from `freshBrowser` | `reconnexion-froide-sans-local.steps.ts` (@data) | that the data **reached the broker** (or did not) | nothing about the real UI journey (this is the harness, not the app) |
|
||||
|
||||
**Invariant.** Any assertion of the form "the write is durable broker-side" **requires** the second setup. Making that assertion on a fresh page of the persistent context produces a false green (or a red blamed on the broker when it is really local/timing).
|
||||
|
||||
## What makes the "no-local" verdict valid (do not break it)
|
||||
|
||||
Three conditions, all met in `reconnexion-froide-sans-local.steps.ts`:
|
||||
|
||||
1. **Separate process** — `freshBrowser` is a non-persistent `chromium.launch`, distinct from the profile carrying the wallet (see [[knowledge_multibrowser-harness]] for the isolation proven all the way to the broker origin).
|
||||
2. **Hermetic partition** — every Playwright `newContext()` has its own storage; no IndexedDB shared with the writing page.
|
||||
3. **The only pre-injected state is `pool.sharedWalletState`**, captured **once at `BeforeAll`**, hence **before** the scenario writes anything → the snapshot **cannot** contain the data under test.
|
||||
|
||||
> **Impact if you touch the storageState capture** (`hooks.ts` `BeforeAll` → `pool.sharedWalletState`): moving it later, re-capturing it per scenario, or adding a warm-up that writes data **silently invalidates** the verdict of every "cold, no local" scenario — they would turn green by re-reading the snapshot. The step **fails outright** when `sharedWalletState` is missing (by design: no verdict beats a false verdict).
|
||||
|
||||
## Reconnection is not isolation — the identifier decides
|
||||
|
||||
`isolation.steps.ts` and `reconnexion.steps.ts` set up **the same machinery** (fresh page plus an identifier injected into `localStorage['festipod.account.identifier']` via `addInitScript`, before any script, on every origin). Only one thing tells them apart:
|
||||
|
||||
- **reconnection**: `this.freshIdentifier` is re-injected — **the SAME identity** as the writing page.
|
||||
- **isolation**: a **new** identifier is minted → a distinct identity B.
|
||||
|
||||
Changing that identifier therefore silently turns a reconnection test into an isolation test (and the other way round). `this.freshIdentifier` is set by the `Before` hook in `hooks.ts` for **every** single-browser `@data`/`@e2e` scenario.
|
||||
|
||||
## Reads stay reactive, even when "waiting a long time"
|
||||
|
||||
The reconnection `Then` steps read the **reactive** state (`homeEventTitles` on the bridge, via `waitForFunction`) — never a broker re-read loop ([[rule_no-broker-polling]]). The long diagnostic step (« … en laissant jusqu'à 60 secondes à la barrière avec rechargements ») does loop, but over the **reactive state already pushed** plus **full page reloads** (each reload = new mount = new sync-barrier attempt): that is the pragmatic fallback the rule explicitly allows, not broker polling. The distinction to keep in mind — *observing the reactive state* versus *re-issuing a broker read*.
|
||||
|
||||
## Current state of the scenarios
|
||||
|
||||
`reconnexion-froide-sans-local.feature`, the `@reconnexion-pause` scenario of `reconnexion-meme-identite.feature` and `reconnexion-persistance-e2e.feature` are **`@wip`**: they are **diagnostic instruments** (they print a verdict to stdout / as a Cucumber attachment), not regression guards. `@wip` is excluded from the default run (`cucumber.json`) — run them explicitly with `--tags`. The **non-`@wip`** scenario of `reconnexion-meme-identite.feature`, on the other hand, is a genuine guard and must stay green.
|
||||
|
||||
> The *why* on the NextGraph side (what a write must clear to be durable, socket behaviour, repo reopening) belongs to the `@ng-eventually/client` SDK — not to this repo. Here we only describe **the test setup that produces a readable verdict**.
|
||||
|
||||
## Links
|
||||
|
||||
- [[knowledge_data-layer-broker]] — the single-browser `@data` layer (persistent profile).
|
||||
- [[knowledge_multibrowser-harness]] — `freshBrowser`, `spawnContext`, `pool.sharedWalletState`.
|
||||
- [[knowledge_e2e-layer]] — the `@e2e` counterpart (real app) of the "close and reopen" setup.
|
||||
- [[rule_no-broker-polling]] — the red line waiting steps must not cross.
|
||||
@@ -1,21 +1,21 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: world.ts still carries leftovers from the "source analysis" era (screenFileMap, screenFieldDetectors, screenExpectedContent, screenRequiredFields; hasText/hasField/hasElement with a source fallback) — to be deleted once the @ui migration to the rendered DOM is complete
|
||||
summary: world.ts garde des vestiges de l'ère « analyse de source » (screenFileMap, screenFieldDetectors, screenExpectedContent, screenRequiredFields ; hasText/hasField/hasElement à fallback source) — à supprimer une fois la migration @ui vers le DOM rendu terminée
|
||||
last_checked: 2026-06-15
|
||||
---
|
||||
|
||||
# Caveat: source-analysis leftovers in `world.ts`
|
||||
# Caveat : vestiges d'analyse de source dans `world.ts`
|
||||
|
||||
The `@ui` suite **predates** the layer contract ([[rule_test-layer-contracts]]). Remnants of the "grep the source code" era are still around and **must not be extended**:
|
||||
La suite `@ui` **précède** le contrat de couches ([[rule_test-layer-contracts]]). Des restes de l'ère « grep sur le code source » subsistent et **ne doivent pas être étendus** :
|
||||
|
||||
- `world.ts:screenFileMap`, `screenFieldDetectors`, `screenExpectedContent`, `screenRequiredFields` — mappings from the source-analysis approach.
|
||||
- `hasText` / `hasField` / `hasElement` — they now **prefer the rendered DOM** but **fall back to the source** so that unmigrated steps keep working during the transition.
|
||||
- `world.ts:screenFileMap`, `screenFieldDetectors`, `screenExpectedContent`, `screenRequiredFields` — mappings de l'approche analyse-de-source.
|
||||
- `hasText` / `hasField` / `hasElement` — **préfèrent désormais le DOM rendu** mais **retombent sur la source** pour que les steps non migrés continuent de marcher pendant la transition.
|
||||
|
||||
## Migration plan (in progress)
|
||||
## Plan de migration (en cours)
|
||||
|
||||
1. Rewrite grep-the-source assertions as DOM queries through the render helper.
|
||||
2. Delete tests that assert implementation details (`/showDuplicateWarning/`, `/importableEvents/`, regexes over JSX).
|
||||
3. Move behavioural assertions to `@e2e` where they are not already covered.
|
||||
4. Drop the `@e2e` content checks that duplicate `@ui`.
|
||||
1. Réécrire les assertions grep-source → requêtes DOM via le render helper.
|
||||
2. Supprimer les tests sur détails d'implémentation (`/showDuplicateWarning/`, `/importableEvents/`, regex sur JSX).
|
||||
3. Déplacer les assertions comportementales vers `@e2e` quand pas déjà couvertes.
|
||||
4. Retirer les checks de contenu `@e2e` redondants avec `@ui`.
|
||||
|
||||
Once the migration is done, the 4 leftover maps can go away in favour of assertions on the rendered DOM plus seed data. **As long as they exist, do not rely on them for new tests.**
|
||||
Une fois la migration terminée, les 4 maps vestiges peuvent disparaître au profit d'assertions sur le DOM rendu + seed. **Tant qu'elles existent, ne pas s'appuyer dessus pour de nouveaux tests.**
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: The shared test wallet (.playwright-profile) accumulates data on every run; past a threshold, sparql_query calls anchored to the private store hang (>15s) and the whole @data suite fails during setup — starting from a fresh profile restores ~1s reads
|
||||
last_checked: 2026-07-06
|
||||
---
|
||||
|
||||
# Pitfall: the test wallet bloats and makes @data reads hang
|
||||
|
||||
The persistent Chromium profile `.playwright-profile` (at the root of the working tree) carries the **shared wallet** opened by the whole `@data`/`@e2e` suite. That wallet **accumulates data on every run**: shim accounts (one per scenario, through the fresh identifier `freshScenarioIdentifier`), seeded entity docs, historical inbox deposits… The private store is the **anchor point of the shim** (account resolution) and is queried by **every** read and write (`resolveAccount`, `listMyEntityDocs`, …).
|
||||
|
||||
**Symptom.** Past a certain volume (observed around 99 MB of profile), a `sparql_query` **anchored to the private store** stops returning within 15 s — it hangs. Since account resolution sits on the path of **every** read/write, **the entire @data suite fails during setup** (0 events loaded, timeouts), with no explicit error. Verified diagnosis: on a fresh wallet the same query comes back in **~1.5 s** and the seed completes normally.
|
||||
|
||||
**Workaround.** Move the bloated profile aside and let the auth hook (beforeAll) recreate a fresh one:
|
||||
|
||||
```bash
|
||||
mv .playwright-profile /tmp/festipod-bloated-$(date +%s)
|
||||
```
|
||||
|
||||
The per-scenario fresh identifier (`freshScenarioIdentifier`) bounds the account *registry* but **not** the physical growth of the shared private store — hence the recurrence. Durable hygiene (periodic purge / throwaway wallet per run) still has to be put in place; until then, if the `resolveAccount failed` errors and timeouts come back, start again from a fresh profile.
|
||||
|
||||
> The *why* on the broker side (how an anchored query reaches the private store repo) belongs to the `@ng-eventually/client` SDK, not here — this caveat only describes the consequence on the test side.
|
||||
@@ -1,29 +1,29 @@
|
||||
---
|
||||
type: cookbook
|
||||
summary: How to add a BDD scenario/step — a tagged French .feature, steps per layer, the appFrame.evaluate serialization pitfall (pass arguments, no closures), add helpers to BOTH harnesses, @wip tag for what is not implemented yet
|
||||
summary: Procédure pour ajouter un scénario/step BDD — .feature français taggé, steps par couche, piège de sérialisation de appFrame.evaluate (passer les args, pas de closure), ajouter les helpers aux DEUX harness, tag @wip pour le non-implémenté
|
||||
---
|
||||
|
||||
# Cookbook: adding a scenario / a step
|
||||
# Cookbook : ajouter un scénario / un step
|
||||
|
||||
1. **Write the `.feature`**: `src/modules/{module}/features/us-N-slug.feature`, `# language: fr`, a header tag `@CATEGORIE @priority-N`, and one layer tag per scenario (`@ui` / `@data` / `@e2e`). French keywords: `Fonctionnalité`, `Contexte` (Background), `Scénario`, `Étant donné`/`Quand`/`Alors`. Tag `@wip` any scenario whose steps are not written yet.
|
||||
1. **Écrire le `.feature`** : `src/modules/{module}/features/us-N-slug.feature`, `# language: fr`, tag de tête `@CATEGORIE @priority-N`, et un tag de couche par scénario (`@ui` / `@data` / `@e2e`). Mots-clés FR : `Fonctionnalité`, `Contexte` (Background), `Scénario`, `Étant donné`/`Quand`/`Alors`. Tagger `@wip` un scénario dont les steps ne sont pas encore écrits.
|
||||
|
||||
2. **Pick the layer** (see [[rule_test-layer-contracts]]): rendering assertion → `@ui`; mutation/persistence → `@data`; full journey → `@e2e`.
|
||||
2. **Choisir la couche** (cf. [[rule_test-layer-contracts]]) : assertion de rendu → `@ui` ; mutation/persistance → `@data` ; parcours complet → `@e2e`.
|
||||
|
||||
3. **Write the steps** in `src/modules/{module}/steps/{ui,data,e2e}/*.steps.ts` (or `src/shared/steps/ui/` if cross-domain). Signature: `async function (this: FestipodWorld, …)`. Import `FestipodWorld` from `../../../../shared/support/world` (adjust the relative path).
|
||||
3. **Écrire les steps** dans `src/modules/{module}/steps/{ui,data,e2e}/*.steps.ts` (ou `src/shared/steps/ui/` si cross-domaine). Signature : `async function (this: FestipodWorld, …)`. Importer `FestipodWorld` depuis `../../../../shared/support/world` (ajuster le chemin relatif).
|
||||
|
||||
4. **Data access, per layer**:
|
||||
- `@ui`: `this.renderedDoc` / `this.getDomText()` / `this.hasText(...)` after `navigateTo(...)` (see [[knowledge_ui-layer]]).
|
||||
- `@data`/`@e2e`: `await this.appFrame!.evaluate(fn, ...args)` on the `window.__testData` bridge (see [[knowledge_data-layer-broker]]).
|
||||
4. **Accès aux données selon la couche** :
|
||||
- `@ui` : `this.renderedDoc` / `this.getDomText()` / `this.hasText(...)` après `navigateTo(...)` (voir [[knowledge_ui-layer]]).
|
||||
- `@data`/`@e2e` : `await this.appFrame!.evaluate(fn, ...args)` sur le bridge `window.__testData` (voir [[knowledge_data-layer-broker]]).
|
||||
|
||||
5. **⚠️ `appFrame.evaluate` serialization pitfall**: the function you pass runs **inside the iframe**, and the step's variables **are not captured** (closures are lost). **Pass every value as an argument**:
|
||||
5. **⚠️ Piège de sérialisation `appFrame.evaluate`** : la fonction passée s'exécute **dans l'iframe**, les variables du step **ne sont pas capturées** (closures perdues). **Passer toute valeur en argument** :
|
||||
```ts
|
||||
// ❌ const title = eventTitle; await appFrame.evaluate(() => td.getEventByTitle(title)) // title is undefined
|
||||
// ❌ const title = eventTitle; await appFrame.evaluate(() => td.getEventByTitle(title)) // title undefined
|
||||
// ✅ await appFrame.evaluate((t) => td.getEventByTitle(t), eventTitle)
|
||||
```
|
||||
Always `await` (forgetting it means asserting before the promise resolves).
|
||||
Toujours `await` (oublier → assertion avant résolution).
|
||||
|
||||
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. **Si tu ajoutes une opération de données** : exposer le helper sur `window.__testData` dans **les deux** harness (`src/shared/test-harness/harness.tsx` ET `harness-ng.tsx`) — sinon le fallback mock diverge du broker réel.
|
||||
|
||||
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. **Câbler un écran testé** : si le nom français de l'écran ne se résout pas vers son `id`, ajouter un alias dans `screenNameMap` (`src/shared/steps/ui/navigation.steps.ts`).
|
||||
|
||||
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. **Lancer** : `bun run test:cucumber` (tout) ou `bun run test:data` (@data). Rapport : `reports/cucumber-report.html`. Le `@data`/`@e2e` exige le wallet de test (`bun run test:auth-setup` au premier coup si besoin, sinon création auto — cf. [[decision_2026-03-12_headless-wallet-creation]]).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
type: decision
|
||||
summary: Decision 2026-03-12 — create the test wallet by automating the broker UI headlessly (Playwright) rather than through the NG API, because it exercises the real auth flow and avoids reverse-engineering the sign-up API
|
||||
summary: Décision 2026-03-12 — créer le wallet de test en automatisant l'UI broker headless (Playwright) plutôt que par API NG, car ça teste le vrai flux d'auth et évite de reverse-engineer l'API d'inscription
|
||||
---
|
||||
|
||||
# Automated Headless Wallet Creation for CI
|
||||
@@ -10,28 +10,28 @@ summary: Decision 2026-03-12 — create the test wallet by automating the broker
|
||||
|
||||
## Context
|
||||
|
||||
`@data` tests require a NextGraph wallet inside a persistent Chromium profile. Previously, the first run required manual interaction (visible browser, wallet created by hand) → CI was blocked.
|
||||
Les tests `@data` exigent un wallet NextGraph dans un profil Chromium persistant. Avant, le premier run exigeait une interaction manuelle (navigateur visible, création de wallet à la main) → bloquait le CI.
|
||||
|
||||
## Options Considered
|
||||
|
||||
### Option A: create the wallet programmatically through the NG SDK
|
||||
Call `ng.wallet_create()` from Node/Bun, without any UI.
|
||||
- **Pros**: faster, no browser needed.
|
||||
- **Cons**: `@ng-org/web` is browser-only (WASM + postMessage); we would have to reverse-engineer the sign-up API of `account.nextgraph.eu`; it does not exercise the real auth flow.
|
||||
### Option A: création programmatique du wallet via SDK NG
|
||||
Appeler `ng.wallet_create()` depuis Node/Bun, sans UI.
|
||||
- **Pour** : plus rapide, pas de navigateur.
|
||||
- **Contre** : `@ng-org/web` est browser-only (WASM + postMessage) ; il faudrait reverse-engineer l'API d'inscription d'`account.nextgraph.eu` ; ne teste pas le vrai flux d'auth.
|
||||
|
||||
### Option B: automate the UI flow headlessly
|
||||
Drive the very same wallet-creation UI with Playwright, in headless mode.
|
||||
- **Pros**: exercises the real auth/login flow end to end; no reverse-engineering; the same persistent profile is reused; CI-ready with no manual step.
|
||||
- **Cons**: depends on `nextgraph.eu`/`account.nextgraph.eu` being reachable; brittle to NextGraph UI changes; adds ~27s to the first run.
|
||||
### Option B: automatiser le flux UI headless
|
||||
Piloter via Playwright la même UI de création de wallet, en headless.
|
||||
- **Pour** : teste le vrai flux auth/login de bout en bout ; pas de reverse-engineering ; même profil persistant réutilisé ; CI-ready sans étape manuelle.
|
||||
- **Contre** : dépend de `nextgraph.eu`/`account.nextgraph.eu` joignables ; fragile aux changements d'UI NextGraph ; +~27s au premier run.
|
||||
|
||||
## Decision
|
||||
|
||||
**Option B** — automate the broker UI. The creation flow (navigate → Create Wallet → ToS → username/password → submit) is itself a legitimate test of the auth feature. Depending on external services is acceptable since the tests already depend on the broker being reachable.
|
||||
**Option B** — automatiser l'UI broker. Le flux de création (navigate → Create Wallet → ToS → username/password → submit) est lui-même un test légitime de la feature d'auth. La dépendance aux services externes est acceptable puisque les tests dépendent déjà du broker joignable.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:** fully CI-ready tests (zero interaction); the auth flow gets tested along the way; `bun run test:data` starts from a clean state.
|
||||
**Negative:** requires internet access (nextgraph.eu, account.nextgraph.eu); brittle to NextGraph UI changes (button texts, form IDs).
|
||||
**Risk:** rate-limiting from `account.nextgraph.eu` if CI recreates wallets often.
|
||||
**Positif :** tests pleinement CI-ready (zéro interaction) ; flux auth testé en passant ; `bun run test:data` part d'un état propre.
|
||||
**Négatif :** exige un accès internet (nextgraph.eu, account.nextgraph.eu) ; fragile aux changements d'UI NextGraph (textes de boutons, IDs de formulaire).
|
||||
**Risque :** rate-limiting d'`account.nextgraph.eu` si le CI recrée souvent des wallets.
|
||||
|
||||
> Detailed lifecycle mechanics: [[knowledge_data-layer-broker]].
|
||||
> Mécanique de cycle de vie détaillée : [[knowledge_data-layer-broker]].
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Cucumber config (cucumber.json, fr language, tsx loader), layout of features/steps colocated per module, shared steps under shared/steps/, and the scripts that generate features.ts/testResults.ts/stepDefinitions.ts
|
||||
summary: Config Cucumber (cucumber.json, langue fr, loader tsx), layout des features/steps colocalisés par module, steps partagés dans shared/steps/, et les scripts qui génèrent features.ts/testResults.ts/stepDefinitions.ts
|
||||
---
|
||||
|
||||
# Cucumber setup
|
||||
# Setup Cucumber
|
||||
|
||||
26 `.feature` files (US-1 to US-26), all in **French**, tagged `@CATEGORIE @priority-N` (categories EVENT, WORKSHOP, USER, MEETING, NOTIF).
|
||||
26 fichiers `.feature` (US-1 à US-26), tous en **français**, taggés `@CATEGORIE @priority-N` (catégories EVENT, WORKSHOP, USER, MEETING, NOTIF).
|
||||
|
||||
## Layout
|
||||
|
||||
Features and steps are **colocated with their module**:
|
||||
Features et steps **colocalisés avec leur module** :
|
||||
|
||||
```
|
||||
src/modules/event/features/us-13-creer-evenement.feature
|
||||
src/modules/event/steps/{ui,data,e2e}/
|
||||
```
|
||||
|
||||
**Shared** (cross-domain) steps live in `src/shared/steps/ui/`:
|
||||
- `navigation.steps.ts` — navigation, auth, clicks/selections, section/button/field assertions
|
||||
- `form.steps.ts` — field validation, required fields, import/duplicate
|
||||
- `screen.steps.ts` — screen content (participants, events, profiles, QR)
|
||||
Steps **partagés** (cross-domaine) dans `src/shared/steps/ui/` :
|
||||
- `navigation.steps.ts` — navigation, auth, clics/sélections, assertions section/bouton/champ
|
||||
- `form.steps.ts` — validation de champs, champs requis, import/duplicate
|
||||
- `screen.steps.ts` — contenu d'écran (participants, events, profils, QR)
|
||||
|
||||
The French screen names (`"accueil"`, `"détail événement"`, `"mon profil"`…) map to screen IDs through `screenNameMap`.
|
||||
Les noms français des écrans (`"accueil"`, `"détail événement"`, `"mon profil"`…) mappent vers les IDs d'écran via `screenNameMap`.
|
||||
|
||||
Scenario tags: `@ui` / `@data` / `@e2e` (layer) plus **`@wip`** for a scenario whose steps are not implemented yet **or whose application behaviour is not reliable yet** (intended use: flagging a genuine expectation that fails because of a product bug, not marking an obsolete test — historical example: sign-up cancellation not being reflected in the UI, `@wip` **lifted** once it was fixed in T02.c, see [[caveat_participation-deletion]]). **`@wip` is EXCLUDED from the default run** (`cucumber.json: "tags": "not @wip"`): those scenarios document an expectation without breaking the suite; drop the `@wip` once they are reliable. A common `Contexte` (Background) — « Étant donné que je suis connecté » — merely sets an `isAuthenticated` flag, there is no real auth in `@ui`.
|
||||
Tags de scénario : `@ui` / `@data` / `@e2e` (couche) + **`@wip`** pour un scénario dont les steps ne sont pas encore implémentés. Un `Contexte` (Background) fréquent — « Étant donné que je suis connecté » — ne fait que poser un flag `isAuthenticated`, pas d'auth réelle en `@ui`.
|
||||
|
||||
## Config
|
||||
|
||||
`cucumber.json`: `import` of `src/shared/support/**`, `src/shared/steps/**`, `src/modules/*/steps/**`; `paths` = `src/modules/*/features/**`; `tags: "not @wip"` (excludes WIP scenarios); `language: fr`. **Runner = Node + tsx**, not Bun — the plugins (Playwright, happy-dom) do not load under a native Bun import. Do not "bunify" `cucumber:run`/`test:data`. ⚠️ The runner must point at the package's **actual JS entry** (`node_modules/@cucumber/cucumber/bin/cucumber.js`), **never** at `node_modules/.bin/cucumber-js`: depending on the installer, `.bin/` holds a **shell shim** (not JS) that `node --import tsx/esm` cannot execute.
|
||||
`cucumber.json` : `import` de `src/shared/support/**`, `src/shared/steps/**`, `src/modules/*/steps/**` ; `paths` = `src/modules/*/features/**`; `language: fr`. **Runner = Node + tsx** (`node --import tsx/esm node_modules/.bin/cucumber-js`), pas Bun — les plugins (Playwright, happy-dom) ne chargent pas en import Bun natif. Ne pas « bunifier » `cucumber:run`/`test:data`.
|
||||
|
||||
## The test harness is built on demand
|
||||
## Le harness de test est buildé à la demande
|
||||
|
||||
The `@data`/`@e2e` harnesses (`src/shared/test-harness/harness.tsx`, `harness-ng.tsx`) are **not** built by `build.ts`. The `BeforeAll` hook in `hooks.ts` compiles them **on demand** (`bun build` → `dist/test-harness*.js`). The test wallet can be created ahead of time with `bun run test:auth-setup` (`scripts/setup-test-auth.ts`); otherwise it is created automatically on the first run (see [[decision_2026-03-12_headless-wallet-creation]]).
|
||||
Les harness `@data`/`@e2e` (`src/shared/test-harness/harness.tsx`, `harness-ng.tsx`) **ne sont pas** buildés par `build.ts`. Le `BeforeAll` de `hooks.ts` les compile **à la demande** (`bun build` → `dist/test-harness*.js`). Le wallet de test peut être créé d'avance via `bun run test:auth-setup` (`scripts/setup-test-auth.ts`), sinon il est créé automatiquement au premier run (cf. [[decision_2026-03-12_headless-wallet-creation]]).
|
||||
|
||||
## Auto-generated files
|
||||
## Fichiers auto-générés
|
||||
|
||||
Scripts under `scripts/` parse features and steps into TS data consumed by the browsing tool:
|
||||
Des scripts `scripts/` parsent features/steps en data TS consommée par l'outil de parcours :
|
||||
|
||||
| Script | Input | Output |
|
||||
| Script | Entrée | Sortie |
|
||||
|---|---|---|
|
||||
| `parse-features.ts` | `*/features/*.feature` | `src/shared/data/features.ts` |
|
||||
| `parse-test-results.ts` | `reports/cucumber-report.json` | `src/shared/data/testResults.ts` |
|
||||
| `extract-step-definitions.ts` | `shared/steps/ui/*.ts` | `src/shared/data/stepDefinitions.ts` |
|
||||
|
||||
Run: `bun run test:cucumber` (everything), `bun run test:data` (@data). After adding steps: `bun run steps:extract`.
|
||||
Lancer : `bun run test:cucumber` (tout), `bun run test:data` (@data). Après ajout de steps : `bun run steps:extract`.
|
||||
|
||||
@@ -1,42 +1,36 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: The @data layer — Playwright drives Chromium (persistent profile), which logs into the real NextGraph broker that loads harness-ng.tsx in an iframe; automated wallet lifecycle (creation + bootstrap login), window.__testData bridge, mock fallback; per-scenario isolation through a fresh virtual identifier (this.freshIdentifier), no more per-scenario purge
|
||||
last_checked: 2026-07-27
|
||||
summary: Couche @data — Playwright pilote Chromium (profil persistant) qui s'authentifie au broker NextGraph réel chargeant harness-ng.tsx en iframe ; cycle de vie wallet automatisé (création + login bootstrap), bridge window.__testData, fallback mock
|
||||
---
|
||||
|
||||
# The `@data` layer (real broker)
|
||||
# Couche `@data` (broker réel)
|
||||
|
||||
`@data` exercises the **real NextGraph pipeline** through a broker, not mocked data.
|
||||
`@data` teste le **vrai pipeline NextGraph** via un broker, pas des données mockées.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Cucumber → Playwright (Chromium, persistent profile)
|
||||
→ broker wallet login (automated)
|
||||
→ broker loads the harness in an iframe (http://127.0.0.1:{port})
|
||||
Cucumber → Playwright (Chromium, profil persistant)
|
||||
→ broker wallet login (automatisé)
|
||||
→ broker charge le harness en iframe (http://127.0.0.1:{port})
|
||||
→ harness-ng.tsx (init → useShape → ORM → broker)
|
||||
→ window.__testData bridge
|
||||
→ bridge window.__testData
|
||||
```
|
||||
|
||||
**Dual mode**: real broker (`harness-ng.tsx`, the default) or mock fallback (`harness.tsx`, standalone DeepSignalSets if the NG build fails).
|
||||
**Dual mode** : broker réel (`harness-ng.tsx`, défaut) ou fallback mock (`harness.tsx`, DeepSignalSets standalone si le build NG échoue).
|
||||
|
||||
## Wallet lifecycle (automated, CI-ready)
|
||||
## Cycle de vie du wallet (automatisé, CI-ready)
|
||||
|
||||
- **First run**: no `.wallet-ready` marker → headless Chromium creates the wallet (`nextgraph.eu` → Create Wallet → ToS on `account.nextgraph.eu` → username/password → submit), **then logs in** — that initial login is required to bootstrap the session (saved in localStorage); without it, writes would not go through. Marker written.
|
||||
- **Subsequent runs**: marker found → automated login (click Login → wallet → password → submit) → harness in the iframe → `window.__testData.ready`.
|
||||
- Wallet credentials: `festipod-tests` / `festipod-tests`.
|
||||
- **Premier run** : pas de marker `.wallet-ready` → Chromium headless crée le wallet (`nextgraph.eu` → Create Wallet → ToS sur `account.nextgraph.eu` → username/password → submit), **puis se logge** — ce login déclenche le bootstrap du verifier depuis le broker distant (peuple `self.repos`, sauvé en localStorage). **Sans ce login initial, toutes les écritures échoueraient en `RepoNotFound`.** Marker écrit.
|
||||
- **Runs suivants** : marker trouvé → login automatisé (click Login → wallet → password → submit) → harness en iframe → `window.__testData.ready`.
|
||||
- Credentials wallet : `festipod-tests` / `festipod-tests`.
|
||||
|
||||
> The choice of "automating the headless UI rather than creating the wallet through an API" is settled in [[decision_2026-03-12_headless-wallet-creation]].
|
||||
> Le choix « automatiser l'UI headless plutôt que créer le wallet par API » est tranché dans [[decision_2026-03-12_headless-wallet-creation]].
|
||||
|
||||
## Technical details
|
||||
## Détails techniques
|
||||
|
||||
- **Chromium flags** (`--disable-web-security`, `--allow-insecure-localhost`, Private Network Access turned off): necessary because the public broker loads a `http://127.0.0.1` harness in an iframe.
|
||||
- **Persistent profile** `.playwright-profile/` (gitignored, wallet in localStorage) — requires the real Chrome binary, not `chrome-headless-shell`.
|
||||
- **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. The harness therefore mounts the **`AccountProvider`** and logs in by default (`@mariedupont`) to establish the current identity (without it the ReadCap filter would only let public data through). It reads `appData` through a **live ref** (a captured snapshot goes stale after a seed re-render).
|
||||
- Low-level probe paths are kept (root-store scope `protectedNuri`) for the ReadCap/isolation scenarios that *govern* that document: `rawJoin`/`rawParticipations`, `governDocument`/`governProtected`/`documentNuri`, `FilterProbe`/`FanoutProbe`.
|
||||
- **Identity before writing.** A `Participation` has a mandatory `fp:user`; since reading the profile can lag behind the public events, the steps wait for `ensureCurrentUser()` before `joinEvent` (otherwise a participation is written without a user → dropped on read, and never makes the round trip) and then wait (`waitForFunction`) for the participation to be read back.
|
||||
- **Per-scenario isolation = a fresh virtual identifier, NOT a purge.** The @data `Before` hook mints a unique identifier per scenario (`freshScenarioIdentifier` in `hooks.ts`), exposes it as `this.freshIdentifier` on the World, and injects it via `addInitScript` into `localStorage['festipod.account.identifier']` **on every origin** (including the harness iframe on 127.0.0.1) — before any script. The shim then serves a **fresh, empty virtual account**, whose registry starts empty *by construction*: **nothing to purge**. The old per-scenario reset (`window.__testData.resetDataState()`, a SPARQL DELETE of the `urn:ng-eventually:shim:Account` records on the anchor graph) is **no longer called** — it cost up to 10 s taken out of the 60 s budget of the `Before` hook, already eaten by the broker login. The helper still exists on the bridge (`harness-ng.tsx`) but is no longer on the default path: do not put it back into the `Before` hook without measuring.
|
||||
- **What the fresh identifier does NOT bound**: the *physical* growth of the shared wallet — see [[caveat_wallet-bloat-hang]] (profile to be moved aside when anchored reads start to hang).
|
||||
- `this.freshIdentifier` is also what distinguishes a **reconnection** test (same identifier re-injected) from an **isolation** test (new identifier) — see [[caveat_reconnexion-froide-local-vs-broker]].
|
||||
- The connected seed stays **lightweight** (few docs) because each `docCreate` is a serial broker round trip of about 2s.
|
||||
- **Flags Chromium** (`--disable-web-security`, `--allow-insecure-localhost`, désactivation de Private Network Access) : nécessaires car le broker public charge un harness `http://127.0.0.1` en iframe.
|
||||
- **Profil persistant** `.playwright-profile/` (gitignored, wallet en localStorage) — exige le vrai binaire Chrome, pas `chrome-headless-shell`.
|
||||
- **Serveur HTTP** lancé en `BeforeAll` (port auto), sert le HTML + `/harness.js` (fichiers séparés — le script inline casse à cause de caractères spéciaux du bundle).
|
||||
- **Subscriptions ORM** : les 3 shapes avec scope `did:ng:${session.private_store_id}` (cf. concept `data-layer`).
|
||||
- **Bridge `window.__testData`** : `events`/`users`/`participations` (sets live), `currentUserId`, lookups (`getEvent`, `getEventByTitle`), mutations (`joinEvent`, `leaveEvent`, `updateEvent`), requêtes (`isParticipating`, `getEventParticipants`).
|
||||
|
||||
@@ -1,77 +1,47 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: The @e2e layer — Playwright boots the REAL app (not a harness) inside the broker iframe, interacts through appFrame.evaluate()/locator(), reuses setupBrokerPage() from @data; tests navigation/redirects/clicks, no mock fallback; per-scenario identity (this.freshIdentifier) plus the access barrier disabled by init script; "close and reopen" idiom for reconnection scenarios
|
||||
last_checked: 2026-07-27
|
||||
summary: Couche @e2e — Playwright boote l'app RÉELLE (pas un harness) dans l'iframe broker, interagit via appFrame.evaluate()/locator(), réutilise setupBrokerPage() de @data ; teste navigation/redirects/clics, pas de fallback mock
|
||||
---
|
||||
|
||||
# The `@e2e` layer (real app)
|
||||
# Couche `@e2e` (app réelle)
|
||||
|
||||
`@e2e` tests the **UI of the real app** running inside the broker iframe — unlike `@data`, which loads a test harness.
|
||||
`@e2e` teste l'**UI de l'app réelle** tournant dans l'iframe broker — contrairement à `@data` qui charge un harness de test.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Cucumber → Playwright (Chromium, persistent profile)
|
||||
Cucumber → Playwright (Chromium, profil persistant)
|
||||
→ https://nextgraph.net/redir/#/?o=http://127.0.0.1:{appPort}
|
||||
→ broker login (automated, same machinery as @data)
|
||||
→ broker loads the REAL APP in an iframe
|
||||
→ app renders with a self-connecting NextGraphProvider
|
||||
→ steps through appFrame.evaluate() + Playwright locators
|
||||
→ login broker (automatisé, même mécanique que @data)
|
||||
→ broker charge la VRAIE APP en iframe
|
||||
→ app rend avec NextGraphProvider auto-connectant
|
||||
→ steps via appFrame.evaluate() + locators Playwright
|
||||
```
|
||||
|
||||
**App server**: started in `BeforeAll` (`spawn('bun', ['src/index.ts'], { env: { PORT } })`, polled until it answers over HTTP, killed in `AfterAll`). It reuses the `setupBrokerPage()` helper from `@data` (redirect, login, iframe discovery).
|
||||
**Serveur app** : lancé en `BeforeAll` (`spawn('bun', ['src/index.ts'], { env: { PORT } })`, poll jusqu'à réponse HTTP, tué en `AfterAll`). Réutilise le helper `setupBrokerPage()` de `@data` (redirect, login, découverte de l'iframe).
|
||||
|
||||
## Step definitions
|
||||
|
||||
Inside the modules (e.g. `src/modules/auth/steps/e2e/connexion.steps.ts`):
|
||||
- `this.appFrame!.evaluate()` — JS inside the app iframe (hash/path navigation, content checks)
|
||||
- `this.appFrame!.locator()` — DOM elements
|
||||
- `this.appFrame!.waitForFunction()` — poll for an expected state
|
||||
- `SCREEN_MARKERS` — map from screen ID to a unique verification text
|
||||
Dans les modules (ex. `src/modules/auth/steps/e2e/connexion.steps.ts`) :
|
||||
- `this.appFrame!.evaluate()` — JS dans l'iframe app (navigation hash/path, checks de contenu)
|
||||
- `this.appFrame!.locator()` — éléments DOM
|
||||
- `this.appFrame!.waitForFunction()` — poll d'état attendu
|
||||
- `SCREEN_MARKERS` — map ID d'écran → texte unique de vérification
|
||||
|
||||
Navigation: `window.history.pushState` plus a `popstate` dispatch (path-based routing, see `app-architecture`).
|
||||
Navigation : `window.history.pushState` + dispatch `popstate` (routing path-based, cf. `app-architecture`).
|
||||
|
||||
## Differences from `@data`
|
||||
## Différences avec `@data`
|
||||
|
||||
| Aspect | `@data` | `@e2e` |
|
||||
|---|---|---|
|
||||
| Loaded in the iframe | harness (`harness-ng.tsx`) | real app (`src/index.ts`) |
|
||||
| Ready signal | `window.__testData.ready` | `root.innerHTML.length > 100` |
|
||||
| Interaction | `evaluate()` bridge | `evaluate()` + locators |
|
||||
| Mock fallback | yes | **no** (real broker required) |
|
||||
| Tests | data operations | UI behaviour (nav, redirects, clicks) |
|
||||
| Chargé en iframe | harness (`harness-ng.tsx`) | app réelle (`src/index.ts`) |
|
||||
| Signal ready | `window.__testData.ready` | `root.innerHTML.length > 100` |
|
||||
| Interaction | bridge `evaluate()` | `evaluate()` + locators |
|
||||
| Fallback mock | oui | **non** (broker réel requis) |
|
||||
| Teste | opérations données | comportement UI (nav, redirects, clics) |
|
||||
|
||||
> **Do not re-check in `@e2e` what `@ui` already covers** — `@e2e` must break when the *collaboration* between layers breaks, not when an icon changes (see [[rule_test-layer-contracts]]).
|
||||
> **Ne pas re-vérifier en `@e2e` ce que `@ui` couvre déjà** — `@e2e` doit casser quand la *collaboration* entre couches casse, pas quand une icône change (cf. [[rule_test-layer-contracts]]).
|
||||
|
||||
## Scenario identity + access barrier
|
||||
## Fichiers clés
|
||||
|
||||
Two settings applied by the `Before` hook in `hooks.ts` govern **every** `@e2e` scenario:
|
||||
|
||||
- **`this.freshIdentifier`** — a virtual identifier **unique to each scenario**, injected via `addInitScript` into `localStorage['festipod.account.identifier']` on **every** origin before any script. The real app therefore boots straight into that identity, and each scenario starts from an empty space. This is the **same** machinery as in `@data` (same World field).
|
||||
- **Access barrier disabled** — `browserContext.addInitScript` sets `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ = true` on the **persistent** context: `@e2e` sees the app, not the `AccessGateScreen`. **Fresh** contexts (`@humain`, see [[knowledge_multibrowser-harness]]) do not inherit that setting → the barrier is ON for them.
|
||||
|
||||
> **Impact:** any page opened by hand inside a step (`ctx.newPage()`) must **re-apply both init scripts itself** — the context's `addInitScript` only applies to pages of that context, and the identifier must be written **before** the app's first script.
|
||||
|
||||
## The "close and reopen" idiom (reconnection scenarios)
|
||||
|
||||
`reconnexion-persistance-e2e.feature` / `src/modules/event/steps/e2e/reconnexion-persistance.steps.ts` reproduce the "I create, I close, I come back" journey inside the REAL app:
|
||||
|
||||
1. **Creation through the real form** — the step drives the actual creation wizard at DOM level (3-step wizard, selection by *placeholder*: event name, venue; submit button by its label). ⚠️ **These steps are coupled to the French labels of the creation screen**: renaming a placeholder or the submit button breaks the scenario, not the app.
|
||||
2. **Reopening** — a second page on the **same** persistent context, with the **same** `this.freshIdentifier` and the barrier disabled, then `pool.setupBrokerPage(page, pool.appUrl!)` → new broker login, fresh verifier session.
|
||||
3. **Proof** — the step captures the console of **both** pages and publishes a summary through `this.attach` (Cucumber attachment) plus stdout; a raw dump of the connection/sync lines is **opt-in** through the `RECO_RAW_DUMP=1` environment variable (noisy, off by default).
|
||||
|
||||
> **Limitation to know about**: this setup proves the reconnection *of the journey*, **not** the broker durability of the write — the second page shares the IndexedDB of the persistent profile. See [[caveat_reconnexion-froide-local-vs-broker]] for the setup that does settle broker-vs-local.
|
||||
|
||||
The scenario is **`@wip`** (diagnostic instrument, excluded from the default run).
|
||||
|
||||
## `@smoke` — guarding the "blank page once connected" class
|
||||
|
||||
`@e2e @smoke` (`src/modules/home/features/accueil-connecte-rend.feature`) guards a CLASS of regression: a rendering crash that happens ONLY once the app is connected and mounted on real data (symptom: only the broker iframe banner shows, `#root` stays empty). The smoke test reuses the boot from the @e2e `Before` hook, navigates to the connected home screen and asserts TWO things:
|
||||
1. **HomeScreen really mounted** — presence of strong markers (`.app-navbar` plus the `[aria-label="Relayer un événement"]` button), absent from a spinner or from the broker banner. A `throw` inside a component/provider mounted after connection unmounts the tree (there is no `ErrorBoundary`) → those markers disappear → red.
|
||||
2. **Zero runtime errors** — `this.pageErrors` (see below) must be empty.
|
||||
|
||||
The @e2e `Before` hook now **collects** the app page's `pageerror` and `console.error` events into the World (field `pageErrors`, reset per scenario) — that is what makes the "no error" assertion possible. The default run of `bun run validate` executes `@smoke and not @wip` (not all of `@e2e`, to stay fast). **Detection proof**: a `throw` at the top of `HomeScreen` turns the smoke test red; without it, green.
|
||||
|
||||
## Key files
|
||||
|
||||
`src/shared/support/hooks.ts` (Playwright lifecycle + `pageErrors` collection), `world.ts` (fields `page`/`appFrame`/`pageErrors`), `scripts/debug-browser.ts` (headed debugging), `.playwright-profile{,-debug}/` (gitignored).
|
||||
`src/shared/support/hooks.ts` (lifecycle Playwright), `world.ts` (champs `page`/`appFrame`), `scripts/debug-browser.ts` (debug headed), `.playwright-profile{,-debug}/` (gitignored).
|
||||
|
||||
@@ -1,70 +1,71 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Multi-browser harness along TWO orthogonal axes — number of browsers (the machinery, isolated fresh contexts spawned from a non-persistent freshBrowser) AND wallet model (own/@private-wallet vs shared/@shared-wallet); shared is provisioned by storageState injection (test-only); an @humain e2e validates the REAL product mechanism through the real staging app (.ngw file downloaded from the screen → nextgraph.eu "Import a Wallet File" → Entrer → connected); @wip convention excluded through cucumber.json
|
||||
summary: Harness multi-navigateur sur DEUX axes orthogonaux — nombre de navigateurs (machinerie, contextes frais isolés via un freshBrowser non-persistant) ET modèle de wallet (own/@private-wallet vs shared/@shared-wallet) ; shared provisionné par injection storageState (test) ; e2e @humain qui valide le mécanisme produit RÉEL via la vraie app staging (fichier .ngw téléchargé depuis l'écran → import nextgraph.eu « Import a Wallet File » → Entrer → connecté) ; convention @wip exclue via cucumber.json
|
||||
last_checked: 2026-06-16
|
||||
---
|
||||
|
||||
# Multi-browser harness (private-wallet vs shared-wallet)
|
||||
# Harness multi-navigateur (private-wallet vs shared-wallet)
|
||||
|
||||
The ability of the `@data`/`@e2e` harness to drive **several isolated browsers** within a single scenario, along **two orthogonal axes**. It makes it possible to test both the "everyone has their own wallet" model (`@private-wallet`) and the "wallet shared between browsers" model (`@shared-wallet`).
|
||||
Capacité du harness `@data`/`@e2e` à piloter **plusieurs navigateurs isolés** dans un même scénario, sous **deux axes orthogonaux**. Sert à tester le stopgap wallet partagé (cf. concept `nextgraph-platform` → `brief_2026-06-15_shared-wallet-shim`) **et** le modèle cible (chacun son wallet).
|
||||
|
||||
## The two axes (orthogonal)
|
||||
## Les deux axes (orthogonaux)
|
||||
|
||||
| Axis | What it decides | Expressed by |
|
||||
| Axe | Ce qu'il décide | Exprimé par |
|
||||
|---|---|---|
|
||||
| **Number of browsers** (machinery) | 1..N isolated named contexts | `openBrowser(name, …)` + steps `… dans le navigateur "X"` |
|
||||
| **Wallet model** | distinct vs shared NG identity | **step phrasing + tag** (see below) |
|
||||
| **Nombre de navigateurs** (machinerie) | 1..N contextes nommés isolés | `openBrowser(name, …)` + steps `… dans le navigateur "X"` |
|
||||
| **Modèle de wallet** | identité NG distincte vs partagée | **phrasing du step + tag** (voir ci-dessous) |
|
||||
|
||||
Do **not** confuse `@multibrowser` (several browsers) with `@shared-wallet` (same wallet): we run multibrowser **in private** (everyone with their own wallet) **and in shared** (shared wallet), and compare both setups with the **same** behavioural steps.
|
||||
Ne **pas** confondre `@multibrowser` (plusieurs navigateurs) avec `@shared-wallet` (même wallet) : on fait du multibrowser **en private** (utile dès que NextGraph livrera la lecture cross-wallet — le modèle cible) **et en shared** (stopgap), et on compare les deux setups avec les **mêmes** steps de comportement.
|
||||
|
||||
## Wallet model: phrasing + tags
|
||||
## Modèle de wallet : phrasing + tags
|
||||
|
||||
- `Étant donné un navigateur "A" avec son propre wallet` → **own** model, tag `@private-wallet`.
|
||||
- `Étant donné un navigateur "A" avec le wallet partagé` → **shared** model, tag `@shared-wallet`.
|
||||
- Umbrella tag `@multibrowser` (whole feature).
|
||||
- `Étant donné un navigateur "A" avec son propre wallet` → modèle **own**, tag `@private-wallet`.
|
||||
- `Étant donné un navigateur "A" avec le wallet partagé` → modèle **shared**, tag `@shared-wallet`.
|
||||
- Tag umbrella `@multibrowser` (feature entière).
|
||||
|
||||
## Architecture (where things live)
|
||||
## Architecture (où vit quoi)
|
||||
|
||||
- **`src/shared/support/browserPool.ts`** — shared state + factory. Outside the **persistent** Chromium context that carries the shared wallet (legacy single-browser `@data`/`@e2e`, **unchanged**, see [[knowledge_data-layer-broker]]), the harness launches a **non-persistent** browser `freshBrowser` (`chromium.launch`) which mints fresh isolated contexts on demand (`spawnContext(wallet)`). The module is imported by `hooks.ts` (lifecycle) and `world.ts` (per-scenario use) — no import cycle.
|
||||
- **`world.ts`** — API: `openBrowser(name, wallet)`, `browser(name)`, `loadAppInBrowser(name, 'app'|'harness')`, `closeBrowsers()`; registry `browsers: Map<name, NamedBrowser>`. Named browsers are closed in `After`, `freshBrowser` in `AfterAll`.
|
||||
- **`hooks.ts`** — a scenario tagged `@multibrowser` **does not get** the legacy single page; the steps open the browsers themselves. Requires the real broker mode (`freshBrowser` is unavailable in the mock fallback).
|
||||
- **`src/shared/support/browserPool.ts`** — état partagé + fabrique. Hors du contexte Chromium **persistant** porteur du wallet partagé (legacy mono-navigateur `@data`/`@e2e`, **inchangé**, cf. [[knowledge_data-layer-broker]]), le harness lance un navigateur **non-persistant** `freshBrowser` (`chromium.launch`) qui mint des contextes frais et isolés à la demande (`spawnContext(wallet)`). Module importé par `hooks.ts` (cycle de vie) et `world.ts` (usage par scénario) — pas de cycle d'import.
|
||||
- **`world.ts`** — API : `openBrowser(name, wallet)`, `browser(name)`, `loadAppInBrowser(name, 'app'|'harness')`, `closeBrowsers()` ; registre `browsers: Map<name, NamedBrowser>`. Navigateurs nommés fermés en `After`, `freshBrowser` en `AfterAll`.
|
||||
- **`hooks.ts`** — un scénario taggé `@multibrowser` **ne reçoit pas** la page unique legacy ; les steps ouvrent les navigateurs. Exige le mode broker réel (`freshBrowser` indispo en fallback mock).
|
||||
|
||||
## Wallet provisioning
|
||||
## Provisioning du wallet
|
||||
|
||||
- **own**: empty `newContext()` → distinct NG identity / no wallet.
|
||||
- **shared**: `newContext({ storageState })`, where `storageState` is **captured once** at `BeforeAll` from the persistent profile (warm-up through `setupBrokerPage`, then `browserContext.storageState()`), exposed as `pool.sharedWalletState`. **Empirically verified (2026-06-16)**: the `nextgraph.eu` and `nextgraph.net` origins round-trip into the fresh contexts, and two **shared** browsers both reach the app **connected** to NextGraph (`window.__testData.ready`) **without any manual login**.
|
||||
- **own** : `newContext()` vide → identité NG distincte / pas de wallet.
|
||||
- **shared** : `newContext({ storageState })`, où `storageState` est **capturé une fois** au `BeforeAll` depuis le profil persistant (warm-up via `setupBrokerPage` puis `browserContext.storageState()`), exposé par `pool.sharedWalletState`. **Vérifié empiriquement (2026-06-16)** : les origines `nextgraph.eu` + `nextgraph.net` round-trippent dans les contextes frais, et deux navigateurs **shared** atteignent tous deux l'app **connectée** à NextGraph (`window.__testData.ready`) **sans login manuel**.
|
||||
|
||||
> This provisioning is **test-only** — distinct from the **product** mechanism (FILE-assisted import). The shared-wallet scenario using storageState **bypasses the import**; to validate the REAL mechanism, see the `@humain` e2e below.
|
||||
> Ce provisioning est **de test** — distinct du mécanisme **produit** (import assisté par FICHIER). Le scénario shared-wallet par storageState **court-circuite l'import** ; pour valider le mécanisme RÉEL, voir l'e2e `@humain` ci-dessous.
|
||||
|
||||
## Human journey — e2e of the product mechanism (green)
|
||||
## Parcours humain — e2e du mécanisme produit (vert)
|
||||
|
||||
The `@humain` scenario validates the REAL wallet distribution flow **end to end, through the real app**, not through test injection. A blank browser opens the staging app → the `AccessGateScreen` offers the **file** and the **password** → the file is downloaded **from the screen**, the displayed password is checked to **equal** the wallet's own → import on `nextgraph.eu` "Import a Wallet File" → back to the app → an **identifier is typed in**, then a click on « Entrer » (naming the space and opening the wallet are a single act, see concept `app-security` [[decision_2026-07-06_identifier-at-access-barrier]]) → app connected, landing straight on the home screen (no more separate « nom d'utilisateur » screen).
|
||||
Scénario `@humain` : valide le flux RÉEL de distribution du wallet **de bout en bout, via la vraie app**, pas l'injection de test (cf. concept `nextgraph-platform` → `decision_2026-06-17_assisted-wallet-import`). Un navigateur vierge ouvre l'app staging → l'`AccessGateScreen` propose le **fichier** + le **mot de passe** → on télécharge le fichier **depuis l'écran**, on vérifie que le mot de passe affiché **égale** celui du wallet → import sur `nextgraph.eu` « Import a Wallet File » → retour → clic « Entrer » → app connectée (`ConnexionScreen`).
|
||||
|
||||
- **e2e wallet**: a `.ngw` file (`festipod-e2e-tests`, password = identifier) placed **at the root of the worktree**; `findE2eWalletFile()` locates it (`*.ngw`). Gitignored → each environment has to add it (otherwise a clear error is raised).
|
||||
- `pool.ensureStagingApp()` (`hooks.ts`) — an **isolated** build `bun run build.ts --outdir=dist-staging` (access barrier **ON by default**; password baked in and the **file copied** to `/shared-wallet.ngw`, see `build.ts`), served statically. Memoized and lazy (only `@humain` pays for it).
|
||||
- **Barrier bypass for `@e2e`**: the harness calls `browserContext.addInitScript` on the **persistent** context to set `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ = true` (which applies to the app iframe before its scripts) → `@e2e` sees the app directly, not the barrier. Fresh contexts (`@humain`) leave it alone → barrier ON. The old `/login` `LoginScreen` has been removed.
|
||||
- `pool.importWalletViaFile(page, filePath, password)` — `nextgraph.eu/#/wallet/login` → `setInputFiles('input[type=file]')` (wait for the SPA to render, otherwise `EncryptionError`) → password field → unlock.
|
||||
- `pool.completeBrokerLogin(page, appUrl, walletPassword?)` — the "broker login" half extracted from `setupBrokerPage`. **Robust waiting**: after the (multi-hop) redirect, it waits for either the app iframe or the "Click here to login with your wallet" link, then unlocks with the password. Since the broker session is **not** persisted between launches, this wallet login is required on every run (warm-up + `@e2e` + `@humain`).
|
||||
- **Wallet e2e** : un fichier `.ngw` (`festipod-e2e-tests`, mot de passe = identifiant) placé **à la racine du worktree** ; `findE2eWalletFile()` le localise (`*.ngw`). Gitignoré → chaque environnement doit l'ajouter (sinon erreur claire).
|
||||
- `pool.ensureStagingApp()` (`hooks.ts`) — build **isolé** `bun run build.ts --outdir=dist-staging` (barrière d'accès **ON par défaut** ; mot de passe gravé + **fichier copié** en `/shared-wallet.ngw`, cf. `build.ts`), servi statiquement. Mémoïsé, lazy (seul `@humain` le paie).
|
||||
- **Bypass de la barrière pour `@e2e`** : le harness fait `browserContext.addInitScript` sur le **contexte persistant** pour poser `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ = true` (s'applique à l'iframe app avant ses scripts) → `@e2e` voit l'app directement, pas la barrière. Les contextes frais (`@humain`) n'y touchent pas → barrière ON (cf. `decision_2026-06-17`). L'ancien `LoginScreen` `/login` a été retiré.
|
||||
- `pool.importWalletViaFile(page, filePath, password)` — `nextgraph.eu/#/wallet/login` → `setInputFiles('input[type=file]')` (attendre que la SPA rende, sinon `EncryptionError`) → champ password → unlock.
|
||||
- `pool.completeBrokerLogin(page, appUrl, walletPassword?)` — moitié « login broker » extraite de `setupBrokerPage`. **Attente robuste** : après le redirect (multi-hop), attend l'iframe app OU le lien « Click here to login with your wallet », puis déverrouille avec le mot de passe. La session broker n'étant **pas** persistée entre lancements, ce login wallet est requis à chaque run (warm-up + `@e2e` + `@humain`).
|
||||
|
||||
> **The e2e is what guarantees it works for a real human**: Festipod hands out the RIGHT file plus password, and importing that file yields a working wallet on a blank device. The `@shared-wallet` scenario (storageState) remains a test provisioning shortcut, it does not validate the import.
|
||||
> **C'est l'e2e qui garantit que ça marche pour un humain réel** : Festipod fournit le BON fichier + mot de passe, et ce fichier importé donne un wallet fonctionnel sur un device vierge. Le scénario `@shared-wallet` (storageState) reste un raccourci de provisioning de test, il ne valide pas l'import.
|
||||
|
||||
## Isolation (guaranteed at 3 levels, proven by the scenarios)
|
||||
## Isolation (garantie à 3 niveaux, prouvée par les scénarios)
|
||||
|
||||
1. `freshBrowser` runs in a **separate process** from the persistent profile carrying the wallet → an **own** browser starts **with no wallet**.
|
||||
2. Every `newContext()` is a **hermetic storage partition** (Playwright guarantee).
|
||||
3. Isolation is proven not only on the **local** origin (`127.0.0.1`) but also on the **broker origin** `nextgraph.net` **where the wallet actually lives** (a localStorage probe written in A is absent from B).
|
||||
1. `freshBrowser` est un **process séparé** du profil persistant porteur du wallet → un navigateur **own** démarre **sans wallet**.
|
||||
2. Chaque `newContext()` est une **partition de stockage hermétique** (garantie Playwright).
|
||||
3. Isolation prouvée non seulement sur l'origine **locale** (`127.0.0.1`) mais aussi sur l'**origine broker** `nextgraph.net` **où vit réellement le wallet** (sonde localStorage écrite dans A absente de B).
|
||||
|
||||
## Files
|
||||
## Fichiers
|
||||
|
||||
- Feature: `src/modules/workshop/features/multibrowser-harness.feature`.
|
||||
- Steps: `src/modules/workshop/steps/data/multibrowser.steps.ts`.
|
||||
- A `/blank` route added to the harness server (`hooks.ts`): a minimal page **with no NG stack**, for the localStorage isolation checks.
|
||||
- Feature : `src/modules/workshop/features/multibrowser-harness.feature`.
|
||||
- Steps : `src/modules/workshop/steps/data/multibrowser.steps.ts`.
|
||||
- Route `/blank` ajoutée au serveur harness (`hooks.ts`) : page minimale **sans stack NG**, pour les checks d'isolation localStorage.
|
||||
|
||||
## The `@wip` convention (now enforced)
|
||||
## Convention `@wip` (désormais appliquée)
|
||||
|
||||
`cucumber.json` (profile `default`) carries `"tags": "not @wip"`. `cookbook_add-scenario` prescribed `@wip` for anything unimplemented, but it was **excluded nowhere**; now `not @wip` is **AND**ed with the CLI filters (e.g. `--tags @data` → `(not @wip) and @data`, verified).
|
||||
`cucumber.json` (profile `default`) porte `"tags": "not @wip"`. Le `cookbook_add-scenario` prescrivait `@wip` pour le non-implémenté mais ce n'était **exclu nulle part** ; maintenant `not @wip` s'**AND** avec les filtres CLI (ex. `--tags @data` → `(not @wip) and @data`, vérifié).
|
||||
|
||||
## Links
|
||||
## Liens
|
||||
|
||||
- [[knowledge_data-layer-broker]] — the single-browser `@data` layer (persistent profile) that this capability extends.
|
||||
- [[cookbook_add-scenario]] — the `@wip` convention, step pitfalls.
|
||||
- [[knowledge_data-layer-broker]] — la couche `@data` mono-navigateur (profil persistant) que cette capability étend.
|
||||
- [[cookbook_add-scenario]] — convention `@wip`, pièges de steps.
|
||||
- Concept `nextgraph-platform` → `brief_2026-06-15_shared-wallet-shim` — le stopgap wallet partagé que ce harness sert à tester.
|
||||
|
||||
@@ -1,47 +1,33 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: The @ui layer — renderHelper.tsx renders any screen inside LocalDataProvider + happy-dom, world.renderCurrentScreen() invokes it on every navigateTo, assertions run against the rendered DOM with the deterministic seed fixtures; pitfall of screens reading a global injected at build time (access barrier → lazy import mandatory)
|
||||
last_checked: 2026-07-27
|
||||
summary: Couche @ui — renderHelper.tsx rend tout écran dans LocalDataProvider + happy-dom, world.renderCurrentScreen() l'invoque à chaque navigateTo, assertions sur le DOM rendu avec les fixtures de seed déterministes
|
||||
---
|
||||
|
||||
# The `@ui` layer
|
||||
# Couche `@ui`
|
||||
|
||||
`@ui` renders a screen with `LocalDataProvider` (seed) plus `RouterProvider` through happy-dom, then asserts on the **rendered DOM**.
|
||||
`@ui` rend un écran avec `LocalDataProvider` (seed) + `RouterProvider` via happy-dom, puis assert sur le **DOM rendu**.
|
||||
|
||||
- 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.
|
||||
- Helper : `src/shared/test-harness/renderHelper.tsx` (installe les globals happy-dom, enveloppe l'écran). Invoqué depuis `world.ts:renderCurrentScreen()` à chaque `navigateTo(...)`.
|
||||
- Fixtures déterministes (`src/shared/data/seedData.ts`, voir concept `data-layer`) : `Marie Dupont`/`@mariedupont` = currentUser, `Jean Durand`/`@jeandurand` existe, 5 events, etc.
|
||||
|
||||
## Good assertion patterns
|
||||
## Bons patterns d'assertion
|
||||
|
||||
```ts
|
||||
// Visible text
|
||||
// Texte visible
|
||||
expect(this.getDomText()).to.include('Marie Dupont');
|
||||
// Element present, by class/role
|
||||
// Présence d'élément par classe/rôle
|
||||
expect(this.renderedDoc!.querySelector('.app-avatar')).to.not.be.null;
|
||||
// Conditional rendering (filled vs empty)
|
||||
// Rendu conditionnel (rempli vs vide)
|
||||
expect(this.renderedDoc!.querySelectorAll('.app-card').length).to.be.greaterThan(0);
|
||||
// Required field rendered with a label and an asterisk
|
||||
// Champ requis rendu avec label + astérisque
|
||||
const labels = Array.from(this.renderedDoc!.querySelectorAll('p')).map(p => p.textContent ?? '');
|
||||
expect(labels.some(t => t.includes("Nom de l'événement *"))).to.be.true;
|
||||
```
|
||||
|
||||
## `FestipodWorld` fields & helpers (`src/shared/support/world.ts`)
|
||||
## Champs & helpers de `FestipodWorld` (`src/shared/support/world.ts`)
|
||||
|
||||
- `renderedDoc: Document | null` — the rendered happy-dom DOM (populated by `renderCurrentScreen()`, called on every `navigateTo(...)`).
|
||||
- `currentScreenId: string | null` — the current screen.
|
||||
- Assertion helpers: `getDomText()` (DOM text), `hasText(t)`, `hasField(name)`, `hasElement(selector)` — they **prefer the rendered DOM** but **fall back to the screens' source** for unmigrated steps (a leftover, see [[caveat_source-grep-vestiges]]).
|
||||
- `renderedDoc: Document | null` — le DOM happy-dom rendu (peuplé par `renderCurrentScreen()`, appelé à chaque `navigateTo(...)`).
|
||||
- `currentScreenId: string | null` — l'écran courant.
|
||||
- Helpers d'assertion : `getDomText()` (texte du DOM), `hasText(t)`, `hasField(name)`, `hasElement(selector)` — ils **préfèrent le DOM rendu** mais **retombent sur la source** des écrans pour les steps non migrés (vestige, voir [[caveat_source-grep-vestiges]]).
|
||||
|
||||
## ⚠️ Screens that read a global injected at **build** time (access barrier)
|
||||
|
||||
`src/modules/auth/sharedWallet.ts` **captures, at module evaluation time**, a global set by `build.ts` (`__FESTIPOD_SHARED_WALLET_PASSWORD__`). The `@ui` harness runs under Node **without going through the build** → that global is missing, `hasSharedWallet()` returns false, and since the **shared wallet is the only supported mode** (concept `app-security`), `AccessGateScreen` renders its **configuration error** branch: **no identifier field at all** in the DOM → every barrier step fails with a misleading message ("field not found").
|
||||
|
||||
**The mandatory setup** (applied in `src/modules/auth/steps/ui/barriere-acces.steps.ts`):
|
||||
|
||||
1. set the global **at the top of the steps module**, before any import of the screen;
|
||||
2. **import the screen lazily** (memoized `await import(...)`) — a static `import` would be **hoisted above** the assignment and `sharedWallet.ts` would capture an empty value.
|
||||
|
||||
> **Impact if you touch this:**
|
||||
> - Adding a static `import` of `AccessGateScreen` (or of any module that reaches `sharedWallet.ts`) in **any** `@ui` steps file re-introduces the bug — Cucumber loads every steps module, so the screen would be evaluated before the global is set.
|
||||
> - The current determinism relies on **this file being the only** `@ui` module that reaches `sharedWallet.ts`. A second entry point would make the evaluation order unguaranteed → the global injection would then have to move into the shared support, not be duplicated.
|
||||
|
||||
> The `app-*` classes confirm the modern theme (see `app-architecture`). Anti-patterns (regexes over the source, implementation details) are banned by [[rule_test-layer-contracts]]. To write a new scenario, see [[cookbook_add-scenario]].
|
||||
> Les classes `app-*` confirment le thème moderne (cf. `app-architecture`). Les anti-patterns (regex sur source, détails d'implémentation) sont proscrits par [[rule_test-layer-contracts]]. Pour écrire un nouveau scénario, voir [[cookbook_add-scenario]].
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
---
|
||||
type: rule
|
||||
summary: NEVER poll the broker (re-reading in a loop "is it there yet?"). NextGraph is subscription-based — data arrives by PUSH, and the first `State` of a `doc_subscribe` is the deterministic sync barrier (after it — presence guaranteed, absence definitive). Tests AND app wait for the push / for the reactive state to settle, never a broker re-read loop.
|
||||
last_checked: 2026-07-09
|
||||
---
|
||||
|
||||
# Never poll the broker — wait for the subscription
|
||||
|
||||
NextGraph is **subscription-based (reactive)**. A read is NOT "query in a loop until it shows up"; it is "subscribe, react to the push". The **first `State`** of a `doc_subscribe` marks the end of the initial synchronization (a synchronous barrier): after it, the **presence** of a piece of data is **guaranteed** and its **absence** is **definitive**. Contract verified empirically on the SDK side (`@ng-eventually/client`, e2e test « CONTRAT 3 »).
|
||||
|
||||
## The anti-pattern to ban
|
||||
|
||||
```
|
||||
for (i = 0; i < N; i++) { if (await authParticipationCount(...) === X) break; sleep(500); }
|
||||
```
|
||||
|
||||
Any loop that **re-queries the broker** (repeated `authParticipationCount`, `listMyEntityDocs`, `sparql_query`) in order to "wait" for data is forbidden: it hides the real mechanism, makes the test brittle (guessed timeout), and directly contradicts the NextGraph model. That remark is what caused the deletion of the old caveat which wrongly held polling up as a practice.
|
||||
|
||||
## What to do instead
|
||||
|
||||
Wait for the **reactive push**. In practice (app AND test): the reactive state (`AD().*` fed by `subscribeDoc` in the data context) updates **on push**. We wait for THAT state to reflect the expectation — we **observe the settled reactive state**, we do NOT re-issue a broker read. The data mechanism is the subscription; waiting only **observes the reactive result**.
|
||||
|
||||
- App: the screen is already reactive (`subscribeDoc` → re-render on push) — no application-level polling, no spinner driven by a guessed timeout (if a waiting state is wanted, it comes from the native subscription barrier, not from an added signal).
|
||||
- Test: **a helper that reliably waits for the push/barrier is welcome** (it makes things reliable without making them brittle). What is banned is the **re-read loop**, not waiting for a signal.
|
||||
- **Pragmatic fallback**: if strictly waiting for the push/signal turns out to be brittle one way or another, a **short interval** (`setInterval` / closely spaced re-checks) that **observes the ALREADY updated reactive state** (the local state fed by the subscription — NOT a broker re-read) is acceptable: it is as close as it gets to what the user experiences, simply **waiting** for the (reactive) screen to update. The red line is invariant: **never re-query the broker in a loop**; observing the settled reactive state, yes.
|
||||
|
||||
See also [[caveat_wallet-bloat-hang]] (another source of @data flakiness, orthogonal to this one). The non-polling mechanism on the library side (`open-repo`: subscribe + wait for the first State + read) lives in the `@ng-eventually/client` repo, not here.
|
||||
@@ -1,29 +1,29 @@
|
||||
---
|
||||
type: rule
|
||||
summary: Each BDD layer answers a distinct question — @ui = rendering (DOM + seed), @data = broker mutations/persistence, @e2e = collaboration of the layers over a journey; push every assertion down to the lowest layer that can answer it
|
||||
summary: Chaque couche BDD répond à une question distincte — @ui = rendu (DOM + seed), @data = mutations/persistance broker, @e2e = collaboration des couches sur un parcours ; descendre chaque assertion à la couche la plus basse qui peut y répondre
|
||||
---
|
||||
|
||||
# Rule: the test layer contract
|
||||
# Règle : contrat des couches de test
|
||||
|
||||
Each layer answers **one distinct question**. Mixing concerns produces brittle tests that break on refactors without catching any real regression. **Push every assertion down to the lowest layer that can answer it.**
|
||||
Chaque couche répond à **une question distincte**. Mélanger les préoccupations produit des tests fragiles qui cassent au refactor sans attraper de vraie régression. **Descendre toute assertion à la couche la plus basse qui peut y répondre.**
|
||||
|
||||
- **`@ui` — display layer.** Renders a screen with `LocalDataProvider` (seed) plus happy-dom and asserts on the DOM. Checks that *given known data, the screen shows the expected text and elements*. **Does not test** navigation, mutations or persistence.
|
||||
- **`@ui` — couche affichage.** Rend un écran avec `LocalDataProvider` (seed) + happy-dom et assert sur le DOM. Vérifie que *données connues → l'écran montre le texte et les éléments attendus*. **Ne teste pas** la navigation, les mutations, ni la persistance.
|
||||
|
||||
- **`@data` — data layer.** Drives ORM mutations through the **real NextGraph broker** (headless harness, no app UI). Checks that *shape operations are persisted and observable in the wallet*. No DOM here — use the `window.__testData` bridge.
|
||||
- **`@data` — couche données.** Pilote des mutations ORM via le **broker NextGraph réel** (harness headless, pas d'UI app). Vérifie que *les opérations sur shapes sont persistées et observables dans le wallet*. Pas de DOM ici — utiliser le bridge `window.__testData`.
|
||||
|
||||
- **`@e2e` — integration layer.** Boots the real app inside the broker iframe (Playwright/Chromium). Checks that *the layers collaborate to deliver a journey* (create → list → edit → reload → still there). **Rare**: 1 scenario per critical path; **never duplicate** a `@ui` content check.
|
||||
- **`@e2e` — couche intégration.** Boote l'app réelle dans l'iframe broker (Playwright/Chromium). Vérifie que *les couches collaborent pour livrer un parcours* (créer → lister → modifier → recharger → toujours là). **Rare** : 1 scénario par chemin critique ; **ne jamais dupliquer** un check de contenu `@ui`.
|
||||
|
||||
## Why cost dictates the pyramid
|
||||
## Pourquoi le coût impose la pyramide
|
||||
|
||||
`@ui` runs in-process (instant); `@data` boots a broker (~50s); `@e2e` boots broker + app + browser (~2min). A rendering claim belongs to `@ui`, not to `@e2e`.
|
||||
`@ui` tourne in-process (instantané) ; `@data` boote un broker (~50s) ; `@e2e` boote broker + app + navigateur (~2min). Une affirmation de rendu appartient à `@ui`, pas à `@e2e`.
|
||||
|
||||
## `@ui` anti-patterns to ban
|
||||
## Anti-patterns `@ui` à proscrire
|
||||
|
||||
```ts
|
||||
// ❌ regex over the source: couples the test to the structure of the code
|
||||
// ❌ regex sur la source : couple le test à la structure du code
|
||||
expect(/<Title[^>]*>Marie Dupont<\/Title>/.test(source)).to.be.true;
|
||||
// ❌ implementation details
|
||||
// ❌ détails d'implémentation
|
||||
expect(/showDuplicateWarning/.test(source)).to.be.true;
|
||||
```
|
||||
|
||||
Prefer assertions on the **rendered DOM** plus seed data (see [[knowledge_ui-layer]]). The source-analysis helpers and maps are leftovers on their way out: [[caveat_source-grep-vestiges]].
|
||||
Préférer des assertions sur le **DOM rendu** + données de seed (voir [[knowledge_ui-layer]]). Les helpers/maps d'analyse de source sont des vestiges en voie de suppression : [[caveat_source-grep-vestiges]].
|
||||
|
||||
@@ -1,40 +1,35 @@
|
||||
---
|
||||
type: _overview
|
||||
summary: How Festipod persists its data through the @ng-eventually/client SDK — entities stored as documents placed by scope, direct SPARQL writes + union-model reads, SHEX stack, connected/demo modes, seed
|
||||
summary: Couche données NextGraph telle qu'utilisée AUJOURD'HUI (mono-store) — stack ORM/SHEX, modes connected/demo, entités, seed, et 3 règles d'écriture critiques
|
||||
triggers:
|
||||
keywords: [nextgraph, "@ng-eventually", polyfill, union, readUnion, readEntities, SHEX, shape, scope, "@graph", NURI, overlay, ReadCap, WriteCap, cap-less, sparql, seed, wallet, FestipodData, ngSession, ngGraph, bootstrap, document, entité, déconnexion, reconnexion, durabilité, outbox, SerializationError]
|
||||
paths: ["src/shared/shapes/**", "src/shared/data/readEntities.ts", "src/shared/data/entityWrites.ts", "src/shared/context/NextGraphContext.tsx", "src/shared/context/FestipodDataContext.tsx", "src/shared/utils/ng*", "src/shared/data/seedData.ts"]
|
||||
keywords: [nextgraph, useShape, ORM, SHEX, shape, store, private_store, "@graph", NURI, sparql, sparql_update, seed, wallet, RepoNotFound, FestipodData, ngGraph, bootstrap]
|
||||
paths: ["src/shared/shapes/**", "src/shared/hooks/useShape*", "src/shared/context/NextGraphContext.tsx", "src/shared/context/FestipodDataContext.tsx", "src/shared/utils/ng*", "src/shared/data/seedData.ts"]
|
||||
---
|
||||
|
||||
# Data layer
|
||||
|
||||
How Festipod **persists its data** through NextGraph (P2P, local-first, end-to-end encrypted). The data SDK is **`@ng-eventually/client`**: we treat it as a finished NextGraph SDK — every entity is a **document** placed in the store of its **scope** (public / protected / private). A **write** is direct SPARQL into the entity's own document; a **read** is the **union model** (resolve the documents on demand → open/sync → **one** unanchored `sparql_query` over the union → re-query on signal), not a fan-out reactive ORM subscription (which *hangs*). See [[rule_document-per-entity]]. The mapping *which entity → which scope* is a **product** fact (concept `functional-domain`, [[knowledge_data-scopes-and-discovery]]); this concept describes the **persistence mechanics**.
|
||||
Comment Festipod **persiste ses données aujourd'hui** via NextGraph (P2P, local-first, chiffré). État actuel : **mono-store** — tout atterrit dans le `private_store` de l'utilisateur connecté.
|
||||
|
||||
> **SDK boundary.** Festipod's data SDK is `@ng-eventually/client` — initialized/injected **exactly once** through `ngSession.configure(...)`. We write against it as a **finished** NextGraph SDK: never document NextGraph's current state here (constraints, workarounds, broker internals) — that lives in the `@ng-eventually/client` repo. See [[knowledge_nextgraph-stack]].
|
||||
> Distinction importante : ce concept décrit le **code actuel**. Le modèle *cible* (multi-store, multi-user, autorisations) est de la doctrine **prospective** qui vit dans le concept `nextgraph-platform` (briefs). NextGraph comme **système externe** (stores, permissions, inbox, SDK) y est aussi documenté.
|
||||
|
||||
## Model & data
|
||||
**À lire avant de toucher aux écritures :** les 3 règles ci-dessous — chacune corrige un bug réel (`RepoNotFound`, suppression non persistée, redirect intempestif).
|
||||
|
||||
- [[knowledge_sdk-surface]] — **the data contract**: the `@ng-eventually/client` surface the app codes against (reads, writes, documents, inbox, discovery, capabilities, identity) and what may / may not be assumed of each
|
||||
- [[knowledge_nextgraph-stack]] — the `@ng-eventually/client` SDK, SHEX shapes, reactive ORM, `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` (currentUser, **two id spaces** principal ↔ profile NURI, dev auto-seed, `participantCount` cache, reset on identity change, local no-op)
|
||||
## Règles d'écriture (chacune adossée à une décision)
|
||||
|
||||
## Write rules
|
||||
- [[rule_private-store-scope]] ← [[decision_2026-03-17_private-store-nuri-scope]]
|
||||
- [[rule_conditional-ng-init]] ← [[decision_2026-03-13_conditional-ng-init-broker-detection]]
|
||||
|
||||
- [[rule_document-per-entity]] — every entity gets **its own document** (per scope), never one at store level; this is what makes the SDK's per-document isolation possible
|
||||
- [[rule_app-uses-sdk-surface-only]] — the app behaves as if NextGraph were finished; every workaround lives in the polyfill
|
||||
## Pièges (lire avant de toucher au contexte / aux suppressions / aux champs d'event)
|
||||
|
||||
## What leaves this repo (two destinations, don't confuse them)
|
||||
- [[knowledge_context-internals]] — currentUser `@mariedupont`, auto-seed dev, `participantCount` cache, IRI vide, no-op local
|
||||
- [[caveat_participation-deletion]] — `leaveEvent` via `ngSet.delete()` (décision SPARQL annulée), persistance possiblement partielle
|
||||
- [[caveat_event-fields-not-persisted]] — `startTime`/`themes`… perdus en connecté (SHEX incomplet)
|
||||
|
||||
- [[rule_capture-nextgraph-findings]] — established **knowledge** about how NextGraph actually works → the **polyfill**'s reference docs, at the moment of discovery
|
||||
- [[rule_nextgraph-inbox]] — a NextGraph **malfunction**, or a **gap** we need and emulate in the meantime → a note in `orm-tests/INBOX/`, which tracks upstream progress and says what to remove from the polyfill
|
||||
## Modèle & données
|
||||
|
||||
## Pitfalls (read before touching deletions / event fields)
|
||||
- [[knowledge_nextgraph-stack]] — paquets `@ng-org/*`, SHEX, ORM, `build:orm`
|
||||
- [[knowledge_data-modes]] — connected vs disconnected/demo, providers selon le statut NG
|
||||
- [[knowledge_entities]] — types `Fp*` et shapes
|
||||
- [[knowledge_seed-data]] — données de seed, `CURRENT_USER_ID`
|
||||
|
||||
- [[caveat_participation-deletion]] — withdrawal must be **authoritative** and must not come back
|
||||
- [[caveat_event-fields-not-persisted]] — `startTime`/`themes`… not covered by the Event shape → lost when connected
|
||||
- [[caveat_write-durability-across-disconnect]] — a write made just before an idle period / socket drop can be **lost** (no broker durability); the account survives. Open incident → post-mortem in the polyfill
|
||||
|
||||
> Confidentiality (scope isolation, trusting the SDK): concept `app-security`. Product scopes per entity + discovery: concept `functional-domain`.
|
||||
> Sécurité/confidentialité (mono-store, confiance broker) : concept `app-security`.
|
||||
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
---
|
||||
type: contract
|
||||
summary: The Refine data, auth and live providers @activitypods/refine-providers exposes to an application over a Solid Pod — what each operation does, what a record looks like, and what the package does not offer
|
||||
built_from: https://github.com/activitypods/refine-providers.git/src
|
||||
built_version: 66616484b96fb2227cd8cdf02321aacfb4811365
|
||||
built_at: 2026-10-02
|
||||
---
|
||||
|
||||
# contract_activitypods-providers — `@activitypods/refine-providers`
|
||||
|
||||
**This is a RECONSTRUCTION, not the provider's promise.** The package publishes no engagement and no change policy, so this document is an observation made once, at adoption time, and pinned to the commit named in `built_version`. Nothing here is committed to by its authors: any release may move any of it. Re-observe at each upgrade — the pin is what makes a difference visible.
|
||||
|
||||
## Scope
|
||||
|
||||
Three Refine providers over a Solid Pod running ActivityPods: a `dataProvider` (CRUD over LDP containers), an `authProvider` (Solid-OIDC sign-in plus the app-registration dance ActivityPods requires), and a `liveProvider` (push over the Solid Notifications Protocol). Resources are not addressed by a path the caller chooses: each configured resource is mapped at runtime to an LDP container discovered in a WebID's Solid TypeIndex.
|
||||
|
||||
Two Ant Design components also ship (`antd-auth-page`, `antd-background-checks`) and are outside this scope — `antd` and `@ant-design/icons` are optional peer dependencies, so an app with its own interface consumes the three providers without them. `react-router` is likewise an optional peer dependency.
|
||||
|
||||
Package `@activitypods/refine-providers`, version 0.1.5 at the pinned commit. Peer dependency `@refinedev/core ^5.0.12`. Full typed shape: the package's shipped declarations, `./types` (`dist/types.d.ts`) and `./index` (`dist/index.d.ts`).
|
||||
|
||||
## Surface
|
||||
|
||||
```ts
|
||||
dataProvider({ resources, authProvider, jsonContext? }): DataProvider
|
||||
authProvider({ clientId, redirectUri?, allowAnonymous?, appStatusCheckInterval? }): AuthProvider
|
||||
liveProvider({ resources, authProvider, jsonContext? }): LiveProvider
|
||||
|
||||
type ResourceConfig = { shapeTreeUri?: string; types?: string | string[] }
|
||||
type ResourcesConfig = Record<string, ResourceConfig> // keyed by Refine resource name
|
||||
type JsonContext = string | string[] | Record<string, any>
|
||||
type AuthSession = { token: string; webId: string }
|
||||
```
|
||||
|
||||
`AuthProvider` is Refine's `AuthProvider` plus four members: `getSession(): AuthSession | undefined` (synchronous), `handleCallback(): Promise<{ webId, redirect }>`, `registerApp(webId?): Promise<string | undefined>`, `getAppStatus(): Promise<AppStatus>`. Its `login`/`register` take `{ issuer, redirect?, isSignup? }` — the Pod provider's base URL is a caller-supplied parameter, so the app must let the user pick one.
|
||||
|
||||
`DataProvider` implements `getList`, `getOne`, `getMany`, `create`, `update`, `deleteOne` and `getApiUrl`. Nothing else.
|
||||
|
||||
Utility functions are exported from the package root as well (notably the TypeIndex discovery and container resolution used internally); they are not part of what this document engages.
|
||||
|
||||
## Guarantees
|
||||
|
||||
Observed at the pinned commit.
|
||||
|
||||
**A record is a JSON-LD resource with an `id`.** `id` is the resource's own URI — `id` or `@id` of the fetched document, whichever is present; a resource carrying neither raises. The record also carries `@context` (the container's, on a listed record) and every other predicate verbatim. There is exactly ONE identifier: `getOne`, `update` and `deleteOne` take that same `id` and use it directly as the URL they fetch.
|
||||
|
||||
**`getList`** resolves the resource's container, fetches it whole, and maps `ldp:contains`. Refine's filters and sorters are then applied **in memory**, and pagination slices the result; `total` is the count before slicing. The filter set is broad (equality, `in`, substring, prefix/suffix, ordering, `between`, null checks, and `and`/`or` nesting), string comparison being case-insensitive unless the case-sensitive variant is used, and a filter field may be a dotted path.
|
||||
|
||||
**`create`** POSTs `{ "@context": jsonContext, "@type": <resolved types>, ...variables }` to the container, requires a `Location` response header, and returns the record re-fetched from it. The `@type` is the resource's configured `types` expanded through `jsonContext`, or, when only `shapeTreeUri` is configured, the `sh:targetClass` of the shape the shape tree points at.
|
||||
|
||||
**`update` merges — a partial write is safe.** It fetches the current resource, drops its `id` and `@context`, and PUTs `{ "@context": jsonContext, ...current, ...variables }`. So values the caller omits survive. The merge is **shallow**: a nested object in `variables` replaces its counterpart whole. It returns the record re-fetched after the write.
|
||||
|
||||
**`deleteOne`** DELETEs the resource and returns `{ id }` only.
|
||||
|
||||
**A configured resource's container is discovered, not configured.** Resolution reads the WebID document, then its `solid:publicTypeIndex`, then best-effort its `pim:preferencesFile` → `solid:privateTypeIndex`, and takes the first type registration whose `solid:forClass` matches the resource's resolved types and which carries a `solid:instanceContainer`. No match raises, naming the WebID and the types. A `shapeTreeUri` is fetched over plain HTTP, as is the shape it points at — so a shape tree the caller hosts itself works, provided it is reachable and carries `st:shape` → `sh:targetClass`.
|
||||
|
||||
**`jsonContext`** is sent as `@context` on every create and update, and is what expands configured CURIE types. It defaults to the ActivityStreams 2 context; an application with its own vocabulary passes its own.
|
||||
|
||||
**`liveProvider` pushes — the caller never polls.** It opens a `notify:WebSocketChannel2023` channel per topic, discovering the subscription endpoint from the Pod origin's `.well-known/solid`. Watching specific records subscribes to each record and reports `Update` → `updated`, `Delete`/`Remove` → `deleted`. Watching a list subscribes to the **container** and reports `Add` → `created`, `Remove` → `deleted`. Unsubscribing closes the socket and deletes the channel best-effort. A resource absent from `resources` yields a no-op unsubscribe rather than an error.
|
||||
|
||||
**Auth is Solid-OIDC with PKCE.** `login` redirects to the issuer's authorization endpoint with scope `openid webid offline_access`; `handleCallback`, called from the redirect route, completes the code grant and stores the ID token. The session's `webId` is the token's `webid` claim, and an expired token reads as no session. `logout` clears local state. `check` reports unauthenticated with `redirectTo: "/login"` unless `allowAnonymous` is set. `getIdentity` returns `{ id: webId, name, avatar }`, name and avatar read from the WebID document and the profile it links — ActivityPods' own profile vocabulary, unrelated to any vocabulary the app defines for its own user records. `registerApp` returns the AppRegistration URI when the app is registered and its grants current, and otherwise redirects the browser to the user's authorization agent and resolves `undefined`.
|
||||
|
||||
**Failures throw, and carry the HTTP status.** Every Pod call that returns non-2xx raises an `Error` with `status` and `body` attached, its message taken from the response body's error message when there is one. `onError` maps a 401 to logout.
|
||||
|
||||
## Non-guarantees
|
||||
|
||||
**Every read and write is scoped to the logged-in user's own Pod.** Container resolution always runs against the session's own WebID — in `getList`, in `create`, and in a list subscription alike. There is no parameter, filter or option through which an operation reaches another user's resources: `getList` returns what the current user holds, and nothing else. (`getOne`/`getMany` fetch a URI directly and so are not bound this way, but they require the caller to already hold that URI.)
|
||||
|
||||
**No server-side querying.** Filtering, sorting and pagination are applied after the whole container has been fetched, so cost grows with the container, not with the page.
|
||||
|
||||
**`getManyReference` is not implemented**, despite the data provider's own documentation mentioning it. Neither are `createMany`, `updateMany` and `deleteMany`.
|
||||
|
||||
**`getMany` drops what it cannot fetch.** Failed fetches are filtered out silently, so the returned array may be shorter than `ids` with no indication of which are missing.
|
||||
|
||||
**Caches are module-level and never invalidated** — shape trees by URI, type registrations by WebID, subscription endpoints by origin. A container that becomes available after a first lookup (the user granting access, for instance) is not seen until the page reloads, which is what the registration flow expects.
|
||||
|
||||
**`authProvider` installs process-global timers it never removes.** Unless `appStatusCheckInterval: false` is passed, construction registers a `setInterval` and a `visibilitychange` listener, with no teardown; constructing the provider more than once per page accumulates them.
|
||||
|
||||
**One session per page.** The token lives at a fixed `localStorage` key, so two concurrent sessions cannot coexist.
|
||||
|
||||
**A list subscription does not report field changes.** A container notifies about membership only, so a member's own predicates changing is invisible to a list; the record has to be watched directly.
|
||||
|
||||
**`types` wins over `shapeTreeUri` when both are set** — the opposite of what the configuration type's own documentation states. Configure exactly one. Likewise, when several type registrations match a resource's types, the first encountered wins, with no stated ordering.
|
||||
|
||||
**Unregistered apps read and write nothing.** An ActivityPods app must hold an AppRegistration before any data operation succeeds, and keeping it current is the app's business, through `registerApp` and `getAppStatus`.
|
||||
|
||||
## Change policy
|
||||
|
||||
None is published. The package carries no engagement document, no stated versioning commitment and, at the pinned commit, no git tags — its released versions are visible only as `package.json` version bumps. Treat every upgrade as potentially breaking: re-read the surface against the new commit and re-stamp this document. Divergences found between this reconstruction and observed behaviour are defects in this document, to be fixed here.
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
type: usage
|
||||
summary: What Festipod consumes from @activitypods/refine-providers as one of its interchangeable Refine backends, and the conditions its own vocabulary and confidentiality model put on that backend
|
||||
against: 66616484b96fb2227cd8cdf02321aacfb4811365
|
||||
---
|
||||
|
||||
# usage_festipod — Festipod on `@activitypods/refine-providers`
|
||||
|
||||
Festipod is a mobile-first web application: users create **meeting points** grafted onto public events, and sign up to them. Its entire persistence goes through Refine, behind a **backend seam** that exists so the app can support several Refine backends and let the user switch between them at runtime. This package is one of the two backends targeted; the other is a Refine provider over NextGraph plus its polyfill. The seam's own contract — what a backend must answer before it can be added — lives in code, in `src/shared/refine/providers/types.ts`.
|
||||
|
||||
Nothing is wired yet, so the list below is **declared, not measured**: it is what Festipod will call, derived from the seam's requirements and from the observation of the package pinned beside this declaration. `against:` names that observation's commit. The package publishes no engagement, so there is nothing to pull and nothing that can drift — what is recorded here is read against our own reconstruction, and re-read when it is re-stamped.
|
||||
|
||||
## Consumed surface
|
||||
|
||||
### Provider construction
|
||||
|
||||
- `authProvider({ clientId, redirectUri, appStatusCheckInterval: false })` — one call site, per backend selection. `appStatusCheckInterval` is disabled deliberately, see friction 1; Festipod drives the check itself.
|
||||
- `dataProvider({ authProvider, resources, jsonContext })` — one call site, handed the `authProvider` above.
|
||||
- `liveProvider({ authProvider, resources, jsonContext })` — same, so Refine can subscribe behind every query.
|
||||
|
||||
All three are built together, **per backend instance**, inside the seam's `build()`.
|
||||
|
||||
### Auth members
|
||||
|
||||
`login({ issuer, redirect })` and `register(...)` — Festipod supplies its own Pod-provider picker. `handleCallback()` from its own callback route. `logout()`. `check()`. `getSession()` for the `webId` and the token. `registerApp()` and `getAppStatus()`, the second one on Festipod's own schedule since the built-in interval is off.
|
||||
|
||||
### Resources and operations
|
||||
|
||||
Four resources, one per persisted entity: **events**, **meeting points**, **participations** and **user profiles**. Each is configured with `types` naming Festipod's own class URIs, and `jsonContext` carries the context that expands them.
|
||||
|
||||
Through Refine's hooks only, never the provider directly: `useList` → `getList`, `useCreate` → `create`, `useUpdate` → `update`, `useDelete` → `deleteOne`. `getOne` is reached by Refine's cache plumbing rather than an explicit call. The app mounts Refine with `liveMode: 'auto'` and `mutationMode: 'optimistic'`.
|
||||
|
||||
### Record handling
|
||||
|
||||
A record's `id` — the resource's URI — is the **only** identifier Festipod uses: the seam's `recordKey` and `domainId` both return it. `update` merging the fields it is given means the seam declares this backend as patching, and sends only what changed.
|
||||
|
||||
### Not consumed
|
||||
|
||||
- `shapeTreeUri` — exactly one of the two resource identifiers must be configured, and `types` names Festipod's classes directly, without a shape tree to host and keep reachable.
|
||||
- `getIdentity` — the signed-in identity and a Festipod user profile are two unrelated things in this app; the profile is read as an ordinary record, and only `getSession`'s `webId` is needed.
|
||||
- `getMany`, `getApiUrl`, `allowAnonymous`, and both Ant Design entry points — Festipod has its own interface.
|
||||
|
||||
## Constraints
|
||||
|
||||
What must keep holding for this backend to serve the app.
|
||||
|
||||
1. **Festipod's own vocabulary, not ActivityStreams.** The four entities are Festipod domain types defined by SHEX shapes this repo holds; none is an ActivityStreams type. A caller-supplied `jsonContext` and caller-supplied `types` are what make that possible, and both must stay caller-supplied.
|
||||
2. **The identifier a record is addressed by must stay the identifier other records reference.** The seam carries the two separately because they differ on the other targeted backend; here they coincide, and the app relies on that. Splitting them later would break every cross-entity reference silently.
|
||||
3. **A partial update must stay partial.** The app sends only changed fields. A move to replacing the resource would drop every field omitted, with no error.
|
||||
4. **Every participation must be readable by every user.** The participant count is the number of participations of an event, counted with no filtering. What is confidential is the **link between a participation and a person** — only a connection can read the profile a participation points at and recognise who it is — never the participation record itself. See need 1: this is the constraint the package does not currently let the app satisfy.
|
||||
5. **The count is never a stored, incremented field.** Reading a counter, adding one and writing it back derives each write from a read of the previous one and races itself through any data provider. This is measured, not theoretical, and it is why the app derives the count from the participation list at read time.
|
||||
6. **Changes must be pushed, not polled.** Neither the app nor its BDD suites has a polling loop, and adding one is ruled out by project doctrine. `liveMode: 'auto'` is mounted on that basis.
|
||||
7. **No process-global state that outlives a remount.** Two backends coexist in one page and the user switches between them at runtime; switching remounts Refine to discard the cache of the backend being left. Building this backend's providers must have no side effect that accumulates across switches — see friction 1, which is where this currently bites.
|
||||
8. **Each entity carries a declared visibility** — public, protected or private, per the product's confidentiality model — and it is the backend that honours it. Refine has no notion of it, so the intent is declared at the seam and enforcement is the backend's.
|
||||
|
||||
## Frictions
|
||||
|
||||
1. **`authProvider` installs a timer and a listener it never removes.** Construction registers a `setInterval` and a `visibilitychange` listener with no teardown. Festipod builds providers once per backend selection, so every switch to this backend would accumulate another pair — directly against constraint 7. Workaround carried: pass `appStatusCheckInterval: false` and call `getAppStatus()` on the app's own schedule. That works, but it means the package's own staleness handling is unusable here, and the only way to opt out is to opt out entirely.
|
||||
2. **`getList` fetches the whole container on every call.** Filtering, sorting and pagination run in memory afterwards, so the cost of listing an event's participations grows with the user's total number of participations rather than with the event. No workaround; it is why a filter on a `getList` buys nothing beyond convenience.
|
||||
3. **A list subscription reports membership only.** A container channel delivers `Add`/`Remove`, not a member's own fields changing, so a list of events does not refresh when an event's title, place or time is edited. Watching each record instead would open one channel per row. The event list is exactly the screen where this is visible.
|
||||
4. **`types` takes precedence over `shapeTreeUri`, the opposite of what the configuration type documents.** The declared behaviour is that `types` is "only used as a fallback when `shapeTreeUri` is not set"; a resource setting both is in fact resolved from `types`. Establishing which one actually wins cost a read of the resolution path. Configuring exactly one avoids it, but the documentation should say which wins.
|
||||
|
||||
## Needs
|
||||
|
||||
1. **A read that is not scoped to the logged-in user's own Pod.** Every container resolution — in `getList`, in `create`, and in a list subscription — runs against the session's own WebID. So `getList('events')` returns only the events the current user created, and `getList('participations')` only their own participations.
|
||||
|
||||
This blocks the product, not a detail of it. Festipod exists to let a user **discover other people's public events** and see **how many people have signed up** to a meeting point. Through this provider the app can show neither: no other user's event is reachable, and the participant count of constraint 4 cannot be computed from participations it cannot read.
|
||||
|
||||
The request is modest in shape, because the capability already sits one layer down: the package's own TypeIndex discovery takes a WebID as a parameter, and notes that the public TypeIndex is readable without any special grant. What is missing is a way to reach it **through the data provider** — a way to scope a `getList` (and the matching list subscription) to a given WebID, or to a set of them. Festipod has no interest in reimplementing container resolution beside the provider; that would put the app back in the business the provider exists to hold.
|
||||
|
||||
2. **Releases that can be pinned, and a statement of what changes between them.** The package carries no git tags, so the only thing this repo can pin is a commit on a moving branch, and the only way to learn what an upgrade changed is to re-read the source. Tagged releases plus a note of what moved in each — in whatever form suits the project — would let a consumer upgrade deliberately instead of re-observing from scratch every time.
|
||||
@@ -1,186 +0,0 @@
|
||||
---
|
||||
type: brief
|
||||
summary: Implementation design (historical) — make reads REACTIVE via doc_subscribe (per-document, without the ORM fan-out that hangs) and replace the mutated-in-place participantCount with the Option-B flow (the participant deposits into the event's inbox, the owner materializes and increments their own doc). READ IN THIS LIGHT — the app-side doc_subscribe wiring was later SUPERSEDED by the SDK's watchShape/useShapeQuery surface, and the « reactive with no reload » framing was RETRACTED for « the owner processes their inbox at their next connection »; Option B (P4-P5) is still pending
|
||||
---
|
||||
|
||||
# Reactive reads + correct participant count (Option B)
|
||||
|
||||
Implementation brief, anchored in the current code. Goal: two coupled evolutions of Festipod's data layer (connected mode / `@ng-eventually/client`).
|
||||
|
||||
1. **Cross-session reactive reads** — replace the one-shot `readUnion` + `bumpRead` (manual re-query, local-only) with real broker-pushed reactivity, **never polling** and **without the ORM fan-out that hangs**.
|
||||
2. **Correct participant count (Option B)** — remove the current isolation violation (the participant writes `participantCount` on the event doc, which is not theirs) and replace it with the inbox-deposit → owner-materialization flow.
|
||||
|
||||
This brief describes **what to build and in what order**. No code change is made here.
|
||||
|
||||
Cross-cutting references: [[knowledge_context-internals]], [[rule_document-per-entity]], [[caveat_participation-deletion]], `functional-domain/knowledge_data-scopes-and-discovery`, `app-security/knowledge_trust-model`, and the `@ng-eventually/client` SDK contract (`docs/sdk-reference.md`, `docs/read-model.md`, `docs/nextgraph-current-state.md`).
|
||||
|
||||
---
|
||||
|
||||
## 0. Current state (the starting point, file:function)
|
||||
|
||||
### Reads (one-shot, manual re-query)
|
||||
`src/shared/context/FestipodDataContext.tsx` → `useNgData()`:
|
||||
- The set of docs to read **on demand** is two `useState`s: `publicDocs` / `protectedDocs` (lines 232-233). It is fed by (a) the listing effect (lines 302-332) which calls `listMyEntityDocs(owner, 'public'|'protected')` (bounded to my own account) + `readDiscoveredEvents()` (the global index), and (b) `registerDoc(scope, nuri)` (lines 251-255) which adds a freshly created doc.
|
||||
- The **actual read** (lines 347-364): `readEntities(allReadDocs)` → `readModel.readUnion(docs)` (one `sparql_query` anchored per doc, in parallel, per-doc tolerant). It **re-runs** when `allReadDocs` changes **or** when `readTick` changes.
|
||||
- `readTick`/`bumpRead` (lines 236-237) = a **manual re-query signal**, bumped after every mutation. **There is NO signal coming from the broker**: a write made by ANOTHER session never increments this session's `readTick` → **no cross-session reactivity**. That is the gap this brief fills.
|
||||
- `listTick`/`relist` (lines 246-247) replays the listing effect after a seed.
|
||||
|
||||
### Writing the counter (the violation to remove)
|
||||
- `joinEvent` (lines 597-668): after writing its own `Participation` (protected doc, lines 621-631), it calls `updateEntityField(eventId, eventId, 'participantCount', int(next))` on **the event's doc** (lines 635-640) — but that doc belongs to the **event's owner**, not to the participant. That is an out-of-scope write. It *also* deposits into the inbox via `depositRegistration` (line 652) — that deposit is the right channel; it is the direct `participantCount` write that must go.
|
||||
- `leaveEvent` (lines 670-712): symmetrically, decrements `participantCount` on the event's doc (lines 705-710) after the authoritative DELETE of the participation.
|
||||
- `caveat_participation-deletion`: the participation DELETE must remain **authoritative** (SPARQL DELETE-WHERE via `deleteParticipation`, `src/shared/data/registration.ts` lines 260-334, verified `remaining === 0`) — this brief does not change that contract.
|
||||
- [[knowledge_context-internals]] already documents that `participantCount` is a **cache mutated in place**, never recomputed, and "not a source of truth". Option B turns it into a value **derived and owned by the owner**.
|
||||
|
||||
### Display (already "count + anonymous", to be kept)
|
||||
`src/modules/event/screens/EventDetailScreen.tsx`:
|
||||
- `joined = isParticipating(eventId)` (line 20).
|
||||
- `participants = getEventParticipants(eventId)` (line 21) → in the context, `getEventParticipants` (FestipodDataContext lines 108-111) filters the known `participations` by `eventId` and joins the **readable** `users` (so only my connections, per the protected cap).
|
||||
- `knownParticipants = participants.filter(p => p.id !== currentUserId)` (line 33).
|
||||
- The label **« Participants ({event.participantCount}) »** (line 146) displays the **derived count**, and `knownParticipants.length < event.participantCount` renders the **« voir tous les participants » placeholders** (lines 163-170) — exactly the intended "count + anonymous" model. **This display does not change**: Option B only makes `participantCount` correct and reactive, and `knownParticipants` remains governed by the protected read cap.
|
||||
|
||||
### The lib's polling watchers (to be replaced)
|
||||
Confirmed by reading the lib (`packages/client/src/`):
|
||||
- `inbox.watch(target, onDeposits, {intervalMs=1000})` (`inbox.ts:195-223`) = **`setInterval` polling**, firing only when `deposits.length` changes.
|
||||
- `discovery.watchIndex(onEntries, {intervalMs=1000})` (`discovery.ts:163-187`) = the same **`setInterval` polling**.
|
||||
- `useShape` (`use-shape.ts:12`) IS push-based/reactive, but **only safe on ONE already-open document** — the `graphs:[…]` fan-out hangs (§2).
|
||||
- **No `doc_subscribe` wrapper is exposed today** in `docs.ts` (which only exposes `docCreate` / `sparqlUpdate` / `sparqlQuery`). The `ng.doc_subscribe` primitive is reachable *untyped* through the `ng` proxy (`ng-proxy.ts:54-56` passthrough), but there is **no typed layer** → **the lib must add one** (§A).
|
||||
|
||||
---
|
||||
|
||||
## 1. The platform primitives (nextgraph-rs, verified)
|
||||
|
||||
- `doc_subscribe(repo_o: String, session_id, callback)` (`sdk/js/lib-wasm/src/lib.rs:1907`) is **per-document**: a single repo NURI, a single callback. It mounts a subscription on **one branch** of the doc (`verifier.rs:352` `create_branch_subscription`), first pushes a `TabInfo` + initial `State` (`verifier.rs:470-477`), then a stream of `Patch`es on every commit.
|
||||
- The push: on every verified transaction on a branch B, the verifier calls `push_app_response(&B, AppResponse::…)` (`verifier.rs:252`) on the `Sender` registered in `branch_subscriptions[B]` (`verifier.rs:115`). **Unit of subscription = one branch of one doc.**
|
||||
- The **ORM fan-out** lives elsewhere: `orm_start_graph(scope.graphs[], …)` (a single call over an array). There, a **single** unsynchronized repo in the array makes `open_for_target → resolve_target` return `RepoNotFound` (`request_processor.rs:147-171`, and above all the `initialize.rs:125-128` loop where the `?` **aborts the whole subscription**). The `readyPromise` then never resolves → **~75s hang** (`nextgraph-current-state.md` § *The ORM fan-out hang*, quoted in `read-model.md:93-98` and the header of `read-model.ts:24-31`). **Corollary: per-doc `doc_subscribe` does NOT have this flaw** — it is not subject to fan-out, so a missing doc only breaks its own subscription.
|
||||
- **Writes are membership-bound, with no append** (confirmed, `repo.rs:584` `verify_permission`: a non-member author → `PermissionDenied`; `commit.rs`: a transaction requires `WriteAsync`/`WriteSync`, obtainable only via a grant from the owner; **there is no `Append` variant in `PermissionV0`**). ⇒ **Option A is impossible**: a participant cannot write to / increment a counter on someone else's public doc. Hence Option B through the inbox.
|
||||
- **The inbox is a real platform primitive** (`server_broker.rs:826` `inbox_post`: no membership check on the sender; the message is sealed to the inbox's key, readable only by the registered *readers*). That is exactly the "anyone deposits, only the owner drains" channel. Today the lib emulates it over the shared wallet (`inbox.ts` post/read RDF), the native one being deferred.
|
||||
|
||||
---
|
||||
|
||||
## A. Reactive reads — the design
|
||||
|
||||
### Principle: per-doc `doc_subscribe` as a **change signal**, `readUnion` remains the reader
|
||||
We do **not** make `readUnion` reactive and we do **not** introduce an ORM fan-out. We keep the documented pattern (`read-model.md:100-110`):
|
||||
|
||||
> a lightweight reactive subscription (`doc_subscribe`, or the ORM on a single already-open store — never a per-entity fan-out) on the synchronized docs; on its change signal, replay the bounded set of per-doc `sparql_query` calls (`readUnion`).
|
||||
|
||||
Concretely:
|
||||
|
||||
1. **The lib exposes a typed `doc_subscribe` wrapper.** It does not exist today. Add to `packages/client/src/docs.ts` (or a new `subscribe.ts`) a function, e.g.:
|
||||
```ts
|
||||
// returns an unsubscribe; onChange called on the initial State then on every Patch
|
||||
export function subscribeDoc(nuri: Nuri, onChange: (r: AppResponse) => void): () => void
|
||||
```
|
||||
which wraps `ng.doc_subscribe(nuri, sessionId, cb)` and normalizes the AppResponse (initial + patches) plus stream teardown. It is **per-document** (a single NURI), hence immune to the fan-out hang.
|
||||
- Also expose a helper to subscribe to **a set** of docs by mounting **one subscription per doc** (a `nuri → unsubscribe` map), with **per-doc error isolation**: a `RepoNotFound` / unsynchronized doc only fails ITS OWN subscription (retry/skip), never the others. That is the key point that avoids reproducing the fan-out. The SDK contract (`sdk-reference.md`) will need to document this wrapper.
|
||||
|
||||
2. **The data context (FestipodDataContext) mounts a per-doc subscription over the set it already reads.** The `allReadDocs` set (union of `publicDocs` ∪ `protectedDocs`) is already bounded and on-demand. A new effect in `useNgData()`:
|
||||
```
|
||||
useEffect(() => {
|
||||
const unsubs = allReadDocs.map(nuri => subscribeDoc(nuri, () => bumpRead()));
|
||||
return () => unsubs.forEach(u => u());
|
||||
}, [allReadDocs]);
|
||||
```
|
||||
→ on **any** patch of a subscribed doc (written by THIS session OR another one), `bumpRead()` re-triggers the existing `readUnion` (lines 347-364). **`readTick`/`bumpRead` stay** — they stop being "manual after my own mutation" and become "pushed by the broker". The shape of the context (`events`/`users`/`participations` values in `useState`) **does not change**; screens keep reading through `useFestipodData()` unmodified.
|
||||
|
||||
3. **NEW docs entering the subscribed set, without a fan-out hang:**
|
||||
- **A newly discovered event**: reactive discovery replaces `discovery.watchIndex` (setInterval) with a **`doc_subscribe` subscription on the global index doc** (the index inbox, a single doc — `resolveInboxAnchor`-style). On every patch of the index → re-read `readDiscoveredEvents()` → the new `doc` NURIs enter `publicDocs` (via `setPublicDocs`), which **grows `allReadDocs`**, which **remounts the per-doc subscription** (the new `useEffect` above) → the new event is read AND from then on subscribed. No fan-out: each doc is subscribed **individually**, as it enters.
|
||||
- **A new inbox deposit** (new participant, host notification): likewise, replace `inbox.watch` (setInterval) with a **`doc_subscribe` subscription on the relevant inbox doc** (a single doc). A patch → re-materialize (§B).
|
||||
- **A doc I just created**: `registerDoc` keeps adding it to `publicDocs`/`protectedDocs` → it enters `allReadDocs` → it gets subscribed. (An immediate `bumpRead` keeps perceived local latency at zero.)
|
||||
|
||||
4. **The lib replaces its polling watchers**: `inbox.watch` and `discovery.watchIndex` become `doc_subscribe` wrappers on the inbox doc / index doc respectively (one doc each — no fan-out). The public signature is preserved (callback + unsubscribe) so callers do not break; the implementation moves from `setInterval(read)` to `subscribeDoc(anchor, () => read().then(onX))`.
|
||||
|
||||
### What does NOT change
|
||||
- `readUnion` stays one-shot, per-doc, tolerant (a failing doc → `[]`, never an abort).
|
||||
- The `readEntities` mapping (`src/shared/data/readEntities.ts`) is unchanged.
|
||||
- **No per-entity `useShape({graphs:[…]})` is introduced** — the only remaining `useShape` is the test harness's `FanoutProbe` (whose very purpose is to *demonstrate* the hang), not an application path.
|
||||
|
||||
---
|
||||
|
||||
## B. Participant count — Option B (deposit → owner materialization)
|
||||
|
||||
### The documents / inboxes involved
|
||||
- **The participant's participation doc**: protected, **owned by the participant** (already created by `joinEvent`, `createEntityDoc(owner,'protected')` + `writeEntity(ENTITY_TYPE.participation, …)`). Readable in plaintext only by the participant's **connections** (protected cap + `declareConnections`).
|
||||
- **The event's inbox**: resolved by `hostInboxNuri(eventId)` → `resolveInboxAnchor()` (today a single anchor; after migration, one inbox doc per event — `hostInboxNuri` already reserves the `eventId` param). That is where the participant **deposits the participation link**.
|
||||
- **The event's doc**: public, **owned by the owner**. It is **the owner** who writes `participantCount` there — never the participant.
|
||||
- **A (reference) recorded by the owner**: an entry linking the incremented count to the deposit (idempotence + audit); it can live in the event's doc (reference to an already-materialized deposit) or in a protected doc of the owner's.
|
||||
|
||||
### The flow (who writes what)
|
||||
1. **Participant — `joinEvent`** (modified):
|
||||
- Writes its own `Participation` (protected, theirs) — **unchanged**.
|
||||
- **Deposits into the event's inbox** a `{ kind:'new-participant', eventId, participationDoc, participantId, uid }` payload via `depositRegistration` (today `inbox.post(target, {from:null, payload})`, `registration.ts:110-125`). `from` stays anonymous at the transport level (the SDK binds `from` to the identity and rejects a spoof — see `registration.ts:106-108`); the domain identity travels in the payload. **The deposit carries the NURI of the participation doc** (`participationDoc`) so that the owner, if they are a connection, can read it in plaintext.
|
||||
- **REMOVES the `participantCount` write on the event's doc** (current lines 635-640). The participant never writes to someone else's doc again.
|
||||
2. **Owner — materialization (when connected)**: the owner's session is subscribed (`doc_subscribe`, §A.3) to their event's inbox doc. On a new `new-participant` deposit:
|
||||
- dedup via `uid` (idempotence: do not re-count an already materialized deposit — check the recorded (reference));
|
||||
- **increments `participantCount` on THEIR OWN event doc** (`updateEntityField(eventDoc, eventDoc, 'participantCount', int(next))`) — **the owner writing their own doc**, not a read privilege nor an out-of-scope write;
|
||||
- records the **(reference)** of the materialized deposit (idempotence marker).
|
||||
- This logic replaces/extends the existing **notification materialization** effect (FestipodDataContext lines 443-479, `readRegistrationNotifications`): today it only surfaces notifications; it also becomes the point where the counter is incremented. The trigger moves from implicit polling to the `doc_subscribe` subscription on the inbox.
|
||||
3. **Other sessions see the count change**: the event's doc is **public**, so **every** session that has it in its `allReadDocs` is subscribed to it (§A). The owner's write produces a patch → `bumpRead()` → `readUnion` re-reads → `event.participantCount` updated → `EventDetailScreen` re-renders « Participants (N) » **with no reload and no user action**. That is the complete reactive path, cross-session.
|
||||
|
||||
### Withdrawal (symmetric, authoritative)
|
||||
- `leaveEvent`: keeps the **authoritative DELETE** of the participation (`deleteParticipation`, verified `remaining === 0`) — [[caveat_participation-deletion]] intact (it must not come back to life).
|
||||
- **Removes the direct decrement** of `participantCount` by the participant (lines 705-710). Instead, the participant **deposits a `leave`** (`{ kind:'leave-participant', eventId, uid }`) into the event's inbox; the owner materializes → **decrements their own doc** (idempotent via `uid`, `max(0, n-1)`, and refuses to re-decrement an already processed `uid` so as not to "resurrect" a wrong count).
|
||||
- **Owner-offline case = eventual behaviour, ACCEPTED**: if the owner is not connected, the deposit stays in the inbox; the count is **not** updated until they reconnect and materialize. **This is accepted behaviour** (eventual consistency, local-first). Others see the count correct itself when the owner comes back. To be stated as such in the product contract.
|
||||
|
||||
### Identity (C)
|
||||
- A participant is shown **by name** only if the viewer is a **connection** of that participant: the participation doc + the participant's profile are protected, so they are readable in plaintext only through the cap granted by `declareConnections` (`src/shared/utils/connections.ts` → `grantRead(protectedDocsOf(owner), neighbour)`). Otherwise the doc stays unreadable → the participant does **not** appear in `getEventParticipants` (which joins on the `users`/`participations` that were read) → they fall into the **« inconnu » placeholders** of `EventDetailScreen` (lines 163-170), the derived count remaining visible through `participantCount`.
|
||||
- **No privileged read for the host**: the owner does not read participations; they only **count deposits** and write their own counter. They see a named participant only if they are a connection of theirs — exactly like any other viewer. This matches `functional-domain/knowledge_data-scopes-and-discovery` ("identified if known, anonymous otherwise") and `app-security/knowledge_trust-model` (no application-level access control; isolation is per-document and delegated to the SDK).
|
||||
|
||||
---
|
||||
|
||||
## D. Test plan (real e2e, no polling)
|
||||
|
||||
### D.1 — POLYFILL low-level: `doc_subscribe` really does react
|
||||
Goal: prove the reactive primitive works, independently of Festipod.
|
||||
- Location: a unit/integration test of the lib (`packages/client`) — or a Festipod `@data` test if the broker harness is required.
|
||||
- Setup: two "views" of the **same** doc (two subscriptions, or one subscription plus a write through another path). Mount `subscribeDoc(nuri, onChange)`, write to the doc via `sparqlUpdate`.
|
||||
- **Assertion**: `onChange` is called (initial State) **and then** called again after the write, **without polling** (no `setInterval`; the assertion waits on an event, not on a timeout). Check that a write on **another** doc does NOT trigger `onChange` (per-branch isolation). Check that an unsynchronized doc which fails **does not abort** the other subscriptions (per-doc).
|
||||
|
||||
### D.2 — FESTIPOD app-level: 2 real browsers, with no reload and no action from A
|
||||
Goal: B signs up → A's `EventDetailScreen` shows `participantCount` incremented **and** an "unknown participant", **without A reloading or acting**.
|
||||
- Extend `src/modules/event/features/e2e-multibrowser.feature` (`@multibrowser @shared-wallet`) and `src/modules/event/steps/e2e/multibrowser-features.steps.ts`.
|
||||
- New scenario (French Gherkin sketch):
|
||||
```
|
||||
Scénario: Un participant apparaît réactivement dans l'autre navigateur sans reload
|
||||
Étant donné un navigateur "A" avec le wallet partagé
|
||||
Et un navigateur "B" avec le wallet partagé
|
||||
Et le navigateur "A" charge l'application via le broker
|
||||
Et le navigateur "B" charge l'application via le broker
|
||||
Et le navigateur "A" est connecté à NextGraph
|
||||
Et le navigateur "B" est connecté à NextGraph
|
||||
Et le navigateur "A" crée l'événement "Apéro réactif"
|
||||
Et le navigateur "A" ouvre le détail de l'événement "Apéro réactif"
|
||||
Et le compteur de participants affiché dans "A" pour "Apéro réactif" vaut 1
|
||||
Quand le navigateur "B" s'inscrit à l'événement "Apéro réactif"
|
||||
Alors sans recharger, le compteur de participants affiché dans "A" pour "Apéro réactif" passe à 2
|
||||
Et le navigateur "A" affiche un participant "inconnu" pour "Apéro réactif"
|
||||
```
|
||||
- **Exact assertions**:
|
||||
1. `participantCount` **on A's side** goes from 1 to 2 — asserted via `frame.waitForFunction` on the context's reactive state (`__testData.events` → the event → `participantCount === 2`) **and then** confirmed on the rendered DOM (the « Participants (2) » label of `EventDetailScreen`), **with no `loadAppInBrowser`/reload call** between B's join and A's assertion.
|
||||
2. **Unknown placeholder**: `knownParticipants.length < participantCount` → assert the presence of the « Voir tous les participants » block (or an anonymous count = `participantCount − knownParticipants.length ≥ 1`), B not being a connection of A → not named.
|
||||
3. **Negative, no-polling**: the 1→2 transition arrives through the subscription (event-driven); the test waits on the event, and must not depend on a fixed `waitForTimeout` as the *source* of the update (a guard timeout remains tolerated to let the broker sync, as in the existing withdrawal scenario, line 131).
|
||||
- **Harness helpers required** (in `harness-ng.tsx`, exposed on `window.__testData`, and replicated in BOTH harnesses — see `bdd-testing/cookbook_add-scenario`):
|
||||
- a getter for an event's reactive `participantCount` (already reachable via `__testData.events`).
|
||||
- a way to reach A's **rendered** `EventDetailScreen` **without manual navigation**: either mount the real app on the detail route (the @e2e path), or expose `knownParticipants` / the anonymous count. Reuse `createEventReal` (line 232), `appJoinEvent` (line 245), `readInboxDeposits` (line 283), `authParticipationCount` (line 302).
|
||||
- a "the owner has materialized" hook: since A is the owner AND connected, their inbox subscription must increment their own doc — the test observes the outcome (count 2) without driving materialization by hand.
|
||||
- **Withdrawal symmetry**: extend the existing scenario « la désinscription ne ressuscite pas » (lines 36-48) with a reactive assertion: after B's leave, `participantCount` on A's side **goes back to 1 without a reload**, and `authParticipationCount === 0` (already covered).
|
||||
|
||||
---
|
||||
|
||||
## E. Risks / open questions
|
||||
|
||||
1. **The fan-out hang** (risk #1). The design avoids it **by construction**: **per-document** subscription (`doc_subscribe`), never `orm_start_graph(graphs:[…])`. To be kept as an invariant: every new doc enters through an **individual** subscription with per-doc error isolation — an unsynchronized doc must never be able to abort the other subscriptions nor block `readUnion` (which stays per-doc tolerant). Residual risk: the **volume** of per-doc subscriptions (one per doc read) — to be validated against the real broker; failing that, cap/prioritize the subscribed docs (current event + its inbox + my own docs) rather than the whole union.
|
||||
|
||||
2. **Owner-offline count = eventual — DECIDED (2026-07-06).** As long as the owner is not connected, no deposit is materialized → `participantCount` stays stale for everyone else (the participation itself is persisted broker-side — nothing is lost, only the aggregate waits for the host to reconnect). Accepted for V1. **Later, a SERVICE will take over** when the owner is disconnected (the deferred `@ng-eventually/service` package — the "curator" mentioned in the lib's inbox docs): an always-available actor will materialize the inbox in the host's stead. No « N+ en attente » fallback in V1.
|
||||
|
||||
3. **Per-doc `doc_subscribe` — DONE (lib `c0498a6`).** The lib now exposes `subscribeDoc`/`subscribeDocs` (per-doc error isolation, no ORM fan-out), `inbox.watch`/`discovery.watchIndex` have moved to `doc_subscribe` (no more polling), and the contract is in `sdk-reference.md`. Validated against the real broker (the callback crosses the iframe RPC and fires on change). Remaining: wire the subscription into the app's read path (P3).
|
||||
|
||||
> **The SDK's reactive hooks** (clarification): NextGraph's React adapter exposes `useShape` (reactive RDF shapes) and `useDiscrete` (discrete CRDT docs) — there is no `useQuery`. The lib re-exposes `useShape`. For a UNION read over N docs (Festipod's case), `useShape`/the ORM in fan-out *hangs*; the app's reactive path therefore goes through `subscribeDocs` (per-doc) + a re-`readUnion`, possibly wrapped into a reactive read hook on the lib side (to be decided in P3).
|
||||
|
||||
Other points to settle:
|
||||
|
||||
> ⚠️ **REFRAMED + CORRECTED (2026-07-13).** The claim below, "Proven by the D.2 e2e … with no reload", was **FALSE** (the "green" came from a bloated wallet). But more importantly the framing "reactive / no reload / cross-session push" was an **OVER-FRAMING**: the real spec is **"the owner reliably processes their inbox at their NEXT CONNECTION"** (not a live real-time notification between two connected users). The bug fixed under that framing: the materializer read the inbox **before it had synced** (→ a memoized 0). Fix = inbox read **gated on a barrier** (`inbox.readSynced` = `ensureRepoOpen` + `read`) + triggering on connection + a single source of truth, `event.participantCount`. The `event/e2e-multibrowser.feature` scenario was **reframed as "at the next connection" and un-`@wip`'d, GREEN on a fresh profile** (a reconnection/re-materialization by A is the accepted mechanism). Details: [[knowledge_context-internals]] §participantCount. The phasing plan below must be re-read in that light ("no reload" is no longer the requirement).
|
||||
|
||||
- **Phasing order:** ~~(P1) lib: `subscribeDoc` + multi-doc variant + D.1 tests~~ **DONE (`c0498a6`)**; ~~(P2) lib: replace `inbox.watch`/`discovery.watchIndex` with `doc_subscribe`~~ **DONE (`c0498a6`)**; ~~(P3) app: wire the per-doc subscription into `useNgData` (pushed bumpRead) + reactive discovery~~ **DONE, then SUPERSEDED** — P3 first wired an app-side `subscribeDocs(allReadDocs, …)` effect + a reactive discovery effect on top of the one-shot `readUnion`. That app-side wiring **no longer exists**: the read path has since moved entirely behind the SDK surface (`watchShape` bound by `useShapeQuery`), with no doc set, no `bumpRead` and no per-doc subscription left in the app (verified 2026-07-28 — see [[rule_app-uses-sdk-surface-only]] and [[rule_document-per-entity]] §Reads). **Validation — the earlier « proven by the D.2 e2e, with no reload » claim is RETRACTED**: per the REFRAMED + CORRECTED box above, that green came from a bloated wallet, and « live cross-session push with no reload » was never the spec. What `e2e-multibrowser.feature` covers is the reframed contract — **the owner reliably processes their inbox at their NEXT CONNECTION** (un-`@wip`'d, green on a fresh profile). So P3 is delivered as *the app reads through a reactive SDK surface*, **not** as *a proven reload-free live push*. ; (P4) app: Option B join (remove the participant's counter write, owner materialization); (P5) app: symmetric Option B leave; ~~(P6) e2e D.2~~ **DONE with P3** (the reactive scenario above; the reactive withdrawal symmetry remains to be added with P5). P1→P3 deliver reactivity; P4→P6 the correct counter. P1–P3 can ship before P4–P6.
|
||||
- **Materialization idempotence**: the per-deposit `uid` (`RegistrationPayload.uid`, `registration.ts:56`) is the pivot; the (reference) recorded by the owner must be consulted before any increment/decrement so as never to double-count (sync replay) nor "resurrect" a count.
|
||||
- **Native inbox migration**: today the inbox is emulated over the shared wallet (`inbox.ts` post/read RDF). On migration to the native broker inbox (`inbox_post`/`inbox_pop_for_user`, sealed), the Option B flow **remains valid** (non-member deposits allowed, reads reserved to the *readers* = the owner), but the `subscribeDoc` wrapper on the inbox will have to target the native deposit-notification mechanism. To be checked at migration time.
|
||||
@@ -1,104 +0,0 @@
|
||||
---
|
||||
type: brief
|
||||
summary: Target model for sign-ups — a Participation READABLE by everyone (event ref + `active` boolean + cap-less did to the participant's profile), deposited into the event's inbox; the creator processes the inbox, dedups on the overlay without knowing who, files the reference into a Set on the event and PURGES the cancelled ones; count = Set.size with no filtering (accepted upper bound); only connections hold the profile cap and recognize the person. Supersedes Option-B (mutated counter + plaintext userId).
|
||||
---
|
||||
|
||||
# Brief (2026-07-20, revised 2026-07-27) — Set-based sign-ups
|
||||
|
||||
## The model
|
||||
|
||||
Laid down and refined by the PO on 2026-07-27. Everything is **keys and URLs** — no roles, no membership, no allow-list.
|
||||
|
||||
1. The participant creates a **Participation** object, **readable by everyone**, holding: the **reference to the event**, an **`active` boolean**, and a **cap-less did to their *protected* profile**. **Nothing else** — no description for now.
|
||||
2. They deposit the **Participation's did** into the **event's inbox**.
|
||||
3. The **creator** processes their inbox **automatically**, as soon as they are online.
|
||||
4. They **dedup** (see below) — **without knowing who the participant is**: they hold the profile's did, not its cap.
|
||||
5. They file a **reference** to the Participation into a **Set** carried by the event's document.
|
||||
6. Anyone reads **`Set.size`** → the number of participants.
|
||||
7. Someone **connected** to the participant holds their profile's cap, reads it, and **recognizes** the person.
|
||||
|
||||
**Withdrawal**: the participant sets `active` to false **on their own object**. The creator sees it when reading, and **purges** — removing the reference from the Set.
|
||||
|
||||
Three properties follow: **anonymous attendance by default** (even the creator cannot see who); **nobody modifies anyone else's sign-up** (only the participant holds the write key to their own object); **no plaintext `userId`** ever travels.
|
||||
|
||||
### The principle that holds it all together: the truth is in the object, messages are only hints
|
||||
|
||||
The object **controlled by the participant** is what counts. Any message — an inbox deposit, a purge notification — is only a **hint** that triggers a check, never an authority.
|
||||
|
||||
Consequence: **forgery becomes structurally harmless**. A fake « purge X » leads the creator to read X, find it still active, and do nothing. That is why inbox deposits **need not be signed** — which is just as well, since NextGraph does not offer that (see table).
|
||||
|
||||
### Why a flag rather than a deletion
|
||||
|
||||
A **deletion** is **not detectable** without the read key (VERIFIED: append-only, encrypted tombstone). A **readable** object carrying a **flag** transforms the problem: the cancellation no longer has to be *detected*, it is simply *read*. The blocker disappears instead of being worked around with a forgeable message.
|
||||
|
||||
### Why the identity pointer targets the existing profile
|
||||
|
||||
No need for a second document per participation: the participant's **protected profile** already plays that role, and their connections **already** hold its cap — that is the very definition of being connected. A third party sees an opaque did.
|
||||
|
||||
The advantage over an encrypted field inside the Participation: **adding a connection rewrites nothing**. The profile's cap is sealed to them once, durably. An encrypted field would require re-sealing to N recipients and rewriting the Participation on every new connection. *(Incidentally, an encrypted field is not a NextGraph primitive: the encryption granularity is the document, all-or-nothing.)*
|
||||
|
||||
## What this rests on — facts established in NextGraph
|
||||
|
||||
Verified by reading `nextgraph-rs`. Details and pointers live on the polyfill side (`docs/readcap-and-nuri-model.md`) — see [[rule_capture-nextgraph-findings]].
|
||||
|
||||
| Fact | Status | Role here |
|
||||
|---|---|---|
|
||||
| The **overlay** (`:v:`) is **store-scoped**, never document-scoped | VERIFIED | **The dedup key** |
|
||||
| A cap-less NURI **names without granting read access** | VERIFIED | The profile's did points without disclosing |
|
||||
| A cap is **sealed durably** to a recipient (no ACL re-declared) | VERIFIED | The profile's cap, sealed once to the connections |
|
||||
| Without the key, blocks remain **ciphertext** | VERIFIED | The creator genuinely cannot read the profile |
|
||||
| A **deletion** is **NOT** detectable without the key | VERIFIED | **Why this is a flag, not a deletion** |
|
||||
| An inbox deposit is **NOT authenticated** (anonymous sealed box) | VERIFIED | **Why messages must stay hints** |
|
||||
| Author signature verification **is not implemented** at runtime, and would require decrypting | VERIFIED | Rules out the « signed inbox deposit » alternative |
|
||||
|
||||
## The dedup: on exactly what
|
||||
|
||||
**Validated by the PO (2026-07-27).**
|
||||
|
||||
A NURI's `:v:` segment comes **not from the document** but from **its store**. And a person has a single store per scope. So **all their Participations carry the same `:v:`**, however many objects they create. That is what the creator dedups on: two references with the same `:v:` in the Set of a single event = the same person. **Without ever knowing who.**
|
||||
|
||||
This is the **robust** criterion — more so than the profile's did, which a participant could multiply by creating several profile documents in their store.
|
||||
|
||||
Design consequence: the Set is **keyed by `:v:`** — at most one reference per `:v:`. `Set.size` = the number of distinct `:v:` = the number of distinct people.
|
||||
|
||||
### The trade-off — a standing reservation, not to be lost
|
||||
|
||||
> **It lives in `app-security/`[[caveat_stable-overlay-pseudonym]]**, not here. This brief is meant to be dissolved when it graduates; the reservation must outlive it.
|
||||
|
||||
In short: this `:v:` is a **stable, permanent pseudonym** for the person, present in every cap-less reference to their documents. It does not say *who*, but a **single** cross-reference de-anonymizes their whole history **retroactively** — and **no way out exists** (no rotation is possible, VERIFIED). It is **the same bit of information** that makes it possible to dedup without reading and to trace from one event to the next: the two cannot be separated. Making the Participation public **increases the collection surface** for this pseudonym.
|
||||
|
||||
## Trade-offs deliberately accepted (PO, 2026-07-27)
|
||||
|
||||
- **No filtering on read.** The count is `Set.size`, **without** checking the `active` flags. We accept the **staleness risk**: a cancelled participation still counts until the creator has purged. `Set.size` is therefore an **upper bound**, exact once purged. *Rationale: keep reads simple and O(1).*
|
||||
- **Purging is the creator's job.** No curator service, no catch-up by readers.
|
||||
- **No description** in the Participation for now. *(To be reopened when the need arises: whatever we put there would become public.)*
|
||||
- **Creator offline**: the Set does not move until they have processed their inbox. Accepted.
|
||||
|
||||
## What changes vs the current implementation (Option-B)
|
||||
|
||||
What exists today ([[brief_2026-07-06_reactive-reads-and-attendance]]) derives a `participantCount` **mutated in place** from inbox markers carrying the **plaintext `userId`**.
|
||||
|
||||
- **Drop the `userId`** from inbox deposits → only the **Participation's did** remains.
|
||||
- **Count distinct references** (by `:v:`), no longer `userId`s.
|
||||
- **The mutated `event.participantCount` goes away**, replaced by `Set.size`.
|
||||
- **Identity resolution** now goes through **reading the profile** (hence through its cap), no longer through the marker.
|
||||
- **Withdrawal stops being a deletion** → `active` set to false + a purge by the creator. See [[caveat_participation-deletion]], whose requirement (« authoritative, must not come back ») still holds but changes mechanism.
|
||||
|
||||
Still valid as-is: **reactive reads**, **re-arming on reconnection**, and the **id-space fix** already shipped.
|
||||
|
||||
## Open points
|
||||
|
||||
- **Participation scope** — it becomes **public**, whereas current product doctrine places it in *protected* ([[knowledge_data-scopes-and-discovery]], concept `functional-domain`). That leaf describes **what is implemented**: do not change it until this brief has graduated, but **do update it at that point**.
|
||||
- **Recognition by connections** (step 7) — how the profile's cap gets sealed, and what happens to a broken connection (revocation is a coarse, non-retroactive re-key). Explicitly deferred to a second stage.
|
||||
- **Public reads are not recursive** — this is the principle the whole model rests on, and it deserves to be stated on its own: *an item in the **public** store is public — whoever has the URL reads the content.* But **not recursively**: public content may **reference** private content, and **that is exactly our case**. So the creator reads the Participation (public) and **cannot** follow the reference to the profile (protected). That is what yields both readability by the creator and anonymity towards them — with no additional mechanism.
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **Blocking**: the **polyfill's caps emulation**. Today `caps.ts` models an **ACL** (a set of principals per document) where the reality is **key possession**, and the content stays readable in plaintext (`sparqlQuery` and `inbox.read` bypass the filter). Until that is fixed, coding anonymity on the Festipod side would produce code that **claims** to isolate without isolating. Polyfill brief `2026-07-20-caps-emulation-alignment`, batch P1.
|
||||
- **Parked**: **identity terminology** (wallet / user / profile) — see `.project/to-discuss.md`.
|
||||
|
||||
## Status: model settled, implementation gated
|
||||
|
||||
The model is **settled** (PO, 2026-07-27) and its foundations are **verified**. What remains gated is the **implementation**: it is waiting on the polyfill's P1 batch. **Do not remove Option-B** in the meantime.
|
||||
|
||||
Links: [[brief_2026-07-06_reactive-reads-and-attendance]] (superseded), [[caveat_participation-deletion]], [[rule_capture-nextgraph-findings]], [[rule_document-per-entity]], app-security ([[caveat_stable-overlay-pseudonym]], [[brief_2026-05-18_authorization-matrix]], [[knowledge_trust-model]]), polyfill `readcap-and-nuri-model.md` + `docs/vision.md`.
|
||||
@@ -1,17 +1,17 @@
|
||||
---
|
||||
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)
|
||||
summary: Le type FpEventData et le seed portent startDate/endDate/startTime/endTime/themes, mais le SHEX Event ne les définit pas — ces champs sont silencieusement perdus en mode connected (NextGraph)
|
||||
last_checked: 2026-06-15
|
||||
---
|
||||
|
||||
# Caveat: event fields not persisted in connected mode
|
||||
# Caveat : champs d'événement non persistés en mode connected
|
||||
|
||||
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 only covers: `title, description, date, location, distance, participantCount, coverImage, hostName, hostInitials` (to be checked in the `.shex`).
|
||||
Le type app `FpEventData` (`src/shared/data/types.ts`) et le seed (`seedData.ts`) portent des champs **`startDate`, `endDate`, `startTime`, `endTime`, `themes`** — mais la **shape SHEX `Event`** (`src/shared/shapes/shex/festipodShapes.shex`) ne les définit **pas**. La shape ne couvre que : `title, description, date, location, distance, participantCount, coverImage, hostName, hostInitials` (à vérifier dans le `.shex`).
|
||||
|
||||
## Consequence
|
||||
## Conséquence
|
||||
|
||||
In **connected mode** (SDK), the mapping (`mapEvent` in `FestipodDataContext.tsx`) only reads/writes the shape's fields. Fields outside the shape are **silently lost**: filled with defaults, or empty. Yet screens **do display them** (e.g. `startTime`/`endTime` in `EventDetailScreen`) — so in demo mode (the local seed) they show up, but when connected they vanish. The discrepancy is only observable in actual use.
|
||||
En **mode connected** (NextGraph), le mapping (`mapEvent` dans `FestipodDataContext.tsx`) ne lit/écrit que les champs de la shape. Les champs hors-shape sont **silencieusement perdus** : remplis par des defaults ou vides. Or des écrans **les affichent** (ex. `startTime`/`endTime` dans `EventDetailScreen`) — donc en mode démo (seed local) ils apparaissent, mais en connecté ils disparaissent. Décalage observable seulement à l'usage.
|
||||
|
||||
## To fix it (if we want them persisted)
|
||||
## Pour corriger (si on veut les persister)
|
||||
|
||||
Add the fields to `festipodShapes.shex`, then `bun run build:orm`, and extend `mapEvent`. Until that is done, **do not rely on the date/time/theme fields in connected mode**.
|
||||
Ajouter les champs à `festipodShapes.shex` puis `bun run build:orm`, et étendre `mapEvent`. C'est aussi un prérequis de la modélisation complète du point de rencontre (cf. concept `nextgraph-platform`, [[brief_2026-05-21_fork-nextgraph-inbox]] §Couche 3). Tant que ce n'est pas fait, **ne pas se fier aux champs date/heure/thèmes en mode connecté**.
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: Withdrawing from a meeting point must be AUTHORITATIVE — once the Participation is deleted, it must not come back; after a real refresh, check that the sign-up is really gone on the data side
|
||||
last_checked: 2026-07-03
|
||||
summary: La suppression de Participation (leaveEvent) se fait via ngSet.delete() — le bug de non-persistance qui avait motivé SPARQL DELETE est en grande partie corrigé, mais la persistance peut rester partielle ; vérifier après refresh
|
||||
last_checked: 2026-06-15
|
||||
---
|
||||
|
||||
# Caveat: withdrawal must be authoritative
|
||||
# Caveat : suppression de Participation via `ngSet.delete()`
|
||||
|
||||
Business contract: when a user **withdraws** from a meeting point (`leaveEvent` in `src/shared/context/FestipodDataContext.tsx`), the `Participation` must be **durably deleted**. It must **not come back to life** after a resynchronization.
|
||||
**État actuel du code** (`src/shared/context/FestipodDataContext.tsx`, `leaveEvent` en mode NG) : la suppression d'une `Participation` se fait via **`participationsShape.ngSet.delete(ngPart)`** — pas via `ng.sparql_update()` DELETE WHERE.
|
||||
|
||||
## The pitfall
|
||||
## Histoire (important)
|
||||
|
||||
Reflecting the deletion only in the UI's reactive state is not enough: the sign-up can reappear if the deletion is not **persisted** on the data side. Withdrawal must therefore be **authoritative at the document level**, not merely at the display level.
|
||||
Une décision antérieure ([[decision_2026-03-17_sparql-delete-for-orm-objects]], **annulée le 2026-06-15**) imposait SPARQL DELETE car `ngSet.delete()` ne persistait pas (l'objet réapparaissait au refresh). Ce **bug du `@ng-org/orm` a depuis été en grande partie corrigé** : `ngSet.delete()` est redevenu le chemin utilisé.
|
||||
|
||||
**To check after any change to `leaveEvent`**: sign up, then withdraw, do a **real refresh**, and confirm that the participation is indeed gone (the button must not stay on « ✓ Je participe »). Covered by the `@e2e` scenario « Se désinscrire d'un événement » (`src/modules/event/features/cycle-de-vie-evenement.feature`) and a `@data` one, « désinscription persistante » (`inscription-inbox.feature`).
|
||||
## Le piège (pourquoi un caveat et pas une règle)
|
||||
|
||||
La correction **semble partielle** : selon les cas, la suppression via `ngSet.delete()` peut ne **pas se propager complètement** au broker. Donc :
|
||||
|
||||
- **Ne pas tenir pour acquis** que `leaveEvent` persiste à coup sûr — **vérifier après un vrai refresh** que la participation a bien disparu côté wallet.
|
||||
- Si une suppression se révèle non persistée, le repli connu reste `ng.sparql_update()` avec `DELETE WHERE { GRAPH <…> { <…> ?p ?o } }` (le mécanisme décrit dans la décision annulée). **Ne pas combiner** les deux (conflit CRDT — c'était l'autre enseignement de la décision).
|
||||
- Re-tester ce point à chaque montée de version de `@ng-org/orm`.
|
||||
|
||||
> À valider : ouvrir `FestipodDataContext.tsx` → `leaveEvent` (mode NG, `console.log('Deleting participation via ngSet.delete()')`). Si le code est repassé à `sparql_update`, mettre ce caveat à jour ou le promouvoir en règle.
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: An entity written just before an idle period / socket drop can be silently lost (never made durable broker-side); the account survives (no fork). Observed on Firefox. The SDK neither confirms durability nor reconnects on its own.
|
||||
last_checked: 2026-07-14
|
||||
---
|
||||
|
||||
# Pitfall: a write made just before a disconnect is not guaranteed durable
|
||||
|
||||
**Product symptom.** The user creates an entity (an event), it appears to succeed, then a **period of inactivity** follows; on reload / reconnection, the entity has **disappeared**. The scope reads back **empty**. The **identity/account survives** — this is NOT a fork, it is a write that was never made durable.
|
||||
|
||||
**Mechanism (summary, not settled).** The broker socket can die spontaneously while idle (`SOCKET IS CLOSED … SerializationError`). The write was in the local outbox; on return, the replay fails (`Err(TopicNotFound)`) and the entity is abandoned. **Observed on Firefox only** so far. A cold @data test (2026-07-14) also showed that a **fresh** session (no local state, same account A) does **not** recover A's own scope from the broker: the @data reconnection test that "passed" was in fact re-reading the **local** IndexedDB. Still to be settled: **loss at write time** vs **failure to rehydrate from cold** (two distinct mechanisms) — see the post-mortem in the polyfill.
|
||||
|
||||
**Why the app does not see it.** `NgStatus` is derived from the initial session **exactly once** → blind to drops that happen mid-session. The SDK's `disconnections_subscribe` channel does fire on the failure but **is not consumed** (neither by the polyfill nor by the app). No API confirms that a write reached the broker.
|
||||
|
||||
**Do not document NextGraph internals here.** SDK boundary (see [[knowledge_nextgraph-stack]]): the root cause, the causal chain (socket, reconnection still TODO) and the fix leads live in the `@ng-eventually/client` repo → `docs/incidents/2026-07-14-write-loss-on-disconnect.md`. This note keeps only the **consumer-side impact** + the pointer.
|
||||
|
||||
**Status: open, not addressed (2026-07-14).** To revisit when the core/SDK addresses reconnection or exposes a durability confirmation — this caveat will then fall away. See also the cold-read vs real-loss debate in [[brief_2026-07-06_reactive-reads-and-attendance]] (@data's `BARRIER timed-out` is a distinct signature, not confirmed to be this bug).
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
---
|
||||
type: decision
|
||||
summary: Décision 2026-03-13 — auto-init NextGraph seulement quand dans l'iframe broker (window.self !== window.top), sinon initNgWeb() redirige la page et casse le dev/démo standalone
|
||||
---
|
||||
|
||||
# Conditional NextGraph Init Based on Broker Iframe Detection
|
||||
|
||||
**Date:** 2026-03-13 14:00
|
||||
**Status:** Accepted
|
||||
|
||||
## Context
|
||||
|
||||
`initNgWeb()` de `@ng-org/web` teste `window.self === window.top`. En standalone (hors iframe), il redirige toute la page vers `nextgraph.net/redir/` pour déclencher l'auth broker. Résultat : l'app redirigeait à chaque chargement — même en dev ou quand l'utilisateur n'avait pas cliqué « Se connecter ».
|
||||
|
||||
## Options Considered
|
||||
|
||||
### Option A: toujours auto-init NG au mount
|
||||
- Plus simple (pas de branchement).
|
||||
- **Contre** : redirect immédiat vers le broker en standalone ; casse le workflow de dev ; l'utilisateur voit la page de login broker au lieu de l'app.
|
||||
|
||||
### Option B: auto-init conditionnel selon détection iframe
|
||||
- En iframe, le broker a déjà authentifié → auto-init sûr ; en standalone, l'utilisateur doit cliquer « Se connecter » ; préserve l'expérience démo/dev ; calque la propre logique de détection de `@ng-org/web`.
|
||||
- **Contre** : repose sur l'heuristique `window.self !== window.top` (théoriquement faillible si embarqué dans une iframe non-broker).
|
||||
|
||||
## Decision
|
||||
|
||||
**Option B.** `NextGraphContext` calcule `isInsideBroker = typeof window !== 'undefined' && window.self !== window.top` au niveau module. `useEffect` n'auto-appelle `initNg()` que si `isInsideBroker`. Le callback `connect()` reste disponible pour la connexion explicite. De plus, `FestipodDataContext` rend des données vides (pas le seed) pendant `connecting` pour éviter de flasher le contenu démo.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positif :** l'app charge sans rediriger (standalone dev/démo) ; en iframe broker, connexion fluide et automatique ; pas de flash de seed pendant la connexion.
|
||||
**Négatif :** aucun significatif.
|
||||
**Risque :** si `@ng-org/web` change sa logique de détection, notre garde peut diverger — les garder alignés.
|
||||
|
||||
> Règle dérivée : [[rule_conditional-ng-init]].
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
type: decision
|
||||
summary: Décision 2026-03-17 — utiliser private_store_id comme scope useShape ET @graph (calqué sur expense-tracker-rdf) pour que orm_start_graph ouvre le repo et que les écritures ne lèvent plus RepoNotFound
|
||||
---
|
||||
|
||||
# Use private_store_id as useShape scope and @graph
|
||||
|
||||
**Date:** 2026-03-17 16:00
|
||||
**Status:** Accepted
|
||||
|
||||
## Context
|
||||
|
||||
Cliquer « Charger données de test » chargeait les données en mémoire (signaux ORM) mais produisait des `RepoNotFound` sur `doc_create` et `orm_frontend_update`. Les données disparaissaient au reload car les écritures SPARQL n'atteignaient jamais le broker. La HashMap `self.repos` du verifier ne contenait pas le repo du private store → `resolve_target()` échouait.
|
||||
|
||||
## Options Considered
|
||||
|
||||
### Option A: `did:ng:i` scope + `doc_create` pour @graph
|
||||
- `did:ng:i` bien documenté comme scope d'abonnement, `doc_create` renvoie un vrai NURI.
|
||||
- **Contre** : `did:ng:i` passe par `NuriTargetV0::UserSite` qui n'ouvre pas les repos individuels ; `doc_create` appelle `resolve_target(PrivateStore)` qui exige le repo dans `self.repos` → échoue ; exige une logique de retry/timing complexe.
|
||||
|
||||
### Option B: `private_store_id` comme scope ET @graph
|
||||
- Calque exact de l'exemple `expense-tracker-rdf` qui fonctionne ; `orm_start_graph` avec le NURI du private store ouvre le repo dans `self.repos` ; les écritures `orm_frontend_update` trouvent ensuite le repo. Simple, sans retry.
|
||||
- **Contre** : un peu moins flexible que `did:ng:i` (scopé à un store) ; exige de passer la session à `useShapeWithDefaults`.
|
||||
|
||||
### Option C: `did:ng:i` scope + réutiliser le @graph d'une entité existante
|
||||
- Marche pour les users qui ont déjà des données.
|
||||
- **Contre** : échoue pour les wallets vides (aucune entité à réutiliser) ; retombe sur `doc_create` et le même `RepoNotFound`.
|
||||
|
||||
## Decision
|
||||
|
||||
**Option B** : `did:ng:${session.private_store_id}` comme scope `useShape` ET `@graph` d'écriture, exactement comme `expense-tracker-rdf`. `useShapeWithDefaults` accepte un `storeNuri` ; `FestipodDataContext.useNgData()` récupère la session via `useNextGraph()` et passe le NURI du private store. `ensureGraphNuri()` simplifié : entités existantes d'abord (optimisation), sinon fallback `private_store`.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positif :** écritures immédiates après connexion (sans retry) ; persistance au reload ; aligné sur les exemples officiels ; les 7 scénarios e2e passent (dont la persistance).
|
||||
**Négatif :** signature de `useShapeWithDefaults` modifiée (param `storeNuri`).
|
||||
**Risque :** si NextGraph change le comportement du private store, ça casse.
|
||||
|
||||
> Règle dérivée : [[rule_private-store-scope]]. Décision *remise en cause* par le futur multi-store : [[brief_2026-05-17_multi-store-refactor]].
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
type: decision
|
||||
summary: Décision 2026-03-17 — supprimer les objets ORM via ng.sparql_update (DELETE WHERE) seul, car ngSet.delete() ne persiste pas et les combiner crée un conflit CRDT
|
||||
---
|
||||
|
||||
# Use SPARQL DELETE instead of ORM ngSet.delete() for object removal
|
||||
|
||||
**Date:** 2026-03-17 18:00
|
||||
**Status:** ~~Accepted~~ → **Superseded (2026-06-15)**
|
||||
|
||||
> **Annulée le 2026-06-15.** Le bug de non-persistance de `ngSet.delete()` qui motivait cette décision a depuis été en grande partie corrigé côté `@ng-org/orm` : le code (`leaveEvent`) est repassé à `ngSet.delete()`. La persistance reste toutefois possiblement partielle — l'état courant et le repli SPARQL sont décrits dans [[caveat_participation-deletion]]. Décision conservée comme mémoire d'arbitrage (le conflit CRDT « ne pas combiner les deux » reste vrai).
|
||||
|
||||
## Context
|
||||
|
||||
Quitter un event exige de supprimer l'objet `Participation` du store NextGraph. `DeepSignalSet.delete()` met à jour l'état réactif local (UI immédiate) mais **ne persiste pas** au broker — après refresh, la participation réapparaît.
|
||||
|
||||
## Options Considered
|
||||
|
||||
### Option A: ORM `ngSet.delete(item)`
|
||||
- API officielle (README ORM), update réactif local instantané.
|
||||
- **Contre** : ne persiste pas en pratique (`delete()` renvoie `true`, set local à jour, mais objet de retour après refresh) ; `graph_orm_update` semble mal gérer les patches "remove" pour objets de set top-level (bug moteur probable) ; échoue silencieusement.
|
||||
|
||||
### Option B: `ng.sparql_update()` avec SPARQL DELETE
|
||||
- `DELETE WHERE { GRAPH <graph> { <subject> ?p ?o } }` retire tous les triples RDF.
|
||||
- **Pour** : persiste (survit au refresh) ; le broker confirme via `GraphOrmUpdate` remove qui retire réactivement l'item du set ORM ; contrôle direct.
|
||||
- **Contre** : pas instantané (round-trip SPARQL + callback broker, ~50ms) ; ne doit pas être combiné avec `ngSet.delete()`.
|
||||
|
||||
### Option C: les deux ensemble
|
||||
- **Ne marche pas** : le patch ORM `.delete()` et le DELETE SPARQL entrent en conflit CRDT → ni UI ni persistance.
|
||||
|
||||
## Decision
|
||||
|
||||
**Option B : SPARQL DELETE seul.** Le broker renvoie un `GraphOrmUpdate` `op: "remove"` qui retire réactivement l'item du set ORM (UI à jour, juste pas synchrone). **Ne pas** appeler `ngSet.delete()` à côté.
|
||||
|
||||
```typescript
|
||||
// FestipodDataContext.tsx leaveEvent():
|
||||
const session = await sessionPromise;
|
||||
await ng.sparql_update(
|
||||
session.session_id,
|
||||
`DELETE WHERE { GRAPH <${partGraph}> { <${partId}> ?p ?o } }`,
|
||||
partGraph,
|
||||
);
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positif :** suppression persistée ; source de vérité unique (broker → ORM → UI).
|
||||
**Négatif :** léger délai UI (~50ms) ; diverge des exemples README ORM.
|
||||
**Risque :** si `ng.sparql_update` change, ça casse ; toute future suppression doit suivre le même pattern ; revisiter si `ngSet.delete()` est corrigé en montée de version.
|
||||
|
||||
> État courant (la règle a été retirée) : [[caveat_participation-deletion]].
|
||||
@@ -1,98 +1,30 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Internal pitfalls of FestipodDataContext — currentUserId = a stable principal derived from the identifier, TWO id spaces joined through the normalized identifier (resolveParticipantUser / USER_PRINCIPAL_PREFIX), OPT-IN auto-seed (FESTIPOD_AUTO_SEED, OFF by default), Option-B derived participantCount (reliable at the owner's connection because it reads under the synced-view contract; single source = event.participantCount), session reset on identity change (overlay + caps), useShapeQuery instrumentation (spinner + timing) + identity-first logs, mutations that are no-ops in local mode despite the toast
|
||||
last_checked: 2026-07-27
|
||||
summary: Pièges internes de FestipodDataContext — currentUser NG résolu par username '@mariedupont' (fallback users[0]), auto-seed dev-only après 3s sans retry, participantCount muté en place (cache), currentUserId vide → IRI invalide, mutations no-op en mode local malgré le toast
|
||||
last_checked: 2026-06-15
|
||||
---
|
||||
|
||||
# Internals & pitfalls of `FestipodDataContext`
|
||||
# Internals & pièges de `FestipodDataContext`
|
||||
|
||||
Non-obvious behaviours of `src/shared/context/FestipodDataContext.tsx` to know about before touching the data context.
|
||||
Comportements non évidents de `src/shared/context/FestipodDataContext.tsx` à connaître avant de toucher au contexte de données.
|
||||
|
||||
## Resolving `currentUser` (NG mode)
|
||||
## Résolution du `currentUser` (mode NG)
|
||||
|
||||
In connected mode, the currentUser's **principal** (`currentUserId`) is **not** `CURRENT_USER_ID` ('user-1', local mode), nor the IRI of the profile that was read. When an identifier is logged in, it is an id **derived from that identifier and stable**: `urn:festipod:user:<normalized-identifier>`, available immediately (without depending on reading the protected profile) and invariant for the session — the same key used by `setCurrentUser`, the owner cap and the shim account (see [[rule_document-per-entity]], identity corollary). Remaining pitfalls:
|
||||
- The `currentUser` object (the displayed profile), by contrast, is resolved by `users.find(u => normalizeIdentifier(u.username) === identifiant)` with a **fallback** to `@mariedupont` then `users[0]` — a silent fallback if the identifier matches no profile (the identifier is a space id, not necessarily the `username` of a seeded profile).
|
||||
- With no identifier logged in (dev/demo), `currentUserId` falls back to the IRI of the profile that was read (or `''` if the wallet is empty → a `Participation` with `user: ''`, which is invalid): only create a participation once the principal is resolved.
|
||||
En mode connected, le currentUser n'est **pas** `CURRENT_USER_ID` ('user-1', qui ne vaut qu'en mode local). Il est résolu par **`users.find(u => u.username === '@mariedupont') || users[0]`** (vers ligne 286). Pièges :
|
||||
- **Fallback silencieux** sur `users[0]` si `@mariedupont` absent → currentUser arbitraire.
|
||||
- Si le wallet est **vide** (`users.length === 0`), `currentUserId` devient `''` → toute `Participation` créée a un `user: ''` (**IRI invalide**), sans alerte. Bug silencieux possible à la première connexion sur un wallet vierge.
|
||||
- L'IRI du currentUser diffère entre mode local (ID de seed statique) et mode NG (IRI NextGraph dynamique) — ne pas comparer les deux.
|
||||
|
||||
## TWO id spaces meet — joining a participation to its profile
|
||||
## Auto-seed de dev
|
||||
|
||||
**Invariant.** A `Participation` stores its user as a **principal** (`urn:festipod:user:<normalized-identifier>`, = `currentUserId`), whereas a `UserProfile` has as its `id` the **NURI of its document** (`did:ng:…`). In connected mode, **these two values are never equal**. So a raw `participation.userId === profile.id` join **never** matches — a symptom that shipped and was then fixed (2026-07-27): every participant displayed as « participant inconnu ». Every participation→profile join goes through **`resolveParticipantUser`** (`FestipodDataContext`), never through a direct comparison.
|
||||
Un auto-seed se déclenche (vers lignes 263-283) **uniquement hors production** (`process.env.NODE_ENV !== 'production'`), après un **`setTimeout` de ~3s**, si les sets events ET users sont vides. Pièges :
|
||||
- **Pas de retry** : `hasTriedAutoSeed` (useRef) est posé une fois ; si le seed échoue, jamais réessayé (écran vide, juste un `console.error`).
|
||||
- Le délai de 3s est **heuristique** : si l'hydratation ORM est lente, le seed peut partir alors que des données arrivent.
|
||||
|
||||
The **bridge** between the two spaces is the **normalized identifier**: `principal − prefix` == `normalizeIdentifier(profile.username)` (the same equality that resolves `currentUser`). Hence the order in which `resolveParticipantUser` tries: (1) a **direct match** `u.id === userId` — the demo seed's space, where both sides hold the same bare id (`user-1`) and where the seeded username `@mariedupont` would *not* normalize to that id, so the direct match must come first; (2) failing that, a **match on the normalized identifier** after stripping the prefix.
|
||||
## `participantCount` muté en place
|
||||
|
||||
**`USER_PRINCIPAL_PREFIX` is the single source of the prefix**, shared by the **write** side (deriving `currentUserId`) and the **read** side (`resolveParticipantUser`). If you change the shape of the principal, change it **there**: otherwise write and read drift apart silently and the join falls back to « inconnu » without raising an error.
|
||||
`joinEvent`/`leaveEvent`/`updateEvent` **mutent directement** `ngEvent.participantCount` (`+1`/`-1`) — c'est un **cache** du nombre de `Participation`, pas une valeur recalculée. Il peut **désynchroniser** des objets `Participation` réels (ex. après un crash, un rejeu, ou la suppression partielle décrite dans [[caveat_participation-deletion]]). Ne pas s'y fier comme source de vérité du nombre de participants.
|
||||
|
||||
A **third** id space exists and takes **no** part in this join: the inbox deposit `uid` (`mint…`) — it identifies a **deposit** for the counter, never a user.
|
||||
## Mutations no-op en mode local
|
||||
|
||||
> **Horizon.** This paragraph describes **what is implemented** (Option-B). 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 |
|
||||
|---|---|
|
||||
| `getUserEvents(userId)`, `isParticipating(eventId, userId?)`, `getFriends(userId?)` | **expect the principal** (they 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 |
|
||||
|
||||
**Screen-side impact**: filtering yourself out of a participant list compares against **`currentUser?.id`** (the profile NURI, the same space as the rendered items), **not** against `currentUserId` (the principal) — otherwise you do not remove yourself and you see yourself appear as one more participant. Conversely, passing a **profile id** to `getUserEvents`/`isParticipating` returns an **empty** list in connected mode. See `app-architecture`, [[caveat_identity-ids-in-screens]].
|
||||
|
||||
## Reads = `watchShape` (the SDK surface), no more bespoke machinery
|
||||
|
||||
**Since 2026-07-10**: `useNgData` reads through `useShapeQuery(shape, scope)` (a `useSyncExternalStore` binding over the polyfill's `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]].
|
||||
|
||||
**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]]). Cleared on identity change.
|
||||
|
||||
## Dev auto-seed
|
||||
|
||||
**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]]).
|
||||
|
||||
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 is **owned by the current identity** (`bootstrapWallet(…, owner)`): the seeded protected entities go through the owner's per-document read cap.
|
||||
- **No retry**: if the seed fails, you get an empty screen + a `console.error`.
|
||||
|
||||
## `participantCount` — derived and owned by the owner (Option B)
|
||||
|
||||
> ✅ **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 under the **synced-view contract** — `inbox.readSynced` instead of `inbox.read`, so a deposit already synced by another identity IS seen from a cold session (the two differ by contract, see [[knowledge_sdk-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]]).
|
||||
|
||||
**Since Option B (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 (that would be an isolation violation — the participant writing someone else's doc; NextGraph writes are membership-bound, with no append). 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 is subscribed (`inbox.watch`, `doc_subscribe`, no polling) to the inbox of the events it owns (`ownedEventIds` = `listMyEntityDocs(owner,'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 broker sync 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/deposits stay persisted — nothing is lost; a future service will materialize in their stead).
|
||||
- The counter nevertheless remains an **aggregate**, not the list of named participants: `getEventParticipants` (named identity) is still governed by the protected read cap ([[caveat_participation-deletion]] for the authoritative deletion, unchanged). See the brief `brief_2026-07-06_reactive-reads-and-attendance` §B.
|
||||
|
||||
### Id-form invariant: match on the CANONICAL form of the event id
|
||||
|
||||
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.
|
||||
|
||||
**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.)
|
||||
|
||||
**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.
|
||||
|
||||
## Identity change = a fresh session (isolation)
|
||||
|
||||
> **History of the symptom** (the paragraph that follows describes the setup of the time — the bespoke read set `publicDocs`/`protectedDocs`/`readTick` **no longer exists** since the move to `watchShape`). It is kept because it explains *why* the reset rule exists; the **current mechanism** is described further down.
|
||||
|
||||
The on-demand read set (`publicDocs`/`protectedDocs`) **accumulated** the current identity's scope docs (so as not to lose a just-created doc before the re-listing). But the shared-wallet stopgap keeps **a single React tree** across a fake logout + re-login under a **different identifier** (no page reload — `AccountContext.login` merely rewrites the identifier in localStorage, and `AuthGate` remounts nothing). Without a reset, **the previous identity's PROTECTED docs (its participations) survive in the new identity's read set and leak** through the union read: the cap gate cannot filter them out when the (in-memory) cap registry does not govern that doc in *this* session (a doc persisted from an earlier run, or a fresh load where the caps are empty). Symptom observed: a user B saw A's participation (and A's event appeared on B's **home screen**, since home = `getUserEvents(currentUserId)`, see concept `app-architecture`).
|
||||
|
||||
**Rule**: treat **any identifier change** as a **fresh session**. A `useEffect([identifier])`, **ref-guarded** (it does not fire on first mount, only on a genuine value change), resets **all session state carried by the app**. Isolation remains per-document/emulated (concept `app-security`, [[knowledge_trust-model]]); this reset only removes the carry-over of state between identities.
|
||||
|
||||
**Current mechanism** (since reads go through `watchShape`): the **read** side has nothing left to reset — `watchShape` re-resolves its scope against the new `getCurrentUser()` on the next push. What the effect clears is the **app-side** state: `ownedEventIds` (the owner materializer's set), the `joinUids` map (the current session's deposit uids), the **optimistic overlay** (`pendingAddEvents`/`pendingAddParticipations`/`pendingRemoveIds` — otherwise the old identity's mutations bleed into the new one's reads), then `resetCaps()` + `resetRegistryCache()`.
|
||||
|
||||
> **Impact — the invariant not to break**: **any new session state** added to the provider (a cache, a `useRef`, the overlay, a doc set) must be added to that effect. Forgotten state **leaks from one identity to the next** with no error — exactly the class of bug the regression guard below covers.
|
||||
|
||||
**Mechanism confirmed empirically (2026-07-07)**: the leak reproduces ONLY when TWO conditions coincide — (a) the read set still carries A's PROTECTED doc across the switch (no reset), AND (b) the in-memory cap registry does not govern that doc (`resetCaps()` already fired / caps empty for a doc persisted from a session earlier than the reload). Then A's participation makes it through B's union read (the per-document filter has no cap to check). With the reset fired, A's doc left B's read set BEFORE the cap-less read could expose it → no more leak whatever the state of the caps (at the time via `setProtectedDocs([])`; today it is `watchShape` that re-resolves the scope, and the reset now carries only the app-side state listed above). **Regression guarded** by the `@data` scenario « Une identité fraîche ne voit pas la participation d'une autre » (event/isolation-deux-identites.feature): A creates E and signs up to it, B (a fresh page on the same wallet, with a distinct identifier) has NEITHER E on their home screen (`getUserEvents(B)`), NOR `isParticipating(E,B)`, AND reads NO participation carrying A's principal. The historical symptom « B voit "Je participe" » mostly occurred when B **reused an identifier already used by A** (the same normalized principal) on a **bloated** wallet (docs persisted from an earlier run, empty caps).
|
||||
|
||||
## `useShapeQuery` instrumentation — global spinner + timing
|
||||
|
||||
`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.
|
||||
|
||||
## Logging convention — identity-first prefix, and 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: with the shared wallet, **two identities share the same console** (two tabs / a multi-browser run) — 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`.
|
||||
|
||||
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.
|
||||
|
||||
## 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.
|
||||
En mode local/demo (`useLocalData`), `createEvent`/`joinEvent`/`leaveEvent`/`updateEvent` sont des **no-ops** (`console.log`, l'état ne change pas) — mais les écrans affichent quand même un **toast de succès** (« Tu participes »). UX potentiellement trompeuse : l'utilisateur croit s'être inscrit alors que rien n'a changé. Voir [[knowledge_data-modes]] pour le choix du provider selon le statut.
|
||||
|
||||
@@ -1,28 +1,29 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Two modes (connected = the @ng-eventually/client SDK, disconnected/demo = seeded local state); FestipodDataContext picks the provider based on connection status, and every screen goes through useFestipodData()
|
||||
summary: Deux modes (connected = NextGraph ORM, disconnected/demo = état local seedé) ; FestipodDataContext choisit le provider selon le statut NextGraphContext, tous les écrans passent par useFestipodData()
|
||||
---
|
||||
|
||||
# Data modes & contexts
|
||||
# Modes de données & contextes
|
||||
|
||||
The app has **two modes**, both consumed through the `useFestipodData()` hook:
|
||||
L'app a **deux modes**, tous deux consommés via le hook `useFestipodData()` :
|
||||
|
||||
1. **Connected** — ORM shapes from the `@ng-eventually/client` SDK (P2P, encrypted, local-first)
|
||||
2. **Disconnected / Demo** — local React state seeded from `seedData.ts` (see [[knowledge_seed-data]])
|
||||
1. **Connected** — shapes ORM NextGraph (P2P, chiffré, local-first)
|
||||
2. **Disconnected / Demo** — état React local seedé depuis `seedData.ts` (voir [[knowledge_seed-data]])
|
||||
|
||||
## NextGraphContext (`src/shared/context/NextGraphContext.tsx`)
|
||||
|
||||
- Connection cycle: `disconnected` → `connecting` → `connected` | `error`.
|
||||
- Provides the session (the current user and their access to the per-scope stores).
|
||||
- Cycle de connexion : `disconnected` → `connecting` → `connected` | `error`.
|
||||
- Fournit la session avec les IDs de stores (private, protected, public).
|
||||
- **Auto-init conditionnel** : voir [[rule_conditional-ng-init]] (n'auto-initialise que dans l'iframe broker).
|
||||
|
||||
## FestipodDataContext (`src/shared/context/FestipodDataContext.tsx`)
|
||||
|
||||
- Reads the shapes reactively through **`useShapeQuery()`** (`src/shared/data/useShapeQuery.ts`), the app's React binding over the SDK's `watchShape(shape, scope)` observable — three scoped reads: events (`public`), profiles and participations (`protected`), mapped to the `Fp*` domain types by `shapeAdapters.ts` (see [[rule_document-per-entity]] §Reads). There is no `useShapeWithDefaults` — that name is obsolete (verified 2026-07-28).
|
||||
- Exposes `useFestipodData()` (consumed by every screen) + CRUD (`createEvent`, `updateEvent`, `joinEvent`, `leaveEvent`, etc.).
|
||||
- **Provider chosen by connection status**:
|
||||
- `disconnected` → `LocalDataProvider` with the seed (demo)
|
||||
- `connecting` → an **empty** `LocalDataProvider` (avoids flashing the seed before the wallet loads)
|
||||
- `connected` → `NgDataProvider` (real wallet data)
|
||||
- `error` → `LocalDataProvider` with the seed (graceful fallback)
|
||||
- Enveloppe les shapes via `useShapeWithDefaults()`.
|
||||
- Expose `useFestipodData()` (consommé par tous les écrans) + CRUD (`createEvent`, `updateEvent`, etc.).
|
||||
- **Provider selon le statut NG** :
|
||||
- `disconnected` → `LocalDataProvider` avec seed (démo)
|
||||
- `connecting` → `LocalDataProvider` **vide** (évite de flasher le seed avant le chargement du wallet)
|
||||
- `connected` → `NgDataProvider` (données réelles du wallet)
|
||||
- `error` → `LocalDataProvider` avec seed (fallback gracieux)
|
||||
|
||||
> 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]]).
|
||||
> Réserve : certaines mutations (`joinEvent`/`leaveEvent`) sont encore des **no-ops** (`console.log`) en attendant le chantier données — cf. [[brief_2026-05-21_fork-nextgraph-inbox]] §Couche 3.
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
---
|
||||
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: Types de données Fp* (Event, UserProfile, Participation persistés NextGraph ; MeetingPoint et Friendship encore local-only)
|
||||
---
|
||||
|
||||
# Data entities
|
||||
# Entités de données
|
||||
|
||||
`src/shared/data/types.ts`:
|
||||
`src/shared/data/types.ts` :
|
||||
|
||||
| Type | Persistence | Key fields |
|
||||
| Type | Persistance | Champs clés |
|
||||
|---|---|---|
|
||||
| `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` | NextGraph (shape Event) | id, title, date, location, distance, themes |
|
||||
| `FpUserData` | NextGraph (shape UserProfile) | id, name, username, bio, city, counts |
|
||||
| `FpParticipationData` | NextGraph (shape Participation) | eventId + userId + confirmed |
|
||||
| `FpMeetingPointData` | **local-only** | eventId, location, time, host |
|
||||
| `FpFriendshipData` | **local-only** | userId + friendId |
|
||||
|
||||
`MeetingPoint` 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`).
|
||||
`MeetingPoint` et `Friendship` n'ont **pas encore de shape SHEX** ni de persistance NextGraph (cf. [[knowledge_nextgraph-stack]]). Les brancher au store est un prérequis du multi-user — voir les briefs du concept `nextgraph-platform`.
|
||||
|
||||
`Friendship` has **no** SHEX shape and no persistence — it stays app-TS-only (see [[knowledge_nextgraph-stack]]).
|
||||
|
||||
> 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]].
|
||||
> Piège : même pour `FpEvent` (persisté), plusieurs champs du type app ne sont **pas** dans la shape et sont perdus en connecté — voir [[caveat_event-fields-not-persisted]].
|
||||
|
||||
@@ -1,36 +1,26 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: The data SDK is @ng-eventually/client (treated as a finished NextGraph SDK) — injected exactly once through ngSession.configure; reactive useShape ORM over the festipodShapes SHEX shapes, bindings regenerated with build:orm; never document NextGraph's current state here
|
||||
summary: Paquets @ng-org/* (web, orm, shex-orm, alien-deepsignals), shapes SHEX festipodShapes, bindings ORM générés, régénérés via build:orm
|
||||
---
|
||||
|
||||
# Data stack (the `@ng-eventually/client` SDK)
|
||||
|
||||
Festipod persists through **`@ng-eventually/client`** — the NextGraph SDK the app consumes. We treat it as a **finished, mature SDK**: documents per entity placed by scope, capabilities, inboxes, a reactive ORM.
|
||||
|
||||
> **It is a polyfill, and that word carries its whole job**: closing the gap between the SDK **as it should be** and what NextGraph provides **today**. The app codes against the target and **ignores the current state entirely**; the polyfill absorbs the difference. The contract itself — which surfaces exist and what may be assumed of them — is written down in this repo: [[knowledge_sdk-surface]]. See [[rule_app-uses-sdk-surface-only]].
|
||||
# Stack NextGraph (côté app)
|
||||
|
||||
```
|
||||
@ng-eventually/client # THE app's data SDK (reactive useShape ORM, docs, scopes, inbox)
|
||||
@ng-org/web # Runtime navigateur (proxy postMessage vers l'iframe)
|
||||
@ng-org/orm # ORM réactif basé sur les shapes RDF (useShape…)
|
||||
@ng-org/shex-orm # Génération SHEX → TypeScript
|
||||
@ng-org/alien-deepsignals # Pont de signaux réactifs
|
||||
```
|
||||
|
||||
## SDK boundary (the golden rule)
|
||||
Installés depuis npm (`@ng-org/*`, versions alpha). Pour développer contre un build local non publié de `nextgraph-rs`, `scripts/build-ng-packages.sh` pack le monorepo en tarballs et repointe `package.json` (cf. `nextgraph-platform` — le pattern d'origine du projet, réactivable pour un fork).
|
||||
|
||||
- The app **depends on `@ng-eventually/client` only** for data.
|
||||
- The SDK is **initialized/injected exactly once** through `ngSession.configure(...)` (`src/shared/utils/ngSession.ts`) — a single injection point. Everything else in the app (data plane, lifecycle, login, types) goes through the lib.
|
||||
- **Never document NextGraph's current state in this repo** (constraints of the underlying SDK, workarounds, broker/verifier internals): that lives in the `@ng-eventually/client` repo. Here we describe only **how Festipod uses that SDK**.
|
||||
## Shapes SHEX
|
||||
|
||||
## ORM & SHEX shapes
|
||||
`src/shared/shapes/shex/festipodShapes.shex` définit :
|
||||
- **Event** — titre, description, dates, lieu, thèmes, participants
|
||||
- **UserProfile** — nom, username, bio, ville, visibilité
|
||||
- **Participation** — lie event + user, statut de confirmation
|
||||
|
||||
The reactive ORM (`useShape`) is built on **SHEX shapes**: `src/shared/shapes/shex/festipodShapes.shex` defines:
|
||||
Bindings ORM dans `src/shared/shapes/orm/` (`*.schema.ts`, `*.shapeTypes.ts`, `*.typings.ts`). **Régénérer** avec `bun run build:orm` après toute modif `.shex`.
|
||||
|
||||
- **Event** — title, description, dates, location, themes, participants
|
||||
- **UserProfile** — name, username, bio, city, visibility
|
||||
- **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)
|
||||
|
||||
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.
|
||||
|
||||
> **Recommended way to read = the SDK's reactive hook.** The canonical way to read is `useShape`: you subscribe to a shape on a scope, you get the current value, and the component re-renders on every change (local **or** remote once synchronized) — subscription/push, never polling; one-shot reads are the exception. The SDK's full reference (read/reactivity contract + where the current emulation still diverges) lives on the lib side: `packages/client/docs/sdk-reference.md` in `@ng-eventually/client`. Do not copy NextGraph internals here.
|
||||
|
||||
> `Friendship` has **no** SHEX shape and no persistence — it stays app-TS-only (see [[knowledge_entities]]).
|
||||
> Manque côté shapes : **pas de `MeetingPoint`** ni d'entité notification — le point de rencontre est aujourd'hui local-only côté types (voir [[knowledge_entities]]). Leur modélisation est un chantier de [[brief_2026-05-21_fork-nextgraph-inbox]].
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: The `@ng-eventually/client` contract Festipod is written against, as a FINISHED NextGraph SDK — reactive reads (`watchShape`, `useShape`), document writes (`docs`), per-scope placement (`storeRegistry`), `inbox`, `discovery`, capabilities (`capFor` / `inbox.shareCap` / `publishRepoLink`), identity — with what the app MAY and MAY NOT assume of each, so no agent ever needs to open the SDK's own repo.
|
||||
---
|
||||
|
||||
# The SDK surface Festipod codes against
|
||||
|
||||
This is Festipod's **data contract**: what `@ng-eventually/client` offers, and what the app is entitled to rely on. It describes the SDK **as it should be** — a finished NextGraph SDK — because that is what the app is written against ([[rule_app-uses-sdk-surface-only]]). It says **nothing** about NextGraph's or the package's implementation state, on purpose: the app ignores that entirely, and any gap is the package's to absorb, never the app's.
|
||||
|
||||
Everything below is exported from the SDK entry `@ng-eventually/client`, **except** the few items explicitly marked `/polyfill` — the bootstrap subpath `@ng-eventually/client/polyfill`, the one part that disappears at migration. Injection happens exactly once, in `ngSession` ([[knowledge_nextgraph-stack]]).
|
||||
|
||||
## Reactive reads — the canonical path
|
||||
|
||||
**`watchShape(shapeType, scope) -> ShapeObservable`** — the read Festipod uses. It observes one SHEX shape over one **logical scope** (`'public' | 'protected' | 'private'`) and yields a `useQuery`-shaped snapshot: `{ data, isPending, isSuccess, isError, error }`. Bind it with `useSyncExternalStore` (`src/shared/data/useShapeQuery.ts`).
|
||||
|
||||
May assume:
|
||||
|
||||
- `data` is **always an array**, never `undefined`; its items are `UnionSubject` (`{ subject, graph, props }`) — raw per-subject property bags, mapped to `Fp*` types by `src/shared/data/shapeAdapters.ts` ([[knowledge_entities]]).
|
||||
- `isPending` and `isSuccess` are **mutually exclusive**, and a synchronized-but-empty scope is `isSuccess` with `data: []` — the distinction the surface exists for. Never guess emptiness with a timer.
|
||||
- The snapshot reference is **stable** until the value actually changes (safe for `useSyncExternalStore`).
|
||||
- Reactivity is **push**: the snapshot updates on any change in scope, local or remote, and on any change to what the current identity may read. Never polling.
|
||||
- The observable is **inert until first `subscribe()`** (or `refetch()`); the last unsubscribe tears everything down. `refetch()` forces a re-resolve and is idempotent w.r.t. subscriptions.
|
||||
- `isError` fires **only** on a real thrown exception, never on a slow or absent peer.
|
||||
|
||||
**`useShape(shapeType, scope) -> DeepSignalSet<T>`** — the ORM hook, for **one already-known document NURI** as scope. Returns a live reactive set that re-renders on every change. Festipod uses it in the `@data` harness; screens go through `watchShape`.
|
||||
|
||||
May not assume: any ordering of `data`; that a value seen once stays; that a document the identity holds no capability for will ever appear (it silently does not).
|
||||
|
||||
## Writes — one document at a time
|
||||
|
||||
**`docs.docCreate(sessionId, crdt, cls, dest, store?)`** creates one document and returns its NURI. **`docs.sparqlUpdate(sessionId, query, anchor)`** writes into it: a SPARQL `INSERT`/`DELETE` scoped to the **anchor document's** graph. **`docs.sparqlQuery(sessionId, query, base?, anchor?)`** is the one-shot, non-reactive read.
|
||||
|
||||
May assume:
|
||||
|
||||
- One document = one repo = one entity ([[rule_document-per-entity]]); a write is a change on that document, and every observer of it is pushed.
|
||||
- A write **targets exactly one document**. There is no "write to the union", and no primitive by which a non-owner appends to someone else's document — surfacing data to another identity goes through the **inbox**, or through each identity owning its own document.
|
||||
|
||||
May not assume: that `sparqlQuery` is reactive (it is a snapshot — to stay live, use `watchShape`); that an unanchored update means anything.
|
||||
|
||||
## Placement by scope — `storeRegistry`
|
||||
|
||||
**`storeRegistry.createEntityDoc(id, scope)`** — create the entity's own document in the right scope, and record it as the identity's. **`storeRegistry.listEntityDocs(scope)`** / **`listMyEntityDocs(id, scope)`** enumerate documents in a scope, all or mine. **`resolveWriteGraph(id, scope)`**, **`resolveScopeGraph(scope)`**, **`resolveReadGraphs(scope)`**, **`resolveInboxAnchor()`** resolve the NURIs a call needs. **`ensureAccount(id)`**, **`resolveAccount(id)`**, **`allAccounts()`** yield `AccountRecord`s (`{ id, docPublic, docProtected, docPrivate }`).
|
||||
|
||||
Festipod's own glue (`src/shared/utils/storeRegistry.ts`) adds only the **domain mapping** entity kind → scope; placement itself belongs to the SDK.
|
||||
|
||||
May assume: the SDK owns NURI construction and placement. May not assume: that the app may build a NURI by hand, or read/write a scope's container document directly.
|
||||
|
||||
## Inbox — delivery to an identity
|
||||
|
||||
**`inbox.post(targetInbox, { payload, from?, ts? })`** deposits into a document's inbox. `from` omitted defaults to the current identity; **`from: null` is an explicit anonymous deposit**, and naming another identity is rejected as a spoof. **`inbox.read(targetInbox)`** returns every `Deposit` (`{ from, payload, ts }`) sorted by ascending `ts`. **`inbox.watch(targetInbox, onDeposits)`** fires once on the initial state and again on every change; it returns an unsubscribe. **`inbox.readSynced`** is the same read under a stronger contract: it returns once the deposits synced to that inbox are visible, where `read` returns what is known locally right now. **Choose by need, not by habit**: `read` inside a session already watching the inbox, `readSynced` whenever correctness depends on a cold session seeing another identity's deposit. `inbox.materialize` is an alias of `read`.
|
||||
|
||||
May assume:
|
||||
|
||||
- **Any identity — even anonymous — can deposit** into an inbox it knows. That is the only way data reaches an identity that cannot write your documents. See [[rule_nextgraph-inbox]].
|
||||
- `watch` is **push, never polling**; its `intervalMs` option exists for signature compatibility and is ignored.
|
||||
- `payload` is **opaque to the SDK** — Festipod defines its own kinds (`src/shared/data/registration.ts`).
|
||||
|
||||
May not assume: exactly-once delivery semantics, or that a deposit is removed once read.
|
||||
|
||||
## Discovery — the global index
|
||||
|
||||
**`discovery.submitToIndex(ref, opts?)`** makes a reference discoverable; `SubmitOptions.from` follows the same identified/anonymous rule as `inbox.post`, and `SubmitOptions.doc` names the document being announced. **`discovery.readIndex()`** returns `IndexEntry[]` (`{ ref, from, ts }`, deduplicated). **`discovery.watchIndex(onEntries)`** is the push-based observer.
|
||||
|
||||
May assume: the index admits a document only if it was **published** as a repo link — announcing something past the reach you chose for it is refused. May not assume: that `ref` means anything to the SDK (it is app-defined), or that being indexed grants any read.
|
||||
|
||||
## Capabilities — reading is key possession
|
||||
|
||||
The model has **no authorization list**. You hold a document's `ReadCap` (a NURI carrying a `:k:` segment) and you read it, or you do not. A bare `Nuri` **names** a document without granting anything.
|
||||
|
||||
- **`capFor(nuri): ReadCap | undefined`** (`/polyfill`, also `getCaps().capFor`) — do I hold this document's key? Nothing derives a key from a bare reference; it is either in your keyring because you created the document, or it was delivered to you.
|
||||
- **`inbox.shareCap(cap, toInbox)`** — the act of sharing: **one document, to one recipient inbox**. Several recipients means several calls. Recipients are addressed as **inboxes**, never as principals.
|
||||
- Receiving a capability needs **no dedicated call**: it arrives as an inbox deposit, is applied inline by `inbox.read`/`watch`, and the resulting keyring change **re-triggers the reads that were empty for want of it** — a `watchShape` view fills in on its own.
|
||||
- **`getCaps().publishRepoLink(nuri)`** (`/polyfill`) — publish a document as a shareable link; that link, not the bare NURI, is what goes into anything discoverable. **`getCaps().open(nuri, scope)`** records a document as mine in a scope (publishing it when `public`).
|
||||
- **Public is readable by whoever has the link, and NOT recursive**: a public document may *reference* a private one without disclosing it. Festipod relies on exactly that.
|
||||
- Key rotation **redelivers** through the same inbox channel; access is deferred to the next connection, never lost. The app implements nothing to "keep" an access.
|
||||
|
||||
May not assume: that a store-level key grants its documents (it does not — isolation is per document); that `Nuri`/`ReadCap` are compile-time-branded (they are plain strings, checked at runtime); that revocation is retroactive.
|
||||
|
||||
## Identity and lifecycle
|
||||
|
||||
**`accounts.IdentityStore`** / **`accounts.browserIdentityStore(key?)`** persist the current identity id over an injected `AccountStorage`; it is an opaque id, with no notion of password or login step. `/polyfill` adds **`setCurrentUser(id)`**, **`getCurrentUser()`**, **`resetCaps()`**, **`configure(...)`** and **`configureStoreRegistry(...)`** — the bootstrap. **`init` / `initNg`** are the lifecycle entry points, and **`ng`** is the raw SDK object, both re-exported from the SDK entry.
|
||||
|
||||
May assume: switching identity **switches** keyrings, it does not wipe them — a delivered capability is durable across sessions.
|
||||
|
||||
## SPARQL safety
|
||||
|
||||
**`escapeLiteral(value)`**, **`escapeIri(value)`**, **`assertNuri(nuri)`** — the app reuses the SDK's own escaping whenever it builds SPARQL by interpolation. Any untrusted value crossing into a query goes through one of them; never hand-roll quoting.
|
||||
|
||||
## Types re-exported for the app
|
||||
|
||||
`Nuri`, `ReadCap`, `Scope`, `PrincipalId`, `UnionSubject`, `ShapeQuery`, `ShapeObservable`, `IndexEntry`, `SubmitOptions`, `Deposit`, `PostOptions`, `AccountRecord`, `RegistrySession`, `AccountStorage`, `DocChange`, `Unsubscribe` — plus `ShapeType`, `BaseType`, `Schema`, `DeepSignalSet` and `NG`, so the app never imports from `@ng-org/*` directly.
|
||||
|
||||
## Exported, but not for the app
|
||||
|
||||
`readModel.readUnion`, `subscribeDoc` / `subscribeDocs` / `docChangeType`, and `docs.sparqlQuery` used as a listing primitive are **lower-level** surfaces. Festipod reads through `watchShape` and does **not** assemble its own reactivity on top of them ([[rule_app-uses-sdk-surface-only]]). In demo mode none of this is reached at all ([[knowledge_data-modes]]).
|
||||
@@ -1,17 +1,17 @@
|
||||
---
|
||||
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 fournit des fixtures déterministes (10 users, events, participations) avec CURRENT_USER_ID = 'user-1' (Marie Dupont) ; utilisé en mode démo et par les tests @ui
|
||||
---
|
||||
|
||||
# Seed data
|
||||
|
||||
`src/shared/data/seedData.ts` provides **deterministic** fixtures:
|
||||
`src/shared/data/seedData.ts` fournit des fixtures **déterministes** :
|
||||
|
||||
- 10 users — **Marie Dupont = the current user**, `user-1`
|
||||
- Several events (dates, locations, themes)
|
||||
- 10 users — **Marie Dupont = utilisateur courant**, `user-1`
|
||||
- Plusieurs events (dates, lieux, thèmes)
|
||||
- Participations, meeting points, friendships
|
||||
- `CURRENT_USER_ID = 'user-1'`
|
||||
|
||||
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`).
|
||||
Ces fixtures servent (a) le **mode démo** (`LocalDataProvider`, cf. [[knowledge_data-modes]]) et (b) les tests **`@ui`** qui rendent les écrans avec ces données prévisibles (`Marie Dupont`/`@mariedupont` = currentUser, `Jean Durand`/`@jeandurand` existe, etc. — voir concept `bdd-testing`).
|
||||
|
||||
> `bootstrapWallet()` (`src/shared/utils/ngBootstrap.ts`) seeds this data into the wallet in connected mode — triggered only by an explicit user action (« Charger données de test »).
|
||||
> `bootstrapWallet()` (`src/shared/utils/ngBootstrap.ts`) seede ces données dans le wallet NG en mode connected — déclenché uniquement par action explicite de l'utilisateur (« Charger données de test »). Sa refonte par documents/périmètres est un point des briefs `nextgraph-platform`.
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
---
|
||||
type: rule
|
||||
summary: The app IGNORES NextGraph's implementation state entirely and is coded against the SDK as it SHOULD BE — the contract written down in this repo ([[knowledge_sdk-surface]]). @ng-eventually/client is a POLYFILL whose mission is to COMPENSATE THE GAP between that target SDK and what NextGraph provides today (the virtual wallet being the largest piece, not the whole mission). When something breaks, the question is never "how do we work around it in the app" but "what must the polyfill compensate".
|
||||
---
|
||||
|
||||
# The app uses the SDK surface only — never the polyfill's internals
|
||||
|
||||
## The rule
|
||||
|
||||
The Festipod app treats `@ng-eventually/client` as a **finished, flawless NextGraph SDK**. Concretely:
|
||||
|
||||
1. **Reactive reads = `useShape`** (the SDK-shaped surface provided by the polyfill, **scoped to the virtual wallet**). The app does NOT read through the polyfill's internals (`readModel.readUnion`, `subscribeDoc`, a home-made read model…), and does NOT mount its own reactivity (a re-run on a signal).
|
||||
2. **The app NEVER reasons about NextGraph's current state**: no code and no comment of the kind "we do X because the ORM fan-out hangs / because a cold read returns 0". From the app's point of view, those problems do not exist.
|
||||
|
||||
## The contract is the SDK as it SHOULD BE — written down here, in this repo
|
||||
|
||||
The app is coded against the SDK **as it should be**, and that contract lives in Festipod's own doctrine: [[knowledge_sdk-surface]]. That is what an agent reads to know what it may rely on. It never needs to open the polyfill's repo, and it never needs to know what NextGraph does or does not implement today.
|
||||
|
||||
**Ignore NextGraph's implementation state — entirely.** Not "mostly", not "except when it bites". The app's code and comments must contain **nothing** of the form "we do X because NextGraph does Y today". From the app's point of view, that state does not exist.
|
||||
|
||||
## The polyfill's mission: COMPENSATE THE GAP
|
||||
|
||||
`@ng-eventually/client` is a **polyfill**, and its mission is exactly that of any polyfill: **close the gap between the target SDK and what the underlying platform currently provides**.
|
||||
|
||||
The **virtual wallet** (several identities on one physical wallet) is the largest piece of that gap, and historically the reason the polyfill was created — but it is **one piece, not the whole mission**. Emulating capabilities, the union read-model, `open-repo`, readiness mirroring, reconnection: all of it is gap-compensation, all of it is legitimately the polyfill's job, and **none of it surfaces in the app**.
|
||||
|
||||
**The operative consequence.** When something does not work, the question is never *"how do we work around NextGraph in the app?"* — it is *"what does the polyfill have to compensate?"*. An app-side workaround is a doctrine violation even when it works, because it hard-codes a temporary state of NextGraph into code that must outlive it.
|
||||
|
||||
## Status (deviation resolved)
|
||||
|
||||
**Resolved**: `FestipodDataContext` now reads through `useShapeQuery` (a `useSyncExternalStore` binding over the polyfill's `watchShape`) + Fp adapters (`src/shared/data/shapeAdapters.ts`). **Removed**: `readEntities.ts`, the bespoke reactivity (`subscribeDocs`+`bumpRead`+`readTick`), the manual listing (`publicDocs`/`protectedDocs`/`registerDoc` for reads), and the comments reasoning about the ORM hang. The auto-seed is gated on `isSuccess` (no more 3s timer). The app consumes nothing but the SDK surface.
|
||||
|
||||
**Target (design reminder)**: the polyfill exposes a `useShape` that is **reactive and scoped to the virtual wallet**, whose **shape follows TanStack `useQuery`** — `{ data, isPending/isLoading, isSuccess, isError, … }` — **in anticipation of the PLANNED update of `useShape` by NextGraph** (which is going to adopt that behaviour). So this is not an invention: it is a future NextGraph API, emulated ahead of time, that will align once NextGraph ships it. It **natively distinguishes** `isPending` (sync in progress) from `isSuccess` + empty `data` (synchronized, genuinely empty) — exactly what is needed. Internally, the hook encapsulates readUnion over `subscribeDoc` plus the identity scoping (invisible to the app). The app **removes** its bespoke machinery (`readEntities`/`subscribeDocs`/`bumpRead`) and reads through that hook.
|
||||
|
||||
The auto-seed bug (the 3s timer) is a **symptom**: with `isSuccess`, the auto-seed decides "empty" only once the sync is confirmed, instead of guessing a delay. See [[rule_no-broker-polling]] and [[knowledge_nextgraph-stack]].
|
||||
@@ -1,33 +0,0 @@
|
||||
---
|
||||
type: rule
|
||||
summary: Any important knowledge established about how NextGraph ACTUALLY works (a core/broker/verifier mechanism, a primitive's semantics, a shape property) → record it AT THE MOMENT of discovery in the polyfill's reference docs `../../nextgraph/ng-eventually-js/docs/`, never in the Festipod repo; distinguish VERIFIED from INFERRED, and never deduce the TARGET shape from the source's CURRENT state
|
||||
---
|
||||
|
||||
# Rule: record NextGraph knowledge the moment you establish it
|
||||
|
||||
When an investigation establishes an **important fact about how NextGraph actually works** — a primitive's mechanism, a structure's semantics, a shape property ("the overlay is *store*-scoped, never document-scoped"), an access guard, what an operation does or does not require — **write it down straight away** in the polyfill's reference documentation:
|
||||
|
||||
`../../nextgraph/ng-eventually-js/docs/` (from this repo's root) — typically the reference note for the subject (caps/NURI model, current state, SDK reference).
|
||||
|
||||
**Never in the Festipod repo.** `AGENTS.md` forbids it explicitly: Festipod doctrine describes *how Festipod uses the SDK*, not the state of NextGraph. See [[rule_app-uses-sdk-surface-only]].
|
||||
|
||||
## At the moment of discovery — not at the end
|
||||
|
||||
"I will write it up at the end of the session" does not work: the context is compacted before that, and the fact is lost. This knowledge is **very expensive** to establish (several agent investigations through the Rust source, often contradicting each other before they converge) and **impossible to verify from memory** — a second session will pay full price again for the same answer, or worse, will settle for a wrong intuition.
|
||||
|
||||
## The central pitfall: current state ≠ target shape
|
||||
|
||||
**Never read `nextgraph-rs`'s current state to DEDUCE the target shape from it.** The source contains **unfinished scaffolding** that looks like model: you can find membership and permission types in it that are **inert at runtime** (never called outside unit tests, structures built empty). Deducing a "membership" primitive from that and shaping it into the polyfill means carving in a shape that will never exist — exactly the failure mode the polyfill exists to prevent.
|
||||
|
||||
The source is there to **verify an existing mechanism**, never to **infer an intention**. Intentions are to be asked of NextGraph's designer.
|
||||
|
||||
## Shape of the note
|
||||
|
||||
- **Distinguish VERIFIED** (a path read end to end, or better: observed at runtime) from **INFERRED** (deduced, not traced). A load-bearing fact left unmarked silently turns into a certainty.
|
||||
- **Point at symbols**, not line numbers (which are volatile) — and date the note.
|
||||
- Write down the fact's **consequence** too, not just the fact: that is what will be re-read.
|
||||
- A fact that **contradicts** an existing note → fix the note, do not pile on.
|
||||
|
||||
## Sibling rule
|
||||
|
||||
This one covers **knowledge** — what *is*; [[rule_nextgraph-inbox]] covers what must be **reported upstream or waited for** — the malfunctions and the gaps (→ `../../nextgraph/orm-tests/INBOX/`). One and the same investigation often produces both: file each half in its own place. See [[knowledge_nextgraph-stack]].
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
type: rule
|
||||
summary: N'auto-initialiser NextGraph que dans l'iframe broker (window.self !== window.top) ; en standalone, initNgWeb() redirige toute la page — attendre un connect() explicite
|
||||
---
|
||||
|
||||
# Règle : auto-init NextGraph seulement dans l'iframe broker
|
||||
|
||||
`initNgWeb()` de `@ng-org/web` teste `window.self === window.top`. **Hors iframe** (app standalone), il **redirige toute la page** vers `nextgraph.net/redir/` pour déclencher l'auth broker.
|
||||
|
||||
Donc `NextGraphContext` calcule `isInsideBroker = window.self !== window.top` et **n'auto-appelle `initNg()` que si `isInsideBroker`**. En standalone, la connexion attend un `connect()` explicite (clic « Se connecter ») — sinon l'app redirige à chaque chargement et casse le dev/démo.
|
||||
|
||||
De plus, `FestipodDataContext` rend des données **vides** (pas le seed) pendant la phase `connecting`, pour éviter de flasher du contenu démo avant le chargement du wallet (voir [[knowledge_data-modes]]).
|
||||
|
||||
> Garder ce garde **aligné** sur la détection interne de `@ng-org/web` : si leur heuristique change, le nôtre doit suivre. Pourquoi + alternatives : [[decision_2026-03-13_conditional-ng-init-broker-detection]].
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
type: rule
|
||||
summary: Festipod persists EVERY entity as ITS OWN document (through the SDK), placed in its scope (public/protected/private) — never several entities written into a store-level document. The document is the unit of sharing and of rights: the SDK's isolation is PER-DOCUMENT, so one document per entity is what makes it possible.
|
||||
---
|
||||
|
||||
# Rule: one document per entity (never at store level)
|
||||
|
||||
When Festipod creates an entity (event, meeting point, profile, participation, notification), it writes it as **its own document**, through the data SDK's "create a document" call ([[knowledge_nextgraph-stack]]), stating its **scope** (`public` / `protected` / `private`). The entity is then read from and written to **that** document.
|
||||
|
||||
**Never** write several entities into a shared "store-level" document (e.g. putting everything into a single root document). That is an anti-pattern that breaks isolation.
|
||||
|
||||
## Why
|
||||
|
||||
The **document is the SDK's unit of sharing and of rights**: isolation (who can read what) is enforced **per document**. `private` → the owner; `protected` → the owner + their connections; `public` → everyone. That discrimination is possible **only if each entity has its own document**: putting several entities (or worse, several owners) into a single document makes sharing all-or-nothing and defeats scope-based isolation.
|
||||
|
||||
Isolation itself is **entirely handled by the SDK** ([[knowledge_trust-model]] in the `app-security` concept) — the app carries no access logic; it only declares its identity (at login) and its connections (an act of sharing), then trusts whatever the SDK returns. The "one document per entity" granularity is the write-side counterpart of that trust.
|
||||
|
||||
## How to apply it
|
||||
|
||||
- At creation time: ask the SDK for **a document for the entity, in its scope** (`createEntityDoc(scope)`); write the entity into it. Do not reuse a document from another scope, nor a store-level document.
|
||||
- For reads: go through the SDK's **reactive shape surface** (see below) — the app names a SHEX shape and a **logical scope**, and the SDK resolves that scope to the documents to read (the discovery index for public events; its own scope documents for its own entities), opens/synchronizes them and pushes changes. No NURI resolution, no document listing and no query written on the app side.
|
||||
- The *entity → scope* mapping (event/meeting point → public, network profile/participation → protected, settings → private) is a product fact (concept `functional-domain`, [[knowledge_data-scopes-and-discovery]]).
|
||||
|
||||
## Reads: the SDK's reactive shape surface (`watchShape` / `useShapeQuery`)
|
||||
|
||||
**Reads go through the SDK surface only** ([[rule_app-uses-sdk-surface-only]]). The app names a shape and a scope, and gets a live, `useQuery`-shaped result back:
|
||||
|
||||
1. `watchShape(shapeType, scope)` (SDK) returns an **observable** — `{ data, isPending, isSuccess, isError }` — which resolves the scope against the current identity's wallet (its own scope documents, plus the discovery index for `public`), waits for the sync barrier, and **pushes** on every change. `data` is always an array; a synced-but-empty scope reads `{ data: [], isPending: false, isSuccess: true }`, which is what distinguishes "still syncing" from "genuinely empty".
|
||||
2. `useShapeQuery(shapeType, scope)` (`src/shared/data/useShapeQuery.ts`) is the app's **only** React binding over it (`useSyncExternalStore`), memoized per (shape, scope) so the underlying document subscriptions are not churned on every render.
|
||||
3. `FestipodDataContext` mounts exactly three of them — events (`public`), profiles (`protected`), participations (`protected`) — and maps the SDK's `UnionSubject` property bags onto the app's `Fp*` domain types through `src/shared/data/shapeAdapters.ts`.
|
||||
|
||||
**The app resolves, lists, registers and re-queries nothing.** There is no app-side document set, no manual re-read signal and no per-document subscription: reactivity is the SDK's own push. The single app-side layer laid over the read is a **pure optimistic overlay** (`pendingAdd*` / `pendingRemoveIds` in `FestipodDataContext`), auto-reconciled the moment the reactive set catches up — it hides the broker's push latency, it is not a read path.
|
||||
|
||||
> **Removed (verified 2026-07-28).** An earlier version of this rule described reads as a bespoke union model: an on-demand document set (`publicDocs`/`protectedDocs` fed by `listMyEntityDocs` + `registerDoc`), a one-shot `readEntities` → `readModel.readUnion`, and a manual re-query signal (`bumpRead`/`readTick`). **None of those read symbols exist in `src/` any more** — `src/shared/data/readEntities.ts` is gone, and the surviving mentions are historical code comments. Do not code against them ([[rule_app-uses-sdk-surface-only]]).
|
||||
|
||||
## Direct writes (the round-trip pitfall)
|
||||
|
||||
**Writing** an entity happens **directly into its own document** (through the SDK's SPARQL call — `src/shared/data/entityWrites.ts`, `writeEntity`), **not** by adding to a reactive set. Reason: a reactive set is only *writable* if the target document is **already** within its subscription scope; but registering the freshly created document is React state that only takes effect on the **next** render → you cannot create-then-add in a single synchronous pass (seed loop, first creation). Against the real broker, an `add` on an empty scope raises "Set is readonly because scope is empty" (the fake-ng unit tests do not catch it).
|
||||
|
||||
So: **write = direct SPARQL into the entity's document** (immediate, per-document); **read = the SDK's reactive shape surface** (above).
|
||||
|
||||
**Graph convention (write into the anchored default graph).** A write passes the document's NURI as the **anchor** of `docs.sparqlUpdate` and writes the SPARQL body **without** an explicit `GRAPH <…>` clause; the SDK's shape read queries that same anchored default graph. This is the **canonical, always-safe** form — to be kept for `writeEntity`, `updateEntityField` and `registration.ts`.
|
||||
|
||||
> **Correction (2026-07-06).** An earlier comment (and an earlier version of this paragraph) claimed that an explicit `GRAPH <docNuri>` body writes into a *distinct named graph* that an anchored read would not see → the entity would "disappear". **That is false on the current broker** (`@ng-org/web 0.1.2-alpha.13`): the lib's real e2e harness (`packages/client/e2e/`) verifies that an `INSERT DATA { GRAPH <plainNuri> {…} }` **anchored** to the doc round-trips (read back both from the default graph and from `GRAPH <plainNuri>`). The "0 entities" symptom we had attributed to that "pitfall" in fact came from the **bloated-wallet hang** (see `bdd-testing/caveat_wallet-bloat-hang`), not from a graph mismatch. So the "no `GRAPH` wrapper" rule remains a choice of **simplicity/safety**, not a round-trip necessity. (The *why* on the SDK side lives in `@ng-eventually/client`, not here.)
|
||||
|
||||
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 principal (the entity would be discarded on read). The current user's principal is **stable and derived from the username** (`urn:festipod:user:<normalized-username>`), available **immediately** after login (no dependency on reading the protected profile, which may lag) and **invariant** (it does not flip from a fallback to the profile IRI mid-session, which would desynchronize a participation written under one value from a check made under the other). It is the same principal that the SDK identity (`setCurrentUser`) and the owner cap derive from the username; bilateral connections (`declareConnections`) are declared with those same username keys (not profile IRIs) so that "protected = my connections" discriminates.
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
type: rule
|
||||
summary: The shared NextGraph inbox `../../nextgraph/orm-tests/INBOX/` takes TWO families of notes — malfunctions (a primitive misbehaves) AND gaps (a primitive we need, not yet implemented, which we emulate in the polyfill in the meantime). It doubles as a tracker of NextGraph's progress: when a gap is filled upstream, its note says what to REMOVE from the polyfill.
|
||||
---
|
||||
|
||||
# Rule: the NextGraph inbox takes malfunctions AND gaps
|
||||
|
||||
The shared NextGraph inbox is `../../nextgraph/orm-tests/INBOX/` (from this repo's root) — in the sibling repo `nextgraph/orm-tests`, which hosts the ORM integration tests against a real broker (`tests/standalone/` for repros).
|
||||
|
||||
It is **not** just a bug tracker. It has **two inputs** and **one feedback loop**.
|
||||
|
||||
## Input 1 — malfunctions
|
||||
|
||||
A NextGraph primitive exists but **misbehaves**: a socket that dies (`SerializationError`), no automatic reconnection, a `doc_subscribe` that does not deliver or delivers late, a slow repo cold-open, a write that is not durable broker-side, a reachable panic.
|
||||
|
||||
## Input 2 — the gaps we need
|
||||
|
||||
A primitive **is not implemented yet** (or is only inert scaffolding) while our model depends on it. File it too, with the three pieces of information that make it valuable:
|
||||
|
||||
- **what we need** and why — the model that depends on it;
|
||||
- **what the polyfill does in the meantime** — the emulation that fills the hole;
|
||||
- **what will have to be removed** from the polyfill the day it lands upstream.
|
||||
|
||||
It is that third point that turns the note into a **cleanup ticket**. Without it, the emulation outlives its reason for existing and the polyfill starts drifting away from the target — exactly what it exists to prevent.
|
||||
|
||||
## What does NOT qualify
|
||||
|
||||
An **app** bug (a badly wired React effect, an effect's gating) or a **polyfill wiring** issue (wrong NURI, subscription not re-armed). Those are fixed **on our side**. The distinction is crucial: first prove that the primitive is at fault — ideally with a test — not our integration. See [[rule_app-uses-sdk-surface-only]].
|
||||
|
||||
## The loop: the inbox tracks NextGraph's progress
|
||||
|
||||
The notes do not only travel upstream, they are also **re-read**: taken together, they say where NextGraph stands relative to what Festipod needs. When a note is resolved upstream, the polyfill update follows — often by **removing** emulation that has become useless, not by adding code.
|
||||
|
||||
## Note format
|
||||
|
||||
Name: `YYYY-MM-DD-<slug>.md`. Contents: nature (**malfunction** or **gap**), symptom or need, **verbatim evidence** (logs, measurements, source pointers marked "to re-verify"), a repro when it is a malfunction (ideally a standalone in `orm-tests/tests/standalone/`), expected vs observed, and — for a gap — the **polyfill workaround** and **what will have to be removed**. Severity + status.
|
||||
|
||||
The inbox receives the **report that is actionable for the NextGraph maintainers**; a longer post-mortem can live on the polyfill side.
|
||||
|
||||
## Sibling rule
|
||||
|
||||
This one covers what must be **reported upstream or waited for**; [[rule_capture-nextgraph-findings]] covers established **knowledge** about how things actually work (→ the polyfill's reference docs). One and the same investigation often produces both: file each half in its own place. See [[knowledge_nextgraph-stack]].
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
type: rule
|
||||
summary: Utiliser did:ng:${private_store_id} comme scope useShape ET comme @graph d'écriture ; ne jamais utiliser did:ng:i comme scope (casse toutes les écritures par RepoNotFound)
|
||||
---
|
||||
|
||||
# Règle : scope = `@graph` = private_store_id
|
||||
|
||||
Pour lire **et** écrire via l'ORM NextGraph :
|
||||
|
||||
- **Scope** : `useShape(shapeType, \`did:ng:${session.private_store_id}\`)`
|
||||
- **`@graph`** (cible des écritures) : `did:ng:${session.private_store_id}`
|
||||
|
||||
C'est critique : `orm_start_graph` avec le NURI du private_store **ouvre explicitement le repo** dans la HashMap `self.repos` du verifier. Sans ça, `orm_frontend_update` échoue en `RepoNotFound`.
|
||||
|
||||
## Interdit
|
||||
|
||||
**Ne pas utiliser `did:ng:i` comme scope.** Il s'abonne au site entier de l'utilisateur via un chemin de code spécial (`NuriTargetV0::UserSite`) qui **n'ouvre pas les repos individuels** → casse toutes les écritures.
|
||||
|
||||
## Fichiers porteurs
|
||||
|
||||
- `src/shared/hooks/useShapeWithDefaults.ts` — accepte un `storeNuri`, le passe à `useShape`.
|
||||
- `src/shared/utils/ngGraph.ts` — `ensureGraphNuri()` retourne le `@graph` (entités existantes d'abord, sinon fallback `private_store`).
|
||||
- `src/shared/utils/ngBootstrap.ts` — seede en utilisant `ensureGraphNuri()`.
|
||||
|
||||
> Le *pourquoi* complet et les alternatives écartées : [[decision_2026-03-17_private-store-nuri-scope]]. **Ce scope mono-store est précisément ce que le chantier multi-store viendra remplacer** — voir [[brief_2026-05-17_multi-store-refactor]].
|
||||
@@ -1,29 +1,28 @@
|
||||
---
|
||||
type: _overview
|
||||
summary: Festipod's product model — the meeting point grafted onto a public event as the unit of value, its actors, its business concepts, and the confidentiality scopes (public/protected/private) per entity
|
||||
summary: Modèle produit Festipod — le point de rencontre greffé sur un événement public comme unité de valeur, ses acteurs et ses concepts métier
|
||||
triggers:
|
||||
keywords: [point de rencontre, rencontre, greffe, greffer, événement, déclarant, hôte, inscrit, inscription, communauté, connexion, festival, déduplication, découverte, périmètre, scope, public, protected, privé]
|
||||
keywords: [point de rencontre, rencontre, greffe, greffer, événement, déclarant, hôte, inscrit, inscription, communauté, connexion, festival, déduplication]
|
||||
paths: ["src/modules/*/features/**"]
|
||||
---
|
||||
|
||||
# Functional domain
|
||||
|
||||
Festipod's **functional domain**: what the product promises and the business vocabulary that describes it. Original source: `README.md §Modèle fonctionnel`.
|
||||
Le **domaine fonctionnel** de Festipod : ce que le produit promet et le vocabulaire métier qui le décrit. Source d'origine : `README.md §Modèle fonctionnel`.
|
||||
|
||||
**Read this first:** [[knowledge_business-model]] — without it, you conflate the event (the anchor) with the meeting point (the value), and you model things backwards.
|
||||
**À lire en premier :** [[knowledge_business-model]] — sans lui, on confond l'événement (l'ancrage) et le point de rencontre (la valeur), et on modélise à l'envers.
|
||||
|
||||
## Pivotal idea
|
||||
## Idée pivot
|
||||
|
||||
Festipod lets users create **meeting points** that *graft* themselves onto existing **public events**. The event (festival, conference…) is only a *pretext* and a space-time anchor; the value produced is the meeting point. **You sign up to a meeting point, never to an event.**
|
||||
Festipod laisse les utilisateurs créer des **points de rencontre** qui se *greffent* sur des **événements publics** existants. L'événement (festival, conférence…) n'est qu'un *prétexte* et un point d'ancrage spatio-temporel ; la valeur produite, c'est le point de rencontre. **On s'inscrit à un point de rencontre, jamais à un événement.**
|
||||
|
||||
## Scope & confidentiality
|
||||
## Périmètre & sécurité
|
||||
|
||||
The product model of **who sees what** — personal data reserved to one's network, public events/meeting points, identified-or-anonymous sign-up notification — is a business fact: see [[knowledge_data-scopes-and-discovery]]. The detailed authorization matrix (actor × verb) and its incubation live in the `app-security` concept ([[brief_2026-05-18_authorization-matrix]]).
|
||||
Le modèle d'**autorisations / confidentialité** (qui voit quoi : « données personnelles = réseau seulement », anonymat via inbox, capabilities) n'est pas encore implémenté — il vit aujourd'hui comme incubation dans [[brief_2026-05-18_authorization-matrix]] (concept `nextgraph-platform`). Il graduera en règles/`behavior_` quand le multi-user atterrira. C'est la raison pour laquelle il n'y a pas encore de concept `app-security` distinct.
|
||||
|
||||
## Links
|
||||
## Liens
|
||||
|
||||
- [[knowledge_business-model]] — the event / meeting point inversion
|
||||
- [[knowledge_actors-and-concepts]] — reference for actors and business concepts
|
||||
- [[knowledge_data-scopes-and-discovery]] — public/protected/private scopes per entity + discovery
|
||||
- [[knowledge_roadmap]] — current features vs upcoming evolutions
|
||||
- [[brief_2026-06-15_event-deduplication]] — open challenge of deduplicating events in P2P
|
||||
- [[knowledge_actors-and-concepts]] — référence des acteurs et concepts métier
|
||||
- [[knowledge_roadmap]] — fonctionnalités actuelles vs évolutions à venir
|
||||
- [[brief_2026-06-15_event-deduplication]] — défi ouvert de déduplication des événements en P2P
|
||||
- `nextgraph-platform` — où vit la dérivation de la structure de données cible (authz matrix, multi-store)
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
---
|
||||
type: brief
|
||||
summary: Open challenge — on a P2P infrastructure, two users can declare the same public event and fragment the meeting points grafted onto it; leads not yet settled
|
||||
summary: Défi ouvert — en infra P2P, deux utilisateurs peuvent déclarer le même événement public et fragmenter les points de rencontre greffés ; pistes non tranchées
|
||||
---
|
||||
|
||||
# Deduplicating events on a decentralized infrastructure
|
||||
# Déduplication des événements en infra décentralisée
|
||||
|
||||
**Status:** Open challenge — not settled
|
||||
**Captured:** 2026-06-15 (from `README.md §Défis ouverts`)
|
||||
**Status:** Défi ouvert — non tranché
|
||||
**Capturé:** 2026-06-15 (issu de `README.md §Défis ouverts`)
|
||||
|
||||
## Problem
|
||||
## Problème
|
||||
|
||||
Since NextGraph is P2P, nothing prevents two users from **independently declaring the same public event** (e.g. "Eurockéennes 2027") and producing two distinct entries. The resulting dispersion **fragments the meeting points grafted onto it** and reduces their visibility — which runs against the app's primary purpose (see [[knowledge_business-model]]).
|
||||
NextGraph étant P2P, rien n'empêche deux utilisateurs de **déclarer indépendamment le même événement public** (par ex. « Eurockéennes 2027 ») et de produire deux entrées distinctes. La dispersion qui en résulte **fragmente les points de rencontre greffés** et réduit leur visibilité — ce qui va à l'encontre de la fonction première de l'app (cf. [[knowledge_business-model]]).
|
||||
|
||||
## Leads considered (not settled)
|
||||
## Pistes envisagées (non tranchées)
|
||||
|
||||
- **Search before create** — when declaring, offer the user the events already declared within their network / their communities that match what they are typing.
|
||||
- **Canonical external identifier** — use an official event URL, Wikidata, or `schema.org/Event` to recognize duplicates and present them as a single event on display.
|
||||
- **Curation** — let curators (human or community-run) merge / vet the canonical entries.
|
||||
- **Recherche avant création** — proposer à l'utilisateur, lors de la déclaration, les événements déjà déclarés dans son réseau / ses communautés qui correspondent à sa saisie.
|
||||
- **Identifiant externe canonique** — utiliser une URL officielle de l'événement, Wikidata, ou `schema.org/Event` pour reconnaître les doublons et les présenter comme un seul événement à l'affichage.
|
||||
- **Curation** — laisser des curators (humains ou communautaires) fusionner / vetter les entrées canoniques.
|
||||
|
||||
## Link with the write model
|
||||
## Lien avec le modèle d'écriture
|
||||
|
||||
This challenge is coupled to an open question in [[brief_2026-05-18_authorization-matrix]]: **who may modify a declared event** (owner / wiki / immutable). A *wiki* model would make convergence easier; an *owner* model makes it harder. To be decided together.
|
||||
Ce défi est couplé à une question ouverte de [[brief_2026-05-18_authorization-matrix]] : **qui peut modifier un événement déclaré** (propriétaire / wiki / immuable). Un modèle *wiki* faciliterait la convergence ; un modèle *propriétaire* la complique. À arbitrer ensemble.
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Reference for the actors (user, connection, declarer, host, participant, member) and the business concepts (meeting point, event, community, curated list, connection)
|
||||
summary: Référence des acteurs (utilisateur, connexion, déclarant, hôte, inscrit, membre) et des concepts métier (point de rencontre, événement, communauté, liste curated, connexion)
|
||||
---
|
||||
|
||||
# Actors and business concepts
|
||||
# Acteurs et concepts métier
|
||||
|
||||
Vocabulary reference. Every actor is a specialization of an authenticated **user** in a given context — not a separate account role.
|
||||
Référence du vocabulaire. Tous les acteurs sont des spécialisations d'un **utilisateur** authentifié dans un contexte donné — pas des rôles de compte distincts.
|
||||
|
||||
## Actors
|
||||
## Acteurs
|
||||
|
||||
| Actor | Definition |
|
||||
| Acteur | Définition |
|
||||
|---|---|
|
||||
| **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. |
|
||||
| **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. |
|
||||
| **Utilisateur** | Toute personne ayant un compte (un wallet NextGraph). Racine de tous les autres. |
|
||||
| **Connexion (« ami »)** | Un autre utilisateur avec qui je suis connecté. Sert à scoper les listes (« mes amis qui participent à… ») et la confiance. Bilatérale (acceptation des deux côtés). |
|
||||
| **Déclarant d'un événement** | L'utilisateur qui a inséré l'événement dans Festipod. *N'est pas forcément l'organisateur réel* : juste celui qui le référence. |
|
||||
| **Hôte d'un point de rencontre** | L'utilisateur qui a créé un point de rencontre rattaché à un événement. |
|
||||
| **Inscrit à un point de rencontre** | Un utilisateur inscrit à un point de rencontre ; de fait il devient participant à l'événement parent. |
|
||||
| **Membre d'une communauté d'intérêt** | Un utilisateur abonné à une communauté pour découvrir les événements qu'elle référence. |
|
||||
|
||||
## Business concepts
|
||||
## Concepts métier
|
||||
|
||||
| Concept | Definition |
|
||||
| Concept | Définition |
|
||||
|---|---|
|
||||
| **Meeting point** | *The app's unit of value.* A moment of meeting proposed by a host at a given place and time, grafted onto a public event. It is what you sign up to. |
|
||||
| **Event** | The anchor. A real public event referenced in Festipod to serve as a support for meeting points. A mere pretext (title, dates, location, themes). |
|
||||
| **Interest community** | A thematic grouping of users. Used mainly to discover events (through subscription) and to delimit referencing scopes. |
|
||||
| **Curated list** | An editorialized list of events (by a user or a community), distinct from "the events I declared". Allows organizing/recommending. |
|
||||
| **Connection** | A bilateral trust link between two users (the equivalent of "friend"). |
|
||||
| **Point de rencontre** | *L'unité de valeur de l'app.* Un moment de rencontre proposé par un hôte à un endroit et un horaire donnés, greffé sur un événement public. C'est ce à quoi on s'inscrit. |
|
||||
| **Événement** | L'ancrage. Un événement public réel référencé dans Festipod pour servir de support à des points de rencontre. Simple prétexte (titre, dates, lieu, thèmes). |
|
||||
| **Communauté d'intérêt** | Un groupement thématique d'utilisateurs. Sert surtout à découvrir des événements (via abonnement) et à délimiter les périmètres de référencement. |
|
||||
| **Liste curated** | Une liste d'événements éditorialisée (par un utilisateur ou une communauté), distincte de « les événements que j'ai déclarés ». Permet d'organiser/recommander. |
|
||||
| **Connexion** | Lien de confiance bilatéral entre deux utilisateurs (équivalent « ami »). |
|
||||
|
||||
> Communities, curated lists and subscriptions are largely **prospective** (see [[knowledge_roadmap]]). The detailed authorization matrix by data type lives in [[brief_2026-05-18_authorization-matrix]].
|
||||
> Communauté, liste curated et abonnement sont en grande partie **prospectifs** (cf. [[knowledge_roadmap]]). La matrice d'autorisations détaillée par type de donnée vit dans [[brief_2026-05-18_authorization-matrix]].
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: The meeting point is the unit of value, grafted onto an event that is only a pretext; you sign up to the meeting point, not to the event
|
||||
summary: Le point de rencontre est l'unité de valeur, greffée sur un événement-prétexte ; on s'inscrit au point de rencontre, pas à l'événement
|
||||
---
|
||||
|
||||
# Business model: the grafted meeting point
|
||||
# Modèle métier : le point de rencontre greffé
|
||||
|
||||
> Festipod lets users create **meeting points** that come to be "grafted" onto **existing public events**. The goal: encourage people to meet around those events.
|
||||
> Festipod permet aux utilisateurs de créer des **points de rencontre** qui viennent se « greffer » sur des **événements publics existants**. L'objectif : favoriser les rencontres autour de ces événements.
|
||||
|
||||
## The inversion to grasp
|
||||
## L'inversion à comprendre
|
||||
|
||||
The **public event** (festival, conference, trade show, exhibition…) is **only a pretext** and a *temporal and geographical anchor point*. The value the app produces is the **meeting point** that users come and graft onto it.
|
||||
L'**événement public** (festival, conférence, salon, exposition…) n'est **qu'un prétexte** et un *point d'ancrage temporel et géographique*. La valeur produite par l'app, c'est le **point de rencontre** que les utilisateurs viennent y greffer pour se retrouver.
|
||||
|
||||
Direct consequences for modelling:
|
||||
Conséquences directes sur la modélisation :
|
||||
|
||||
- **You sign up to a meeting point, not to an event.** Without meeting points, a Festipod event is of no interest whatsoever.
|
||||
- The **declarer** of an event is *not* (necessarily) its real organizer — just someone who inserted the reference into Festipod so that others can attach meeting points to it.
|
||||
- The **host** of a meeting point is whoever created it; the act of creating makes you the host. Likewise the act of declaring an event makes you its declarer.
|
||||
- **On s'inscrit à un point de rencontre, pas à un événement.** Sans points de rencontre, un événement Festipod n'a aucun intérêt.
|
||||
- Le **déclarant** d'un événement n'est *pas* (forcément) son organisateur réel — c'est juste quelqu'un qui a inséré la référence dans Festipod pour que d'autres puissent y attacher des points de rencontre.
|
||||
- L'**hôte** d'un point de rencontre est celui qui l'a créé ; l'acte de créer rend hôte. De même l'acte de déclarer un événement rend déclarant.
|
||||
|
||||
## Authentication
|
||||
## Authentification
|
||||
|
||||
**Every user is authenticated** (each one owns a NextGraph wallet) — there is no anonymous access to the app. The various "actors" (declarer, host, participant, connection…) are *specializations of a user in a given context*, not separate accounts. See [[knowledge_actors-and-concepts]].
|
||||
**Tous les utilisateurs sont authentifiés** (chacun possède un wallet NextGraph) — il n'y a pas d'accès anonyme à l'app. Les différents « acteurs » (déclarant, hôte, inscrit, connexion…) sont des *spécialisations d'un utilisateur dans un contexte donné*, pas des comptes distincts. Voir [[knowledge_actors-and-concepts]].
|
||||
|
||||
## Underlying stack
|
||||
## Stack porteuse
|
||||
|
||||
A mobile-first web app, Bun + React + **NextGraph** (P2P, local-first, end-to-end encrypted). The P2P choice has a strong business consequence: see the challenge in [[brief_2026-06-15_event-deduplication]].
|
||||
App web mobile-first, Bun + React + **NextGraph** (P2P, local-first, chiffré de bout en bout). Le choix P2P a une conséquence métier forte : voir le défi de [[brief_2026-06-15_event-deduplication]].
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
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 reads a global event index
|
||||
---
|
||||
|
||||
# Data scopes and discovery
|
||||
|
||||
The **product** model of who sees what, and of how events are found. This is **domain**: the technical *how* (documents, capabilities, index) is handled by the `@ng-eventually/client` data SDK — the app only states **the business intent**.
|
||||
|
||||
## Three scopes per piece of data
|
||||
|
||||
Every entity is stored in the **scope** matching who must be able to read it:
|
||||
|
||||
| Entity | Scope | Who reads it |
|
||||
|---|---|---|
|
||||
| Event (the anchor) | **public** | everyone |
|
||||
| Meeting point | **public** | everyone |
|
||||
| Network profile (name, avatar, bio, city, interests) | **protected** | the holder + their connections |
|
||||
| Participation / sign-up to a meeting point | **protected** | the participant + their connections |
|
||||
| Connection index | **protected** | the holder + their connections |
|
||||
| Private profile (settings, email, preferences) | **private** | the holder alone |
|
||||
| Connection A↔B (bilateral link, + future messaging) | **dialog** | both users |
|
||||
|
||||
Guiding principle: **the "public" side (meeting point, event) and the "personal" side (profile, participations, connections) coexist within a single user.** Personal information is reserved to the **network** (bilateral connections), never visible to an ordinary user.
|
||||
|
||||
- **Meeting points / events are universally public.** Any user can read them and subscribe; creating a meeting point makes you its host, declaring an event makes you its declarer (no prerequisite).
|
||||
- **The host is the sole holder of write rights** on their meeting point; the declarer has no particular right over the meeting points grafted onto their event.
|
||||
- **Bilateral connection**: `DemandeDeConnexion` (unilateral, transient) → `Connexion` (bilateral, persistent) — the latter opens access to the other person's *protected* data.
|
||||
|
||||
Festipod **places each entity in the store of its scope**; isolation between scopes is **handled by the data SDK**, not by application code (see concept `app-security`).
|
||||
|
||||
## Event discovery
|
||||
|
||||
A user discovers the events they did not create through a **global index**: the SDK reads that index, which yields the references (NURIs) of the event documents, then synchronizes and queries locally. **Primary** discovery goes through that index; a **secondary**, relational axis is layered on top (the connections' *protected* participations: "my friends are attending…").
|
||||
|
||||
> **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, **anonymous otherwise**. This "identified if known, anonymous otherwise" is a property of the data model — the app relies on it, the mechanism is provided by the SDK.
|
||||
|
||||
## 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**.
|
||||
|
||||
> The detailed authorization matrix by actor × verb lives in the `app-security` concept ([[brief_2026-05-18_authorization-matrix]]).
|
||||
@@ -1,27 +1,25 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: What is implemented today (event + meeting point lifecycle, profiles, connections) vs the evolutions identified but not built (communities, subscriptions, curated lists, multi-user)
|
||||
summary: Ce qui est implémenté aujourd'hui (cycle événement + point de rencontre, profils, connexions) vs les évolutions identifiées mais non faites (communautés, abonnements, listes curated, multi-user)
|
||||
---
|
||||
|
||||
# Current features vs upcoming evolutions
|
||||
# Fonctionnalités actuelles vs évolutions à venir
|
||||
|
||||
## Implemented (screens reachable through the router)
|
||||
## Implémenté (écrans visibles via le router)
|
||||
|
||||
- Authentication through a NextGraph wallet
|
||||
- Event lifecycle (declaration, viewing, update)
|
||||
- Meeting point lifecycle (attached to an event)
|
||||
- Signing up to / withdrawing from a meeting point
|
||||
- List of an event's participants
|
||||
- User profile, profile update, profile sharing
|
||||
- Friends list (connections), another user's profile
|
||||
- Authentification via wallet NextGraph
|
||||
- Cycle de vie d'événement (déclaration, consultation, mise à jour)
|
||||
- Cycle de vie de point de rencontre (rattaché à un événement)
|
||||
- Inscription / désinscription à un point de rencontre
|
||||
- Liste des participants à un événement
|
||||
- Profil utilisateur, mise à jour, partage de profil
|
||||
- Liste d'amis (connexions), profil d'un autre utilisateur
|
||||
|
||||
> 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.
|
||||
> Réserve : certaines actions de données restent des no-ops en l'état (ex. `joinEvent`/`leaveEvent` côté `FestipodDataContext` — détail dans [[brief_2026-05-21_fork-nextgraph-inbox]] §Couche 3). Le router et les écrans existent, mais le branchement données suit le chantier multi-store.
|
||||
|
||||
> **Product reservation — persistence is not guaranteed end to end.** An event that was created can **disappear** after a period of inactivity followed by a reconnection under the same identity (same wallet). This is an **open defect**, not a property of the product model: impact and pointer on the `data-layer` side → [[caveat_write-durability-across-disconnect]]. Consequence for the domain: "my events / my sign-ups" behave as *implemented* but **not yet as durable** — do not build any product promise (reminders, history, commitment) on top of them while this caveat is open. The `src/modules/event/features/reconnexion-*.feature` scenarios of the `event` module are the non-regression guard for that promise (execution status: concept `bdd-testing`).
|
||||
## Évolutions identifiées (non implémentées)
|
||||
|
||||
## Identified evolutions (not implemented)
|
||||
|
||||
- **Subscribing to an interest community** in order to discover its events (distributed discovery).
|
||||
- **Subscribing to a user** in order to follow their declarations without being a friend.
|
||||
- **Curated lists** — creating/sharing editorialized selections.
|
||||
- **Collaborative multi-user**: actually sharing a meeting point seen by several users, resting on the public/protected/private scopes (see [[knowledge_data-scopes-and-discovery]]).
|
||||
- **Abonnement à une communauté d'intérêt** pour découvrir ses événements (discovery distribué).
|
||||
- **Abonnement à un utilisateur** pour suivre ses déclarations sans être ami.
|
||||
- **Listes curated** — créer/partager des sélections éditorialisées.
|
||||
- **Multi-utilisateurs collaboratif** : aujourd'hui chaque utilisateur a ses données isolées dans son wallet. Le passage collaboratif (un point de rencontre vu par plusieurs) suppose un refactor de la couche données — voir [[brief_2026-05-17_multi-store-refactor]].
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
type: _overview
|
||||
summary: NextGraph comme système EXTERNE (stores, permissions, inbox, modèle d'intégration iframe, limites SDK) + les 4 briefs prospectifs qui dérivent la structure de données cible et le chemin multi-user de Festipod
|
||||
triggers:
|
||||
keywords: [nextgraph-rs, store, group store, dialog store, protected_store, public_store, inbox, capability, nuri, fork, ngd, broker, verifier, multi-store, multi-user, sharedWalletShim, storeRegistry, permission, social_query, OpenRepo, wallet, auto-import, textcode, wallet import, AccessGateScreen]
|
||||
paths: ["src/shared/utils/ngGraph.ts", "src/shared/hooks/useShapeWithDefaults.ts", "scripts/build-ng-packages.sh", "src/modules/auth/sharedWallet.ts", "src/modules/auth/screens/AccessGateScreen.tsx"]
|
||||
---
|
||||
|
||||
# NextGraph platform
|
||||
|
||||
Deux choses ici, distinctes du concept `data-layer` (qui décrit l'**usage actuel** de NextGraph par l'app) :
|
||||
|
||||
1. **Référence du système externe NextGraph** — ses primitives de stockage et de permission, son inbox, son modèle d'intégration/déploiement, et ce que son SDK JS expose (ou pas).
|
||||
2. **Briefs prospectifs** — la dérivation de la structure de données *cible* de Festipod et les chemins pour y arriver (stopgap wallet partagé, refactor multi-store, fork moteur pour l'inbox).
|
||||
|
||||
> Le code de l'app touché par ces chantiers : `src/shared/utils/ngGraph.ts`, `useShapeWithDefaults.ts`, `FestipodDataContext.tsx`, `ngBootstrap.ts` — les seams du futur multi-store. Le modèle de **confidentialité/autorisations** (qui peut faire quoi) vit dans le concept `app-security` ([[brief_2026-05-18_authorization-matrix]]) ; ces chantiers data en sont l'infrastructure.
|
||||
|
||||
## Source locale
|
||||
|
||||
Le repo `nextgraph-rs` est cloné en `/home/sylvain/projects/nextgraph/nextgraph-rs` (soit `../../nextgraph/nextgraph-rs` depuis la racine projet). À consulter pour vérifier ce qui est réellement exposé au protocole/SDK plutôt que la doc.
|
||||
|
||||
## Référence (système externe)
|
||||
|
||||
- [[knowledge_stores-permissions]] — 5 types de stores, document/repo, capabilities/Nuri, inbox, exposition SDK JS
|
||||
- [[knowledge_integration-model]] — paquets JS, modèle iframe, où tourne le verifier, broker `ngd`, déploiement, reciblage build-time
|
||||
- [[knowledge_broker-import-constraint]] — le broker hébergé n'autorise pas l'auto-import d'un wallet par une web-app tierce (vérifié 2026-06-17)
|
||||
|
||||
## Briefs (chantiers prospectifs)
|
||||
|
||||
- [[brief_2026-05-17_multi-store-refactor]] — passer du mono-store actuel à une structure par entité
|
||||
- [[brief_2026-06-15_shared-wallet-shim]] — stopgap staging : wallet partagé unique + `storeRegistry`
|
||||
- [[brief_2026-05-21_fork-nextgraph-inbox]] — forker `nextgraph-rs` pour exposer l'inbox au SDK JS
|
||||
|
||||
## Décisions
|
||||
|
||||
- [[decision_2026-06-17_assisted-wallet-import]] — distribution du wallet partagé par import assisté (l'auto-import zéro-touche étant impossible)
|
||||
@@ -0,0 +1,84 @@
|
||||
---
|
||||
type: brief
|
||||
summary: Passer du mono-store actuel (tout dans private_store) à une structure de stores par entité ; hardcoding dans ngGraph.ts + useShapeWithDefaults ; contrainte SDK bloquante (Group stores/inbox non exposés) ; refactor structurel possible avec placeholders en attendant l'API
|
||||
last_updated: 2026-05-17
|
||||
---
|
||||
|
||||
# Refactor multi-store NextGraph
|
||||
|
||||
**Status:** Incubating — aucun travail démarré
|
||||
**Last updated:** 2026-05-17
|
||||
|
||||
## Context
|
||||
|
||||
L'app est aujourd'hui *mono-store* : tout (events, profils, participations, friendships) atterrit dans le `private_store` de l'utilisateur connecté. Héritage de l'exemple expense-tracker-rdf, formalisé dans la décision du 2026-03-17 (concept `data-layer`, [[decision_2026-03-17_private-store-nuri-scope]]).
|
||||
|
||||
Ce choix bloque le multi-utilisateurs : le `private_store` est non partageable (*« not possible to share the documents of your private store »*, cf. [[knowledge_stores-permissions]]). Tant que tout y est, Bob ne verra jamais l'event d'Alice. Le modèle natif NextGraph est *multi-store par utilisateur* — Festipod doit s'y aligner avant de devenir collaboratif.
|
||||
|
||||
**Déclencheur :** discussion du 2026-05-17 — *poser le cap, exécuter plus tard*.
|
||||
|
||||
## What We Know
|
||||
|
||||
### État actuel du code
|
||||
|
||||
Deux fichiers concentrent le hardcoding du store unique :
|
||||
- `src/shared/utils/ngGraph.ts` — `ensureGraphNuri()` retourne `did:ng:${session.private_store_id}` pour TOUTES les entités.
|
||||
- `src/shared/hooks/useShapeWithDefaults.ts` — accepte un `storeNuri` mais l'appelant unique (`FestipodDataContext`) lui passe toujours le NURI du private_store.
|
||||
|
||||
Entités impactées (toutes mélangées) : `FpEvent` (→ store partagé), `FpUserProfile` (→ partie privée/publique), `FpParticipation` (→ avec son event), `FpMeetingPoint` (local-only aujourd'hui), `FpFriendship` (local-only, privée). Cf. concept `data-layer` §entités.
|
||||
|
||||
### Modèle cible proposé
|
||||
|
||||
> **Note (2026-05-19)** : [[brief_2026-05-18_authorization-matrix]] a depuis dérivé, à partir des seuls points validés, une structure différente — 3 stores natifs par utilisateur + Dialog stores, **sans Group store** dans le périmètre actuel. La structure à 4 niveaux ci-dessous reste pertinente pour le périmètre élargi (communautés, collaboration multi-hôte), aujourd'hui hors périmètre. À reconcilier à l'exécution.
|
||||
|
||||
Structure hiérarchique en **4 niveaux de Group stores** : index communautaire ⊃ communauté ⊃ event ⊃ meeting point.
|
||||
|
||||
| Entité | Store cible | Justification |
|
||||
|---|---|---|
|
||||
| Event (métadonnées) | Group « communauté » | La communauté possède l'event → contrôle qui le modifie |
|
||||
| Référence d'event (pointeur) | Group « index communautaire » | Discovery |
|
||||
| Participation | Group « event » | N'a de sens que dans son event |
|
||||
| MeetingPoint (métadonnées) | Group « event » | Le RDV appartient à l'event |
|
||||
| Participation à un MeetingPoint | Group « meeting point » | RSVP scopé au RDV |
|
||||
| UserProfile (partie publique) | public_store de l'utilisateur | Modèle natif |
|
||||
| Friendship | private_store de l'utilisateur | Purement personnelle |
|
||||
|
||||
### Contrainte SDK bloquante
|
||||
|
||||
Primitives présentes au protocole mais **non exposées dans `@ng-org/web`** (vérifié `0.1.2-alpha.13`) : création de Group stores + invitations/permissions ; **dépôt/lecture d'inbox** (mécanisme retenu pour la notif d'inscription, cf. [[brief_2026-05-18_authorization-matrix]]). `app_request_stream` est la méthode générique la plus susceptible de porter ce mécanisme une fois exposée (à confirmer côté Rust). Cf. [[knowledge_stores-permissions]] §Limites SDK.
|
||||
|
||||
**Implication :** le refactor *structurel* peut commencer sans attendre l'API, avec des placeholders (continuer à pointer `private_store_id` pour les Group stores impossibles). L'**aboutissement complet** (vrai multi-user) dépend de l'arrivée de l'API ou d'un contournement (voir [[brief_2026-05-21_fork-nextgraph-inbox]], [[brief_2026-06-15_shared-wallet-shim]]).
|
||||
|
||||
### Implications côté code
|
||||
|
||||
1. **Disparition de `ensureGraphNuri()`** comme helper unique → helpers par entité ou couche `storeRegistry` résolvant le NURI selon `(entité, contexte)`.
|
||||
2. **`useShapeWithDefaults` reste un wrapper** mais l'appelant choisit explicitement le store (N appelants demain).
|
||||
3. **Chaque entité déclare son store cible** (mapping centralisé ou convention shape→store).
|
||||
4. **`bootstrapWallet()`** (`src/shared/utils/ngBootstrap.ts`) revu : seed réparti, ou seed = données de l'utilisateur courant seulement.
|
||||
5. **`FestipodDataContext`** : hooks par entité, chacun avec son store résolu.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Quand crée-t-on un Group store de communauté (API absente) ? Acte explicite vs communauté par défaut ?
|
||||
2. Comment Bob connaît-il l'index communautaire d'Alice ? (possiblement via le public_store d'Alice)
|
||||
3. Faut-il vraiment 4 niveaux ? Le « meeting point = group store » mérite validation.
|
||||
4. Que devient le seed de démo quand les Group stores n'existent pas encore ?
|
||||
5. Migration des wallets de test existants (script / wipe-reseed / ignore) ?
|
||||
6. Bootstrap d'un user vierge : auto-créer un Group store « par défaut » ou attendre ?
|
||||
|
||||
## Possible Approaches
|
||||
|
||||
- **Refactor structurel d'abord, partage ensuite** (placeholders `private_store_id`).
|
||||
- **Registry centralisé** vs **résolution par convention**.
|
||||
- **Big-bang** vs **par entité** (commencer par Event).
|
||||
- **Maintenir un mode mono-store** parallèle pour dev/demo.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
Invitation effective (capability sharing), permissions par rôle, discovery cross-wallet, contournement de l'UI wallet, mode P2P direct sans broker. → second chantier multi-user dont ce refactor est le prérequis structurel.
|
||||
|
||||
## Starting Points
|
||||
|
||||
- Concept `data-layer` → [[decision_2026-03-17_private-store-nuri-scope]] (la décision qu'on viendra modifier), état du pattern d'écriture
|
||||
- `src/shared/utils/ngGraph.ts`, `src/shared/hooks/useShapeWithDefaults.ts`, `src/shared/context/FestipodDataContext.tsx`, `src/shared/utils/ngBootstrap.ts`
|
||||
- NextGraph docs : [Documents et Stores](https://docs.nextgraph.org/en/documents/), [Getting started](https://docs.nextgraph.org/en/getting-started/)
|
||||
@@ -0,0 +1,96 @@
|
||||
---
|
||||
type: brief
|
||||
summary: Forker temporairement nextgraph-rs pour exposer l'inbox au SDK JS (notif d'inscription, anonymat via from optionnel) — 3 couches : patch Rust (4 fichiers), auto-hébergement ngd+ng-app sur Coolify, intégration Festipod ; fork jetable abandonné quand l'upstream livrera sa solution
|
||||
last_updated: 2026-05-21
|
||||
---
|
||||
|
||||
# Forker NextGraph pour exposer l'inbox au SDK JS
|
||||
|
||||
**Status:** Incubating — aucun travail démarré
|
||||
**Last updated:** 2026-05-21
|
||||
|
||||
## Context
|
||||
|
||||
Festipod doit notifier l'hôte d'un PdR quand quelqu'un s'inscrit, avec **identification si connexion / anonyme sinon** (cf. décision cadre inbox dans [[brief_2026-05-18_authorization-matrix]]). L'**inbox** NextGraph est idéale (le `from` optionnel donne l'anonymat) **mais n'est pas exposée au SDK JS** (cf. [[knowledge_stores-permissions]] §Inbox). Ce brief évalue **forker/patcher `nextgraph-rs`** pour l'exposer.
|
||||
|
||||
### Posture stratégique (cadrée par l'utilisateur)
|
||||
|
||||
Le fork est **explicitement temporaire, non destiné à l'upstream**. Hypothèse : NextGraph finira par exposer sa **propre** solution d'inbox au SDK JS, **possiblement différente**. Quand elle arrivera, on **abandonne le fork et on adapte Festipod**. Tant que leur solution n'est pas là : maintenir le fork à jour (rebase sur `upstream/main`, qui bouge vite en `0.1.2-alpha`) ; **déployer broker + ng-app depuis le fork** ; surveiller l'upstream pour basculer dès que possible. On ne vise **pas** une PR.
|
||||
|
||||
## What We Know
|
||||
|
||||
Trois couches.
|
||||
|
||||
### Couche 1 — Le patch Rust : 4 fichiers (broker vanilla)
|
||||
|
||||
1. **`engine/net/src/types.rs`** — `InboxMsgContent::Link` est une variante **unit** (stub) ; lui donner un payload (ou variante `Notification`) portant le NURI du PdR + lien vers l'`Inscription`. Ajouter un builder `InboxPost::new_link(...)` calqué sur `new_contact_details`. `from = None` → anonymat.
|
||||
2. **`engine/verifier/src/request_processor.rs`** — ajouter le bras de commande manquant (pas de bras `InboxPost`). Idéalement une commande haut-niveau (`NotifyInbox`) construisant le post côté Rust (garde le scellement crypto en Rust). Calquer sur `SocialQueryStart`.
|
||||
3. **`sdk/js/lib-wasm/src/lib.rs`** — exposer `pub async fn inbox_post_link(session_id, to_inbox_nuri, to_profile_nuri, link, anonymous)`, calqué sur `social_query_start`.
|
||||
4. **`engine/verifier/src/inbox_processor.rs`** (`process_inbox`) — bras de réception qui **matérialise** le message en document dans le store de l'hôte (calquer sur le handler `ContactDetails`). L'app lit ensuite via ORM/SPARQL — pas de nouvelle API de lecture d'inbox.
|
||||
|
||||
**Résolution d'identité** (connu/anonyme) : gratuite via SPARQL côté app (JOIN du NURI d'inbox émetteur contre les docs `social:contact`). **Découverte de l'inbox de l'hôte** : embarquer le NURI d'inbox du `public_store` de l'hôte dans le doc PdR ou le profil public (le flux QR-code de partage de profil le porte déjà).
|
||||
|
||||
### Couche 2 — Déploiement (depuis le fork)
|
||||
|
||||
Détail dans [[knowledge_integration-model]]. Le verifier patché tourne **dans l'iframe ng-app** → **construire et auto-héberger le `ngd` + le ng-app** depuis le fork, puis rebuilder le `@ng-org/web` de Festipod avec `NG_REDIR_SERVER`/`NG_DEV*` pointant sur ce ng-app. **Aucune réécriture de l'intégration Festipod** (reste iframe). Le routage inbox du broker est déjà natif, mais comme on auto-héberge le ng-app patché, **on déploie toute la stack depuis le fork** (un seul arbre source).
|
||||
|
||||
- **Local** : `ngd` + ng-app du fork ; Festipod buildé avec `NG_DEV`/`NG_DEV_LOCAL_BROKER`.
|
||||
- **Serveur de test** : `ngd` + ng-app du fork sur notre domaine ; Festipod buildé avec `NG_REDIR_SERVER=notre-domaine`.
|
||||
|
||||
#### Hébergement sur Coolify — 3 pièces web
|
||||
|
||||
1. **`ngd`** — démon WebSocket **stateful** : conteneur avec **volume persistant** pour `--base-path` (RocksDB + clés + PeerId, jamais wipé), mode `--domain` derrière le Traefik de Coolify. Build : Dockerfiles officiels cassés → **écrire notre Dockerfile multi-stage Rust** (RocksDB exige llvm/clang). Premier démarrage **interactif** (lien d'invitation wallet admin) → scripter via `ngcli` ou faire une fois à la main puis persister dans le volume.
|
||||
2. **ng-app** (frontend iframe, wasm patché) — **build statique** (`pnpm webfilebuild`). Servi en statique (buildpack ou nginx).
|
||||
3. **Routage** : un même domaine sert le statique du ng-app ET proxifie le WebSocket vers ngd.
|
||||
|
||||
Plus **Festipod** lui-même (app Bun → skill `coolify-hosting` pour CELLE-CI, pas pour le `ngd` Rust). Drivers de complexité : build Rust+RocksDB sans Dockerfile prêt, conteneur stateful à volume critique, premier-run interactif, double-service (statique + WS).
|
||||
|
||||
### Couche 1 (libs JS) — paquets npm clients patchés
|
||||
|
||||
**On maintient des versions patchées des paquets clients, pas seulement le wasm.** Le forwarding générique permet *techniquement* d'atteindre une méthode wasm sans toucher le JS, mais c'est un **hack** (non typé, fragile) — test rapide seulement. À modifier réellement :
|
||||
|
||||
- **`@ng-org/web`** — modifié de toute façon (URL broker) → y ajouter `inbox_post_link` dans la **surface d'API typée + `.d.ts`**.
|
||||
- **Méthodes streamées** (si lecture inbox en *flux* un jour) — entrée des deux côtés (`E` + `streamed_api`). Pour la seule **écriture** (requête/réponse), inutile.
|
||||
- **`@ng-org/orm`** — à modifier **si** on intègre l'écriture inbox au flux ORM. Sinon (appel `ng.inbox_post_link` à côté), inutile.
|
||||
- **`@ng-org/alien-deepsignals`, `@ng-org/shex-orm`** — a priori inchangés.
|
||||
|
||||
#### Outillage existant : `scripts/build-ng-packages.sh`
|
||||
|
||||
`bun run build:ng` build les 4 paquets depuis `$NEXTGRAPH_RS/sdk/js/*` (défaut `../../nextgraph/nextgraph-rs`) → `pnpm pack` → `.tgz` dans `.ng-tarballs/` → `bun add` réécrit `package.json` vers les tarballs locaux. **Pattern d'origine du projet** : le commit `fd6d408` l'a abandonné quand les alphas ont été publiées sur npm. Pour repasser au custom : **réactiver `bun run build:ng`**. Nuances : `@ng-org/web` est TS pur (le script crée un *stub* `lib-wasm` ; le tarball porte l'API inbox typée + l'URL broker bakée, **pas** le wasm) ; pointer le script sur la **branche patchée** (retirer le `git pull --ff-only`) ; option recommandée : patcher `@ng-org/web` pour lire l'URL broker au **runtime** (évite de rebuilder par domaine).
|
||||
|
||||
### Couche 3 — Intégration dans Festipod
|
||||
|
||||
Exposer la méthode ne suffit pas. Chantiers (certains préexistent à l'inbox) :
|
||||
|
||||
- **Modéliser le PdR.** Les SHEX (`src/shared/shapes/shex/festipodShapes.shex`) ne définissent qu'`Event`/`UserProfile`/`Participation` — **pas de `MeetingPoint`** (local-only), ni d'entité notification. Ajouter les shapes + `bun run build:orm`.
|
||||
- **Implémenter l'inscription (aujourd'hui no-op).** Dans `FestipodDataContext.tsx`, `joinEvent`/`leaveEvent` sont des `console.log`. Le vrai flux : (a) écrire l'`Inscription` dans le `protected_store` de l'inscrit (via multi-store, [[brief_2026-05-17_multi-store-refactor]]), (b) appeler `ng.inbox_post_link(...)` pour notifier l'inbox du PdR.
|
||||
- **Porter le NURI d'inbox de l'hôte** sur le doc PdR (ou lookup profil).
|
||||
- **Lire et résoudre les notifications côté hôte** : lire les docs notification matérialisés (ORM/SPARQL), JOIN identité contre `social:contact`. UI : « N inscrits dont X identifiés ».
|
||||
- **Câblage session** via `src/shared/utils/ngSession.ts`.
|
||||
|
||||
**Dépendances** : présuppose (1) le fork SDK livré, (2) le refactor multi-store. **Surface jetable** : à l'arrivée de l'API officielle, migrer aussi ces points d'appel Festipod.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- `NotifyInbox` haut-niveau vs `InboxPost` brut ? (haut-niveau préféré, garde la crypto en Rust)
|
||||
- Où sourcer le NURI d'inbox de l'hôte (doc PdR vs lookup profil) ?
|
||||
- Forme de la matérialisation côté réception (quels triples) ?
|
||||
- Suppression côté inbox : un déposant peut-il retirer son dépôt ? (résiduelle, cf. [[brief_2026-05-18_authorization-matrix]])
|
||||
- Cadence de rebase du fork ? Critère de bascule vers la solution upstream ?
|
||||
- `@ng-org/web` : patch runtime vs tarball par domaine ?
|
||||
- `ngd` Coolify : automatiser le premier-run vs one-shot manuel persisté ? Un service (reverse-proxy maison) ou deux ?
|
||||
|
||||
## Possible Approaches
|
||||
|
||||
- **A. Fork temporaire + auto-hébergement (retenu comme stopgap)** — patch des 4 fichiers, déploiement depuis le fork. Vrai inbox, anonymat natif. Coût : maintenir le fork + héberger. Jetable.
|
||||
- **B. Contribution upstream — écartée** comme objectif.
|
||||
- **C. Pas de patch, détourner `social_query_start`** — repli, livrable tout de suite mais limité aux **contacts** (pas d'anonyme vers un hôte non-connecté).
|
||||
|
||||
> Voir aussi [[brief_2026-06-15_shared-wallet-shim]] : le vrai multi-user (lecture cross-wallet) suppose en plus un patch `OpenRepo` + capabilities, au-delà de l'inbox.
|
||||
|
||||
## Starting Points
|
||||
|
||||
- [[knowledge_integration-model]], [[knowledge_stores-permissions]]
|
||||
- [[brief_2026-05-18_authorization-matrix]] — la décision cadre inbox que ce patch sert
|
||||
- Repo local `nextgraph-rs` : `sdk/js/lib-wasm/src/lib.rs`, `engine/verifier/src/{request_processor,inbox_processor}.rs`, `engine/net/src/types.rs`
|
||||
- Remotes : `origin` = `git.nextgraph.org/slaivyn/nextgraph-rs` (fork perso), `upstream` = `git.nextgraph.org/NextGraph/nextgraph-rs`
|
||||
@@ -0,0 +1,183 @@
|
||||
---
|
||||
type: brief
|
||||
summary: Stopgap staging multi-user — deux visions cadrées. Lointaine (cible) — multi-wallet, 3 stores natifs par utilisateur + Dialog, 1 document par entité (événement/PdR), partage par capabilities, inbox native du PdR. Adaptée (stopgap) — UN wallet partagé, 1 document par entité dans son private_store, périmètre = métadonnée logique + index, filtre d'isolation applicatif, login simulé. Obstacles NextGraph — pas de lecture cross-wallet (OpenRepo TODO, ReadCap ignoré), capabilities/inbox non exposées au SDK, login non programmable. sharedWalletShim + filtre = jetables à la migration.
|
||||
last_updated: 2026-06-16
|
||||
---
|
||||
|
||||
# Stopgap multi-user : wallet partagé unique (`sharedWalletShim`)
|
||||
|
||||
**Status:** En cours — compte/login + isolation livrés et vérifiés ; **granularité 1 document par entité implémentée** ; primitives + **fan-out multi-documents validés sur broker** (`@data`, 3 scénarios). Reste : reactivity in-app de la création (best-effort) + seeding multi-doc.
|
||||
|
||||
## Objectif & posture
|
||||
|
||||
Mettre Festipod en **staging** avec des **utilisateurs amicaux**, **sans enjeu de sécurité**, branché sur le vrai NextGraph, et **structuré au plus près de l'infra cible** pour qu'une migration soit un simple changement de résolveur, pas une réécriture.
|
||||
|
||||
Trois choses doivent rester nettes pour ne pas dériver, et structurent ce brief :
|
||||
|
||||
1. la **vision lointaine** — ce qu'on aura quand NextGraph offrira lecture cross-wallet, capabilities et inbox ;
|
||||
2. les **obstacles NextGraph** — ce qui, aujourd'hui, empêche cette vision ;
|
||||
3. la **vision adaptée** (stopgap) — au plus proche de la lointaine, compte tenu des obstacles.
|
||||
|
||||
> **Invariant directeur** : tout ce que fait la vision adaptée doit avoir une **correspondance 1:1** explicite avec la vision lointaine (table en fin de section adaptée). Si un choix du stopgap n'a pas d'image claire dans la cible, c'est un signal de dérive.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision lointaine (cible finale)
|
||||
|
||||
Dérivée de [[brief_2026-05-18_authorization-matrix]] et [[knowledge_stores-permissions]]. Périmètre **validé** (hors communautés / listes curées / suivi, encore hors périmètre).
|
||||
|
||||
### Identité & login
|
||||
- **1 utilisateur = 1 wallet NextGraph.** Le wallet **est** l'identité ; pas de compte applicatif séparé.
|
||||
- **Login = ouvrir son propre wallet** (redirect broker). C'est un vrai login par-utilisateur.
|
||||
|
||||
### Stores & granularité documents
|
||||
Modèle natif : `1 document = 1 repo = 1 frontière de permission = 1 inbox`. Un **store** est un document-conteneur qui regroupe et permissionne d'autres documents. Chaque utilisateur a **3 stores natifs** ; les entités sont des **documents individuels** dedans (pas un gros graphe par store).
|
||||
|
||||
| Entité | Store (propriétaire) | Granularité | Notes |
|
||||
|---|---|---|---|
|
||||
| Événement | `public_store` du déclarant | **1 document / événement** | adressable par NURI (utile pour la déduplication) |
|
||||
| Point de rencontre (PdR) | `public_store` de l'hôte | **1 document / PdR** | **possède son inbox native** (reçoit les inscriptions) |
|
||||
| Profil réseau | `protected_store` | 1 document | nom, avatar, bio, ville, intérêts |
|
||||
| Participation / Inscription | `protected_store` de l'inscrit | 1 document / inscription | + dépôt d'un lien dans l'**inbox du PdR** |
|
||||
| Profil privé (settings, email) | `private_store` | 1 document | soi seul |
|
||||
| Connexion A↔B | **Dialog store** A↔B | doc connexion (+ messagerie) | deux écrivains |
|
||||
| Index des connexions | `protected_store` | 1 document | liste les NURIs des Dialog stores |
|
||||
|
||||
**Aucun Group store** sur le périmètre validé (les 3 stores + Dialog + inboxes suffisent).
|
||||
|
||||
### Partage & visibilité
|
||||
- Par **capabilities** : on transmet un Nuri portant un read/write cap. **Public** = lisible par tous sans cap. **Protected** = cap obtenue via la connexion. **Privé** = soi.
|
||||
- Ajout de permission asynchrone ; retrait synchrone (SyncSignature).
|
||||
|
||||
### Inbox & notifications
|
||||
- L'**inbox native du document PdR** reçoit les dépôts d'inscription (lien DID cap). `from` optionnel ⇒ **identifié si connexion de l'hôte, anonyme sinon**, gratuitement.
|
||||
|
||||
### Découverte
|
||||
- **Pas d'annuaire central.** On découvre via les `public_store` et le graphe de connexions (et plus tard `social_query`).
|
||||
|
||||
---
|
||||
|
||||
## 2. Obstacles côté NextGraph (ce qui empêche la vision lointaine aujourd'hui)
|
||||
|
||||
Vérifiés dans `nextgraph-rs` (2026-06-15).
|
||||
|
||||
| Élément de la cible | Obstacle actuel | Preuve |
|
||||
|---|---|---|
|
||||
| Lire le store d'un **autre** utilisateur | `OpenRepo` **non implémenté** ; un NURI étranger lève `RepoNotFound` ; une session ne contient que ses 3 stores dans `self.repos` | `engine/verifier/src/verifier.rs:1423`, `request_processor.rs` `resolve_target` |
|
||||
| Partager une **capability** (Nuri + droits) | non exposé au SDK ; le champ `access`/`ReadCap` du NURI **n'est jamais inspecté** | [[knowledge_stores-permissions]] |
|
||||
| **Inbox** d'un document (notif d'inscription) | pas exposée au SDK JS (nécessite un fork moteur) | [[brief_2026-05-21_fork-nextgraph-inbox]] |
|
||||
| **Login per-utilisateur** fluide | login **non programmable** (redirect web vers le broker) | [[decision_2026-06-15_shared-wallet-login-flow]] |
|
||||
|
||||
**Conséquence centrale** : tant que la lecture cross-wallet n'existe pas, **aucune donnée ne franchit la frontière entre deux wallets**. Bob ne peut pas lire le `public_store` d'Alice. Toute approche « chacun son wallet » est donc bloquée à la racine.
|
||||
|
||||
---
|
||||
|
||||
## 3. Vision adaptée (stopgap) — au plus proche de la cible
|
||||
|
||||
### Principe : UN wallet partagé
|
||||
Tous les utilisateurs amicaux ouvrent **le même** wallet. NextGraph ne voit qu'une identité → **tout est techniquement lisible** (on contourne l'absence de lecture cross-wallet en supprimant la frontière). Le « multi-utilisateur » devient une **fiction applicative**.
|
||||
|
||||
### Granularité documents — **identique à la cible** : 1 document par entité
|
||||
Pour rester fidèle, on reproduit les **deux niveaux** de la cible (conteneur → documents) :
|
||||
|
||||
- chaque **événement** et chaque **PdR** = **son propre document** (`doc_create`), tous physiquement dans le `private_store` de l'unique wallet partagé ;
|
||||
- le **périmètre** (public/protected/private) est une **métadonnée logique** portée par le document, **pas** un store physique ;
|
||||
- un **document-index par (utilisateur × périmètre)** liste les NURIs des entités de ce périmètre — il **joue le rôle du futur store-conteneur** (`docPublic` ≈ futur `public_store`, etc.).
|
||||
|
||||
Garder la granularité « 1 doc par entité » est ce qui rend la migration 1:1 **et** ce qui rendra l'**inbox du PdR** possible plus tard sans refonte (l'inbox est un attribut de document).
|
||||
|
||||
| Entité | Périmètre logique | Document stopgap | Indexé dans |
|
||||
|---|---|---|---|
|
||||
| Événement | public | 1 doc / événement | `docPublic` du déclarant |
|
||||
| PdR | public | 1 doc / PdR | `docPublic` de l'hôte |
|
||||
| Profil réseau | protected | doc profil réseau | `docProtected` |
|
||||
| Participation | protected | 1 doc / participation (ou groupé) | `docProtected` de l'inscrit |
|
||||
| Profil privé | private | doc settings | `docPrivate` |
|
||||
| Connexion A↔B | dialog | doc connexion | index de connexions |
|
||||
|
||||
### `sharedWalletShim` (échafaudage, sans équivalent cible)
|
||||
Index des **comptes** simulés → leurs documents-index : `username → profileId → { docPublic, docProtected, docPrivate }`. Ancré dans le `private_store` du wallet partagé (`session.private_store_id`, toujours connu → ancre de bootstrap). Rend possibles le **login cross-device** et le **picker d'utilisateurs**. **N'a aucun équivalent cible** (la cible n'a pas d'annuaire central) → **jetable**.
|
||||
|
||||
### Partage simulé : filtre d'isolation applicatif
|
||||
Un seul wallet ⇒ tout lisible. Pour **se comporter** comme la cible, la couche données filtre les lectures par `currentAccountId` + connexions : `private` → propriétaire ; `protected` → propriétaire + connexions ; `public` → tous. Remplace les **capabilities** (pas appliqué par la crypto, mais honoré par l'app) → **jetable**.
|
||||
|
||||
### Identité & login simulés
|
||||
- **Couche réelle (technique, invisible)** : le redirect broker du wallet partagé, présenté comme **barrière d'accès à l'environnement** (pas un login). Cf. [[decision_2026-06-15_shared-wallet-login-flow]].
|
||||
- **Couche applicative (le login perçu)** : écran « Connexion » = **username seul** (déclaratif, sans mot de passe) → `localStorage`. « Déconnexion » = efface le username, sans toucher NG. Vrai logout planqué.
|
||||
|
||||
### Inbox / notification d'inscription
|
||||
**Hors périmètre du stopgap** (nécessite le fork). Mais la granularité « 1 doc/PdR » est le **pré-requis** qui la rendra branchable plus tard sans refonte.
|
||||
|
||||
### Correspondance stopgap → cible (l'invariant 1:1)
|
||||
|
||||
| Stopgap | Vision lointaine | Migration |
|
||||
|---|---|---|
|
||||
| doc événement (dans le wallet partagé) | doc événement dans le `public_store` du déclarant | déplacer le doc + appliquer cap publique |
|
||||
| doc PdR | doc PdR dans le `public_store` de l'hôte **+ inbox** | déplacer + brancher l'inbox |
|
||||
| `docPublic`/`docProtected`/`docPrivate` (index) | `public_store`/`protected_store`/`private_store` | l'index devient le store natif |
|
||||
| filtre d'isolation applicatif | capabilities (read caps via connexion) | retirer le filtre, poser les caps |
|
||||
| login applicatif (username) | login = ouvrir son wallet | retirer la couche compte |
|
||||
| `sharedWalletShim` | — (rien) | supprimer |
|
||||
| wallet partagé unique | un wallet par utilisateur | éclater par propriétaire |
|
||||
|
||||
**Migration = swap du résolveur `storeRegistry`** (de « doc dans le wallet partagé » vers « doc dans le vrai store du propriétaire ») + déplacement des documents + pose des capabilities + suppression de l'échafaudage. **Les écrans ne changent pas.**
|
||||
|
||||
---
|
||||
|
||||
## Familles de contournement (pourquoi A)
|
||||
|
||||
| Famille | Idée | Verdict |
|
||||
|---|---|---|
|
||||
| **A — wallet partagé** | un seul wallet, multi-user simulé côté app | **retenue** : livrable vite, zéro travail moteur, local-first préservé |
|
||||
| B — NG comme backend | un backend Bun détient un wallet, clients HTTP | écartée : abandonne le local-first, plus lourd |
|
||||
| C — lecture cross-wallet | chacun son wallet, lecture du public des autres | **infaisable** (obstacle §2) sans fork moteur |
|
||||
| D — fork moteur (`OpenRepo` + capabilities) | rendre la cible réelle | hors stopgap : c'est le chemin cible, lourd (cf. [[brief_2026-05-21_fork-nextgraph-inbox]]) |
|
||||
|
||||
---
|
||||
|
||||
## État d'implémentation (2026-06-16)
|
||||
|
||||
Deux drapeaux de build, **OFF par défaut** (le mono-store validé reste le défaut ; dev/`@ui`/`@e2e` inchangés) :
|
||||
- **`FESTIPOD_STAGING=1`** — flux login option 2 (découplé de `NODE_ENV` pour ne pas bloquer `@e2e`).
|
||||
- **`FESTIPOD_MULTISTORE=1`** — couche multi-document (storeRegistry).
|
||||
|
||||
| Pièce | Fichier | État |
|
||||
|---|---|---|
|
||||
| Couche compte (faux login, localStorage) | `src/shared/context/AccountContext.tsx` | ✅ livré, vérifié (build + `@ui`) |
|
||||
| Gate technique + écran « Connexion » + orchestrateur | `src/modules/auth/screens/{AccessGateScreen,ConnexionScreen}.tsx`, `src/app/AuthGate.tsx` | ✅ livré |
|
||||
| Vrai logout planqué | `ngSession.ts:logoutNg`, `SettingsScreen.tsx` | ✅ livré |
|
||||
| Filtre d'isolation (mode connecté) | `src/shared/utils/isolation.ts` + `FestipodDataContext` | ✅ livré, pur, vérifié |
|
||||
| storeRegistry + sharedWalletShim | `src/shared/utils/storeRegistry.ts` | ✅ **1 doc/entité** (`createEntityDoc`/`listEntityDocs` + index par périmètre) ; primitives **validées broker** |
|
||||
| Câblage multi-document (reads fan-out `{graphs}` + write per-entité `createEntityDoc`) | `FestipodDataContext` (useNgData) derrière `MULTISTORE` | ✅ lecture fan-out validée ; ⚠️ reactivity de la création in-app = **best-effort** (le nouveau doc est ajouté au fan-out, l'`@id` peut être en attente jusqu'au re-subscribe) |
|
||||
| Validation broker (`@data`) | `src/modules/workshop/{features/multistore-stopgap.feature, steps/data/multistore.steps.ts}` | ✅ **3 scénarios verts** (ORM-sur-doc-créé, shim r/w, **fan-out par entité**) |
|
||||
|
||||
**Granularité = 1 document par entité (fait)** : public (événements/PdR) → un `doc_create` **par entité**, NURI ajouté à l'**index** du périmètre (le futur store-conteneur) ; protected (profil, participations) → **groupé** dans l'index protected. Lecture publique = `listEntityDocs('public')` → `useShape({graphs:[…]})`. Mono-store (défaut) inchangé.
|
||||
|
||||
**Validation broker (2026-06-16, `@data` contre `nextgraph.net`, 3 scénarios verts)** :
|
||||
1. ✅ `doc_create("Graph","data:graph","store",undefined)` → NURI utilisable comme `@graph` ORM (write+read d'une `Participation` via `useShape({graphs:[nuri]})`). On n'est **pas** limité au `private_store` comme scope (cf. [[rule_private-store-scope]]).
|
||||
2. ✅ Shim r/w : 3 docs créés + `sparql_update`, rechargés via `sparql_query` (`readBindings` tolérant, OK en pratique).
|
||||
3. ✅ **Fan-out par entité** : 2 comptes × 1 doc-événement (`createEntityDoc` + indexé), un `useShape({graphs:[docA,docB]})` lit **les deux** événements, l'index public liste les deux docs.
|
||||
4. ⏳ **Reste** : reactivity de la création in-app (best-effort, à itérer sur broker) + seeding multi-document (auto-seed neutralisé en `MULTISTORE`).
|
||||
|
||||
## Open Questions
|
||||
|
||||
- **Modèle d'écriture de l'événement** (propriétaire / wiki / immuable) — *ouvert dans la matrice*. Propriétaire/immuable → événement = doc dans le `public_store` du déclarant (granularité par entité exacte). **Wiki** → exigerait un Group store (hors périmètre) et **changerait la cible**.
|
||||
- **Création des documents** : `doc_create` à la création de l'entité (retenu par la granularité par entité) ; création paresseuse des index de périmètre.
|
||||
- **Picker d'utilisateurs** : saisie libre vs liste des comptes du `sharedWalletShim`.
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- Le **vrai multi-user** (lecture cross-wallet) — fork moteur (`OpenRepo` + capabilities), voir [[brief_2026-05-21_fork-nextgraph-inbox]].
|
||||
- L'**inbox du PdR** (notif d'inscription) — même fork.
|
||||
- L'**auto-hébergement** du broker/ng-app (staging sur `nextgraph.net`).
|
||||
- Toute **sécurité réelle** (credential partagé, pas de mot de passe, pas de chiffrement par utilisateur).
|
||||
|
||||
## Starting Points
|
||||
|
||||
- [[brief_2026-05-18_authorization-matrix]] — périmètres et partition de la vision lointaine
|
||||
- [[brief_2026-05-17_multi-store-refactor]] — l'indirection `storeRegistry`
|
||||
- [[brief_2026-05-21_fork-nextgraph-inbox]] — le chemin cible réel (cross-wallet + inbox)
|
||||
- [[decision_2026-06-15_shared-wallet-login-flow]] — flux login/logout
|
||||
- [[knowledge_stores-permissions]] — stores, capabilities, inbox, limites SDK
|
||||
- `src/shared/utils/storeRegistry.ts`, `src/shared/context/FestipodDataContext.tsx`, `src/app/AuthGate.tsx`, `src/shared/utils/isolation.ts`
|
||||
- Source `nextgraph-rs` : `sdk/rust/src/tests/sparql_regressions.rs:136-200` (multi-document), `engine/verifier/src/verifier.rs:1423` (TODO `OpenRepo`)
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
type: decision
|
||||
summary: Flux login/logout du stopgap wallet partagé — le vrai login NextGraph (redirect broker) apparaît en premier, perçu comme une barrière technique d'accès à l'environnement ; l'écran applicatif « Connexion » (username seul → localStorage) EST le login perçu ; « Déconnexion » efface juste le username sans toucher NG ; vrai logout planqué
|
||||
last_updated: 2026-06-15
|
||||
---
|
||||
|
||||
# Décision 2026-06-15 — Flux de login/logout du stopgap wallet partagé
|
||||
|
||||
Arbitrage du flux d'authentification perçu pour le stopgap [[brief_2026-06-15_shared-wallet-shim]]. Frozen.
|
||||
|
||||
## Contrainte de départ
|
||||
|
||||
Le login NextGraph **n'est pas programmable** : c'est une **redirection web** vers la page du broker (`nextgraph.net`). Impossible d'ouvrir le wallet partagé en silence — il faut au minimum un passage par le redirect broker, au moins une fois par device. La question n'est donc pas *« comment éviter le redirect »* mais *« comment l'ordonner et le présenter »* pour que l'UX reste cohérente.
|
||||
|
||||
## Décision : option 2 — gate technique d'abord, « Connexion » applicative ensuite
|
||||
|
||||
Deux couches d'auth distinctes, présentées dans cet ordre :
|
||||
|
||||
1. **Couche réelle (technique, non perçue comme login)** — le redirect broker apparaît **immédiatement, avant tout rendu de l'app**. Comme il précède l'app, l'utilisateur le lit comme une **barrière technique d'accès à l'environnement de test** (type mur de beta), **pas** comme un login applicatif. Mêmes credentials partagés pour tous (donnés dans l'invitation, façon « code d'accès »). Une fois par device, puis persistant. **Jamais étiqueté « login ».** Un splash Festipod minimal précède le redirect pour donner du contexte.
|
||||
2. **Couche applicative (perçue comme LE login)** — écran **« Connexion »** = saisie du **username** (→ `localStorage`, `currentAccountId`). C'est le login *dans la perception* de l'utilisateur. **Sans mot de passe** (décision username-seul) → connexion **déclarative** : n'importe qui prend n'importe quel username (cohérent zéro-sécurité / amis). **« Déconnexion »** = efface **seulement** le username et revient à l'écran « Connexion » ; **n'appelle aucune fonction NG**.
|
||||
|
||||
Le **vrai logout** (`ng.session_stop` / `user_disconnect` / `wallet_close`) reste **planqué** (réglages/debug), car il force un nouveau redirect.
|
||||
|
||||
Le label **« Connexion »/« Déconnexion »** (et non « Changer de profil ») est un choix explicite : on assume de faire passer le username pour le login applicatif, puisque la barrière technique n'est pas perçue comme tel.
|
||||
|
||||
## Pourquoi (vs option 1 écartée)
|
||||
|
||||
**Option 1 écartée** — faux login d'abord (username), puis page d'avertissement « saisissez tel username/password », puis bouton *Continuer* déclenchant le redirect. Rejetée : workflow étrange, **double-login dissonant** (« je me suis déjà connecté, pourquoi je recommence ailleurs ? »), page d'avertissement qui **ressemble à une arnaque**, et le redirect **ressurgit en plein usage** à chaque expiration de session.
|
||||
|
||||
**Option 2 retenue** parce que :
|
||||
- **Cohérence du modèle mental** : la barrière technique n'étant pas perçue comme un login, la paire **Connexion/Déconnexion** applicative est complète et auto-cohérente — plus aucun mismatch sur le logout (se déconnecter ramène à l'écran de connexion, les deux dans la même couche).
|
||||
- **Dégradation gracieuse** : un re-gate après redémarrage navigateur (perte de `sessionStorage`) se lit comme « reconnexion à l'environnement », pas comme un bug.
|
||||
- **Implémentation plus simple** : `NextGraphContext` fait déjà le flux `connect`/redirect ; l'écran « Connexion » est un écran in-app normal ; pas de page d'avertissement bespoke.
|
||||
- **Similarité avec l'infra cible** (objectif directeur du stopgap) : la forme **« redirect broker → app »** est exactement le flux du vrai multi-wallet. À la migration, on **supprime l'écran « Connexion » username** et la **barrière technique devient le vrai login per-user** — la forme du flux ne change pas.
|
||||
|
||||
## Faits techniques vérifiés (`nextgraph-rs`, 2026-06-15)
|
||||
|
||||
- **Persistance de session : OUI.** Wallet mémorisé côté iframe broker (`localStorage` long-terme + `sessionStorage` pour la session active) ; au rechargement, `init()` retrouve la session **sans re-déclencher le redirect** tant que la session broker existe (`sdk/js/web/src/index.ts`, `sdk/js/api-web/main.ts`). Un **redémarrage complet du navigateur** (perte de `sessionStorage`) peut re-déclencher le gate.
|
||||
- **Logout réel exposé : OUI.** `ng.session_stop()`, `ng.user_disconnect()`, `ng.wallet_close()` (`sdk/js/lib-wasm/src/lib.rs`) ; arrêtent la session / effacent le wallet ; **forcent un nouveau redirect** ensuite → d'où le choix de **ne pas** les appeler dans la « Déconnexion » applicative et de planquer le vrai logout.
|
||||
|
||||
## Conséquences côté code (Festipod)
|
||||
|
||||
- `NextGraphContext` — déclencher le `connect`/redirect **au boot**, avant le rendu de l'app (+ splash pré-redirect).
|
||||
- Un écran applicatif **« Connexion »** (username → `localStorage` / `currentAccountId`), username résolu contre les comptes du `sharedWalletShim`.
|
||||
- Une **« Déconnexion »** qui efface seulement le username (aucun appel NG).
|
||||
- Vrai logout exposé seulement en réglages/debug.
|
||||
|
||||
## See Also
|
||||
|
||||
- [[brief_2026-06-15_shared-wallet-shim]] — le stopgap que cette décision complète
|
||||
- Concept `data-layer` — `NextGraphContext`, auto-init conditionnel, flux redirect broker
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
type: decision
|
||||
summary: Distribution du wallet partagé par IMPORT ASSISTÉ PAR FICHIER (.ngw) — l'auto-import zéro-touche étant impossible (broker hébergé), Festipod sert le FICHIER du wallet (téléchargement) + le mot de passe et guide un import unique sur nextgraph.eu « Import a Wallet File » depuis l'AccessGateScreen. Le TextCode a d'abord été retenu puis CORRIGÉ (transfert temporaire 5 min, inutilisable à embarquer). Barrière d'accès ON par défaut (ACCESS_GATE_DISABLED=1 pour bypass tests/dev), ancien LoginScreen retiré. Alternatives écartées (auto-import app, lien magique, self-host) ; provisioning de test (storageState) distinct
|
||||
last_updated: 2026-06-29
|
||||
---
|
||||
|
||||
# Décision 2026-06-17 — Distribution du wallet partagé par import assisté
|
||||
|
||||
Comment un utilisateur récupère le wallet partagé sur un nouveau navigateur, dans le stopgap [[brief_2026-06-15_shared-wallet-shim]]. Frozen.
|
||||
|
||||
## Contrainte de départ
|
||||
|
||||
L'auto-import zéro-touche par l'app est **impossible** avec le broker hébergé (fait vérifié : [[knowledge_broker-import-constraint]]). Le wallet doit préexister dans le navigateur, importé sur `nextgraph.eu` (cross-origin, non pilotable par Festipod). La question n'est donc pas « comment auto-importer » mais « comment **minimiser la friction de récupération** » — le problème initial étant que l'utilisateur devait d'abord *se procurer* le wallet.
|
||||
|
||||
## Décision : import assisté par FICHIER
|
||||
|
||||
Festipod **sert le FICHIER `.ngw`** du wallet partagé (téléchargement) et **affiche le mot de passe** dans l'`AccessGateScreen` (la barrière d'accès, cf. [[decision_2026-06-15_shared-wallet-login-flow]]), avec un guide en 3 étapes :
|
||||
|
||||
1. Télécharger le fichier du wallet partagé (bouton de téléchargement).
|
||||
2. Ouvrir `https://nextgraph.eu/#/wallet/login` (nouvel onglet) → « Import a Wallet File » → choisir le fichier → saisir le mot de passe affiché.
|
||||
3. Revenir et cliquer « Entrer » (redirect broker → demande de déverrouiller le wallet → mot de passe → app).
|
||||
|
||||
Festipod **fournit** ainsi le wallet (fin de la friction de récupération) ; l'**import lui-même reste un geste manuel unique par device**, incompressible avec le broker hébergé. Posture **zéro-sécurité, credential partagé** assumée (cf. brief) → embarquer le fichier + le mot de passe est cohérent.
|
||||
|
||||
> **Correction 2026-06-25 (le TextCode était une fausse piste)** : la 1ʳᵉ version embarquait le **TextCode**. Or le TextCode est un **transfert temporaire** (5 min, deux devices en ligne, usage unique — cf. [[knowledge_broker-import-constraint]]), donc **inutilisable embarqué** (un testeur arrivant plus tard aurait un code mort). Le test e2e passait quand même car il génère+importe le code dans la foulée. La primitive correcte est le **FICHIER statique**.
|
||||
|
||||
## Pourquoi (alternatives écartées)
|
||||
|
||||
- **(a) Auto-import embarqué par l'app** — *impossible* : le broker ne laisse aucune fenêtre d'exécution avant son gate wallet ([[knowledge_broker-import-constraint]]).
|
||||
- **(b) TextCode embarqué** — *cassé* : transfert temporaire 5 min, non réutilisable (cf. correction ci-dessus).
|
||||
- **(c) Lien magique pré-rempli** vers le broker — pas de route d'import par URL côté broker hébergé.
|
||||
- **(d) Self-host / fork du ng-app** — seule voie vers le **vrai zéro-touche**, mais lourde ; track séparé ([[brief_2026-05-21_fork-nextgraph-inbox]]). Non retenu pour le stopgap.
|
||||
|
||||
## Conséquences côté code (Festipod)
|
||||
|
||||
- `src/modules/auth/sharedWallet.ts` — `SHARED_WALLET_PASSWORD` lu depuis un **global gravé au build** `globalThis.__FESTIPOD_SHARED_WALLET_PASSWORD__` (`define` dans `build.ts`, depuis `FESTIPOD_SHARED_WALLET_PASSWORD`) ; `SHARED_WALLET_FILE_URL = /shared-wallet.ngw` ; `hasSharedWallet()` (mot de passe non vide) pilote l'affichage. Vide par défaut → la barrière retombe sur le flux simple.
|
||||
- `build.ts` — copie le fichier (`FESTIPOD_SHARED_WALLET_FILE`) dans le bundle en `/shared-wallet.ngw` + grave le mot de passe.
|
||||
- `src/modules/auth/screens/AccessGateScreen.tsx` — section assistée (téléchargement du fichier + mot de passe + guide) affichée si `hasSharedWallet()`.
|
||||
- `src/app/AuthGate.tsx` — la barrière est **ON PAR DÉFAUT** (« Festipod ne fonctionne jamais sans NextGraph »). **Révision 2026-06-29** : drapeau **inversé** — la barrière n'est désactivée que si `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ === true` (gravé par `build.ts` depuis `ACCESS_GATE_DISABLED=1`, ou injecté par le harness via `context.addInitScript` pour `@e2e`). Absent → barrière ON. (Remplace l'ancien `FESTIPOD_STAGING`/`__FESTIPOD_REQUIRE_NG__`, qui était OFF par défaut.)
|
||||
- **Ancien `LoginScreen` retiré** (`/login`, bouton « Se connecter avec NextGraph » + login démo email/mdp) : obsolète puisque l'`AccessGateScreen` précède le routeur. Évite le dead-end « connecter sans wallet ». Route `/login` supprimée.
|
||||
- **Atterrissage post-login** : après le choix du pseudo, `ConnexionScreen` navigue vers `/home` (la redirection vers l'accueil que faisait l'ancien `LoginScreen` avait disparu avec lui → on retombait sur l'onboarding `WelcomeScreen` à `/`). Filet pour les retours : `WelcomeScreen` redirige vers `/home` si déjà connecté. L'e2e `@humain` va désormais jusqu'à l'accueil pour couvrir ça.
|
||||
- L'admin exporte le fichier une fois (nextgraph.eu : menu wallet → Download/Export Wallet File) et l'injecte au build (`FESTIPOD_SHARED_WALLET_FILE=<chemin.ngw> FESTIPOD_SHARED_WALLET_PASSWORD=<mdp> bun run build` ; barrière ON par défaut, pas de drapeau à poser).
|
||||
|
||||
## À distinguer du provisioning de test
|
||||
|
||||
Le harness multi-navigateur provisionne le wallet partagé par **injection storageState** (niveau navigateur, sans la contrainte broker) — concept `bdd-testing` → `knowledge_multibrowser-harness`. Cela **prouve** « wallet partagé → app connectée » mais **court-circuite l'import**. Le mécanisme RÉEL est validé **de bout en bout par la vraie app** (e2e `@humain`) : un navigateur vierge ouvre l'app staging, **télécharge le fichier proposé par l'`AccessGateScreen`** (et vérifie que le mot de passe affiché est celui du wallet), l'importe via le vrai flux `nextgraph.eu` « Import a Wallet File », revient, clique « Entrer » et atteint l'app connectée. Les deux ne se confondent pas.
|
||||
|
||||
## See Also
|
||||
|
||||
- [[knowledge_broker-import-constraint]] — le fait technique qui force cette décision
|
||||
- [[brief_2026-06-15_shared-wallet-shim]] — le stopgap
|
||||
- [[decision_2026-06-15_shared-wallet-login-flow]] — le flux d'accès dont l'AccessGateScreen est la barrière
|
||||
- [[knowledge_stores-permissions]] — wallet, capabilities, limites SDK
|
||||
@@ -0,0 +1,69 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Le broker NextGraph hébergé n'autorise pas l'auto-import d'un wallet par une web-app tierce — init() top-level redirige, toute méthode ng.* exige d'être déjà loggé dans l'iframe, et le broker renvoie un device sans wallet vers nextgraph.eu (cross-origin, non pilotable). Un wallet doit préexister dans le navigateur. Des 4 méthodes d'import nextgraph.eu, seul le FICHIER .ngw est statique/réutilisable ; le TextCode/QR sont des transferts temporaires (5 min, deux devices en ligne) inutilisables à embarquer.
|
||||
last_checked: 2026-06-25
|
||||
---
|
||||
|
||||
# Contrainte : pas d'auto-import de wallet par une web-app tierce
|
||||
|
||||
Fait technique **vérifié empiriquement (2026-06-17)** : avec le broker NextGraph **hébergé** (`nextgraph.net`), une web-app tierce (Festipod) **ne peut pas** provisionner/importer un wallet par programme. Le wallet doit **préexister** dans le navigateur avant que le redirect d'authentification puisse réussir.
|
||||
|
||||
## Pourquoi (mécanisme du proxy `@ng-org/web`)
|
||||
|
||||
Lecture de `ngweb.js` (dist de `@ng-org/web`) :
|
||||
|
||||
- **`init()` top-level REDIRIGE** : si `window.self === window.top`, il fait `window.location.href = https://nextgraph.net/redir/#/?o=<url>`. Le code de l'app ne tourne plus.
|
||||
- **Toute méthode `ng.*` est relayée** par `parent.postMessage` vers `nextgraph.net`, et le handler **lève `"you must call init() first"` tant que la session n'est pas établie** (garde interne `d !== false`). Cela inclut `wallet_import_from_code`, `add_in_memory_wallet`, `session_in_memory_start`.
|
||||
- L'app tierce ne s'exécute **dans l'iframe qu'APRÈS** que le broker a déjà ouvert un wallet et établi la session. **Il n'existe aucune fenêtre** où notre code tourne *avant* le gate wallet du broker → **rien à quoi accrocher un auto-import**.
|
||||
|
||||
> Vérifier : `node_modules/@ng-org/web/dist/ngweb.js` — fonction `init` (redirect / postMessage selon top-vs-iframe) et le handler `apply` du Proxy `ng`. Cohérent avec [[knowledge_integration-model]] (le verifier tourne dans l'iframe du ng-app, le proxy ne fait que relayer).
|
||||
|
||||
## Ce que montre le broker (probe Playwright, navigateur frais sans wallet)
|
||||
|
||||
Sur `https://nextgraph.net/redir/#/?o=...`, le broker affiche **littéralement** :
|
||||
|
||||
> « We could not find a wallet in your browser. For now, creating a new wallet while a Web App is authenticating, is not implemented. Please create or import your wallet in a new tab. »
|
||||
|
||||
…et renvoie vers `https://nextgraph.eu/` (app wallet **standalone**). L'import standalone (`/#/wallet/login`) propose **4 méthodes**, mais elles ne sont **PAS équivalentes** :
|
||||
|
||||
| Méthode | Nature | Embarquable / réutilisable ? |
|
||||
|---|---|---|
|
||||
| **Import a Wallet File** (`.ngw`) | **fichier statique** (export portable, hors-ligne) | ✅ **oui** — statique, sans expiration, sans device source |
|
||||
| Import with TextCode | **transfert temporaire** device↔device via leurs serveurs : **5 min**, **les deux appareils en ligne**, usage unique | ❌ non |
|
||||
| Import with QR-Code | transfert live (même famille que TextCode) | ❌ non |
|
||||
| Import via Username | récupération liée à un compte | à étudier |
|
||||
|
||||
> **Piège vérifié (2026-06-25)** : le **TextCode n'est PAS un export statique** — l'écran nextgraph.eu le dit (« temporarily stored on our servers for up to 5 minutes », « both devices need to be online »). L'embarquer dans l'app est **inutilisable** : il expire / est à usage unique. Un test automatisé qui génère ET importe le code dans la foulée **passe** (live), masquant le problème — d'où une fausse piste initiale. **La primitive correcte pour un wallet partagé embarqué = le FICHIER `.ngw`.**
|
||||
|
||||
## Logique du redirect nextgraph.net (broker discovery) + ORDRE critique
|
||||
|
||||
`init()` redirige vers `nextgraph.net/redir`. Là, nextgraph.net regarde dans le **localStorage** s'il connaît un **broker** (le domaine du broker, posé lors d'un import/login wallet antérieur) :
|
||||
|
||||
- **trouvé** → redirige vers le broker (`nextgraph.eu/auth/#/wallet/login` → « Click here to login with your wallet <nom> » → mot de passe) → app. **Cas qui marche.**
|
||||
- **absent** → message « We could not find a wallet in your browser… Please create or import your wallet in a new tab by clicking here ». Le lien ouvre `nextgraph.eu` (page **Welcome**) → **Login** → **Import a Wallet File** → on rejoint l'import.
|
||||
|
||||
> **Ordre critique** : il faut **importer le wallet AVANT** d'atteindre nextgraph.net. Le guide de l'`AccessGateScreen` impose cet ordre (télécharger + importer, *puis* « Entrer »).
|
||||
|
||||
**Deux routes vers l'import** : **A** = lien direct `nextgraph.eu/#/wallet/login` (celui de Festipod + des tests — saute la page Welcome/Login) ; **B** = fallback nextgraph.net « no wallet → clicking here » → Welcome → **Login** → Import (si on atteint nextgraph.net sans wallet).
|
||||
|
||||
**Pourquoi l'e2e `@humain` ne bute pas sur le « no wallet »** : il importe le fichier (route A) **avant** le « Entrer », donc nextgraph.net trouve déjà le broker. Le test **ne couvre pas** la route B (message « no wallet » + page Welcome/Login de nextgraph.eu) — ce sont des comportements nextgraph, pas Festipod, mais un humain cliquant « Entrer » en premier y tombe.
|
||||
|
||||
> **Atténuation côté Festipod (2026-06-29)** : la barrière `AccessGateScreen` (qui **fournit** le fichier wallet + le mot de passe + le guide) est désormais l'écran d'entrée **par défaut** (cf. [[decision_2026-06-17_assisted-wallet-import]]) — l'ancien `LoginScreen` « Se connecter avec NextGraph » (qui menait directement au redirect sans fournir le wallet) a été retiré. Un utilisateur ne peut donc plus atteindre nextgraph.net **sans** que Festipod lui ait d'abord proposé le wallet. La route B reste possible s'il clique « Entrer » avant d'importer, mais il a le wallet sous les yeux pour le faire.
|
||||
|
||||
> **Contrainte UX irréductible + dé-piégeage** : « Entrer » fait une **redirection pleine-page**. Si on clique AVANT d'importer → message « no wallet » → l'import se fait dans un **autre onglet** sans retour auto vers Festipod (le broker hébergé ne sait pas revenir). Il faut donc **importer d'abord, PUIS Entrer** (guide de l'`AccessGateScreen`, ordre du `@humain`). Piège corrigé (2026-06-29) : au retour (back) après un « Entrer » prématuré, la page standalone était restaurée du bfcache avec l'état figé sur `connecting` → bouton « Accès en cours » bloqué ; `NextGraphContext` écoute `pageshow.persisted` (sans session) et réinitialise sur `disconnected` pour permettre de réessayer.
|
||||
|
||||
## Pistes d'élimination du va-et-vient — testées, ÉCARTÉES (2026-06-30)
|
||||
|
||||
Deux idées pour éviter le 2ᵉ onglet ; les deux **infaisables** sans fork :
|
||||
|
||||
1. **Embarquer `nextgraph.eu` en iframe** dans Festipod pour guider l'import sur le même écran. nextgraph.eu **n'a pas** d'en-tête anti-framing (embed possible, import OK dans l'iframe), **MAIS** le wallet importé atterrit dans le stockage **partitionné** `(top: festipod, frame: nextgraph.eu)` — invisible du login top-level → « no wallet ». **Confirmé en vrai navigateur (Chrome).** ⚠️ **Le Chromium de Playwright N'applique PAS ce partitioning → faux positif** : un probe Playwright montrait le wallet « transmis », alors que le vrai Chrome bloque. Ne jamais valider une question d'**isolation de stockage** via Playwright ; tester en vrai navigateur.
|
||||
2. **Déclencher l'écriture cross-origin sur nextgraph.net** : le mécanisme existe (pont `/auth` iframe+postMessage qui écrit `ng_bootstrap` sur nextgraph.net pendant le login nextgraph.eu) mais ce sont **les pages de NextGraph** qui l'orchestrent ; un tiers ne peut pas écrire le localStorage d'une autre origine (same-origin policy), et ça ne couvrirait que la découverte du broker, pas le wallet.
|
||||
|
||||
→ Seule élimination réelle = self-host/fork du ng-app ([[brief_2026-05-21_fork-nextgraph-inbox]]). Le flow stopgap reste l'import **en onglet séparé** (top-level `nextgraph.eu`, première-partie → pas de partitioning → fonctionne).
|
||||
|
||||
## Conséquences
|
||||
|
||||
- Le wallet doit être importé **sur `nextgraph.eu` (cross-origin)** — Festipod ne peut **pas** piloter ce flux ni pré-remplir l'import (pas de route d'import par URL côté broker hébergé).
|
||||
- Le **vrai zéro-touche** exigerait de **self-host/forker le ng-app** (territoire de [[brief_2026-05-21_fork-nextgraph-inbox]]).
|
||||
- Distribution produit retenue, vu cette contrainte : **import assisté par FICHIER** — Festipod fournit le `.ngw` (téléchargement) + le mot de passe et guide l'import — voir [[decision_2026-06-17_assisted-wallet-import]].
|
||||
- À ne pas confondre avec le **provisioning de TEST** (injection storageState dans le harness), qui n'a pas cette contrainte car il agit au niveau navigateur — concept `bdd-testing` → `knowledge_multibrowser-harness`.
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Modèle d'intégration NextGraph — @ng-org/web est un proxy iframe (verifier tourne dans l'iframe ng-app, pas dans le broker), reciblable au build via NG_REDIR_SERVER/NG_DEV*, broker ngd stateful WebSocket ; modifier le verifier = rebuilder le ng-app, pas le broker
|
||||
last_checked: 2026-05-21
|
||||
---
|
||||
|
||||
# Modèle d'intégration et de déploiement NextGraph
|
||||
|
||||
Comment une app web tierce s'intègre à NextGraph, et **où tourne le moteur (verifier)**. Vérifié dans `nextgraph-rs` le 2026-05-21.
|
||||
|
||||
NextGraph s'utilise via un **proxy iframe** (`@ng-org/web`) : l'app tierce ne contient pas le moteur, elle délègue à un ng-app hébergé (défaut `nextgraph.net`) qui exécute le moteur dans une iframe.
|
||||
|
||||
## Les paquets JS
|
||||
|
||||
- **`@ng-org/web`** — paquet **publié**. Proxy postMessage léger (aucun wasm embarqué). **Le** chemin d'intégration tierce ; `@ng-org/orm` et tous les exemples en dépendent. **Festipod l'utilise.**
|
||||
- **`@ng-org/api-web`** — **privé** (non publié). Moteur navigateur complet (charge `@ng-org/lib-wasm` dans un Web Worker). Consommé uniquement par `app/nextgraph` (frontend ng-app) — **pas** une cible d'intégration tierce.
|
||||
- **`@ng-org/lib-wasm`** — moteur compilé wasm (contient le verifier). Source `sdk/js/lib-wasm/`.
|
||||
- **`nextgraph`** (npm) — API NodeJS (build `pkg-node`).
|
||||
- **`@ng-org/orm`** — ORM réactif (`useShape`…), bâti sur `@ng-org/web`.
|
||||
|
||||
## Où tourne le verifier
|
||||
|
||||
Dans le modèle web standard (iframe), le verifier tourne **dans l'iframe** : `app/nextgraph` charge `api-web` → `lib-wasm` dans un Web Worker, côté navigateur. Le broker (`ngd`) ne fait que **transport et stockage**.
|
||||
|
||||
**Conséquence** : modifier la logique du verifier (`request_processor`, `inbox_processor`) = reconstruire le **ng-app**, pas le broker.
|
||||
|
||||
## Le modèle iframe & reciblage build-time
|
||||
|
||||
`@ng-org/web` redirige vers le ng-app hébergé, qui recharge l'app tierce en iframe après auth, puis relaie par `postMessage`. **Reciblable au build** (`sdk/js/web/src/index.ts`, `import.meta.env`) :
|
||||
|
||||
| Variable | Cible |
|
||||
|---|---|
|
||||
| `NG_REDIR_SERVER` | défaut `nextgraph.net` |
|
||||
| `NG_DEV3` | `127.0.0.1:3033` |
|
||||
| `NG_DEV` | `localhost:14402`/`14404` |
|
||||
| `NG_DEV_LOCAL_BROKER` | `localhost:1421` |
|
||||
|
||||
**Pas d'override runtime** — `init()` ne prend pas d'URL broker. Pour pointer vers un ng-app auto-hébergé : **rebuilder `@ng-org/web`** (TS pur, sans wasm → build trivial).
|
||||
|
||||
## Plomberie proxy ↔ iframe ↔ worker (générique)
|
||||
|
||||
Le chemin d'appel d'une méthode est **entièrement générique** (aucune allowlist) : `@ng-org/web` est un `Proxy` JS qui relaie *n'importe quel* nom de méthode par `postMessage` ; `app/nextgraph` dispatch via `Reflect.apply(ng[method], …)`. **Conséquence** : une nouvelle fonction wasm en requête/réponse simple est *atteignable* sans toucher le JS — mais c'est un **hack** non typé (test rapide, pas un plan ; cf. [[brief_2026-05-21_fork-nextgraph-inbox]]). Cas **streamé** : exige une entrée des deux côtés (`E` dans `@ng-org/web` + `streamed_api` dans api-web ; méthodes streamées actuelles : `doc_subscribe`, `orm_start_graph`, `orm_start_discrete`, `file_get`, `app_request_stream`).
|
||||
|
||||
## Le broker (ngd)
|
||||
|
||||
- Supporte déjà nativement l'inbox (`inbox_post`, `inbox_register`, `inbox_pop_for_user` dans `engine/net/src/server_broker.rs`) — un `ngd` standard routerait l'inbox, **aucun patch broker nécessaire**.
|
||||
- Démon **WebSocket** (`async-tungstenite`), **stateful** : RocksDB sous `--base-path`, PeerId persisté (volume critique).
|
||||
- CLI : `--local PORT`, `--domain DOMAIN:PORT,LOCAL_PORT` (mode derrière reverse-proxy TLS-terminé type Traefik/Coolify).
|
||||
- **Ne sert pas de statique** : le ng-app frontend est un déploiement statique séparé (`pnpm webfilebuild`). Premier démarrage **interactif** (lien d'invitation wallet admin). Dockerfiles officiels **cassés**.
|
||||
|
||||
> Détail du déploiement depuis un fork : [[brief_2026-05-21_fork-nextgraph-inbox]] §Couche 2.
|
||||
@@ -0,0 +1,60 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Référence des 5 types de stores NextGraph et leurs droits, document=repo, granularité des permissions, capability/Nuri, inbox native (anonymat via from optionnel), et ce que le SDK @ng-org/web n'expose PAS
|
||||
last_checked: 2026-05-21
|
||||
---
|
||||
|
||||
# Stores NextGraph et droits d'accès
|
||||
|
||||
Référence des primitives de stockage et permission de NextGraph (**système externe**, pas le code de Festipod). Socle des briefs [[brief_2026-05-17_multi-store-refactor]] et [[brief_2026-05-18_authorization-matrix]].
|
||||
|
||||
Source : doc NextGraph officielle ([Documents & Stores](https://docs.nextgraph.org/en/documents/), [Getting started](https://docs.nextgraph.org/en/getting-started/)) vérifiée le 2026-05-21.
|
||||
|
||||
## Points d'entrée du code source local
|
||||
|
||||
Repo cloné en `../../nextgraph/nextgraph-rs` (cf. `_overview`) :
|
||||
- `sdk/js/lib-wasm/src/lib.rs` — API wasm effectivement exposée au JS.
|
||||
- `engine/net/src/app_protocol.rs` — enum `AppRequestCommandV0`, formats `NuriV0`.
|
||||
- `engine/verifier/src/request_processor.rs` — dispatch effectif des `app_request` (la vérité sur ce qui est *traité*).
|
||||
- `engine/net/src/types.rs` — types inbox (`InboxPost`, `InboxMsg`, `InboxMsgContent`).
|
||||
- `engine/verifier/src/inbox_processor.rs` — traitement des messages d'inbox.
|
||||
|
||||
## Les 5 types de stores
|
||||
|
||||
| Store | Lecture | Écriture | Création |
|
||||
|---|---|---|---|
|
||||
| **Private** | Titulaire seul | Titulaire seul | Par défaut |
|
||||
| **Protected** | Titulaire + détenteurs d'un lien + permission | Titulaire + collaborateurs permissionnés | Par défaut |
|
||||
| **Public** | Tout le monde, sans capability | Titulaire seul | Par défaut |
|
||||
| **Group** | Membres du groupe | Membres du groupe (collaboratif) | À la demande |
|
||||
| **Dialog** | Les deux utilisateurs uniquement | Les deux utilisateurs uniquement | À la demande |
|
||||
|
||||
Citations doc (verbatim) : Private — *« only you have access to … not possible to share »* ; Protected — *« share … but they will need a special link and permission »*, *« protected social profile »* ; Public — *« equivalent to your website … without the need for special permissions »* ; Group — *« each Group is a separate Store … documents inherit the permissions of the store »* ; Dialog — *« hold all the data you exchange with another user (and only with that other user) … You cannot add more users »*.
|
||||
|
||||
Tout wallet a d'office les **3 stores** private/protected/public (session : `private_store_id`, `protected_store_id`, `public_store_id`). Group et Dialog se créent à la demande.
|
||||
|
||||
## Concepts transverses
|
||||
|
||||
**Document vs Repo.** *« A Repo is the equivalent of an E2EE group for one and only one Document. »* **1 document = 1 repo** (commits + permissions). Identifiant : `did:ng:o:<RepoID>`. Un **store** est lui-même un document spécial qui regroupe et permissionne d'autres documents.
|
||||
|
||||
**Granularité.** Écriture gérée au niveau **Document (repo)**, pas branche/bloc. Lecture plus fine possible (par bloc/branche). Héritage : un Group store peut faire hériter ses permissions à ses documents.
|
||||
|
||||
**Capability / Nuri.** Le partage transmet un **Nuri** embarquant la capability crypto (lecture et/ou écriture). Pas d'ACL centralisée : posséder le Nuri = le droit. *« adding permissions can be done offline »* ; *« removing permissions … requires a SyncSignature »* (synchrone).
|
||||
|
||||
## Inbox
|
||||
|
||||
**Chaque document a une inbox native.** Un non-éditeur peut y **déposer un lien (DID cap)** sans être invité éditeur ; le propriétaire **modère**. NURI : `did:ng:d:<inbox_id>`. Contenu : enum `InboxMsgContent` (`ContactDetails`, `DialogRequest`, **`Link`**, `Patch`, `ServiceRequest`, `ExtRequest`, `RemoteQuery`, `SocialQuery`…). Message **scellé** (`crypto_box::seal`) vers la pubkey de l'inbox → seul le titulaire déchiffre. Champ `from` **optionnel** → expéditeur **anonyme** possible. C'est le « identifié si connu, anonyme sinon » voulu par Festipod, **natif au protocole** (mécanisme retenu pour la notification d'inscription, cf. [[brief_2026-05-18_authorization-matrix]]).
|
||||
|
||||
### L'inbox n'est PAS utilisable directement depuis le SDK JS
|
||||
|
||||
- `app_request(request)` est exposé, et `AppRequestCommandV0::InboxPost` + `AppRequest::inbox_post()` existent. **MAIS** le `request_processor` du verifier **n'a aucun bras `InboxPost`** (commandes traitées : `OrmStart(Discrete)`, `Fetch`, `FileGet`, `OrmUpdate`, `OrmDiscreteUpdate`, `SocialQueryStart`, `QrCodeProfile(Import)`, `Header`, `Create`, `FilePut`). Envoyer un `InboxPost` ne déclenche rien.
|
||||
- Construire un `InboxPost` exige le scellement crypto côté Rust ; **aucun helper wasm** ne l'expose.
|
||||
- Le dépôt en inbox n'est déclenché qu'**en interne** par `QrCodeProfileImport` (`post_to_inbox(new_contact_details)`) et `social_query_start` (propagation via inbox des **contacts**).
|
||||
|
||||
**Conséquence** : pas de moyen propre de « drop a Link » arbitraire dans l'inbox d'un PdR depuis le SDK JS aujourd'hui. → chantier [[brief_2026-05-21_fork-nextgraph-inbox]]. Piste connexe : `social_query_start` EST exposé (requête fédérée via inbox jusqu'à `degree` sauts) mais limité aux **contacts** (ne couvre pas la notif anonyme vers un hôte non-connecté).
|
||||
|
||||
## Limites du SDK JS
|
||||
|
||||
`@ng-org/web` (vérifié `0.1.2-alpha.13` = `upstream/main` au 2026-05-21, version installée) **n'expose pas** : création de Group/Dialog store ; partage de capability (Nuri avec droits) ; manipulation de permissions ; dépôt/lecture d'inbox.
|
||||
|
||||
Méthodes JS disponibles : `doc_create`, `doc_subscribe`, `sparql_query`, `sparql_update`, `orm_start_graph`, `orm_start_discrete`, `graph_orm_update`, `discrete_orm_update`, `file_get`, `app_request_stream`. La doc annonce *« An API will be provided for permission manipulation »* (sans date).
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
type: _overview
|
||||
summary: Stack and tooling — Bun-first (runtime, bundler, native APIs), build pipeline, and the project's commands
|
||||
summary: Stack et outillage — Bun-first (runtime, bundler, APIs natives), build pipeline, et commandes du projet
|
||||
triggers:
|
||||
keywords: [bun, bunx, build, bundler, vite, webpack, jest, npm, storybook, "bun.serve", hmr, tailwind, package.json]
|
||||
paths: ["build.ts", "package.json", "bunfig.toml", "tsconfig.json", "src/index.ts", "src/index.html", ".storybook/**", "scripts/**"]
|
||||
@@ -8,14 +8,14 @@ triggers:
|
||||
|
||||
# Tech stack
|
||||
|
||||
The project's stack and tooling. Guiding principle: **Bun-first** — Bun replaces Node/npm/vite/webpack/jest and provides the native server APIs.
|
||||
Stack et outillage du projet. Principe directeur : **Bun-first** — Bun remplace Node/npm/vite/webpack/jest et fournit les APIs serveur natives.
|
||||
|
||||
**Read this first:** [[rule_bun-first]] — the convention that decides which tool to use.
|
||||
**À lire en premier :** [[rule_bun-first]] — la convention qui décide quel outil utiliser.
|
||||
|
||||
## Links
|
||||
## Liens
|
||||
|
||||
- [[rule_bun-first]] — use Bun, not Node/npm/vite/jest/express/ws/pg…
|
||||
- [[knowledge_bun-apis]] — native Bun APIs (serve, sqlite, redis, sql, file, shell)
|
||||
- [[knowledge_build-pipeline]] — build.ts, bundler, server, harness built separately, Storybook
|
||||
- [[knowledge_stack-and-commands]] — stack components + the real scripts (plus quirks)
|
||||
- [[knowledge_deployment]] — Dockerfile, prod served from src/, no CI, `portless` in dev
|
||||
- [[rule_bun-first]] — utiliser Bun, pas Node/npm/vite/jest/express/ws/pg…
|
||||
- [[knowledge_bun-apis]] — APIs natives Bun (serve, sqlite, redis, sql, file, shell)
|
||||
- [[knowledge_build-pipeline]] — build.ts, bundler, serveur, harness buildé à part, Storybook
|
||||
- [[knowledge_stack-and-commands]] — composants de la stack + scripts réels (+ quirks)
|
||||
- [[knowledge_deployment]] — Dockerfile, prod depuis src/, pas de CI, `portless` en dev
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
type: caveat
|
||||
summary: Firefox 151+ blocks (Local Network Access) the hosted broker embedding the local dev app in its iframe → blank iframe, zero app logs, no error at all. This is NOT a code bug. Browser-side fix — about:config network.lna.enabled=false.
|
||||
last_checked: 2026-07-13
|
||||
---
|
||||
|
||||
# Firefox LNA blocks the broker's app iframe in local dev
|
||||
|
||||
## Symptom
|
||||
|
||||
In local dev, the app runs INSIDE the hosted broker's iframe (`nextgraph.eu`/`nextgraph.net` over HTTPS embeds `festipod.localhost` → `127.0.0.1`). On **Firefox 151+**, the iframe stays **blank**: **no `[FestipodData]`/`[NG]` log at all** (the app's JS never runs), and **no red error** either (the block is a network policy decision, not a throw). Easy to mistake for a Festipod rendering crash — it is NOT one.
|
||||
|
||||
## Cause
|
||||
|
||||
**Local Network Access (LNA)**: Firefox 151+ (on by default, see the 149→151 rollout) forbids a **public site** (the HTTPS broker) from reaching a **local network resource** (`127.0.0.1`) — including embedding it in an iframe. The telltale console log:
|
||||
`Local Network Access detected: ... accessing target "…festipod.localhost…" (127.0.0.1) … prompt action: auto_deny`.
|
||||
|
||||
Two corollaries that mislead:
|
||||
- **The top level loads just fine**: navigating directly to `https://festipod.localhost:1355` (the AccessGateScreen barrier) is NOT subject to LNA. Only **iframe embedding** by the broker is. So "the cert is already accepted / the app starts up" before the iframe does not mean the iframe will go through.
|
||||
- **HTTPS changes nothing**: LNA targets the **local destination address**, not the protocol. Switching to `portless proxy start --https` (app on `https://festipod.localhost`) does not unblock it.
|
||||
|
||||
## Fix (browser, not code)
|
||||
|
||||
`about:config` → **`network.lna.enabled` = `false`** (master flag: disables every LNA check). Targeted alternative: **`network.lna.skip-domains`** with `nextgraph.eu`, `nextgraph.net` (keeps the protection everywhere else). Other LNA prefs: `network.lna.blocking`, `network.lna.block_trackers`.
|
||||
|
||||
Do NOT go hunting for a Festipod rendering bug as long as there is **no `[FestipodData]` log** in the console: with no app logs, the app never ran → it is the environment (LNA, untrusted cert, dev server down), not the code. The `@e2e` smoke test CANNOT catch this: Playwright does not enforce LNA the way a real Firefox does.
|
||||
@@ -1,37 +1,25 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Dev runs on bun --hot, prod builds through build.ts (Bun bundler + Tailwind plugin) into dist/, path alias @/* → ./src/*
|
||||
summary: Dev en bun --hot, build prod via build.ts (bundler Bun + plugin Tailwind) vers dist/, alias @/* → ./src/*
|
||||
---
|
||||
|
||||
# Build pipeline
|
||||
|
||||
- **Dev**: `bun --hot src/index.ts` (through `bun run dev`) — HMR, port 3000.
|
||||
- **Prod**: `bun run build` → `build.ts` (Bun bundler + Tailwind plugin) → `dist/`.
|
||||
- **Path alias**: `@/* → ./src/*` (declared in `tsconfig.json`).
|
||||
- **Dev** : `bun --hot src/index.ts` (via `bun run dev`) — HMR, port 3000.
|
||||
- **Prod** : `bun run build` → `build.ts` (bundler Bun + plugin Tailwind) → `dist/`.
|
||||
- **Alias de chemin** : `@/* → ./src/*` (déclaré dans `tsconfig.json`).
|
||||
|
||||
The server serves `src/index.html`, which loads `src/app/frontend.tsx` (see `app-architecture` §app-shell). The bundler transpiles the TSX and bundles the CSS without any external tool — no Vite/webpack/esbuild (see [[rule_bun-first]]).
|
||||
Le serveur sert `src/index.html`, qui charge `src/app/frontend.tsx` (voir `app-architecture` §app-shell). Le bundler transpile le TSX et bundle le CSS sans outil externe — pas de Vite/webpack/esbuild (cf. [[rule_bun-first]]).
|
||||
|
||||
## Details of `build.ts` and of the server
|
||||
## Détails de `build.ts` et du serveur
|
||||
|
||||
- `build.ts` scans `src/**/*.html` for entrypoints (today just one: `src/index.html`), `target: 'browser'`, minify plus linked sourcemap, `bun-plugin-tailwind` plugin. Adding a second `.html` would create a second bundle.
|
||||
- `src/index.ts` (`Bun.serve`) serves: `/reports/cucumber` (HTML report), `/api/hello*` stubs, `/festipod-config.json` plus `/shared-wallet.ngw` (runtime config, see below), and a **catch-all `/*` → `src/index.html`** (SPA routing, which must stay last). HMR when `NODE_ENV !== 'production'`, port from `PORT`.
|
||||
- `build.ts` scanne `src/**/*.html` comme entrypoints (aujourd'hui un seul : `src/index.html`), `target: 'browser'`, minify + sourcemap linked, plugin `bun-plugin-tailwind`. Ajouter un 2e `.html` créerait un 2e bundle.
|
||||
- `src/index.ts` (`Bun.serve`) sert : `/reports/cucumber` (rapport HTML), des stubs `/api/hello*`, et un **catch-all `/*` → `src/index.html`** (routing SPA, doit rester en dernier). HMR si `NODE_ENV !== 'production'`, port via `PORT`.
|
||||
|
||||
## Build-time globals vs runtime config (the shared wallet pitfall)
|
||||
## Le harness de test est buildé à part
|
||||
|
||||
`build.ts` injects **compile-time globals** through `define` (e.g. `__FESTIPOD_SHARED_WALLET_PASSWORD__` from `FESTIPOD_SHARED_WALLET_PASSWORD`, `__FESTIPOD_ACCESS_GATE_DISABLED__`, and `__FESTIPOD_AUTO_SEED__` from `FESTIPOD_AUTO_SEED` — the dev auto-seed, OFF when absent). **Pitfall**: the `src/index.ts` server (used by `bun run dev` AND `bun run start`) bundles `index.html` through Bun's HTML import, which **applies no `define`** — neither `bun --define` nor `process.env` propagates there (verified). So an environment variable passed to `bun run dev` never reaches the frontend bundle along that path.
|
||||
|
||||
For those paths served from `src/`, the configuration therefore goes through the **runtime**: `src/index.ts` exposes `/festipod-config.json` (read from the environment), and the `src/app/frontend.tsx` entry **fetches it first**, sets the global, **then imports the app dynamically** (`await import('./App')`) — so that `sharedWallet.ts` reads the value when it is evaluated. In a `build.ts` bundle the value is already inlined by `define`, so the fetch is skipped (`NODE_ENV === 'production'`). Practical consequence: to exercise the "shared wallet" flow in dev **end to end** (download plus a working import), pass the REAL password of the e2e wallet **and** the file — the password shown on screen must match the imported `.ngw`, otherwise the import fails (a dummy value such as `1` merely makes the screen appear):
|
||||
|
||||
```
|
||||
FESTIPOD_SHARED_WALLET_PASSWORD=festipod-e2e-tests \
|
||||
FESTIPOD_SHARED_WALLET_FILE=./festipod-e2e-tests.ngw \
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## The test harness is built separately
|
||||
|
||||
⚠️ `build.ts` does **not** build the test harnesses. The Cucumber hooks (`src/shared/support/hooks.ts`) run a `bun build` **on demand** for `src/shared/test-harness/harness.tsx` (and `harness-ng.tsx`) → `dist/test-harness*.js`. It is an entrypoint separate from the app build — see concept `bdd-testing`.
|
||||
⚠️ `build.ts` ne build **pas** les harness de test. Les hooks Cucumber (`src/shared/support/hooks.ts`) lancent un `bun build` **à la demande** pour `src/shared/test-harness/harness.tsx` (et `harness-ng.tsx`) → `dist/test-harness*.js`. C'est un entrypoint séparé du build app — voir concept `bdd-testing`.
|
||||
|
||||
## Storybook
|
||||
|
||||
`storybook dev -p 6006` — **webpack5 + SWC** (not Vite). The decorators (`.storybook/`) inject the full provider stack (Theme > NextGraph > FestipodData > Router) and import `src/index.css`; mobile viewport by default. Tightly coupled to the project context (not reusable outside Festipod).
|
||||
`storybook dev -p 6006` — **webpack5 + SWC** (pas Vite). Les décorateurs (`.storybook/`) injectent la pile complète de providers (Theme > NextGraph > FestipodData > Router) et importent `src/index.css` ; viewport mobile par défaut. Couplage dur au contexte projet (pas réutilisable hors Festipod).
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Native Bun APIs used by the project — Bun.serve (HTTP/WS/routes), bundled HTML imports, bun:sqlite, Bun.redis, Bun.sql, Bun.file, Bun.$
|
||||
summary: APIs natives Bun utilisées par le projet — Bun.serve (HTTP/WS/routes), HTML imports bundlés, bun:sqlite, Bun.redis, Bun.sql, Bun.file, Bun.$
|
||||
---
|
||||
|
||||
# Native Bun APIs
|
||||
# APIs natives Bun
|
||||
|
||||
Reference for the Bun APIs to favour (see [[rule_bun-first]]). Full documentation: `node_modules/bun-types/docs/**.mdx`.
|
||||
Référence des APIs Bun à privilégier (cf. [[rule_bun-first]]). Doc complète : `node_modules/bun-types/docs/**.mdx`.
|
||||
|
||||
## Server — `Bun.serve()`
|
||||
## Serveur — `Bun.serve()`
|
||||
|
||||
Supports WebSockets, HTTPS and routes. No need for `express`/`ws`.
|
||||
Supporte WebSockets, HTTPS et routes. Pas besoin d'`express`/`ws`.
|
||||
|
||||
```ts
|
||||
import index from "./index.html"
|
||||
@@ -23,19 +23,19 @@ Bun.serve({
|
||||
})
|
||||
```
|
||||
|
||||
This is the mechanism behind `src/index.ts` (see concept `app-architecture` §app-shell).
|
||||
C'est le mécanisme de `src/index.ts` (voir concept `app-architecture` §app-shell).
|
||||
|
||||
## HTML imports (frontend)
|
||||
|
||||
`Bun.serve()` serves HTML imports; the Bun bundler automatically transpiles and bundles `.tsx`/`.jsx`/`.js` and the CSS (Tailwind included). A `<script type="module" src="./frontend.tsx">` in the HTML is enough — no Vite.
|
||||
`Bun.serve()` sert des HTML imports ; le bundler Bun transpile/bundle automatiquement `.tsx`/`.jsx`/`.js` et le CSS (Tailwind inclus). Un `<script type="module" src="./frontend.tsx">` dans le HTML suffit — pas de Vite.
|
||||
|
||||
## Storage & shell
|
||||
## Stockage & shell
|
||||
|
||||
- **`bun:sqlite`** for SQLite (not `better-sqlite3`)
|
||||
- **`Bun.redis`** for Redis (not `ioredis`)
|
||||
- **`Bun.sql`** for Postgres (not `pg`/`postgres.js`)
|
||||
- **`WebSocket`** built in (not `ws`)
|
||||
- **`Bun.file`** rather than `node:fs` readFile/writeFile
|
||||
- **`Bun.$\`ls\`** rather than `execa`
|
||||
- **`bun:sqlite`** pour SQLite (pas `better-sqlite3`)
|
||||
- **`Bun.redis`** pour Redis (pas `ioredis`)
|
||||
- **`Bun.sql`** pour Postgres (pas `pg`/`postgres.js`)
|
||||
- **`WebSocket`** intégré (pas `ws`)
|
||||
- **`Bun.file`** plutôt que `node:fs` readFile/writeFile
|
||||
- **`Bun.$\`ls\`** plutôt qu'`execa`
|
||||
|
||||
Bun loads `.env` automatically → do not use `dotenv`.
|
||||
Bun charge `.env` automatiquement → ne pas utiliser `dotenv`.
|
||||
|
||||
@@ -1,32 +1,28 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Deployment — multi-stage Bun Alpine Dockerfile; install through pnpm (git+node inside the image) but bun at runtime; runs `bun run start` from src/ (not dist/), EXPOSE 3000, env PORT/NODE_ENV; no CI/CD committed; dev goes through the portless wrapper
|
||||
last_checked: 2026-07-14
|
||||
summary: Déploiement — Dockerfile multi-stage Bun Alpine qui lance `bun run start` depuis src/ (pas dist/), EXPOSE 3000, env PORT/NODE_ENV ; aucun CI/CD committé ; dev passe par le wrapper portless
|
||||
last_checked: 2026-06-15
|
||||
---
|
||||
|
||||
# Deployment & infra
|
||||
# Déploiement & infra
|
||||
|
||||
## Dockerfile
|
||||
|
||||
A `Dockerfile` exists (multi-stage Bun Alpine). **Installation goes through pnpm, but runtime/build/test stay on bun** (see [[knowledge_stack-and-commands]]):
|
||||
- `FROM oven/bun:1-alpine`, `install` stage: `apk add --no-cache git nodejs npm` then `npm install -g pnpm@10.26.0` (the bun image has neither Node nor pnpm; Alpine's `apk nodejs` does not ship corepack), `COPY package.json pnpm-lock.yaml`, then `pnpm install --frozen-lockfile`. `git` is required because `@ng-eventually/client` is a public **git+https** dependency (Gitea, no auth). `release` stage: copies `node_modules` plus the source.
|
||||
Un `Dockerfile` existe (multi-stage Bun Alpine) :
|
||||
- `FROM oven/bun:1-alpine`, stages `install` (`bun install --frozen-lockfile` depuis `package.json` + `bun.lock`) puis `release` (copie `node_modules` + source).
|
||||
- `ENV NODE_ENV=production`, `USER bun`, `EXPOSE 3000/tcp`, `ENTRYPOINT ["bun","run","start"]`.
|
||||
|
||||
**`bun` peer pitfall**: `bun-plugin-tailwind` declares `bun` as a peerDependency → pnpm materializes the npm `bun` package and **creates a `node_modules/.bin/bun` shim** that shadows the `bun` from the PATH under `bun run`/`pnpm run`. Its postinstall is ignored by default → broken shim → `bun run start` fails. Fixed by approving the build: `pnpm.onlyBuiltDependencies: ["bun"]` in `package.json` (the postinstall then downloads the real binary). Without that, the whole pnpm migration breaks startup.
|
||||
|
||||
**Quirk**: `start` = `NODE_ENV=production bun src/index.ts` → the container **runs the TypeScript source directly** (Bun transpiles on the fly), it **does not use `dist/`**. `bun run build` (→ `dist/`) is therefore **not** on the default production path. Serving the build would require changing the entrypoint.
|
||||
**Quirk** : `start` = `NODE_ENV=production bun src/index.ts` → le conteneur **exécute la source TypeScript directement** (Bun transpile à la volée), il **n'utilise pas `dist/`**. Le `bun run build` (→ `dist/`) n'est donc **pas** sur le chemin de prod par défaut. Si on veut servir le build, il faut changer l'entrypoint.
|
||||
|
||||
## CI/CD
|
||||
|
||||
**No** pipeline is committed (`.github/workflows/` absent, no Coolify config in the repo). A knowingly accepted blind spot. To host the Bun app, the `coolify-hosting` skill applies.
|
||||
**Aucun** pipeline committé (`.github/workflows/` absent, pas de config Coolify dans le repo). Angle mort assumé. Pour héberger l'app Bun, le skill `coolify-hosting` s'applique (mentionné aussi dans concept `nextgraph-platform` pour distinguer Festipod du `ngd` Rust).
|
||||
|
||||
## Environment variables
|
||||
## Variables d'environnement
|
||||
|
||||
- `PORT` (default 3000), `NODE_ENV` (enables/disables HMR and the dev auto-seed — see concept `data-layer`).
|
||||
- No `.env*` is committed (`.env` is gitignored). No secret management in the repo.
|
||||
- `PORT` (défaut 3000), `NODE_ENV` (active/désactive HMR et l'auto-seed dev — cf. concept `data-layer`).
|
||||
- Aucun `.env*` committé (`.env` est gitignored). Pas de gestion de secrets dans le repo.
|
||||
|
||||
## Dev
|
||||
|
||||
`bun run dev` = **`portless festipod bun --hot src/index.ts`** — it goes through the **`portless`** wrapper (an external port-management tool), not a bare `bun --hot`. HMR is active outside production.
|
||||
|
||||
**Reactive local link to the polyfill**: in production the `@ng-eventually/client` dependency comes from Gitea (git+https, pinned by `pnpm-lock.yaml`). To edit the polyfill locally and see the changes live, `pnpm run link:polyfill` (script `scripts/link-polyfill.ts`, strategy S2) replaces `node_modules/@ng-eventually/client` with a **real copy** of the local source (`…/ng-eventually-js/packages/client`) — **without** its own `node_modules/@ng-org` — and resyncs `src/` on every edit. That is what guarantees **a single `@ng-org/web` instance** (a single verifier): a symlink to the monorepo checkout would carry its own `@ng-org` → a 2nd instance → broken SDK. To go back to the committed state: `pnpm install`.
|
||||
`bun run dev` = **`portless festipod bun --hot src/index.ts`** — passe par le wrapper **`portless`** (outil externe de gestion de port), pas un `bun --hot` nu. HMR actif hors production.
|
||||
|
||||
@@ -1,43 +1,40 @@
|
||||
---
|
||||
type: knowledge
|
||||
summary: Stack components (Bun runtime/build/test, install through pnpm, React, NextGraph, Storybook, Cucumber, Tailwind-inside-the-build) and the real list of package.json scripts, quirks included (cucumber through node+tsx, build:ng for the local fork, link:polyfill for the reactive local link)
|
||||
summary: Composants de la stack (Bun, React, NextGraph, Storybook, Cucumber, Tailwind-dans-le-build) et liste réelle des scripts package.json, dont les quirks (cucumber via node+tsx, build:orm au chemin périmé, build:ng pour le fork local)
|
||||
---
|
||||
|
||||
# Stack & commands
|
||||
# Stack & commandes
|
||||
|
||||
## Components
|
||||
## Composants
|
||||
|
||||
| Layer | Technology |
|
||||
| Couche | Techno |
|
||||
|---|---|
|
||||
| Runtime / bundler / test | **Bun** (see [[rule_bun-first]]) |
|
||||
| **Dependency installation** | **pnpm** (`pnpm install`, `pnpm-lock.yaml`) — **only** installation moves to pnpm; runtime/build/test stay on bun. Reason: `@ng-eventually/client` is resolved from Gitea over **git+https** (pnpm handles `git+…#main&path:/packages/client` cleanly, along with deduplication of the `@ng-org` peers). Do not switch installation back to bun/npm. |
|
||||
| UI | **React** (mobile-first, max width 768px — styling covered by concept `app-architecture`) |
|
||||
| Data | **NextGraph** P2P local-first (concept `data-layer`) |
|
||||
| CSS build | **Tailwind** (`tailwindcss` + `bun-plugin-tailwind`) — present in the build, but the screens style themselves with `app-*`/inline, no Tailwind utilities (see concept `app-architecture`) |
|
||||
| UI exploration | **Storybook** (webpack5 + SWC, port 6006) |
|
||||
| Tests | **Cucumber/Gherkin** in French, multi-layer, plus Playwright, happy-dom and chai (concept `bdd-testing`) |
|
||||
| Runtime / bundler / test | **Bun** (cf. [[rule_bun-first]]) |
|
||||
| UI | **React** (mobile-first, largeur max 768px — style dans concept `app-architecture`) |
|
||||
| Données | **NextGraph** P2P local-first (concept `data-layer`) |
|
||||
| Build CSS | **Tailwind** (`tailwindcss` + `bun-plugin-tailwind`) — présent dans le build, mais les écrans stylent via `app-*`/inline, pas d'utilitaires Tailwind (cf. concept `app-architecture`) |
|
||||
| Exploration UI | **Storybook** (webpack5 + SWC, port 6006) |
|
||||
| Tests | **Cucumber/Gherkin** FR multi-couches + Playwright + happy-dom + chai (concept `bdd-testing`) |
|
||||
|
||||
## `package.json` scripts (the real ones)
|
||||
## Scripts `package.json` (réels)
|
||||
|
||||
| Script | Command / role |
|
||||
| Script | Commande / rôle |
|
||||
|---|---|
|
||||
| `dev` | `portless festipod bun --hot src/index.ts` — dev with HMR through the `portless` wrapper (see [[knowledge_deployment]]) |
|
||||
| `start` | `NODE_ENV=production bun src/index.ts` — production, served from `src/` (not `dist/`) |
|
||||
| `build` | `bun run build.ts` — Bun bundler + Tailwind → `dist/` ([[knowledge_build-pipeline]]) |
|
||||
| `test:cucumber` | chains `cucumber:run` → `cucumber:report` → `features:parse` → `steps:extract` |
|
||||
| `cucumber:run` | `node --import tsx/esm node_modules/@cucumber/cucumber/bin/cucumber.js` — **through Node+tsx, not Bun** (Playwright/happy-dom plugin compatibility), and through the package's **actual JS entry**, not the `.bin/` shim (see Pitfalls) |
|
||||
| `test:data` | same, with `--tags @data` |
|
||||
| `test:auth-setup` | `bun scripts/setup-test-auth.ts` — bootstraps the persistent test wallet |
|
||||
| `dev` | `portless festipod bun --hot src/index.ts` — dev HMR via wrapper `portless` (cf. [[knowledge_deployment]]) |
|
||||
| `start` | `NODE_ENV=production bun src/index.ts` — prod, depuis `src/` (pas `dist/`) |
|
||||
| `build` | `bun run build.ts` — bundler Bun + Tailwind → `dist/` ([[knowledge_build-pipeline]]) |
|
||||
| `test:cucumber` | enchaîne `cucumber:run` → `cucumber:report` → `features:parse` → `steps:extract` |
|
||||
| `cucumber:run` | `node --import tsx/esm …/cucumber-js` — **via Node+tsx, pas Bun** (compat plugins Playwright/happy-dom) |
|
||||
| `test:data` | idem `--tags @data` |
|
||||
| `test:auth-setup` | `bun scripts/setup-test-auth.ts` — bootstrap wallet de test persistant |
|
||||
| `cucumber:report` | `bun scripts/parse-test-results.ts` — `cucumber-report.json` → HTML |
|
||||
| `features:parse` | `bun scripts/parse-features.ts` → `features.ts` |
|
||||
| `steps:extract` | `bun scripts/extract-step-definitions.ts` |
|
||||
| `build:orm` | `rdf-orm build --input ./src/shared/shapes/shex --output ./src/shared/shapes/orm` |
|
||||
| `build:ng` | `bash scripts/build-ng-packages.sh` — (re)builds the NextGraph packages from a local source (optional tool) |
|
||||
| `link:polyfill` | `bun scripts/link-polyfill.ts` — **reactive** local link to the `@ng-eventually/client` polyfill (strategy S2: copy-overlay + watcher), preserving the single `@ng-org` instance. Details in [[knowledge_deployment]]. |
|
||||
| `storybook` / `build-storybook` | Storybook dev (6006) / static build |
|
||||
| `build:orm` | `rdf-orm build --input ./src/shapes/shex --output ./src/shapes/orm` |
|
||||
| `build:ng` | `bash scripts/build-ng-packages.sh` — rebuild des `@ng-org/*` depuis le fork local (concept `nextgraph-platform`) |
|
||||
| `storybook` / `build-storybook` | Storybook dev (6006) / build statique |
|
||||
|
||||
## Pitfalls
|
||||
## Pièges
|
||||
|
||||
- **`cucumber:run`/`test:data` run under Node+tsx**, not Bun — the test plugins do not load under a native Bun import. Do not "bunify" these scripts.
|
||||
- **Never point a script at `node_modules/.bin/*`.** Installation goes through pnpm ([[rule_bun-first]] §exception), which puts **shell shims** there rather than JS entries: `node --import tsx/esm node_modules/.bin/cucumber-js` fails. Invoke the package's **actual JS entry** (`node_modules/@cucumber/cucumber/bin/cucumber.js`). This holds for any npm script that would launch a dependency's binary under `node`.
|
||||
- **`build:orm` was broken until 2026-07-28**: it targeted `./src/shapes/`, which does not exist (the shapes live under `src/shared/shapes/`), so the command exited with an error. **Fixed in `package.json`** — it now runs. Beware of a side effect: the generator has moved on since the committed bindings were produced, so a run reformats them and drops the `: Schema` annotation. That regeneration is a **tool-version bump, not a content fix** — treat it as its own validated change, do not let it ride along.
|
||||
- **`cucumber:run`/`test:data` tournent sous Node+tsx**, pas Bun — les plugins de test ne chargent pas en import Bun natif. Ne pas « bunifier » ces scripts.
|
||||
- **`build:orm` cible `./src/shapes/shex` et `./src/shapes/orm`**, alors que les shapes réelles vivent sous **`src/shared/shapes/`** — le chemin du script est vraisemblablement **périmé** (à corriger ou exécuter avec les bons chemins ; vérifier avant de régénérer l'ORM).
|
||||
|
||||
@@ -1,39 +1,31 @@
|
||||
---
|
||||
type: rule
|
||||
summary: By default use Bun and its native APIs, never the Node equivalents — bun instead of node/ts-node, bun test/build, bunx, and no express/ws/pg/dotenv. EXCEPTION — package installation goes through pnpm (in both repos), not bun install
|
||||
summary: Par défaut utiliser Bun et ses APIs natives, jamais les équivalents Node — bun au lieu de node/ts-node, bun install/test/build, bunx, et pas d'express/ws/pg/dotenv
|
||||
---
|
||||
|
||||
# Rule: Bun-first
|
||||
# Règle : Bun-first
|
||||
|
||||
By default, use **Bun** and its native APIs rather than the Node.js equivalents.
|
||||
Par défaut, utiliser **Bun** et ses APIs natives plutôt que les équivalents Node.js.
|
||||
|
||||
| Instead of… | Use |
|
||||
| Au lieu de… | Utiliser |
|
||||
|---|---|
|
||||
| `node <file>`, `ts-node` | `bun <file>` |
|
||||
| `jest`, `vitest` | `bun test` |
|
||||
| `npm/yarn install`, `bun install` | **`pnpm install`** (see the exception below) |
|
||||
| `npm/yarn/pnpm install` | `bun install` |
|
||||
| `npm run <script>` | `bun run <script>` |
|
||||
| `npx <pkg>` | `bunx <pkg>` |
|
||||
| `webpack`, `esbuild`, `vite` | `bun build` / the Bun bundler (HTML imports) |
|
||||
| `webpack`, `esbuild`, `vite` | `bun build` / bundler Bun (HTML imports) |
|
||||
| `express` | `Bun.serve()` |
|
||||
| `better-sqlite3` | `bun:sqlite` |
|
||||
| `ioredis` | `Bun.redis` |
|
||||
| `pg`, `postgres.js` | `Bun.sql` |
|
||||
| `ws` | `WebSocket` (built in) |
|
||||
| `ws` | `WebSocket` (intégré) |
|
||||
| `node:fs` readFile/writeFile | `Bun.file` |
|
||||
| `execa` | `Bun.$\`...\`` |
|
||||
| `dotenv` | (pointless — Bun loads `.env` automatically) |
|
||||
| `dotenv` | (inutile — Bun charge `.env` automatiquement) |
|
||||
|
||||
API details: [[knowledge_bun-apis]].
|
||||
Détail des APIs : [[knowledge_bun-apis]].
|
||||
|
||||
## Exception: package installation goes through pnpm
|
||||
## Pourquoi
|
||||
|
||||
**Dependencies are installed with `pnpm install` — not `bun install` — in BOTH repos** (Festipod *and* the `@ng-eventually/client` polyfill). Everything else stays on Bun: **runtime, build, test, scripts** (`bun run dev`, `bun build`, `bun test`, `bunx`). Only the installation step changes package manager.
|
||||
|
||||
**Why.** In production the polyfill is installed from a Gitea repository as a **subdirectory** git dependency: `git+https://…/ng-eventually.git#main&path:/packages/client`. pnpm (≥ 10.26) resolves that `#<ref>&path:/…` format and guarantees a **single** instance of `@ng-org/*` (a single verifier); `bun install` does not handle this workflow cleanly. The reference lockfile is therefore `pnpm-lock.yaml`, and the reactive local link to the polyfill goes through `pnpm run link:polyfill` (see [[knowledge_deployment]]).
|
||||
|
||||
**Practical consequence.** npm scripts that relied on `node_modules/.bin/*` may break (pnpm puts shell shims there, not JS entries) — call the package's actual JS entry (e.g. `node_modules/@cucumber/cucumber/bin/cucumber.js`) rather than the `.bin/` shim.
|
||||
|
||||
## Why (Bun for everything else)
|
||||
|
||||
The project is all-Bun (runtime, bundler, test, server). Reintroducing a redundant Node tool adds a dependency, diverges from the repo's conventions, and breaks the native integration (HMR, automatic TS transpilation, `.env` loading). This is a consistency choice, not a cosmetic preference. The installation exception above is the only deviation, and it is motivated by the subdirectory git dependency.
|
||||
Le projet est tout-Bun (runtime, bundler, test, serveur). Réintroduire un outil Node redondant ajoute une dépendance, divergerait des conventions du repo, et casse l'intégration native (HMR, transpilation TS automatique, chargement `.env`). C'est un choix de cohérence, pas une préférence cosmétique.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Inter-repo contracts. Festipod is a CONSUMER only: it publishes no interface of its own,
|
||||
# and what it lists under `publish:` is its own consumer-side DECLARATIONS — the channel a
|
||||
# provider pulls to learn what this app calls, what it needs, and what blocks it.
|
||||
#
|
||||
# Festipod's persistence sits behind interchangeable Refine backends (the backend contract
|
||||
# itself is in-code: `src/shared/refine/providers/types.ts`). Each candidate backend is a
|
||||
# separate interface, with its own folder inside `data-layer` — the concept that owns how
|
||||
# Festipod uses an external data surface.
|
||||
#
|
||||
# `@activitypods/refine-providers` publishes NO engagement: no contract document, no stated
|
||||
# change policy, not even a git tag. So its entry is `type: reconstruct` — the one bounded
|
||||
# crossing this protocol allows, made once at adoption time: the leaf under `into:` is OUR
|
||||
# observation of that package, authored here and pinned to the commit it was read at. It is
|
||||
# not a promise anyone made us, and every upgrade has to re-observe it.
|
||||
#
|
||||
# Once reconstructed, that leaf IS the specification this app codes against. Reading the
|
||||
# package's sources again at work time is not how a question gets answered — the leaf is
|
||||
# fixed or the gap goes into our declaration's `## Needs`.
|
||||
|
||||
publish:
|
||||
# Key qualified by interface: a second declaration is coming for the NextGraph+polyfill
|
||||
# backend, and publish keys must stay unique.
|
||||
activitypods-providers--festipod: concepts/data-layer/activitypods-providers/usage_festipod.md
|
||||
|
||||
consume:
|
||||
- contract: activitypods-providers
|
||||
into: concepts/data-layer/activitypods-providers/
|
||||
type: reconstruct
|
||||
# Canonical identity: the package's git remote plus the repo-relative folder the
|
||||
# observation was made from. Per-developer access to a checkout of it lives in
|
||||
# `.project/contracts.local.yaml`, which is never committed.
|
||||
reconstructFrom: https://github.com/activitypods/refine-providers.git/src
|
||||
# The branch the observation was read from. A tag would be the better pin, but this
|
||||
# repository publishes none — the resolver turns this into the commit actually read,
|
||||
# which is what `built_version` records.
|
||||
ref: master
|
||||
@@ -1,4 +0,0 @@
|
||||
# To discuss
|
||||
|
||||
- [ ] revoir l'implémentation des ReadCap et WriteCap, aligner avec NextGraph et vérifier que le polyfill enforce bien la logique de droits d'accès en attendant que cela soit implémenté
|
||||
- [ ] clarifier la terminologie identité NextGraph (wallet = liste de clés ; user = données + username ; profils = identités contenues dans le user) et réconcilier avec decision_2026-07-06 (« identifiant = wallet »), decision_2026-07-20 (« username dans le profil ») et le modèle principal/identity du polyfill
|
||||
@@ -14,27 +14,18 @@ Web app mobile-first où les utilisateurs créent des **points de rencontre** qu
|
||||
- **Un module n'importe QUE depuis `shared/` — jamais d'un autre module.** C'est l'invariant qui rend l'archi réelle.
|
||||
- **Bun-first** : `bun` / `bun install` / `bun test` / `bun build`, jamais node/npm/vite/jest. `bun run dev` (port 3000).
|
||||
|
||||
## Frontière SDK NextGraph
|
||||
|
||||
Le SDK de données de Festipod est **`@ng-eventually/client`** — traité comme un **SDK NextGraph fini et mature** (documents par entité placés par scope public/protected/private, capabilities, inboxes). Il est injecté une seule fois via `ngSession.configure(...)`.
|
||||
|
||||
**`@ng-eventually/client` est un POLYFILL**, et ce mot dit toute sa mission : **compenser l'écart** entre le SDK **tel qu'il devrait être** et ce que NextGraph fournit **aujourd'hui**. Le wallet virtuel (plusieurs identités sur un wallet physique) en est la plus grosse pièce, pas la totalité.
|
||||
|
||||
**L'app ignore ENTIÈREMENT l'état d'implémentation de NextGraph.** Elle est codée contre le SDK cible, dont le contrat est écrit **dans ce repo** (concept `data-layer`, fiche `knowledge_sdk-surface`). Aucun code ni commentaire du type « on fait X parce que NextGraph fait Y aujourd'hui ». Quand quelque chose ne marche pas, la question n'est jamais « comment contourner dans l'app » mais **« qu'est-ce que le polyfill doit compenser »**.
|
||||
|
||||
**Ne jamais documenter dans ce repo l'état courant de NextGraph** (contraintes du SDK sous-jacent, contournements, internes broker/verifier) : cela vit dans le repo `@ng-eventually/client`. La doctrine Festipod décrit uniquement *le contrat SDK cible* + *comment Festipod l'utilise* + le domaine + l'architecture + le contrat BDD.
|
||||
|
||||
## Doctrine du projet — concepts (livrée automatiquement)
|
||||
|
||||
La connaissance détaillée vit dans `.project/concepts/` (système *concept*) : fiches courtes, typées, **livrées par un hook quand tu touches leur territoire** — tu n'as pas à les charger d'avance. Les 6 concepts :
|
||||
|
||||
| Concept | Couvre |
|
||||
|---|---|
|
||||
| `functional-domain` | Modèle produit : point de rencontre, acteurs, concepts métier, périmètres public/protected/private par entité, découverte, défi déduplication |
|
||||
| `functional-domain` | Modèle produit : point de rencontre, acteurs, concepts métier, défi déduplication |
|
||||
| `app-architecture` | Modules, invariant d'imports, app shell, routing path-based, écrans |
|
||||
| `tech-stack` | Bun-first, APIs Bun, build pipeline, commandes |
|
||||
| `data-layer` | Persistance via le SDK `@ng-eventually/client` : entités-documents par scope, shapes SHEX/ORM, modes connected/demo, pièges |
|
||||
| `data-layer` | NextGraph actuel (mono-store), shapes, modes connected/demo, règles + pièges (suppression, champs perdus, internals) |
|
||||
| `bdd-testing` | Cucumber multi-couches FR, contrat `@ui`/`@data`/`@e2e`, harness broker, cookbook |
|
||||
| `app-security` | Isolation déléguée au SDK (pas de contrôle d'accès dans les écrans), auth wallet, matrice d'autorisations cible |
|
||||
| `app-security` | Posture de sécurité actuelle (mono-store, confiance broker), auth wallet, modèle d'autorisations cible |
|
||||
| `nextgraph-platform` | NextGraph système externe (stores, inbox, SDK) + briefs prospectifs (multi-store, fork, shim) |
|
||||
|
||||
Pour **documenter** un fait projet : `/concept document <sujet>` (ne pas écrire en libre dans `.project/`).
|
||||
|
||||
+4
-12
@@ -1,19 +1,11 @@
|
||||
# Use the official Bun image (runtime stays Bun; only install moves to pnpm)
|
||||
# Use the official Bun image
|
||||
FROM oven/bun:1-alpine AS base
|
||||
WORKDIR /app
|
||||
|
||||
# Install dependencies with pnpm.
|
||||
# - git: the @ng-eventually/client polyfill is a git+https (public Gitea) dependency → no auth.
|
||||
# - nodejs + npm: pnpm is a Node CLI; we pin the exact pnpm version via `npm i -g`
|
||||
# (Alpine's nodejs package does not bundle corepack).
|
||||
# The `bun` npm peer (pulled by bun-plugin-tailwind) is approved to build in package.json
|
||||
# (pnpm.onlyBuiltDependencies) so node_modules/.bin/bun is a real binary — required because
|
||||
# `bun run start` puts node_modules/.bin ahead of PATH.
|
||||
# Install dependencies
|
||||
FROM base AS install
|
||||
RUN apk add --no-cache git nodejs npm \
|
||||
&& npm install -g pnpm@10.26.0
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
COPY package.json bun.lock ./
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
# Copy source code and build assets
|
||||
FROM base AS release
|
||||
|
||||
@@ -142,8 +142,8 @@ const result = await Bun.build({
|
||||
"globalThis.__FESTIPOD_SHARED_WALLET_PASSWORD__": JSON.stringify(
|
||||
process.env.FESTIPOD_SHARED_WALLET_PASSWORD ?? "",
|
||||
),
|
||||
// Auto-seed gate (OFF by default): only seed a genuinely-empty wallet with
|
||||
// demo data when FESTIPOD_AUTO_SEED is set (see src/shared/utils/autoSeed.ts).
|
||||
// The app may bootstrap demo data into an empty connected wallet only when
|
||||
// FESTIPOD_AUTO_SEED is set (see src/shared/utils/autoSeed.ts). Absent → OFF.
|
||||
"globalThis.__FESTIPOD_AUTO_SEED__": JSON.stringify(
|
||||
process.env.FESTIPOD_AUTO_SEED ?? "",
|
||||
),
|
||||
|
||||
+1
-1
@@ -6,13 +6,13 @@
|
||||
"src/modules/*/steps/**/*.ts"
|
||||
],
|
||||
"paths": ["src/modules/*/features/**/*.feature"],
|
||||
"tags": "not @wip and not @humain",
|
||||
"format": [
|
||||
"progress-bar",
|
||||
"json:reports/cucumber-report.json",
|
||||
"html:reports/cucumber-report.html"
|
||||
],
|
||||
"language": "fr",
|
||||
"tags": "not @wip",
|
||||
"formatOptions": {
|
||||
"snippetInterface": "async-await"
|
||||
},
|
||||
|
||||
+12
-14
@@ -15,22 +15,20 @@
|
||||
"features:parse": "bun scripts/parse-features.ts",
|
||||
"steps:extract": "bun scripts/extract-step-definitions.ts",
|
||||
"build:orm": "rdf-orm build --input ./src/shared/shapes/shex --output ./src/shared/shapes/orm",
|
||||
"validate": "bun scripts/validate.ts",
|
||||
"build:ng": "bash scripts/build-ng-packages.sh",
|
||||
"link:polyfill": "bun scripts/link-polyfill.ts",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"build-storybook": "storybook build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ng-eventually/client": "git+https://gitea.reconnexion.apps.gueraud.net/Reconnexion/ng-eventually.git#main&path:/packages/client",
|
||||
"@ng-org/alien-deepsignals": "0.1.2-alpha.11",
|
||||
"@ng-org/orm": "0.1.2-alpha.18",
|
||||
"@ng-org/shex-orm": "0.1.2-alpha.8",
|
||||
"@ng-org/web": "0.1.2-alpha.13",
|
||||
"@ng-org/alien-deepsignals": "0.1.2-alpha.13",
|
||||
"@ng-org/orm": "0.1.2-alpha.21",
|
||||
"@ng-org/shex-orm": "0.1.2-alpha.9",
|
||||
"@ng-org/web": "0.1.2-alpha.14",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@refinedev/core": "^5.0.12",
|
||||
"bun-plugin-tailwind": "^0.1.2",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
@@ -43,6 +41,11 @@
|
||||
"@cucumber/cucumber": "^12.5.0",
|
||||
"@cucumber/gherkin": "^29.0.0",
|
||||
"@cucumber/messages": "^26.0.1",
|
||||
"@storybook/addon-a11y": "^10.3.5",
|
||||
"@storybook/addon-docs": "^10.3.5",
|
||||
"@storybook/addon-onboarding": "^10.3.5",
|
||||
"@storybook/addon-webpack5-compiler-swc": "^4.0.3",
|
||||
"@storybook/react-webpack5": "^10.3.5",
|
||||
"@types/bun": "latest",
|
||||
"@types/chai": "^5.2.3",
|
||||
"@types/react": "^19",
|
||||
@@ -50,15 +53,10 @@
|
||||
"chai": "^6.2.2",
|
||||
"happy-dom": "^16.6.0",
|
||||
"playwright": "^1.58.2",
|
||||
"storybook": "^10.3.5",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tsx": "^4.21.0",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"storybook": "^10.3.5",
|
||||
"@storybook/react-webpack5": "^10.3.5",
|
||||
"@storybook/addon-webpack5-compiler-swc": "^4.0.3",
|
||||
"@storybook/addon-a11y": "^10.3.5",
|
||||
"@storybook/addon-docs": "^10.3.5",
|
||||
"@storybook/addon-onboarding": "^10.3.5"
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
||||
Generated
+1218
-1481
File diff suppressed because it is too large
Load Diff
@@ -1,106 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* link-polyfill.ts — Reactive local link for the @ng-eventually/client polyfill (S2).
|
||||
*
|
||||
* WHY S2 (copy-overlay) and not a symlink (S1):
|
||||
* The committed prod dependency installs @ng-eventually/client from Gitea (git+https)
|
||||
* into pnpm's store WITHOUT its own node_modules/@ng-org → @ng-org/web resolves up to
|
||||
* Festipod → ONE @ng-org instance (one verifier). The local polyfill CHECKOUT, however,
|
||||
* carries its own node_modules/@ng-org/* (symlinks into the ng-eventually-js monorepo
|
||||
* store). Symlinking node_modules/@ng-eventually/client to that checkout puts the
|
||||
* checkout's @ng-org in the resolution path → a SECOND @ng-org instance → broken SDK
|
||||
* (two verifiers). So we overlay a real directory that contains ONLY the polyfill's
|
||||
* source (no node_modules) and keep it in sync by copying — @ng-org still resolves to
|
||||
* Festipod, single instance preserved.
|
||||
*
|
||||
* WHAT IT DOES:
|
||||
* 1. Replaces node_modules/@ng-eventually/client (the pnpm store symlink) with a real
|
||||
* directory holding the local polyfill's package.json + src (NO node_modules).
|
||||
* 2. Asserts the single-instance invariant (same @ng-org/web realpath from Festipod and
|
||||
* from the overlay) — aborts if it would break.
|
||||
* 3. Watches the local polyfill src and copies each change into the overlay, so
|
||||
* `bun --hot` (bun run dev) reloads the edited file live.
|
||||
*
|
||||
* USAGE (reactive dev):
|
||||
* Terminal 1: pnpm run link:polyfill # overlays local source, then watches
|
||||
* Terminal 2: bun run dev # portless festipod bun --hot src/index.ts
|
||||
* Edit files under packages/client/src → they land in node_modules → bun --hot reloads.
|
||||
*
|
||||
* pnpm run link:polyfill --once # overlay + verify, no watch (CI / one-shot)
|
||||
* Return to the committed git-installed dependency: pnpm install
|
||||
*
|
||||
* Override the local checkout path with NG_EVENTUALLY_LOCAL=/path/to/packages/client.
|
||||
*/
|
||||
import { existsSync, lstatSync, mkdirSync, rmSync, cpSync, copyFileSync, realpathSync } from "node:fs";
|
||||
import { watch } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
|
||||
const FESTIPOD = realpathSync(join(import.meta.dir, ".."));
|
||||
const LOCAL =
|
||||
process.env.NG_EVENTUALLY_LOCAL ??
|
||||
"/home/sylvain/projects/nextgraph/ng-eventually-js/packages/client";
|
||||
const TARGET = join(FESTIPOD, "node_modules", "@ng-eventually", "client");
|
||||
const SRC_LOCAL = join(LOCAL, "src");
|
||||
const SRC_TARGET = join(TARGET, "src");
|
||||
const ONCE = process.argv.includes("--once");
|
||||
|
||||
function fail(msg: string): never {
|
||||
console.error(`✖ link:polyfill — ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!existsSync(join(LOCAL, "package.json"))) {
|
||||
fail(`local polyfill not found at ${LOCAL} (set NG_EVENTUALLY_LOCAL to override)`);
|
||||
}
|
||||
|
||||
// 1. Replace the pnpm store symlink with a real overlay dir (metadata + src, NO node_modules).
|
||||
console.log(`→ overlaying local polyfill: ${LOCAL}`);
|
||||
if (existsSync(TARGET) || lstatSync(TARGET, { throwIfNoEntry: false })) {
|
||||
rmSync(TARGET, { recursive: true, force: true });
|
||||
}
|
||||
mkdirSync(TARGET, { recursive: true });
|
||||
for (const meta of ["package.json", "tsconfig.json", "README.md"]) {
|
||||
const from = join(LOCAL, meta);
|
||||
if (existsSync(from)) copyFileSync(from, join(TARGET, meta));
|
||||
}
|
||||
// Copy src fresh (NEVER a node_modules dir — that is what guarantees single @ng-org instance).
|
||||
cpSync(SRC_LOCAL, SRC_TARGET, { recursive: true });
|
||||
|
||||
// 2. Assert the single-instance invariant.
|
||||
const fromFestipod = realpathSync(Bun.resolveSync("@ng-org/web", FESTIPOD));
|
||||
const overlayReal = realpathSync(TARGET);
|
||||
const fromPolyfill = realpathSync(Bun.resolveSync("@ng-org/web", overlayReal));
|
||||
console.log(` @ng-org/web (Festipod): ${fromFestipod}`);
|
||||
console.log(` @ng-org/web (overlay) : ${fromPolyfill}`);
|
||||
if (fromFestipod !== fromPolyfill) {
|
||||
fail(
|
||||
"single-instance invariant BROKEN — @ng-org/web resolves to two different realpaths.\n" +
|
||||
" The overlay must not contain its own node_modules/@ng-org. Aborting.",
|
||||
);
|
||||
}
|
||||
console.log("✓ single @ng-org/web instance preserved");
|
||||
|
||||
if (ONCE) {
|
||||
console.log("✓ overlay ready (--once, not watching)");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 3. Watch and copy on change so `bun --hot` sees live edits.
|
||||
console.log(`👀 watching ${SRC_LOCAL} → ${SRC_TARGET} (Ctrl-C to stop)`);
|
||||
watch(SRC_LOCAL, { recursive: true }, (_event, filename) => {
|
||||
if (!filename) return;
|
||||
const from = join(SRC_LOCAL, filename);
|
||||
const to = join(SRC_TARGET, filename);
|
||||
try {
|
||||
if (existsSync(from)) {
|
||||
mkdirSync(dirname(to), { recursive: true });
|
||||
copyFileSync(from, to);
|
||||
console.log(` ↻ ${filename}`);
|
||||
} else if (existsSync(to)) {
|
||||
rmSync(to, { force: true });
|
||||
console.log(` ✗ ${filename} (removed)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(` ! failed to sync ${filename}:`, err);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* overlay-local-checkout.ts — Reactive local overlay of a data-layer PROVIDER's checkout
|
||||
* into node_modules. One script, one provider per run; only the paths differ between them.
|
||||
*
|
||||
* PROVIDERS (first non-flag argument; defaults to `polyfill`):
|
||||
* polyfill → node_modules/@ng-eventually/polyfill override: NG_EVENTUALLY_LOCAL
|
||||
* indexing → node_modules/@ng-helpers/indexing override: NG_HELPERS_LOCAL
|
||||
*
|
||||
* WHY a copy-overlay and not a symlink (identical for every provider):
|
||||
* A committed prod dependency installs from Gitea (git+https) into pnpm's store WITHOUT
|
||||
* its own node_modules/@ng-org, so @ng-org/web resolves UP to Festipod → ONE @ng-org
|
||||
* instance (one verifier). A local CHECKOUT, however, carries its own node_modules/*
|
||||
* (links into that provider's own dev tree). Symlinking node_modules/<pkg> to the
|
||||
* checkout would put the checkout's copies in the resolution path → a SECOND @ng-org
|
||||
* (and, for `indexing`, a second @ng-eventually/polyfill) → broken SDK, two verifiers.
|
||||
* So we overlay a real directory containing ONLY the provider's source (no node_modules):
|
||||
* shared packages still resolve up to Festipod, single instance preserved.
|
||||
*
|
||||
* WHAT IT DOES:
|
||||
* 1. Replaces node_modules/<package> (the pnpm store symlink) with a real directory
|
||||
* holding the local checkout's package.json + src (NO node_modules).
|
||||
* 2. Asserts the single-instance invariant — every package this provider SHARES with
|
||||
* Festipod must resolve to the same realpath from Festipod and from the overlay —
|
||||
* and aborts if it would break.
|
||||
* 3. Watches the local checkout's src and copies each change into the overlay.
|
||||
*
|
||||
* ⚠️ RESTART `bun run dev` AFTER THIS SCRIPT WRITES — a rebuild is NOT a substitute.
|
||||
* A running dev server NEVER picks up a package refreshed inside node_modules, not even
|
||||
* across a genuine rebuild: VERIFIED in a controlled test, an application-source edit
|
||||
* produced a new bundle hash and the rebuilt bundle STILL carried the stale dependency.
|
||||
* The server's resolution of that import is pinned at process start and a rebuild does not
|
||||
* re-resolve it. Only restarting serves the fresh copy, and nothing warns you — a stale
|
||||
* server looks exactly like a current one. See
|
||||
* .project/concepts/tech-stack/caveat_polyfill-overlay-needs-a-dev-restart.md, which cost
|
||||
* an hour to learn. Watching copies the files; it does not make anything reload them.
|
||||
*
|
||||
* USAGE (reactive dev):
|
||||
* Terminal 1: pnpm run overlay:polyfill # or: pnpm run overlay:indexing
|
||||
* Terminal 2: bun run dev # portless festipod bun --hot src/index.ts
|
||||
* Edit the checkout's src → it lands in node_modules → RESTART dev to pick it up.
|
||||
*
|
||||
* pnpm run overlay:indexing --once # overlay + verify, no watch (CI / one-shot)
|
||||
* Return to the committed git-installed dependencies: pnpm install
|
||||
*/
|
||||
import { existsSync, lstatSync, mkdirSync, rmSync, cpSync, copyFileSync, realpathSync } from "node:fs";
|
||||
import { watch } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
|
||||
interface Provider {
|
||||
/** Package as installed, e.g. "@ng-eventually/polyfill" — also its node_modules path. */
|
||||
readonly packageName: string;
|
||||
/** Local checkout used when the env override is unset. */
|
||||
readonly defaultLocal: string;
|
||||
/** Env var overriding the local checkout path. */
|
||||
readonly envOverride: string;
|
||||
/**
|
||||
* Packages this provider SHARES with Festipod and that must stay single-instance.
|
||||
* Each is resolved from Festipod and from the overlay; the realpaths must match.
|
||||
*/
|
||||
readonly singletons: readonly string[];
|
||||
}
|
||||
|
||||
const PROVIDERS: Record<string, Provider> = {
|
||||
polyfill: {
|
||||
packageName: "@ng-eventually/polyfill",
|
||||
defaultLocal: "/home/sylvain/projects/nextgraph/ng-eventually-js/packages/polyfill",
|
||||
envOverride: "NG_EVENTUALLY_LOCAL",
|
||||
singletons: ["@ng-org/web"],
|
||||
},
|
||||
indexing: {
|
||||
packageName: "@ng-helpers/indexing",
|
||||
defaultLocal: "/home/sylvain/projects/nextgraph/ng-helpers",
|
||||
envOverride: "NG_HELPERS_LOCAL",
|
||||
// Consumes the polyfill, so BOTH it and the verifier underneath must stay single.
|
||||
singletons: ["@ng-eventually/polyfill", "@ng-org/web"],
|
||||
},
|
||||
};
|
||||
|
||||
const FESTIPOD = realpathSync(join(import.meta.dir, ".."));
|
||||
const args = process.argv.slice(2);
|
||||
const ONCE = args.includes("--once");
|
||||
const KEY = args.find((a) => !a.startsWith("-")) ?? "polyfill";
|
||||
|
||||
function fail(msg: string): never {
|
||||
console.error(`✖ overlay:${KEY} — ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const provider = PROVIDERS[KEY];
|
||||
if (!provider) {
|
||||
fail(`unknown provider "${KEY}" — expected one of: ${Object.keys(PROVIDERS).join(", ")}`);
|
||||
}
|
||||
|
||||
const LOCAL = process.env[provider.envOverride] ?? provider.defaultLocal;
|
||||
const TARGET = join(FESTIPOD, "node_modules", ...provider.packageName.split("/"));
|
||||
const SRC_LOCAL = join(LOCAL, "src");
|
||||
const SRC_TARGET = join(TARGET, "src");
|
||||
|
||||
if (!existsSync(join(LOCAL, "package.json"))) {
|
||||
fail(`local checkout not found at ${LOCAL} (set ${provider.envOverride} to override)`);
|
||||
}
|
||||
if (!existsSync(SRC_LOCAL)) {
|
||||
fail(`local checkout has no src/ at ${SRC_LOCAL}`);
|
||||
}
|
||||
|
||||
// 1. Replace the pnpm store symlink with a real overlay dir (metadata + src, NO node_modules).
|
||||
console.log(`→ overlaying local ${provider.packageName}: ${LOCAL}`);
|
||||
if (existsSync(TARGET) || lstatSync(TARGET, { throwIfNoEntry: false })) {
|
||||
rmSync(TARGET, { recursive: true, force: true });
|
||||
}
|
||||
mkdirSync(TARGET, { recursive: true });
|
||||
for (const meta of ["package.json", "tsconfig.json", "README.md"]) {
|
||||
const from = join(LOCAL, meta);
|
||||
if (existsSync(from)) copyFileSync(from, join(TARGET, meta));
|
||||
}
|
||||
// Copy src fresh (NEVER a node_modules dir — that is what guarantees single instances).
|
||||
cpSync(SRC_LOCAL, SRC_TARGET, { recursive: true });
|
||||
|
||||
// 2. Assert the single-instance invariant for every package shared with Festipod.
|
||||
const overlayReal = realpathSync(TARGET);
|
||||
for (const spec of provider.singletons) {
|
||||
const fromFestipod = realpathSync(Bun.resolveSync(spec, FESTIPOD));
|
||||
const fromOverlay = realpathSync(Bun.resolveSync(spec, overlayReal));
|
||||
console.log(` ${spec} (Festipod): ${fromFestipod}`);
|
||||
console.log(` ${spec} (overlay) : ${fromOverlay}`);
|
||||
if (fromFestipod !== fromOverlay) {
|
||||
fail(
|
||||
`single-instance invariant BROKEN — ${spec} resolves to two different realpaths.\n` +
|
||||
" The overlay must not contain its own node_modules. Aborting.",
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log(`✓ single instance preserved for: ${provider.singletons.join(", ")}`);
|
||||
|
||||
if (ONCE) {
|
||||
console.log("✓ overlay ready (--once, not watching)");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// 3. Watch and copy on change. This keeps the overlay CURRENT; it does NOT make a running
|
||||
// dev server notice — not even across a rebuild (see the header). Restart it.
|
||||
console.log(`👀 watching ${SRC_LOCAL} → ${SRC_TARGET} (Ctrl-C to stop)`);
|
||||
watch(SRC_LOCAL, { recursive: true }, (_event, filename) => {
|
||||
if (!filename) return;
|
||||
const from = join(SRC_LOCAL, filename);
|
||||
const to = join(SRC_TARGET, filename);
|
||||
try {
|
||||
if (existsSync(from)) {
|
||||
mkdirSync(dirname(to), { recursive: true });
|
||||
copyFileSync(from, to);
|
||||
console.log(` ↻ ${filename}`);
|
||||
} else if (existsSync(to)) {
|
||||
rmSync(to, { force: true });
|
||||
console.log(` ✗ ${filename} (removed)`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(` ! failed to sync ${filename}:`, err);
|
||||
}
|
||||
});
|
||||
@@ -1,490 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* validate.ts — Validation matrix (broker-level tests only, no @ui/@e2e).
|
||||
*
|
||||
* Default run (no flags) — key subset only, fast:
|
||||
* (a) Polyfill unit tests (@ng-eventually/client — bun test)
|
||||
* (b) Polyfill e2e real-broker (@ng-eventually/client — bun run e2e/run.ts)
|
||||
* (c) Festipod @data KEY SUBSET (cucumber --name regex covering terrain bugs)
|
||||
* (d) Festipod @multibrowser (cucumber --tags @multibrowser)
|
||||
* (e) Festipod @smoke (cucumber --tags @smoke — boot connecté rend)
|
||||
* (f) Festipod @wip [informational only, non-blocking]
|
||||
*
|
||||
* With --full flag:
|
||||
* (c) becomes full @data suite (cucumber --tags @data)
|
||||
*
|
||||
* Each step runs even if the previous one failed (--bail mode is OFF).
|
||||
* Exit code is non-zero if any non-informational step has failures.
|
||||
*
|
||||
* Profile rotation: both Playwright profiles are rotated before @data and
|
||||
* @multibrowser when their size exceeds BLOAT_THRESHOLD_MB (default 50 MB),
|
||||
* to avoid the sparql_query hang described in caveat_wallet-bloat-hang.
|
||||
*/
|
||||
|
||||
import { spawnSync } from "child_process";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
// ─── Config ────────────────────────────────────────────────────────────────
|
||||
|
||||
const FESTIPOD_DIR = "/home/sylvain/projects/festipod/festipod";
|
||||
const POLYFILL_DIR =
|
||||
"/home/sylvain/projects/nextgraph/ng-eventually-js/packages/client";
|
||||
|
||||
const FESTIPOD_PROFILE = path.join(FESTIPOD_DIR, ".playwright-profile");
|
||||
const POLYFILL_PROFILE = path.join(
|
||||
POLYFILL_DIR,
|
||||
"e2e",
|
||||
".playwright-profile-lib",
|
||||
);
|
||||
|
||||
/** Rotate profile when it exceeds this many MB (caveat_wallet-bloat-hang). */
|
||||
const BLOAT_THRESHOLD_MB = 50;
|
||||
|
||||
/**
|
||||
* Per-step timeouts:
|
||||
* - polyfill unit/e2e: short steps, keep 10 min
|
||||
* - @data key subset: generous — BeforeAll + 8 scenarios, ~15 min margin
|
||||
* - @data full: full suite, ~35 min margin
|
||||
* - @multibrowser: 7 scenarios, ~10 min margin
|
||||
* - @wip: informational, 10 min
|
||||
*/
|
||||
const TIMEOUT_POLYFILL_UNIT_MS = 10 * 60 * 1000; // 10 min
|
||||
const TIMEOUT_POLYFILL_E2E_MS = 10 * 60 * 1000; // 10 min
|
||||
const TIMEOUT_DATA_KEY_MS = 15 * 60 * 1000; // 15 min (key subset)
|
||||
const TIMEOUT_DATA_FULL_MS = 35 * 60 * 1000; // 35 min (--full)
|
||||
const TIMEOUT_MULTIBROWSER_MS = 10 * 60 * 1000; // 10 min
|
||||
const TIMEOUT_SMOKE_MS = 10 * 60 * 1000; // 10 min (1 @e2e boot scenario)
|
||||
const TIMEOUT_WIP_MS = 10 * 60 * 1000; // 10 min
|
||||
|
||||
/**
|
||||
* Key-subset --name regex: matches exactly the 8 scenarios that cover the
|
||||
* known terrain bugs (inscription, désinscription, isolation, reconnexion,
|
||||
* compteur dérivé, créateur ne participe pas, auth vide, auth distinctes).
|
||||
*
|
||||
* Uses a single cucumber invocation so BeforeAll (broker login) runs once.
|
||||
*
|
||||
* French accent chars must be URL-safe in the regex — cucumber uses JS
|
||||
* RegExp, which handles unicode natively; we pass the literal string.
|
||||
*/
|
||||
const DATA_KEY_NAME_REGEX = [
|
||||
"S'inscrire à un événement",
|
||||
"Se désinscrire d'un événement$",
|
||||
"Une identité fraîche ne voit pas la participation d'une autre",
|
||||
"Une page fraîche pour la même identité relit ses propres données",
|
||||
"Le créateur ne participe pas automatiquement à son événement",
|
||||
"L'inscription fait converger le compteur dérivé du propriétaire",
|
||||
"Un portefeuille connecté est vide par défaut",
|
||||
"Les données du portefeuille sont distinctes des données par défaut",
|
||||
].join("|");
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
function dirSizeMB(dir: string): number {
|
||||
if (!fs.existsSync(dir)) return 0;
|
||||
try {
|
||||
const result = spawnSync("du", ["-sm", dir], { encoding: "utf-8" });
|
||||
const line = result.stdout.trim().split("\n")[0] ?? "";
|
||||
return parseInt(line.split("\t")[0] ?? "0", 10);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove any stale Chromium singleton files from `profilePath`. Chromium refuses
|
||||
* to launch (ProcessSingleton error) if SingletonLock, SingletonCookie, or
|
||||
* SingletonSocket are left over from a previous crashed run. Idempotent — safe to
|
||||
* call even when the profile does not exist yet.
|
||||
*/
|
||||
function cleanSingletons(profilePath: string, label: string): void {
|
||||
if (!fs.existsSync(profilePath)) return;
|
||||
const singletons = ["SingletonLock", "SingletonCookie", "SingletonSocket"];
|
||||
for (const name of singletons) {
|
||||
const p = path.join(profilePath, name);
|
||||
if (fs.existsSync(p)) {
|
||||
try {
|
||||
fs.rmSync(p, { force: true });
|
||||
console.log(`[rotate] ${label}: removed stale ${name}.`);
|
||||
} catch {
|
||||
// Non-fatal: if we can't remove it, launch will fail with a clear error
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function rotateProfile(profilePath: string, label: string): void {
|
||||
const sizeMB = dirSizeMB(profilePath);
|
||||
if (sizeMB > BLOAT_THRESHOLD_MB) {
|
||||
console.log(
|
||||
`[rotate] ${label}: ${sizeMB}MB > ${BLOAT_THRESHOLD_MB}MB — rotating profile...`,
|
||||
);
|
||||
try {
|
||||
fs.rmSync(profilePath, { recursive: true, force: true });
|
||||
console.log(`[rotate] ${label}: profile removed. Will be recreated.`);
|
||||
} catch (e) {
|
||||
console.warn(`[rotate] ${label}: failed to remove profile: ${e}`);
|
||||
}
|
||||
} else {
|
||||
// Even if we keep the profile, remove any stale Chromium singleton files left
|
||||
// by a previous crashed run — Chromium refuses to launch if they exist.
|
||||
cleanSingletons(profilePath, label);
|
||||
console.log(
|
||||
`[rotate] ${label}: ${sizeMB}MB — below threshold, keeping profile.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
interface StepResult {
|
||||
label: string;
|
||||
status: "passed" | "failed" | "error";
|
||||
/** Lines to show in the summary (failed scenario names, FAIL lines, etc.) */
|
||||
failures: string[];
|
||||
/** Raw exit code */
|
||||
exitCode: number;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a command and capture its output. Returns the result with parsed
|
||||
* pass/fail summary. Never throws — all errors are captured in StepResult.
|
||||
*/
|
||||
function runStep(
|
||||
label: string,
|
||||
cmd: string,
|
||||
args: string[],
|
||||
cwd: string,
|
||||
timeoutMs: number,
|
||||
extraEnv: Record<string, string> = {},
|
||||
): StepResult {
|
||||
const t0 = Date.now();
|
||||
console.log(`\n${"═".repeat(60)}`);
|
||||
console.log(`▶ ${label}`);
|
||||
console.log(` ${cmd} ${args.join(" ")} (cwd: ${cwd})`);
|
||||
console.log(` timeout: ${Math.round(timeoutMs / 60000)}min`);
|
||||
console.log(`${"═".repeat(60)}`);
|
||||
|
||||
const env = { ...process.env, ...extraEnv };
|
||||
|
||||
const result = spawnSync(cmd, args, {
|
||||
cwd,
|
||||
env,
|
||||
encoding: "utf-8",
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 20 * 1024 * 1024, // 20MB
|
||||
});
|
||||
|
||||
const durationMs = Date.now() - t0;
|
||||
const stdout = result.stdout ?? "";
|
||||
const stderr = result.stderr ?? "";
|
||||
const combined = stdout + "\n" + stderr;
|
||||
|
||||
// Print output in real-time equivalent (post-hoc since spawnSync)
|
||||
if (stdout) process.stdout.write(stdout);
|
||||
if (stderr) process.stderr.write(stderr);
|
||||
|
||||
if (result.error) {
|
||||
console.error(`[${label}] process error:`, result.error.message);
|
||||
return {
|
||||
label,
|
||||
status: "error",
|
||||
failures: [`Process error: ${result.error.message}`],
|
||||
exitCode: result.status ?? 1,
|
||||
durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
const exitCode = result.status ?? 1;
|
||||
const failures = extractFailures(combined, label);
|
||||
const status = exitCode === 0 ? "passed" : "failed";
|
||||
|
||||
return { label, status, failures, exitCode, durationMs };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract meaningful failure lines from combined stdout+stderr.
|
||||
* Heuristics per step type (cucumber scenario names, FAIL lines, etc.).
|
||||
*/
|
||||
function extractFailures(output: string, label: string): string[] {
|
||||
const lines = output.split("\n");
|
||||
const failures: string[] = [];
|
||||
|
||||
if (label.includes("polyfill:unit")) {
|
||||
// bun test output: lines starting with "✗" or "FAIL" or "fail"
|
||||
for (const line of lines) {
|
||||
const l = line.trim();
|
||||
if (/^(✗|✕|FAIL|fail)\s/.test(l) || l.includes("tests failed")) {
|
||||
failures.push(l);
|
||||
}
|
||||
}
|
||||
// Also capture summary line "N passed, M failed"
|
||||
const summary = lines.find(
|
||||
(l) => l.includes("passed") && l.includes("failed"),
|
||||
);
|
||||
if (summary) failures.push(summary.trim());
|
||||
} else if (label.includes("polyfill:e2e")) {
|
||||
// e2e/run.ts output: lines starting with " [FAIL]"
|
||||
for (const line of lines) {
|
||||
const l = line.trim();
|
||||
if (l.startsWith("[FAIL]")) failures.push(l);
|
||||
}
|
||||
// Summary: "N passed / M failed" style
|
||||
const summary = lines.find(
|
||||
(l) => l.includes("passed") || l.includes("failed"),
|
||||
);
|
||||
if (summary && !failures.includes(summary.trim()))
|
||||
failures.push(summary.trim());
|
||||
} else {
|
||||
// Cucumber steps: look for "✗" scenario lines, "FAILED" scenario names,
|
||||
// or lines beginning with "✖" / "×" / "Scenario:" after a failure tag
|
||||
for (const line of lines) {
|
||||
const l = line.trim();
|
||||
if (
|
||||
/^(✗|✕|×|✖)\s/.test(l) ||
|
||||
l.startsWith("✘") ||
|
||||
l.includes("# Scénario:") ||
|
||||
l.includes("# Scenario:") ||
|
||||
(l.startsWith("F") && l.length === 1) // progress-bar failure tick
|
||||
) {
|
||||
if (l.length > 1) failures.push(l);
|
||||
}
|
||||
}
|
||||
// Cucumber "N scenarios (M failed)" summary
|
||||
const summary = lines.find((l) =>
|
||||
/\d+ sc[eé]narios?.*(failed|undefined)/.test(l),
|
||||
);
|
||||
if (summary) failures.push(summary.trim());
|
||||
// Individual scenario fail lines: "✗ Scenario name (features/...)"
|
||||
for (const line of lines) {
|
||||
const l = line.trim();
|
||||
if (l.startsWith("✗") || l.startsWith("✕")) {
|
||||
if (!failures.includes(l)) failures.push(l);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return failures.filter(Boolean);
|
||||
}
|
||||
|
||||
function fmtDuration(ms: number): string {
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
const m = Math.floor(ms / 60_000);
|
||||
const s = ((ms % 60_000) / 1000).toFixed(0);
|
||||
return `${m}m${s}s`;
|
||||
}
|
||||
|
||||
function printMatrix(
|
||||
steps: StepResult[],
|
||||
wipResult: StepResult | null,
|
||||
fullMode: boolean,
|
||||
): void {
|
||||
console.log("\n");
|
||||
console.log("╔══════════════════════════════════════════════════════════╗");
|
||||
console.log("║ VALIDATION MATRIX ║");
|
||||
if (fullMode) {
|
||||
console.log("║ (mode: --full, @data complet) ║");
|
||||
} else {
|
||||
console.log("║ (mode: défaut, sous-ensemble clé) ║");
|
||||
}
|
||||
console.log("╚══════════════════════════════════════════════════════════╝");
|
||||
console.log("");
|
||||
|
||||
const maxLabel = Math.max(...steps.map((s) => s.label.length));
|
||||
|
||||
for (const step of steps) {
|
||||
const icon = step.status === "passed" ? "✅" : step.status === "failed" ? "❌" : "⚠️ ";
|
||||
const pad = step.label.padEnd(maxLabel + 2);
|
||||
console.log(` ${icon} ${pad} [${fmtDuration(step.durationMs)}]`);
|
||||
for (const f of step.failures) {
|
||||
console.log(` ↳ ${f}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (wipResult) {
|
||||
console.log("");
|
||||
console.log(" ── @wip (informational, non-blocking) ──────────────────");
|
||||
const icon =
|
||||
wipResult.status === "passed"
|
||||
? "✅"
|
||||
: wipResult.status === "failed"
|
||||
? "❌"
|
||||
: "⚠️ ";
|
||||
const pad = wipResult.label.padEnd(maxLabel + 2);
|
||||
console.log(` ${icon} ${pad} [${fmtDuration(wipResult.durationMs)}]`);
|
||||
for (const f of wipResult.failures) {
|
||||
console.log(` ↳ ${f}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
const allPassed = steps.every((s) => s.status === "passed");
|
||||
const totalMs = steps.reduce((sum, s) => sum + s.durationMs, 0) +
|
||||
(wipResult?.durationMs ?? 0);
|
||||
if (allPassed) {
|
||||
console.log(" 🟢 ALL STEPS PASSED");
|
||||
} else {
|
||||
const failed = steps.filter((s) => s.status !== "passed");
|
||||
console.log(` 🔴 ${failed.length} STEP(S) FAILED: ${failed.map((s) => s.label).join(", ")}`);
|
||||
}
|
||||
console.log(` ⏱ Total: ${fmtDuration(totalMs)}`);
|
||||
if (!fullMode) {
|
||||
console.log(" ℹ️ Pour @data complet : bun run validate -- --full");
|
||||
}
|
||||
console.log("");
|
||||
}
|
||||
|
||||
// ─── Cucumber command builder ───────────────────────────────────────────────
|
||||
|
||||
function cucumberArgsByTags(tags: string): string[] {
|
||||
return [
|
||||
"--import",
|
||||
"tsx/esm",
|
||||
"node_modules/.bin/cucumber-js",
|
||||
"--config",
|
||||
"cucumber.json",
|
||||
"--tags",
|
||||
tags,
|
||||
];
|
||||
}
|
||||
|
||||
function cucumberArgsByName(nameRegex: string): string[] {
|
||||
return [
|
||||
"--import",
|
||||
"tsx/esm",
|
||||
"node_modules/.bin/cucumber-js",
|
||||
"--config",
|
||||
"cucumber.json",
|
||||
"--tags",
|
||||
"@data",
|
||||
"--name",
|
||||
nameRegex,
|
||||
];
|
||||
}
|
||||
|
||||
// ─── Main ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = process.argv.slice(2);
|
||||
const fullMode = args.includes("--full");
|
||||
|
||||
if (fullMode) {
|
||||
console.log("🔍 Festipod — Full Validation Run (--full : @data complet)");
|
||||
} else {
|
||||
console.log("🔍 Festipod — Validation Run (sous-ensemble clé)");
|
||||
console.log(" Pour @data complet : bun run validate -- --full");
|
||||
}
|
||||
console.log(` Festipod: ${FESTIPOD_DIR}`);
|
||||
console.log(` Polyfill: ${POLYFILL_DIR}`);
|
||||
console.log("");
|
||||
|
||||
// ── Profile rotation AVANT les étapes broker ──────────────────────────────
|
||||
console.log("── Profile rotation check (avant @data et @multibrowser) ────");
|
||||
rotateProfile(FESTIPOD_PROFILE, "festipod");
|
||||
rotateProfile(POLYFILL_PROFILE, "polyfill-lib");
|
||||
|
||||
const steps: StepResult[] = [];
|
||||
|
||||
// ── (a) Polyfill unit tests ───────────────────────────────────────────────
|
||||
steps.push(
|
||||
runStep(
|
||||
"polyfill:unit",
|
||||
"bun",
|
||||
["test"],
|
||||
POLYFILL_DIR,
|
||||
TIMEOUT_POLYFILL_UNIT_MS,
|
||||
),
|
||||
);
|
||||
|
||||
// ── (b) Polyfill e2e real broker ──────────────────────────────────────────
|
||||
// Clean singleton files immediately before launching Chromium — guards against
|
||||
// any file left by polyfill:unit (unlikely but defensive) or by a previous
|
||||
// interrupted run that the initial rotateProfile call ran before.
|
||||
cleanSingletons(POLYFILL_PROFILE, "polyfill-lib (pre-e2e)");
|
||||
steps.push(
|
||||
runStep(
|
||||
"polyfill:e2e",
|
||||
"bun",
|
||||
["run", "e2e/run.ts"],
|
||||
POLYFILL_DIR,
|
||||
TIMEOUT_POLYFILL_E2E_MS,
|
||||
),
|
||||
);
|
||||
|
||||
// ── (c) Festipod @data ────────────────────────────────────────────────────
|
||||
if (fullMode) {
|
||||
// --full : lance tout @data
|
||||
steps.push(
|
||||
runStep(
|
||||
"festipod:@data (complet)",
|
||||
"node",
|
||||
cucumberArgsByTags("@data"),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_DATA_FULL_MS,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// défaut : sous-ensemble clé en UNE invocation (BeforeAll partagé)
|
||||
steps.push(
|
||||
runStep(
|
||||
"festipod:@data (clé)",
|
||||
"node",
|
||||
cucumberArgsByName(DATA_KEY_NAME_REGEX),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_DATA_KEY_MS,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── (d) Festipod @multibrowser ────────────────────────────────────────────
|
||||
// Exclude @wip: a scenario tagged @wip @multibrowser (e.g. the reactive
|
||||
// cross-session scenario, blocked by the shared-wallet structural limit) must
|
||||
// not gate the baseline — it flows into the informational @wip pass below.
|
||||
steps.push(
|
||||
runStep(
|
||||
"festipod:@multibrowser",
|
||||
"node",
|
||||
cucumberArgsByTags("@multibrowser and not @wip"),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_MULTIBROWSER_MS,
|
||||
),
|
||||
);
|
||||
|
||||
// ── (e) Festipod @smoke — boot connecté rend / page blanche ───────────────
|
||||
// Un seul scénario @e2e : boote le VRAI App, se connecte au broker, et vérifie
|
||||
// que l'accueil connecté rend du contenu d'app réel SANS erreur runtime. Garde
|
||||
// la CLASSE « crash de rendu une fois connecté » (page blanche). On ne lance
|
||||
// QUE @smoke (pas tout @e2e) pour garder le run par défaut rapide.
|
||||
// Nettoie les singletons Chromium juste avant, comme les autres étapes broker.
|
||||
cleanSingletons(FESTIPOD_PROFILE, "festipod (pre-@smoke)");
|
||||
steps.push(
|
||||
runStep(
|
||||
"festipod:@smoke",
|
||||
"node",
|
||||
cucumberArgsByTags("@smoke and not @wip"),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_SMOKE_MS,
|
||||
),
|
||||
);
|
||||
|
||||
// ── (f) Festipod @wip [informational] ────────────────────────────────────
|
||||
console.log("\n── @wip informational pass (non-blocking) ──────────────────");
|
||||
const wipResult = runStep(
|
||||
"festipod:@wip",
|
||||
"node",
|
||||
cucumberArgsByTags("@wip"),
|
||||
FESTIPOD_DIR,
|
||||
TIMEOUT_WIP_MS,
|
||||
);
|
||||
|
||||
// ── Matrix ────────────────────────────────────────────────────────────────
|
||||
printMatrix(steps, wipResult, fullMode);
|
||||
|
||||
// ── Exit code ─────────────────────────────────────────────────────────────
|
||||
const anyFailed = steps.some((s) => s.status !== "passed");
|
||||
process.exit(anyFailed ? 1 : 0);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error("validate.ts: unhandled error:", e);
|
||||
process.exit(1);
|
||||
});
|
||||
+15
-10
@@ -2,6 +2,7 @@ import { RouterProvider, useRouter } from './router';
|
||||
import { ThemeProvider } from '../shared/context/ThemeContext';
|
||||
import { NextGraphProvider } from '../shared/context/NextGraphContext';
|
||||
import { AccountProvider } from '../shared/context/AccountContext';
|
||||
import { RefineProvider } from '../shared/refine/RefineProvider';
|
||||
import { FestipodDataProvider } from '../shared/context/FestipodDataContext';
|
||||
import { AuthGate } from './AuthGate';
|
||||
import { ToastContainer } from '../shared/components/sketchy';
|
||||
@@ -58,16 +59,20 @@ export function App() {
|
||||
<ThemeProvider>
|
||||
<NextGraphProvider>
|
||||
<AccountProvider>
|
||||
<FestipodDataProvider>
|
||||
<RouterProvider>
|
||||
<div className="app-container">
|
||||
<AuthGate>
|
||||
<AppContent />
|
||||
</AuthGate>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</RouterProvider>
|
||||
</FestipodDataProvider>
|
||||
{/* Refine wraps the data context: FestipodDataContext reads and writes
|
||||
events through Refine's hooks, so it must sit inside <Refine>. */}
|
||||
<RefineProvider>
|
||||
<FestipodDataProvider>
|
||||
<RouterProvider>
|
||||
<div className="app-container">
|
||||
<AuthGate>
|
||||
<AppContent />
|
||||
</AuthGate>
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</RouterProvider>
|
||||
</FestipodDataProvider>
|
||||
</RefineProvider>
|
||||
</AccountProvider>
|
||||
</NextGraphProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
+17
-60
@@ -1,10 +1,9 @@
|
||||
/**
|
||||
* AuthGate — the stopgap access flow (see decision_2026-06-15_shared-wallet-login-flow):
|
||||
* 1. Access barrier + identifier (AccessGateScreen) → the user names their
|
||||
* virtual space (an identifier) and opens the SHARED wallet via the broker
|
||||
* redirect (with the wallet file + guide it hands the user). Naming the
|
||||
* space and opening it are ONE act.
|
||||
* 2. The app.
|
||||
* 1. Technical access barrier (AccessGateScreen) → opens the SHARED wallet via
|
||||
* the broker redirect (with the wallet file + guide it hands the user).
|
||||
* 2. Perceived app login (ConnexionScreen) → pick a username.
|
||||
* 3. The app.
|
||||
*
|
||||
* The gate is ON BY DEFAULT (Festipod never functions without NextGraph). It is
|
||||
* disabled only when `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ === true` —
|
||||
@@ -13,11 +12,11 @@
|
||||
* screens, not the auth flow). Absent → gate ON.
|
||||
*/
|
||||
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useNextGraph } from '../shared/context/NextGraphContext';
|
||||
import { useAccount, normalizeIdentifier } from '../shared/context/AccountContext';
|
||||
import { useAccount } from '../shared/context/AccountContext';
|
||||
import { AccessGateScreen } from '../modules/auth/screens/AccessGateScreen';
|
||||
import { useRouter, useNavigate } from './router';
|
||||
import { ConnexionScreen } from '../modules/auth/screens/ConnexionScreen';
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
@@ -27,65 +26,23 @@ const GATE_DISABLED = globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ === true;
|
||||
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
const { status, error, connect } = useNextGraph();
|
||||
const { identifier, login } = useAccount();
|
||||
const { route } = useRouter();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Once connected AND identified, leave the disconnected welcome screen for the
|
||||
// app home. The identifier is now set at the barrier (before the broker
|
||||
// round-trip), so on return the app can land on '/' with a session already
|
||||
// open; the removed ConnexionScreen used to do this navigate on login.
|
||||
useEffect(() => {
|
||||
if (!GATE_DISABLED && status === 'connected' && identifier && route.page === 'welcome') {
|
||||
navigate('/home');
|
||||
}
|
||||
}, [status, identifier, route.page, navigate]);
|
||||
const { username } = useAccount();
|
||||
|
||||
// Gate explicitly disabled (no-gate build / @e2e harness) → straight to app.
|
||||
if (GATE_DISABLED) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
// Access barrier — shown until BOTH the wallet is open AND the space is named.
|
||||
// "Entrer" records the identifier (persisted to localStorage AND written into
|
||||
// the `?id=` URL param, which is what actually survives the broker redirect
|
||||
// across the partitioned frontier) and, if the wallet isn't open yet, triggers
|
||||
// the connect.
|
||||
//
|
||||
// On return (reload / broker round-trip) the identifier is already stored, so
|
||||
// we PREFILL the field with it (`initialIdentifier`) — the user never sees a
|
||||
// bare empty prompt they must re-type. It is captured ONCE, at first access.
|
||||
if (status !== 'connected' || !identifier) {
|
||||
const onEnter = (entered: string) => {
|
||||
login(entered);
|
||||
// Carry the identifier across the broker frontier via the URL. localStorage
|
||||
// is partitioned by top-level site, so the value written here (127.0.0.1)
|
||||
// is NOT what the app reads inside the broker iframe (nextgraph.net). The
|
||||
// `@ng-org/web` redirect embeds the FULL app URL (query included) in the
|
||||
// broker `o=`, which is reloaded in the iframe — so writing the normalized
|
||||
// id into `?id=` BEFORE connect() makes it travel. `history.replaceState`
|
||||
// (not push) keeps a single history entry. See AccountContext resolution.
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('id', normalizeIdentifier(entered));
|
||||
window.history.replaceState(window.history.state, '', url.toString());
|
||||
} catch {
|
||||
/* URL construction can't fail for a real page URL; ignore defensively */
|
||||
}
|
||||
}
|
||||
if (status !== 'connected') connect();
|
||||
};
|
||||
return (
|
||||
<AccessGateScreen
|
||||
status={status}
|
||||
error={error}
|
||||
initialIdentifier={identifier ?? ''}
|
||||
onEnter={onEnter}
|
||||
/>
|
||||
);
|
||||
// 1. Technical access barrier (real NG login) — until the shared wallet opens.
|
||||
if (status !== 'connected') {
|
||||
return <AccessGateScreen status={status} error={error} onEnter={connect} />;
|
||||
}
|
||||
|
||||
// The app.
|
||||
// 2. Perceived app login — until a username is chosen.
|
||||
if (!username) {
|
||||
return <ConnexionScreen />;
|
||||
}
|
||||
|
||||
// 3. The app.
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
+15
-50
@@ -3,59 +3,24 @@
|
||||
* element and renders the App component to the DOM.
|
||||
*
|
||||
* It is included in `src/index.html`.
|
||||
*
|
||||
* Before loading the app tree it pulls the RUNTIME shared-wallet config (dev
|
||||
* server + `bun run start`, which serve from src/ and so miss build.ts's
|
||||
* compile-time `define`), sets the global, then dynamically imports `App` so
|
||||
* `sharedWallet.ts` reads the value on evaluation. In a build.ts bundle the
|
||||
* password is already inlined via `define`, so this step is skipped (NODE_ENV).
|
||||
*/
|
||||
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
|
||||
/** Fetch the runtime shared-wallet config and set the global (dev/start only). */
|
||||
async function loadRuntimeConfig(): Promise<void> {
|
||||
if (process.env.NODE_ENV === "production") return; // build.ts define provides it
|
||||
try {
|
||||
const res = await fetch("/festipod-config.json");
|
||||
if (!res.ok) return;
|
||||
const cfg = (await res.json()) as { sharedWalletPassword?: string; autoSeed?: string };
|
||||
// Bracket access so build.ts's `define` (which matches the dotted global)
|
||||
// never rewrites this assignment. Only set when the env actually carries one.
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
if (cfg.sharedWalletPassword && g["__FESTIPOD_SHARED_WALLET_PASSWORD__"] == null) {
|
||||
g["__FESTIPOD_SHARED_WALLET_PASSWORD__"] = cfg.sharedWalletPassword;
|
||||
}
|
||||
// Auto-seed gate (see src/shared/utils/autoSeed.ts): only set the global when
|
||||
// the env var carries a truthy value; absent → stays undefined → seed OFF.
|
||||
if (cfg.autoSeed && g["__FESTIPOD_AUTO_SEED__"] == null) {
|
||||
g["__FESTIPOD_AUTO_SEED__"] = cfg.autoSeed;
|
||||
}
|
||||
} catch {
|
||||
// No runtime config endpoint (static build) → rely on the compile-time define.
|
||||
}
|
||||
const elem = document.getElementById("root")!;
|
||||
const app = (
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
if (import.meta.hot) {
|
||||
// With hot module reloading, `import.meta.hot.data` is persisted.
|
||||
const root = (import.meta.hot.data.root ??= createRoot(elem));
|
||||
root.render(app);
|
||||
} else {
|
||||
// The hot module reloading API is not available in production.
|
||||
createRoot(elem).render(app);
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
await loadRuntimeConfig();
|
||||
// Dynamic import AFTER the global is set, so sharedWallet.ts reads it on eval.
|
||||
const { App } = await import("./App");
|
||||
const elem = document.getElementById("root")!;
|
||||
const app = (
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>
|
||||
);
|
||||
|
||||
if (import.meta.hot) {
|
||||
// With hot module reloading, `import.meta.hot.data` is persisted.
|
||||
const root = (import.meta.hot.data.root ??= createRoot(elem));
|
||||
root.render(app);
|
||||
} else {
|
||||
// The hot module reloading API is not available in production.
|
||||
createRoot(elem).render(app);
|
||||
}
|
||||
}
|
||||
|
||||
void main();
|
||||
|
||||
@@ -333,17 +333,6 @@ body {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* Global data-query spinner (near the "Festipod" title) */
|
||||
@keyframes app-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.app-spinner {
|
||||
animation: app-spin 0.8s linear infinite;
|
||||
flex-shrink: 0;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
/* Online indicator on avatar */
|
||||
.app-avatar .online-dot {
|
||||
position: absolute;
|
||||
|
||||
@@ -41,28 +41,6 @@ const server = serve({
|
||||
});
|
||||
},
|
||||
|
||||
// Shared-wallet config, exposed at RUNTIME for the dev server + `bun run start`
|
||||
// (both serve from src/, so they miss build.ts's compile-time `define`). The app
|
||||
// entry (frontend.tsx) fetches this before it loads the app tree, so
|
||||
// `sharedWallet.ts` sees the password. Empty env → '' → no shared wallet.
|
||||
"/festipod-config.json": () =>
|
||||
Response.json({
|
||||
sharedWalletPassword: process.env.FESTIPOD_SHARED_WALLET_PASSWORD ?? "",
|
||||
// Auto-seed gate (OFF by default): only set when the env var is present, so
|
||||
// the front seeds an empty wallet only on explicit opt-in (see autoSeed.ts).
|
||||
autoSeed: process.env.FESTIPOD_AUTO_SEED ?? "",
|
||||
}),
|
||||
|
||||
// The shared wallet file (download target of the access barrier), when configured.
|
||||
"/shared-wallet.ngw": async () => {
|
||||
const p = process.env.FESTIPOD_SHARED_WALLET_FILE;
|
||||
if (p) {
|
||||
const file = Bun.file(p);
|
||||
if (await file.exists()) return new Response(file);
|
||||
}
|
||||
return new Response("No shared wallet file configured.", { status: 404 });
|
||||
},
|
||||
|
||||
// Serve index.html for all unmatched routes (must be last)
|
||||
"/*": index,
|
||||
},
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
# language: fr
|
||||
@AUTH @priority-1
|
||||
Fonctionnalité: Barrière d'accès — l'identifiant se saisit une seule fois
|
||||
En tant qu'utilisateur qui revient dans Festipod
|
||||
Je veux retrouver l'identifiant que j'ai déjà choisi, pré-rempli
|
||||
Afin de ne jamais avoir à le retaper à l'arrivée
|
||||
|
||||
# Garde-fou contre la régression rapportée : au retour (rechargement / round-trip
|
||||
# broker) la barrière re-demandait un identifiant NU et VIDE alors qu'il était
|
||||
# déjà stocké. L'identifiant est capturé UNE FOIS au premier accès, persisté,
|
||||
# puis pré-rempli. Voir AuthGate + AccessGateScreen.
|
||||
|
||||
@ui
|
||||
Scénario: Le champ identifiant est pré-rempli avec la valeur déjà stockée
|
||||
Étant donné que la barrière d'accès s'affiche avec l'identifiant stocké "alice"
|
||||
Alors le champ identifiant contient "alice"
|
||||
|
||||
@ui
|
||||
Scénario: Un premier accès sans identifiant stocké affiche un champ vide
|
||||
Étant donné que la barrière d'accès s'affiche sans identifiant stocké
|
||||
Alors le champ identifiant est vide
|
||||
|
||||
@ui
|
||||
Scénario: Entrer remonte l'identifiant saisi
|
||||
Étant donné que la barrière d'accès s'affiche avec l'identifiant stocké "alice"
|
||||
Quand je clique sur "Entrer" dans la barrière
|
||||
Alors l'identifiant remonté à l'application est "alice"
|
||||
@@ -1,42 +0,0 @@
|
||||
# language: fr
|
||||
@AUTH @priority-1
|
||||
Fonctionnalité: Résolution de l'identifiant — le param d'URL prime sur localStorage
|
||||
En tant qu'application relancée dans l'iframe du broker après le round-trip
|
||||
Je veux résoudre l'identifiant depuis le param d'URL "?id="
|
||||
Afin qu'il traverse la frontière top-level↔iframe (que localStorage ne franchit pas)
|
||||
|
||||
# Le flux wallet-partagé fait tourner l'app dans DEUX contextes avec DEUX
|
||||
# partitions localStorage distinctes (top-level 127.0.0.1 vs iframe
|
||||
# nextgraph.net). localStorage ne traverse pas la frontière ; le param "?id="
|
||||
# embarqué dans le redirect broker (o=) la traverse. AccountContext résout donc
|
||||
# dans l'ordre : (1) param d'URL "?id=" (source de vérité) ; (2) sinon
|
||||
# localStorage (préremplissage même-partition). Voir AccountContext + AuthGate.
|
||||
|
||||
@ui
|
||||
Scénario: Le param d'URL est la source de vérité quand il est présent
|
||||
Étant donné que localStorage contient l'identifiant "alice"
|
||||
Et que l'URL porte le param id "bob"
|
||||
Quand le contexte de compte résout l'identifiant
|
||||
Alors l'identifiant résolu est "bob"
|
||||
|
||||
@ui
|
||||
Scénario: Le param d'URL prime même sur une valeur localStorage différente et est persisté
|
||||
Étant donné que localStorage contient l'identifiant "alice"
|
||||
Et que l'URL porte le param id "carol"
|
||||
Quand le contexte de compte résout l'identifiant
|
||||
Alors l'identifiant résolu est "carol"
|
||||
Et localStorage contient désormais l'identifiant "carol"
|
||||
|
||||
@ui
|
||||
Scénario: Sans param d'URL, localStorage sert de repli
|
||||
Étant donné que localStorage contient l'identifiant "dave"
|
||||
Et que l'URL ne porte aucun param id
|
||||
Quand le contexte de compte résout l'identifiant
|
||||
Alors l'identifiant résolu est "dave"
|
||||
|
||||
@ui
|
||||
Scénario: Le param d'URL est normalisé (minuscules, @ retiré)
|
||||
Étant donné que localStorage ne contient aucun identifiant
|
||||
Et que l'URL porte le param id "@Erin"
|
||||
Quand le contexte de compte résout l'identifiant
|
||||
Alors l'identifiant résolu est "erin"
|
||||
@@ -1,48 +1,31 @@
|
||||
/**
|
||||
* AccessGateScreen — the *technical access barrier* of the stopgap.
|
||||
*
|
||||
* SHARED WALLET IS THE SOLE SUPPORTED MODE. Festipod does not function without
|
||||
* the shared wallet (the SDK polyfill runs on it). "No shared wallet configured"
|
||||
* is therefore NOT an offered flow — it is a loud MISCONFIGURATION error
|
||||
* (`!hasSharedWallet()` → a config-error block, no functional form). Configure it
|
||||
* via FESTIPOD_SHARED_WALLET_PASSWORD.
|
||||
*
|
||||
* STOPGAP (see decision_2026-06-15_shared-wallet-login-flow). This is the
|
||||
* REAL NextGraph login, shown before the app renders. Because it precedes the
|
||||
* app, the user reads it as "access to the test environment", not as an app
|
||||
* login. The user also types an IDENTIFIER here — the id that names their
|
||||
* virtual space (a technical id, a pseudo in practice, NOT a Festipod profile
|
||||
* handle like `@mariedupont`).
|
||||
* Clicking "Entrer" records that identifier and triggers `connect()`, which
|
||||
* redirects to the broker to open the SHARED wallet. After return the identity
|
||||
* is already set (persisted before the redirect), so NG auto-connects straight
|
||||
* into the app — there is no separate "choose a handle" screen.
|
||||
* login. Clicking "Entrer" triggers `connect()`, which redirects to the broker
|
||||
* to open the SHARED wallet. After return (inside the broker iframe) NG
|
||||
* auto-connects and the app shows the perceived login (ConnexionScreen).
|
||||
*
|
||||
* ASSISTED IMPORT (see decision_2026-06-17). The hosted broker can't import a
|
||||
* wallet inline during
|
||||
* ASSISTED IMPORT (see the broker-import constraint, concept nextgraph-platform
|
||||
* + decision_2026-06-17). The hosted broker can't import a wallet inline during
|
||||
* web-app auth: a first-time device has no wallet, so the broker redirect would
|
||||
* dead-end. We therefore HAND the user the shared wallet FILE (download) + the
|
||||
* shared password and guide a one-time import on nextgraph.eu ("Import a Wallet
|
||||
* File"), BEFORE they click "Entrer". The wallet FILE is the correct static
|
||||
* primitive — a TextCode is a transient 5-min transfer, unusable to embed. This
|
||||
* assisted flow is the default whenever the shared wallet is open pending.
|
||||
* primitive — a TextCode is a transient 5-min transfer, unusable to embed. Shown
|
||||
* only when a shared wallet is configured (FESTIPOD_SHARED_WALLET_PASSWORD).
|
||||
*/
|
||||
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Button, Input, Title, Text } from '../../../shared/components/sketchy';
|
||||
import { Button, Title, Text } from '../../../shared/components/sketchy';
|
||||
import { SHARED_WALLET_PASSWORD, SHARED_WALLET_FILE_URL, WALLET_IMPORT_URL, hasSharedWallet } from '../sharedWallet';
|
||||
|
||||
interface AccessGateScreenProps {
|
||||
status: 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||
error?: string;
|
||||
/**
|
||||
* The identifier already stored for this space (the persisted one), used to
|
||||
* PREFILL the field so a returning user never re-types it. Empty on a truly
|
||||
* first access. Normalized upstream; shown verbatim.
|
||||
*/
|
||||
initialIdentifier?: string;
|
||||
/** Enter the space: the raw identifier the user typed (normalized upstream). */
|
||||
onEnter: (identifier: string) => void;
|
||||
onEnter: () => void;
|
||||
}
|
||||
|
||||
// One numbered step: a badge + a title + the action for that step.
|
||||
@@ -61,15 +44,9 @@ function Step({ n, title, children }: { n: number; title: string; children: Reac
|
||||
);
|
||||
}
|
||||
|
||||
export function AccessGateScreen({ status, error, initialIdentifier, onEnter }: AccessGateScreenProps) {
|
||||
export function AccessGateScreen({ status, error, onEnter }: AccessGateScreenProps) {
|
||||
const connecting = status === 'connecting';
|
||||
const [copied, setCopied] = useState(false);
|
||||
// The identifier that names this virtual space (a technical id — a pseudo in
|
||||
// practice, but not a Festipod profile handle). Entered HERE, at wallet access, so a
|
||||
// single act both names the space and opens it. Normalized (lowercased) upstream.
|
||||
// PREFILLED from the stored identifier so a returning user (reload / broker
|
||||
// round-trip) sees the value they already chose and never re-types it.
|
||||
const [identifier, setIdentifier] = useState(initialIdentifier ?? '');
|
||||
|
||||
const copyPassword = async () => {
|
||||
try {
|
||||
@@ -81,31 +58,15 @@ export function AccessGateScreen({ status, error, initialIdentifier, onEnter }:
|
||||
}
|
||||
};
|
||||
|
||||
const canEnter = !connecting && identifier.trim().length > 0;
|
||||
const enter = () => { if (canEnter) onEnter(identifier); };
|
||||
|
||||
// Identifier field + Entrer: naming the space and opening it are one act.
|
||||
const entrer = (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<Input
|
||||
data-testid="identifier-input"
|
||||
placeholder="votre identifiant"
|
||||
value={identifier}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setIdentifier(e.target.value)}
|
||||
onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter') enter(); }}
|
||||
/>
|
||||
<Text style={{ margin: 0, fontSize: 12, color: '#999' }}>
|
||||
Il identifie votre espace (mis en minuscules).
|
||||
</Text>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={enter}
|
||||
disabled={!canEnter}
|
||||
style={{ width: '100%', opacity: canEnter ? 1 : 0.6 }}
|
||||
>
|
||||
{connecting ? 'Accès en cours…' : 'Entrer'}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onEnter}
|
||||
disabled={connecting}
|
||||
style={{ width: '100%', opacity: connecting ? 0.6 : 1 }}
|
||||
>
|
||||
{connecting ? 'Accès en cours…' : 'Entrer'}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -114,14 +75,7 @@ export function AccessGateScreen({ status, error, initialIdentifier, onEnter }:
|
||||
<Title style={{ textAlign: 'center', fontSize: 30, marginBottom: 4 }}>Festipod</Title>
|
||||
<Text style={{ textAlign: 'center', marginBottom: 24, color: '#888' }}>Espace de test</Text>
|
||||
|
||||
{!hasSharedWallet() ? (
|
||||
// SOLE-MODE guard: Festipod cannot run without the shared wallet, so a
|
||||
// missing one is a misconfiguration, NOT a functional login form.
|
||||
<Text style={{ textAlign: 'center', fontSize: 14, color: '#c92a2a', lineHeight: 1.5, margin: '0 0 12px' }}>
|
||||
Portefeuille partagé non configuré. Festipod ne fonctionne pas sans
|
||||
(définir <code>FESTIPOD_SHARED_WALLET_PASSWORD</code>).
|
||||
</Text>
|
||||
) : status !== 'connected' ? (
|
||||
{hasSharedWallet() ? (
|
||||
<>
|
||||
<Text style={{ textAlign: 'center', fontSize: 14, color: '#666', margin: '0 0 20px', lineHeight: 1.5 }}>
|
||||
Première connexion sur cet appareil ?<br />Chargez le portefeuille partagé, une seule fois.
|
||||
@@ -160,7 +114,7 @@ export function AccessGateScreen({ status, error, initialIdentifier, onEnter }:
|
||||
</div>
|
||||
</Step>
|
||||
|
||||
<Step n={3} title="Revenez ici, choisissez un identifiant et entrez">
|
||||
<Step n={3} title="Revenez sur cet onglet et entrez">
|
||||
{entrer}
|
||||
</Step>
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* ConnexionScreen — the *perceived* login of the stopgap.
|
||||
*
|
||||
* STOPGAP (see decision_2026-06-15_shared-wallet-login-flow). The real NG
|
||||
* login (AccessGateScreen) already happened and is not perceived as a login;
|
||||
* THIS screen is what the user experiences as "logging in": they pick a
|
||||
* username (no password — declarative). The username is persisted by
|
||||
* AccountContext (localStorage) and resolved against the accounts living in
|
||||
* the shared wallet.
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Button, Input, Title, Text, Avatar } from '../../../shared/components/sketchy';
|
||||
import { useAccount } from '../../../shared/context/AccountContext';
|
||||
import { useFestipodData } from '../../../shared/context/FestipodDataContext';
|
||||
import { useNavigate } from '../../../app/router';
|
||||
|
||||
export function ConnexionScreen() {
|
||||
const { login } = useAccount();
|
||||
const { users } = useFestipodData();
|
||||
const navigate = useNavigate();
|
||||
const [value, setValue] = useState('');
|
||||
|
||||
// Choosing a username completes the login → land on the app (not the '/'
|
||||
// welcome/onboarding screen, which the access gate has replaced upstream).
|
||||
const doLogin = (name: string) => {
|
||||
login(name);
|
||||
navigate('/home');
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (value.trim()) doLogin(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
|
||||
<Title style={{ textAlign: 'center', fontSize: 32, marginBottom: 8 }}>Connexion</Title>
|
||||
<Text style={{ textAlign: 'center', marginBottom: 32, color: '#888' }}>
|
||||
Choisissez votre nom d'utilisateur
|
||||
</Text>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, marginBottom: 24 }}>
|
||||
<Input
|
||||
placeholder="@votrepseudo"
|
||||
value={value}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setValue(e.target.value)}
|
||||
onKeyDown={(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') submit();
|
||||
}}
|
||||
/>
|
||||
<Button variant="primary" onClick={submit} disabled={!value.trim()} style={{ width: '100%' }}>
|
||||
Se connecter
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{users.length > 0 && (
|
||||
<>
|
||||
<Text style={{ textAlign: 'center', fontSize: 13, color: '#888', margin: '8px 0 12px' }}>
|
||||
ou reprenez un compte existant
|
||||
</Text>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{users.map(u => (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => doLogin(u.username)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
padding: 10,
|
||||
border: '1px solid #eee',
|
||||
borderRadius: 12,
|
||||
background: '#fff',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
<Avatar initials={u.initials} color="#E8590C" size="sm" />
|
||||
<span>
|
||||
<Text style={{ margin: 0, fontWeight: 600 }}>{u.name}</Text>
|
||||
<Text style={{ margin: 0, fontSize: 12, color: '#888' }}>{u.username}</Text>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* Shared wallet material for the staging stopgap.
|
||||
*
|
||||
* STOPGAP: the hosted broker can't auto-import a wallet, so Festipod HANDS the
|
||||
* user the shared wallet and guides a one-time import on nextgraph.eu.
|
||||
* STOPGAP (see brief_2026-06-15_shared-wallet-shim + the broker-import
|
||||
* constraint, concept nextgraph-platform): the hosted broker can't auto-import
|
||||
* a wallet, so Festipod HANDS the user the shared wallet and guides a one-time
|
||||
* import on nextgraph.eu.
|
||||
*
|
||||
* The correct primitive is the **wallet FILE** (.ngw), NOT a TextCode: a
|
||||
* TextCode is a transient device-to-device transfer (5 min, source device
|
||||
|
||||
@@ -2,21 +2,31 @@ import { Given, When, Then } from '@cucumber/cucumber';
|
||||
import { expect } from 'chai';
|
||||
import type { FestipodWorld } from '../../../../shared/support/world';
|
||||
|
||||
// Seed data matching what bootstrapWallet uses
|
||||
import { seedEvents, seedUsers } from '../../../../shared/data/seedData';
|
||||
|
||||
// --- Setup ---
|
||||
|
||||
Given('le portefeuille est vide', async function (this: FestipodWorld) {
|
||||
// Each @data scenario runs under a UNIQUE identifier (see hooks.ts
|
||||
// freshScenarioIdentifier), so the shim hands it a FRESH, EMPTY virtual wallet:
|
||||
// "le portefeuille est vide" is trivially true on entry. So this is a fast
|
||||
// INSTANT CHECK — assert the reactive read already shows nothing — NOT the old
|
||||
// `clearWallet` per-entity-doc fan-out (a full physical-wallet enumeration that
|
||||
// was itself slow). No mutation, no polling: a fresh wallet has no docs to scan.
|
||||
// Verify starting state: the harness graph should have its own seeded data.
|
||||
// We clear events/users/participations to simulate a truly empty wallet.
|
||||
await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
// Delete all events
|
||||
for (const e of [...td.events]) td.events.delete(e);
|
||||
// Delete all users
|
||||
for (const u of [...td.users]) td.users.delete(u);
|
||||
// Delete all participations
|
||||
for (const p of [...td.participations]) td.participations.delete(p);
|
||||
});
|
||||
|
||||
// Verify empty
|
||||
const counts = await this.appFrame!.evaluate(() => {
|
||||
const td = (window as any).__testData;
|
||||
return { events: td.events.size, users: td.users.size };
|
||||
return { events: td.events.size, users: td.users.size, participations: td.participations.size };
|
||||
});
|
||||
expect(counts.events, 'Fresh virtual wallet should have no events').to.equal(0);
|
||||
expect(counts.users, 'Fresh virtual wallet should have no users').to.equal(0);
|
||||
expect(counts.events, 'Events should be empty').to.equal(0);
|
||||
expect(counts.users, 'Users should be empty').to.equal(0);
|
||||
});
|
||||
|
||||
Given('le portefeuille contient déjà des événements', async function (this: FestipodWorld) {
|
||||
@@ -30,9 +40,12 @@ Given('le portefeuille contient déjà des événements', async function (this:
|
||||
const td = (window as any).__testData;
|
||||
td.loadTestData();
|
||||
});
|
||||
// Wait for data to propagate
|
||||
// Wait for data to propagate. `waitForFunction(fn, arg, options)` — the
|
||||
// timeout goes in the THIRD slot; passed second it is silently taken as the
|
||||
// predicate's argument and the wait runs on the 30s default instead.
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => (window as any).__testData.events.size > 0,
|
||||
undefined,
|
||||
{ timeout: 75000 },
|
||||
);
|
||||
}
|
||||
@@ -59,19 +72,20 @@ When('je charge les données de test', async function (this: FestipodWorld) {
|
||||
});
|
||||
(this as any)._loadSeeded = seededResult.seeded;
|
||||
|
||||
// Wait for data to propagate. Events reach the read via the discovery index (a
|
||||
// fast, independent path); the seeded PROTECTED user docs reach it only through
|
||||
// the by-need re-list, which can lag the public read under load. So wait for BOTH
|
||||
// events AND users to settle (not just events) — otherwise `contient des
|
||||
// utilisateurs` asserts before the protected read lands and flakes to users:0.
|
||||
// On a wallet that already had data (seeded === false) there is nothing to wait
|
||||
// for. The assertions still verify the real counts; this only synchronizes.
|
||||
// Wait for data to propagate, and for BOTH events and users — not just events.
|
||||
// They arrive by two independent paths here: events through the Refine read,
|
||||
// the seeded profiles through the ORM subscription on the protected scope. If
|
||||
// only events are awaited, `contient des utilisateurs` asserts before the
|
||||
// profile read lands and flakes to users:0. On a wallet that already had data
|
||||
// (seeded === false) there is nothing to wait for. The assertions still verify
|
||||
// the real counts; this only synchronizes.
|
||||
if (seededResult.seeded) {
|
||||
await this.appFrame!.waitForFunction(
|
||||
() => {
|
||||
const td = (window as any).__testData;
|
||||
return td.events.size > 0 && td.users.size > 0;
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 75000 },
|
||||
).catch(() => {
|
||||
// Timeout tolerated — the assertions below surface the real failure with a
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user