From e951eaaf96ecd3f1f74e3cad16d31d483d9ca236 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Mon, 6 Jul 2026 14:52:40 +0200 Subject: [PATCH] feat(auth)+refactor(app): identifier at the access barrier; adopt the lib fidelity refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consumer-side of the @ng-eventually/client fidelity pass, plus the identifier UX: - Identity: the user types an IDENTIFIER at the access barrier (AccessGateScreen), in the same act that opens the shared wallet — the separate 'pick a username' screen (ConnexionScreen) is removed. The identifier is a technical id (a pseudo in practice, not a Festipod username), normalized (trim, @-stripped, lowercased) and persisted before the broker redirect, then handed to the SDK as the identity. AccountContext keeps its API but its stored value is now this normalized id. - Relationship/connections are app-owned: new src/shared/utils/connections.ts holds the bilateral registry and maps each link to the SDK's directed grantRead(doc, grantee); the lib no longer carries a connection concept. Rewired FestipodData and the @data harness to it. - Login removed: accounts use the SDK's IdentityStore (set/clear/get); no faux login/logout framing in the SDK boundary. Doctrine reconciled: app-security (knowledge_authentication flow, knowledge_trust-model directed grants, decision_2026-07-06_identifier-at-access-barrier), data-layer (knowledge_context-internals: stable id principal + single-seed), app-architecture (knowledge_screens auth inventory), bdd-testing (caveat_wallet-bloat-hang). App gates: tsc no new errors, build OK. @data path unaffected (harness bypasses the gate and sets identity directly; login() is not on that path). Co-Authored-By: Claude Opus 4.8 (1M context) --- .project/concepts/app-architecture/_debt.md | 7 -- .../app-architecture/knowledge_screens.md | 4 +- .project/concepts/app-security/_debt.md | 7 -- ...2026-07-06_identifier-at-access-barrier.md | 45 +++++++++ .../app-security/knowledge_authentication.md | 3 +- .../app-security/knowledge_trust-model.md | 3 +- .project/concepts/bdd-testing/_debt.md | 12 --- .../bdd-testing/caveat_wallet-bloat-hang.md | 35 +++++++ .../data-layer/knowledge_context-internals.md | 18 ++-- .project/concepts/functional-domain/_debt.md | 7 -- src/app/AuthGate.tsx | 31 ++++--- src/modules/auth/screens/AccessGateScreen.tsx | 52 ++++++++--- src/modules/auth/screens/ConnexionScreen.tsx | 92 ------------------- src/modules/home/screens/SettingsScreen.tsx | 5 +- src/shared/context/AccountContext.tsx | 74 ++++++++------- src/shared/context/FestipodDataContext.tsx | 35 +++---- src/shared/support/hooks.ts | 2 +- src/shared/test-harness/harness-ng.tsx | 17 ++-- src/shared/utils/connections.ts | 87 ++++++++++++++++++ src/shared/utils/storeRegistry.ts | 5 +- 20 files changed, 311 insertions(+), 230 deletions(-) delete mode 100644 .project/concepts/app-architecture/_debt.md delete mode 100644 .project/concepts/app-security/_debt.md create mode 100644 .project/concepts/app-security/decision_2026-07-06_identifier-at-access-barrier.md delete mode 100644 .project/concepts/bdd-testing/_debt.md create mode 100644 .project/concepts/bdd-testing/caveat_wallet-bloat-hang.md delete mode 100644 .project/concepts/functional-domain/_debt.md delete mode 100644 src/modules/auth/screens/ConnexionScreen.tsx create mode 100644 src/shared/utils/connections.ts diff --git a/.project/concepts/app-architecture/_debt.md b/.project/concepts/app-architecture/_debt.md deleted file mode 100644 index 25be45e..0000000 --- a/.project/concepts/app-architecture/_debt.md +++ /dev/null @@ -1,7 +0,0 @@ -# Doc-debt — app-architecture - -> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file. -> One block = one "big change": `why` + `files` + `verify` (leaves to review). - -## Raw markers (consolidate into blocks, then delete) -- TOUCHED src/shared/context/FestipodDataContext.tsx @2026-07-05 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) diff --git a/.project/concepts/app-architecture/knowledge_screens.md b/.project/concepts/app-architecture/knowledge_screens.md index 39ee5db..1b1d4ee 100644 --- a/.project/concepts/app-architecture/knowledge_screens.md +++ b/.project/concepts/app-architecture/knowledge_screens.md @@ -30,9 +30,9 @@ Utilisé notamment par Storybook (voir concept `tech-stack`) pour parcourir les - **home/** : `welcome`, `home`, `settings` - **event/** : `events`, `event-detail`, `create-event`, `update-event`, `invite`, `participants-list`, `meeting-points` - **user/** : `profile`, `update-profile`, `user-profile`, `friends-list`, `share-profile` -- **auth/** : `login` +- **auth/** : `AccessGateScreen` — la **barrière d'accès** (login NextGraph + saisie de l'identifiant), rendue par `src/app/AuthGate.tsx`, **hors registre/routing** (ce n'est pas un écran routé). Les anciens `LoginScreen` puis `ConnexionScreen` ont été retirés (cf. concept `app-security`, [[knowledge_authentication]]). -> Le mapping path → écran est dans [[knowledge_routing]]. La plupart des écrans consomment `useFestipodData()` (concept `data-layer`) ; exceptions : `LoginScreen`/`WelcomeScreen`. +> Le mapping path → écran est dans [[knowledge_routing]]. La plupart des écrans consomment `useFestipodData()` (concept `data-layer`) ; exceptions : `WelcomeScreen` et la barrière `AccessGateScreen`. ## Piège : registre incomplet diff --git a/.project/concepts/app-security/_debt.md b/.project/concepts/app-security/_debt.md deleted file mode 100644 index d7b87b1..0000000 --- a/.project/concepts/app-security/_debt.md +++ /dev/null @@ -1,7 +0,0 @@ -# Doc-debt — app-security - -> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file. -> One block = one "big change": `why` + `files` + `verify` (leaves to review). - -## Raw markers (consolidate into blocks, then delete) -- TOUCHED src/modules/auth/steps/data/connexion.steps.ts @2026-07-06 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) diff --git a/.project/concepts/app-security/decision_2026-07-06_identifier-at-access-barrier.md b/.project/concepts/app-security/decision_2026-07-06_identifier-at-access-barrier.md new file mode 100644 index 0000000..585a1d0 --- /dev/null +++ b/.project/concepts/app-security/decision_2026-07-06_identifier-at-access-barrier.md @@ -0,0 +1,45 @@ +--- +type: decision +summary: L'identifiant de l'espace virtuel se saisit à la barrière d'accès (AccessGateScreen), dans le même acte que l'ouverture du wallet ; l'écran de « login perçu » séparé (ConnexionScreen, « choisissez un nom d'utilisateur ») est retiré ; l'identifiant est un id technique normalisé en minuscules, pas un username Festipod +--- + +# Décision (2026-07-06) : identifiant saisi à la barrière d'accès + +## Contexte + +Le flux stopgap de [[decision_2026-06-15_shared-wallet-login-flow]] enchaînait **deux +écrans** : (1) `AccessGateScreen`, la barrière d'accès (vrai login NextGraph, ouverture du +wallet partagé) ; (2) `ConnexionScreen`, un « login perçu » où l'utilisateur choisissait un +**nom d'utilisateur**. Cette identité applicative était en réalité la clé du **wallet virtuel** +(clé du compte shim / cap owner), pas un username produit — le cadrage « nom d'utilisateur » +était donc trompeur (logique `setUsername` confuse). + +## Décision + +L'utilisateur saisit son **identifiant** directement dans `AccessGateScreen`, **dans le même +acte** qui ouvre le wallet (« Entrer » enregistre l'identifiant puis déclenche `connect()`). +`ConnexionScreen` est **supprimé**. L'identifiant : + +- est un **id technique** qui nomme l'espace virtuel (un pseudo en pratique, **pas** un + username Festipod) ; +- est **normalisé** à la saisie (trim, `@` retiré, **minuscules**) et persisté avant la + redirection broker (donc il survit au round-trip) ; +- **est** l'id d'identité remis au SDK (`setCurrentUser`), et la clé des caps et du compte + shim — plus de handle à casse mixte à réconcilier. + +`AuthGate` affiche donc la barrière tant que le wallet n'est pas ouvert **ou** que l'identifiant +n'est pas posé, puis l'app directement — sans écran intermédiaire. + +## Alternatives écartées + +- **Garder les deux écrans** : le second écran « nom d'utilisateur » perpétuait la confusion + entre identité-produit et identifiant-de-wallet, et ajoutait une étape sans valeur. +- **Dériver l'identifiant du wallet** (pas de saisie) : impossible ici — le wallet partagé est + unique ; l'identifiant est précisément ce qui distingue les espaces virtuels au sein de ce + wallet (émulation, cf. concept `data-layer` et le SDK `@ng-eventually/client`). + +## Portée + +Supersede la partie « écran 2 / login perçu » de [[decision_2026-06-15_shared-wallet-login-flow]] +(l'ouverture du wallet partagé via broker reste inchangée). État courant du flux : +[[knowledge_authentication]]. diff --git a/.project/concepts/app-security/knowledge_authentication.md b/.project/concepts/app-security/knowledge_authentication.md index afb6ceb..74f6c11 100644 --- a/.project/concepts/app-security/knowledge_authentication.md +++ b/.project/concepts/app-security/knowledge_authentication.md @@ -9,7 +9,8 @@ summary: L'identité d'un utilisateur = son wallet NextGraph ; tous les utilisat ## Flux -- L'écran d'auth (`src/modules/auth/`) déclenche la connexion via `useNextGraph()` (ne consomme pas `useFestipodData`). +- La **barrière d'accès** (`AccessGateScreen`, rendue par `src/app/AuthGate.tsx`) est le vrai login NextGraph : elle ouvre le wallet partagé via la redirection broker. **Dans le même acte**, l'utilisateur saisit un **identifiant** qui nomme son espace virtuel (`onEnter`). Il n'y a **plus d'écran « login perçu » séparé** (l'ancien `ConnexionScreen` « choisissez un nom d'utilisateur » a été retiré — cf. [[decision_2026-07-06_identifier-at-access-barrier]] ; supersede le flux à deux écrans de [[decision_2026-06-15_shared-wallet-login-flow]]). +- Cet **identifiant est un id technique** (un pseudo en pratique, **pas** un username Festipod) : il est **normalisé** (trim, `@` retiré, **minuscules**) puis persisté (`AccountContext` → `IdentityStore`), donc un rechargement — ou un autre appareil rouvrant le même wallet partagé — retombe sur le même espace. C'est cet id qui est donné au SDK (`setCurrentUser`) et sur lequel les caps et le compte shim sont clés. - Une fois la session ouverte, l'utilisateur courant et son accès aux stores par scope sont fournis par `NextGraphContext`. ## Le wallet de test diff --git a/.project/concepts/app-security/knowledge_trust-model.md b/.project/concepts/app-security/knowledge_trust-model.md index 2a5706b..4bc0d57 100644 --- a/.project/concepts/app-security/knowledge_trust-model.md +++ b/.project/concepts/app-security/knowledge_trust-model.md @@ -1,7 +1,7 @@ --- type: knowledge summary: L'isolation entre périmètres (public/protected/private) est assurée par le SDK de données ; l'app lui fait confiance et n'affiche que ce qu'il retourne — aucun contrôle d'accès dans les écrans, toute la confidentialité repose sur le SDK -last_checked: 2026-07-03 +last_checked: 2026-07-06 --- # Modèle de confiance @@ -12,6 +12,7 @@ Principes : 1. **L'isolation est déléguée au SDK.** Chaque entité vit dans le store de son **scope** (public / protected / private, cf. concept `functional-domain` → [[knowledge_data-scopes-and-discovery]]) ; le SDK **n'expose à l'utilisateur courant que ce à quoi il a droit**. L'app suppose que ce qu'elle reçoit est déjà autorisé — la confidentialité repose sur le SDK, pas sur du code Festipod. 2. **Les écrans ne portent aucune règle d'accès.** Pas de vérification « cet utilisateur a-t-il le droit de voir cette donnée » dans les composants ni dans le contexte de données. La séparation public / réseau / privé est une propriété du **placement par scope**, pas d'un filtre applicatif. +3. **La relation entre utilisateurs (« connexions ») est une notion applicative, pas une primitive du SDK.** NextGraph n'a pas de primitive de connexion/amitié bilatérale ; côté SDK il n'existe qu'un **grant de lecture dirigé** vers une identité. L'app **possède** donc son graphe de relations (`src/shared/utils/connections.ts`) et le **traduit** en grants dirigés par document remis au SDK — elle ne délègue pas la notion de relation au SDK, seulement l'**application** de l'isolation qui en découle. Ce que l'app déclare au SDK reste minimal : **son identité** (l'identifiant, cf. [[knowledge_authentication]]) et **ces grants** ; elle ne porte toujours aucune logique d'accès dans les écrans. ## Le point de vigilance diff --git a/.project/concepts/bdd-testing/_debt.md b/.project/concepts/bdd-testing/_debt.md deleted file mode 100644 index c71f855..0000000 --- a/.project/concepts/bdd-testing/_debt.md +++ /dev/null @@ -1,12 +0,0 @@ -# Doc-debt — bdd-testing - -> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file. -> One block = one "big change": `why` + `files` + `verify` (leaves to review). - -## Raw markers (consolidate into blocks, then delete) -- TOUCHED src/shared/test-harness/harness-ng.tsx @2026-07-05 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) -- TOUCHED src/modules/workshop/features/read-model-probe.feature @2026-07-05 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) -- TOUCHED src/modules/workshop/steps/data/read-model-probe.steps.ts @2026-07-05 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) -- TOUCHED src/modules/event/steps/data/inscription.steps.ts @2026-07-05 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) -- TOUCHED src/shared/support/hooks.ts @2026-07-06 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) -- TOUCHED src/modules/auth/steps/data/connexion.steps.ts @2026-07-06 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) diff --git a/.project/concepts/bdd-testing/caveat_wallet-bloat-hang.md b/.project/concepts/bdd-testing/caveat_wallet-bloat-hang.md new file mode 100644 index 0000000..a1461db --- /dev/null +++ b/.project/concepts/bdd-testing/caveat_wallet-bloat-hang.md @@ -0,0 +1,35 @@ +--- +type: caveat +summary: Le wallet de test partagé (.playwright-profile) accumule des données à chaque run ; passé un seuil, les sparql_query ancrées au private store hangent (>15s) et toute la suite @data échoue au setup — repartir d'un profil frais restaure des lectures ~1s +last_checked: 2026-07-06 +--- + +# Piège : le wallet de test se gonfle et fait *hang* les lectures @data + +Le profil Chromium persistant `.playwright-profile` (racine du working tree) porte le **wallet +partagé** ouvert par toute la suite `@data`/`@e2e`. Ce wallet **accumule des données à chaque +run** : comptes shim (un par scénario, via l'identifiant frais `freshScenarioUsername`), docs +d'entités seedés, dépôts d'inbox historiques… Le private store est le **point d'ancrage du shim** +(résolution de compte) et est interrogé par **toute** lecture/écriture (`resolveAccount`, +`listMyEntityDocs`, …). + +**Symptôme.** Passé un certain volume (observé ~99 Mo de profil), une `sparql_query` **ancrée au +private store** ne revient plus sous 15 s — elle *hang*. Comme la résolution de compte est sur le +chemin de **chaque** read/write, **toute la suite @data échoue au setup** (0 événement chargé, +timeouts), sans erreur explicite. Diagnostic vérifié : sur un wallet frais la même requête revient +en **~1,5 s** et le seed complète normalement. + +**Contournement.** Mettre le profil gonflé de côté et laisser le hook d'auth (beforeAll) en +recréer un frais : + +```bash +mv .playwright-profile /tmp/festipod-bloated-$(date +%s) +``` + +L'identifiant frais par scénario (`freshScenarioUsername`) borne le *registre* des comptes mais +**pas** la croissance physique du private store partagé — d'où la récurrence. Une hygiène durable +(purge périodique / wallet jetable par run) reste à mettre en place ; en attendant, si les +`resolveAccount failed`/timeouts réapparaissent, repartir d'un profil frais. + +> Le *pourquoi* côté broker (comment une requête ancrée touche le repo du private store) appartient +> au SDK `@ng-eventually/client`, pas ici — ce caveat ne décrit que la conséquence côté tests. diff --git a/.project/concepts/data-layer/knowledge_context-internals.md b/.project/concepts/data-layer/knowledge_context-internals.md index 872a645..f8a49da 100644 --- a/.project/concepts/data-layer/knowledge_context-internals.md +++ b/.project/concepts/data-layer/knowledge_context-internals.md @@ -1,7 +1,7 @@ --- type: knowledge -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 +summary: Pièges internes de FestipodDataContext — currentUserId = principal stable dérivé de l'identifiant, auto-seed dev-only supprimé par loadTestData (seed possédé par l'identité courante), participantCount muté en place (cache), mutations no-op en mode local malgré le toast +last_checked: 2026-07-06 --- # Internals & pièges de `FestipodDataContext` @@ -10,16 +10,16 @@ Comportements non évidents de `src/shared/context/FestipodDataContext.tsx` à c ## Résolution du `currentUser` (mode NG) -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. +En mode connected, le **principal** du currentUser (`currentUserId`) n'est **pas** `CURRENT_USER_ID` ('user-1', mode local) ni l'IRI du profil lu. Quand un identifiant est connecté, c'est un id **stable dérivé de l'identifiant** : `urn:festipod:user:`, disponible immédiatement (sans dépendre de la lecture du profil protégé) et invariant sur la session — c'est la même clé que `setCurrentUser`, le cap owner et le compte shim (cf. [[rule_document-per-entity]], corollaire d'identité). Pièges restants : +- L'objet `currentUser` (le profil affiché) est, lui, résolu par `users.find(u => normalizeUsername(u.username) === identifiant)` avec **fallback** `@mariedupont` puis `users[0]` — un fallback silencieux si l'identifiant ne correspond à aucun profil (l'identifiant est un id d'espace, pas forcément le `username` d'un profil seedé). +- Sans identifiant connecté (dev/demo), `currentUserId` retombe sur l'IRI du profil lu (ou `''` si le wallet est vide → `Participation` avec `user: ''` invalide) : ne créer une participation qu'une fois le principal résolu. ## Auto-seed de dev -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. +Un auto-seed se déclenche **uniquement hors production** (`process.env.NODE_ENV !== 'production'`), après un `setTimeout` de ~3s, si events ET users sont vides. Pièges : +- **Un seul seed à la fois** : `loadTestData()` pose `hasTriedAutoSeed` et le callback de l'auto-seed le re-teste, donc un chargement explicite **supprime** l'auto-seed en attente (sinon deux `bootstrapWallet` concurrents écrivent en double). Un signal de re-liste (`relist`) fait entrer les docs fraîchement seedés dans le jeu de lecture. +- Le seed est **possédé par l'identité courante** (`bootstrapWallet(…, owner)`), pas par un propriétaire fixe : les entités protégées seedées (profils) passent ainsi le cap de lecture par-document du propriétaire (sinon elles seraient masquées et jamais relues). +- **Pas de retry** au-delà : si le seed échoue, écran vide + `console.error`. Le délai de 3s reste heuristique. ## `participantCount` muté en place diff --git a/.project/concepts/functional-domain/_debt.md b/.project/concepts/functional-domain/_debt.md deleted file mode 100644 index d2a5c5e..0000000 --- a/.project/concepts/functional-domain/_debt.md +++ /dev/null @@ -1,7 +0,0 @@ -# Doc-debt — functional-domain - -> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file. -> One block = one "big change": `why` + `files` + `verify` (leaves to review). - -## Raw markers (consolidate into blocks, then delete) -- TOUCHED src/modules/workshop/features/read-model-probe.feature @2026-07-05 (session 0b064e8b-1717-421f-a20e-a4318ad217b1) diff --git a/src/app/AuthGate.tsx b/src/app/AuthGate.tsx index 9eff32e..d7509d0 100644 --- a/src/app/AuthGate.tsx +++ b/src/app/AuthGate.tsx @@ -1,9 +1,10 @@ /** * AuthGate — the stopgap access flow (see decision_2026-06-15_shared-wallet-login-flow): - * 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. + * 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. * * The gate is ON BY DEFAULT (Festipod never functions without NextGraph). It is * disabled only when `globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ === true` — @@ -16,7 +17,6 @@ import type { ReactNode } from 'react'; import { useNextGraph } from '../shared/context/NextGraphContext'; import { useAccount } from '../shared/context/AccountContext'; import { AccessGateScreen } from '../modules/auth/screens/AccessGateScreen'; -import { ConnexionScreen } from '../modules/auth/screens/ConnexionScreen'; declare global { // eslint-disable-next-line no-var @@ -26,23 +26,24 @@ const GATE_DISABLED = globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ === true; export function AuthGate({ children }: { children: ReactNode }) { const { status, error, connect } = useNextGraph(); - const { username } = useAccount(); + const { username, login } = useAccount(); // Gate explicitly disabled (no-gate build / @e2e harness) → straight to app. if (GATE_DISABLED) { return <>{children}; } - // 1. Technical access barrier (real NG login) — until the shared wallet opens. - if (status !== 'connected') { - return ; + // Access barrier — shown until BOTH the wallet is open AND the space is named. + // "Entrer" records the identifier (persisted immediately, so it survives the + // broker redirect) and, if the wallet isn't open yet, triggers the connect. + if (status !== 'connected' || !username) { + const onEnter = (identifier: string) => { + login(identifier); + if (status !== 'connected') connect(); + }; + return ; } - // 2. Perceived app login — until a username is chosen. - if (!username) { - return ; - } - - // 3. The app. + // The app. return <>{children}; } diff --git a/src/modules/auth/screens/AccessGateScreen.tsx b/src/modules/auth/screens/AccessGateScreen.tsx index 1676fee..d35789e 100644 --- a/src/modules/auth/screens/AccessGateScreen.tsx +++ b/src/modules/auth/screens/AccessGateScreen.tsx @@ -4,9 +4,12 @@ * STOPGAP (see decision_2026-06-15_shared-wallet-login-flow). This is the * REAL NextGraph login, shown before the app renders. Because it precedes the * app, the user reads it as "access to the test environment", not as an app - * login. 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). + * login. The user also types an IDENTIFIER here — the id that names their + * virtual space (a technical id, a pseudo in practice, not a Festipod username). + * Clicking "Entrer" records that identifier and triggers `connect()`, which + * redirects to the broker to open the SHARED wallet. After return the identity + * is already set (persisted before the redirect), so NG auto-connects straight + * into the app — there is no separate "pick a username" screen. * * ASSISTED IMPORT (see decision_2026-06-17). The hosted broker can't import a * wallet inline during @@ -19,13 +22,14 @@ */ import { useState, type ReactNode } from 'react'; -import { Button, Title, Text } from '../../../shared/components/sketchy'; +import { Button, Input, 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; - onEnter: () => void; + /** Enter the space: the raw identifier the user typed (normalized upstream). */ + onEnter: (identifier: string) => void; } // One numbered step: a badge + a title + the action for that step. @@ -47,6 +51,10 @@ function Step({ n, title, children }: { n: number; title: string; children: Reac export function AccessGateScreen({ status, error, onEnter }: AccessGateScreenProps) { const connecting = status === 'connecting'; const [copied, setCopied] = useState(false); + // The identifier that names this virtual space (a technical id — a pseudo in + // practice, but not a Festipod username). Entered HERE, at wallet access, so a + // single act both names the space and opens it. Normalized (lowercased) upstream. + const [identifier, setIdentifier] = useState(''); const copyPassword = async () => { try { @@ -58,15 +66,31 @@ export function AccessGateScreen({ status, error, onEnter }: AccessGateScreenPro } }; + 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 = ( - +
+ ) => setIdentifier(e.target.value)} + onKeyDown={(e: React.KeyboardEvent) => { if (e.key === 'Enter') enter(); }} + /> + + Il identifie votre espace (mis en minuscules). + + +
); return ( @@ -114,7 +138,7 @@ export function AccessGateScreen({ status, error, onEnter }: AccessGateScreenPro - + {entrer} diff --git a/src/modules/auth/screens/ConnexionScreen.tsx b/src/modules/auth/screens/ConnexionScreen.tsx deleted file mode 100644 index c9d48cf..0000000 --- a/src/modules/auth/screens/ConnexionScreen.tsx +++ /dev/null @@ -1,92 +0,0 @@ -/** - * 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 ( -
-
- Connexion - - Choisissez votre nom d'utilisateur - - -
- ) => setValue(e.target.value)} - onKeyDown={(e: React.KeyboardEvent) => { - if (e.key === 'Enter') submit(); - }} - /> - -
- - {users.length > 0 && ( - <> - - ou reprenez un compte existant - -
- {users.map(u => ( - - ))} -
- - )} -
-
- ); -} diff --git a/src/modules/home/screens/SettingsScreen.tsx b/src/modules/home/screens/SettingsScreen.tsx index 35eab45..6011994 100644 --- a/src/modules/home/screens/SettingsScreen.tsx +++ b/src/modules/home/screens/SettingsScreen.tsx @@ -12,8 +12,9 @@ export function SettingsScreen() { const [darkMode, setDarkMode] = useState(false); const [location, setLocation] = useState(true); - // Faux logout: clears the perceived login (username) only — the shared - // wallet stays open underneath. In staging this returns to ConnexionScreen. + // Faux logout: clears the current identifier only — the shared wallet stays + // open underneath. In staging this returns to the access barrier (identifier + // prompt), since the gate shows until an identifier is set again. const handleLogout = () => { logout(); navigate('/'); diff --git a/src/shared/context/AccountContext.tsx b/src/shared/context/AccountContext.tsx index fb6a69a..86fddcb 100644 --- a/src/shared/context/AccountContext.tsx +++ b/src/shared/context/AccountContext.tsx @@ -1,40 +1,49 @@ /** - * AccountContext — the application-level login. + * AccountContext — the current identity of the stopgap. * * STOPGAP (see decision_2026-06-15_shared-wallet-login-flow.md). * - * The real NextGraph login (a redirect to the broker, opening the single - * SHARED wallet) is perceived by the user as a *technical access barrier*, - * NOT as a login. THIS context is what the user perceives as the login: - * they pick a username (no password — declarative), which is persisted in - * localStorage so the "session" survives reloads and a different device, - * re-opening the same shared wallet, lands on the same accounts. + * The user names their virtual space with an IDENTIFIER at the access barrier + * (AccessGateScreen), in the same act that opens the SHARED wallet — there is no + * separate app login. The identifier is a technical id (a pseudo in practice, + * not a Festipod username): it is normalized (trimmed, `@`-stripped, lowercased) + * and persisted in localStorage, so a reload — or another device re-opening the + * same shared wallet — lands on the same space. * - * `login()` / `logout()` here are FAUX: they only read/write the username in - * localStorage. They must NEVER call NextGraph (ng.session_stop / - * wallet_close) — the shared wallet stays open underneath. The real logout - * lives, hidden, in Settings. + * `login()` / `logout()` here only read/write that identifier in localStorage; + * they NEVER call NextGraph (ng.session_stop / wallet_close) — the shared wallet + * stays open underneath. The real logout lives, hidden, in Settings. + * + * The stored value IS the identity id handed to the SDK + * (`setCurrentUser(identifier)`); it is the key the caps and the shim account + * are keyed on. The `username` field name is kept for its many consumers, but it + * now holds this normalized identifier, not a mixed-case display handle. * * Default value is non-null so `useAccount()` never throws outside a provider * (the @ui render harness wraps screens without this provider). */ import { createContext, useContext, useState, useCallback, useMemo, useEffect, type ReactNode } from 'react'; -// Thin React wrapper over the lib's framework-agnostic accounts core (T01.c): -// AccountStore (localStorage-backed faux login) + normalizeUsername. This file -// keeps ONLY the React Context/Provider glue; the login/logout/normalize logic -// lives in the lib. See decision_2026-06-17_eventually-library. +// The SDK's framework-agnostic IdentityStore persists the current identity id +// (localStorage-backed). This file keeps the React Context/Provider glue and the +// Festipod username handle; `normalizeUsername` (the handle → id mapping) is the +// app's own choice. See decision_2026-06-17_eventually-library. import { accounts } from '@ng-eventually/client'; -// Declare the current identity to the SDK: the app tells NextGraph WHO is -// reading, so the SDK returns only the data this identity is authorized to see -// (isolation is the SDK's job — see knowledge_trust-model). This is the SDK's -// "current identity" call, not an access rule the app enforces itself. +// Set the current identity on the SDK: the app tells NextGraph WHO is reading, so +// the SDK returns only the data this identity is authorized to see (isolation is +// the SDK's job — see knowledge_trust-model). This is the SDK's "current +// identity" call, not an access rule the app enforces itself. import { setCurrentUser } from '@ng-eventually/client/polyfill'; // Preserve the historical Festipod localStorage key so existing "logins" survive -// (the lib's default key differs; we pin ours explicitly → no behavior change). +// (the SDK's default key differs; we pin ours explicitly → no behavior change). const STORAGE_KEY = 'festipod.account.username'; +/** Normalise a username handle into the identity id the SDK is given. */ +export function normalizeUsername(username: string | null | undefined): string { + return (username ?? '').trim().replace(/^@+/, '').toLowerCase(); +} + export interface AccountContextValue { /** App-level identity (the perceived "login"). null = not connected. */ username: string | null; @@ -44,10 +53,10 @@ export interface AccountContextValue { logout: () => void; } -/** Browser-safe storage (null in SSR → lib store degrades to non-persisting). */ -function makeStore(): accounts.AccountStore { +/** Browser-safe storage (null in SSR → the store degrades to non-persisting). */ +function makeStore(): accounts.IdentityStore { const ls = typeof window !== 'undefined' ? window.localStorage : null; - return new accounts.AccountStore(ls, STORAGE_KEY); + return new accounts.IdentityStore(ls, STORAGE_KEY); } const AccountContext = createContext({ @@ -62,19 +71,22 @@ export function AccountProvider({ children }: { children: ReactNode }) { // Tell the SDK who the current identity is, on mount and whenever the account // changes (login/logout). The SDK uses it to gate reads to what this identity - // may see; the app performs no access check of its own. Normalize so the id - // matches the same principal key everything else uses. + // may see; the app performs no access check of its own. Normalize the username + // handle into the identity id everything else uses. useEffect(() => { - setCurrentUser(username ? accounts.normalizeUsername(username) : null); + setCurrentUser(username ? normalizeUsername(username) : null); }, [username]); const login = useCallback((name: string) => { - const next = store.login(name); + // The identifier is normalized (trimmed, `@`-stripped, lowercased) at the + // door, so the stored value IS the identity id — the same key the SDK, the + // caps and the shim account are keyed on. No mixed-case handle to reconcile. + const next = store.set(normalizeUsername(name)); if (next) setUsername(next); }, [store]); const logout = useCallback(() => { - store.logout(); + store.clear(); setUsername(null); }, [store]); @@ -88,9 +100,3 @@ export function AccountProvider({ children }: { children: ReactNode }) { export function useAccount(): AccountContextValue { return useContext(AccountContext); } - -/** - * Normalise a username for matching (case-insensitive, optional leading `@`). - * Re-exported from the lib's accounts core so app callers keep this import path. - */ -export const normalizeUsername = accounts.normalizeUsername; diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index c640dca..76e09f2 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -26,7 +26,9 @@ import { } from '../data/seedData'; import { useNextGraph } from './NextGraphContext'; import { useAccount, normalizeUsername } from './AccountContext'; -import { declareConnections } from '@ng-eventually/client/polyfill'; +// Relationship is a Festipod concept: the app keeps its own bilateral registry +// and hands the SDK only directed read grants (see shared/utils/connections). +import { declareConnections } from '../utils/connections'; import { listMyEntityDocs, createEntityDoc } from '../utils/storeRegistry'; import { submitEventToIndex, readDiscoveredEvents } from '../data/discovery'; import { readEntities } from '../data/readEntities'; @@ -444,24 +446,23 @@ function useNgData(): FestipodDataContextValue { // eslint-disable-next-line react-hooks/exhaustive-deps }, [ready, hostedEventIds.join('|')]); - // Protected-sharing act: declare the CURRENT identity's own connections to the - // SDK so an owner's connections may read that owner's PROTECTED entities (public - // = all; private = owner only). The declaration is AUTHENTICATED — it names only - // the current user's own peers and is bound to the current identity by the SDK; - // a protected read is granted only where BOTH sides connected (bilateral). The - // app carries NO access logic (see knowledge_trust-model) — it only declares its - // domain fact (friendships) and trusts the SDK's enforcement. No store id, no - // document NURI crosses here. + // Protected-sharing act: the app owns the relationship concept — it declares the + // current identity's own connections (a Festipod domain fact) and, for each + // bilateral link, hands the SDK directed read grants so an owner's connections + // may read that owner's PROTECTED entities (public = all; private = owner only). + // The declaration names only the current user's own peers, asserted as the + // current identity. The app carries no access CHECK (see knowledge_trust-model) + // — it only declares its own relationship graph, then trusts the SDK to enforce + // the resulting per-document grants. No store id, no document NURI crosses here. useEffect(() => { if (!ready || !currentUserId) return; - // Connection principals must be the SAME key space as the cap owners: the - // SDK keys caps on the NORMALIZED USERNAME (`createEntityDoc` opens each doc - // with `normalizeUsername(owner)`, and login sets the reader identity via - // `setCurrentUser(normalizeUsername(username))`). The app models friendships - // with user IRIs, so map each peer IRI → its username key before declaring, - // and assert AS the current user's username key. Peers with no known username - // are skipped (can't be keyed). This is what makes "protected = my bilateral - // connections" actually discriminate in @data. + // Connection ids must be the SAME key space as the cap owners: each doc is + // opened with `normalizeUsername(owner)`, and the reader identity is set via + // `setCurrentUser(normalizeUsername(username))`. The app models friendships + // with user IRIs, so map each peer IRI → its id key before declaring, and + // assert AS the current user's id key. Peers with no known id are skipped + // (can't be keyed). This is what makes "protected = my bilateral connections" + // actually discriminate in @data. const usernameOf = (userIri: string): string | undefined => { const u = users.find(x => x.id === userIri); return u?.username ? normalizeUsername(u.username) : undefined; diff --git a/src/shared/support/hooks.ts b/src/shared/support/hooks.ts index fcdc103..4c848a1 100644 --- a/src/shared/support/hooks.ts +++ b/src/shared/support/hooks.ts @@ -583,7 +583,7 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) { // UNIQUE app-level username into localStorage['festipod.account.username'] on // EVERY origin (the init script runs in each frame before its scripts do — // including the harness iframe on 127.0.0.1). At mount the harness's - // AccountStore.get() then reads THIS fresh username, so `if (!username) + // IdentityStore.get() then reads THIS fresh username, so `if (!username) // login(DEFAULT_HARNESS_USER)` is skipped and the scenario runs on a fresh, // empty virtual wallet. Overwrites any value persisted in the Chromium profile // (init scripts run on each navigation), so no accumulated wallet leaks in. diff --git a/src/shared/test-harness/harness-ng.tsx b/src/shared/test-harness/harness-ng.tsx index f9cf69d..e5f52f9 100644 --- a/src/shared/test-harness/harness-ng.tsx +++ b/src/shared/test-harness/harness-ng.tsx @@ -14,7 +14,9 @@ import { AccountProvider, useAccount } from '../context/AccountContext'; import { FestipodDataProvider, useFestipodData } from '../context/FestipodDataContext'; // useShape routed through the lib (SDK-identical surface); caps from /polyfill. import { useShape, docs, inbox as docsInbox } from '@ng-eventually/client'; -import { getCaps, getCurrentUser, setCurrentUser, resetCaps, declareConnections } from '@ng-eventually/client/polyfill'; +import { getCaps, getCurrentUser, setCurrentUser, resetCaps } from '@ng-eventually/client/polyfill'; +// Relationship is an app concept: directed grants come from the app's own module. +import { declareConnections, resetConnections } from '../utils/connections'; import { hostInboxNuri as regInboxNuri } from '../data/registration'; import type { DeepSignalSet } from '@ng-eventually/client'; // doc_create goes through the lib's `docs` primitive (T01.a): it calls the REAL @@ -476,10 +478,11 @@ function ConnectedHarness() { // (storeRegistry.createEntityDoc) does; the protected participations // document is governed, and a separate makePublic'd doc models a public // entity. exposes the read-filtered VIEW over the protected - // participations doc. `connect` calls the SDK's declareConnections — the - // app's domain sharing act — never touches a doc NURI or the registry. + // participations doc. `connect` calls the app's declareConnections — the + // domain sharing act — which issues the SDK's directed read grants. governProtected(owner: string, reader: string) { resetCaps(); + resetConnections(); // clear the app's relationship registry too // The protected participations document (owner-only read at first). getCaps().open(protectedNuri!, 'protected', owner); // A public entity document — readable by anyone regardless of caps. @@ -487,9 +490,9 @@ function ConnectedHarness() { setCurrentUser(reader); setFilterActive(true); }, - /** Declare a BILATERAL owner↔reader connection to the SDK (domain sharing - * act). Each side asserts the other (bound to that identity); only then - * does the SDK issue the protected doc's read cap to the connection. */ + /** Declare a bilateral owner↔reader connection (domain sharing act). Each + * side asserts the other; only a two-sided link makes the app issue the + * protected doc's directed read grant to the reader. */ connect(a: string, b: string) { declareConnections([b], a); // a asserts b declareConnections([a], b); // b asserts a → bilateral link materializes @@ -524,7 +527,7 @@ function ConnectedHarness() { const created = await reg.ensureAccount(username); reg.resetRegistryCache(); const reloaded = (await reg.allAccounts()).find( - a => a.username === username, + a => a.id === username, ) ?? null; return { created, reloaded }; }, diff --git a/src/shared/utils/connections.ts b/src/shared/utils/connections.ts new file mode 100644 index 0000000..b079173 --- /dev/null +++ b/src/shared/utils/connections.ts @@ -0,0 +1,87 @@ +/** + * connections (Festipod glue) — the app owns the relationship concept. + * + * "Connected" is a Festipod domain fact (an accepted, two-sided friendship), not + * something the data SDK models: the SDK exposes only a DIRECTED per-document + * read grant (`getCaps().grantRead(doc, granteeId)`). So the app keeps its own + * bilateral relationship registry here and, once a link is two-sided, issues the + * directed read grants for the owner's protected documents — telling the SDK who + * may read what. The app carries no access CHECK (that stays the SDK's job — see + * knowledge_trust-model); it only declares the grants that follow from its own + * relationship graph. + * + * A link between `a` and `b` is live only when BOTH `a → b` and `b → a` have been + * asserted. A reader who unilaterally self-declares a link to an owner gets + * nothing: the owner never asserted them back, so no grant is issued. + */ + +import { getCaps } from '@ng-eventually/client/polyfill'; + +/** Accumulates directed assertions and exposes the bilateral neighbourhood. */ +class RelationshipRegistry { + /** identity id → the set of ids it has asserted a link TO. */ + private asserted = new Map>(); + + /** Record that `from` asserts a link to `to` (one direction only). */ + assert(from: string, to: string): void { + if (!from || !to || from === to) return; + let s = this.asserted.get(from); + if (!s) this.asserted.set(from, (s = new Set())); + s.add(to); + } + + /** Has `from` asserted a link to `to` (one direction)? */ + private hasAsserted(from: string, to: string): boolean { + return this.asserted.get(from)?.has(to) ?? false; + } + + /** The bilateral neighbours of `id`: every `q` that `id` and `q` each asserted. */ + neighbors(id: string): Set { + const out = new Set(); + for (const to of this.asserted.get(id) ?? []) { + if (this.hasAsserted(to, id)) out.add(to); + } + return out; + } + + /** Every id that has asserted at least one link. */ + asserters(): Iterable { + return this.asserted.keys(); + } + + clear(): void { + this.asserted.clear(); + } +} + +const registry = new RelationshipRegistry(); + +/** + * Declare the connections a session asserts, as `self`, to each id in `peers`, + * then re-derive the directed read grants that follow. For every bilateral link + * (both sides asserted), the app grants each neighbour the read cap of the other + * side's protected documents (via `getCaps().protectedDocsOf(owner)` + + * `grantRead`). Re-callable whenever the relationship graph changes — the + * assertions and the grants only ever accumulate (additive, idempotent). + * + * `self` is the id of the asserting identity (its normalized-id key, the same key + * the caps are opened with). A session only ever asserts its own side. + */ +export function declareConnections(peers: Iterable, self: string): void { + if (!self) return; + for (const peer of peers) registry.assert(self, peer); + + const caps = getCaps(); + // Issue directed grants for every bilateral link currently known. For a live + // link owner↔neighbour, the neighbour may read the owner's protected docs. + for (const owner of registry.asserters()) { + for (const neighbour of registry.neighbors(owner)) { + for (const doc of caps.protectedDocsOf(owner)) caps.grantRead(doc, neighbour); + } + } +} + +/** Reset the relationship registry (mainly for tests / fresh sessions). */ +export function resetConnections(): void { + registry.clear(); +} diff --git a/src/shared/utils/storeRegistry.ts b/src/shared/utils/storeRegistry.ts index c898bfd..46c4aed 100644 --- a/src/shared/utils/storeRegistry.ts +++ b/src/shared/utils/storeRegistry.ts @@ -35,7 +35,7 @@ export function entityScope(kind: EntityKind): Scope { } // --- Consumer wiring injected into the lib's storeRegistry --- -// The lib is Festipod-agnostic: it reaches the session and the username +// The lib is Festipod-agnostic: it reaches the session and the identity-id // normalization through these injected deps. Idempotent module-load side effect // (the app imports storeRegistry before any registry call). configureStoreRegistry({ @@ -51,7 +51,8 @@ configureStoreRegistry({ publicStoreId: session.public_store_id, }; }, - normalizeUser: normalizeUsername, + // The app maps its username handle to the identity id the lib keys on. + normalizeId: normalizeUsername, }); // --- Re-export the lib's account record + registry surface (unchanged API) ---