Ng eventually #1
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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}</>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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('/');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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) ---
|
||||
|
||||
Reference in New Issue
Block a user