feat(auth)+refactor(app): identifier at the access barrier; adopt the lib fidelity refactor

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) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-06 14:52:40 +02:00
parent 0911b1f9de
commit e951eaaf96
20 changed files with 311 additions and 230 deletions
@@ -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)
@@ -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
-7
View File
@@ -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)
@@ -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]].
@@ -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
@@ -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
-12
View File
@@ -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)
@@ -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.
@@ -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:<identifiant-normalisé>`, disponible immédiatement (sans dépendre de la lecture du profil protégé) et invariant sur la session — c'est la même clé que `setCurrentUser`, le cap owner et le compte shim (cf. [[rule_document-per-entity]], corollaire d'identité). Pièges restants :
- L'objet `currentUser` (le profil affiché) est, lui, résolu par `users.find(u => 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
@@ -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)
+16 -15
View File
@@ -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 <AccessGateScreen status={status} error={error} onEnter={connect} />;
// 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 <AccessGateScreen status={status} error={error} onEnter={onEnter} />;
}
// 2. Perceived app login — until a username is chosen.
if (!username) {
return <ConnexionScreen />;
}
// 3. The app.
// The app.
return <>{children}</>;
}
+38 -14
View File
@@ -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 = (
<Button
variant="primary"
onClick={onEnter}
disabled={connecting}
style={{ width: '100%', opacity: connecting ? 0.6 : 1 }}
>
{connecting ? 'Accès en cours…' : 'Entrer'}
</Button>
<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>
);
return (
@@ -114,7 +138,7 @@ export function AccessGateScreen({ status, error, onEnter }: AccessGateScreenPro
</div>
</Step>
<Step n={3} title="Revenez sur cet onglet et entrez">
<Step n={3} title="Revenez ici, choisissez un identifiant et entrez">
{entrer}
</Step>
</>
@@ -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 (
<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>
);
}
+3 -2
View File
@@ -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('/');
+40 -34
View File
@@ -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<AccountContextValue>({
@@ -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;
+18 -17
View File
@@ -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;
+1 -1
View File
@@ -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.
+10 -7
View File
@@ -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. <FilterProbe> 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 };
},
+87
View File
@@ -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<string, Set<string>>();
/** 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<string> {
const out = new Set<string>();
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<string> {
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<string>, 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();
}
+3 -2
View File
@@ -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) ---