From c7e924abe7556b4885db71ea6239eaf4dd60fc60 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Mon, 13 Jul 2026 11:55:09 +0200 Subject: [PATCH] =?UTF-8?q?fix(auth):=20porter=20l'identit=C3=A9=20par=20p?= =?UTF-8?q?aram=20d'URL=20(=3Fid=3D),=20pas=20localStorage;=20renommer=20u?= =?UTF-8?q?sername=E2=86=92identifier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cause racine du décalage d'identité : l'app tourne dans DEUX contextes avec DEUX partitions de localStorage — top-level (127.0.0.1:3000 direct, barrière) et iframe (embarquée sous nextgraph.net après le round-trip broker). Le navigateur partitionne le storage par site top-level, donc l'identifiant saisi en top-level n'est jamais celui que l'app connectée lit dans l'iframe (symptôme: deux valeurs divergentes). Fix : le param d'URL ?id= devient la SOURCE DE VÉRITÉ. Le SDK redirige avec encodeURIComponent(window.location.href) (URL app complète, query comprise), donc un param d'URL TRAVERSE la frontière contrairement à localStorage. AuthGate écrit ?id= (replaceState) avant connect(); AccountContext résout par priorité (1) ?id= puis (2) localStorage (préremplissage same-partition seulement). Renommage username→identifier (champ useAccount, normalizeIdentifier, clé festipod.account.identifier) — c'est un id technique d'espace, pas un username. Le username de PROFIL (nom d'affichage) est laissé intact. Test garde-fou @ui (identifiant-resolution.feature) : la priorité param>localStorage, rouge si on l'inverse. Le flux de barrière étant désactivé en @e2e, ces @ui sont la seule couche qui le garde. Doctrine: knowledge_authentication (porteur URL + partition) + knowledge_context-internals (vocab). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app-security/knowledge_authentication.md | 3 +- .../data-layer/knowledge_context-internals.md | 4 +- src/app/AuthGate.tsx | 38 ++++-- .../features/identifiant-resolution.feature | 42 +++++++ .../steps/ui/identifiant-resolution.steps.ts | 111 ++++++++++++++++++ .../event/steps/data/isolation.steps.ts | 2 +- .../event/steps/data/reconnexion.steps.ts | 2 +- src/shared/context/AccountContext.tsx | 101 +++++++++++----- src/shared/context/FestipodDataContext.tsx | 96 +++++++-------- src/shared/support/hooks.ts | 18 +-- src/shared/test-harness/harness-ng.tsx | 20 ++-- src/shared/test-harness/renderHelper.tsx | 31 +++++ src/shared/utils/ngBootstrap.ts | 4 +- src/shared/utils/storeRegistry.ts | 18 +-- 14 files changed, 370 insertions(+), 120 deletions(-) create mode 100644 src/modules/auth/features/identifiant-resolution.feature create mode 100644 src/modules/auth/steps/ui/identifiant-resolution.steps.ts diff --git a/.project/concepts/app-security/knowledge_authentication.md b/.project/concepts/app-security/knowledge_authentication.md index bb344bd..f03a80b 100644 --- a/.project/concepts/app-security/knowledge_authentication.md +++ b/.project/concepts/app-security/knowledge_authentication.md @@ -11,7 +11,8 @@ summary: L'identité d'un utilisateur = son wallet NextGraph ; tous les utilisat - 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. -- **Saisi UNE SEULE FOIS au premier accès + prérempli au retour.** Au rechargement / retour du round-trip broker, l'identifiant est déjà stocké, mais la session NG n'est pas restaurée d'office (`NextGraphContext` repart en `disconnected`) : `AuthGate` réaffiche donc la barrière tant que `status !== 'connected'`. Le champ d'`AccessGateScreen` est alors **prérempli** avec la valeur stockée (prop `initialIdentifier`, passée par `AuthGate` depuis `useAccount().username`) — l'utilisateur ne le retape jamais et ne voit jamais un champ nu et vide à l'arrivée. Il n'est réellement saisi qu'au **premier accès** (aucune valeur stockée). Régression gardée par `src/modules/auth/features/barriere-acces-identifiant.feature` (@ui) — d'autant plus utile que le flux de barrière est **désactivé** dans les tests @e2e (`__FESTIPOD_ACCESS_GATE_DISABLED__`), donc invisible à cette couche. +- **Porté cross-frontière par un PARAM D'URL `?id=`** (source de vérité), PAS par localStorage. L'app tourne dans deux contextes — **top-level** (`127.0.0.1:3000` direct, `window.self === window.top`, où s'affiche la barrière) et **iframe** (embarquée sous `nextgraph.net` après le round-trip broker, `window.self !== window.top`). Le navigateur **partitionne le storage par site top-level** : le localStorage du top-level et celui de l'iframe sont **deux partitions distinctes** → localStorage NE PEUT PAS porter l'identité d'un contexte à l'autre (symptôme observé : deux valeurs divergentes selon le contexte). Le SDK redirige via `location.href = broker + encodeURIComponent(window.location.href)` (embarque l'URL app complète, query comprise, dans le `o=` rechargé en iframe), donc un **param d'URL traverse**. `AuthGate` écrit `?id=` (`history.replaceState`) **avant** `connect()` ; `AccountContext` résout l'identifiant par priorité **(1) `?id=` de l'URL** puis **(2) localStorage** (préremplissage/convenance same-partition uniquement). Clé localStorage : `festipod.account.identifier`. +- **Saisi UNE SEULE FOIS au premier accès + prérempli au retour.** Au rechargement top-level, la session NG n'est pas restaurée d'office (`NextGraphContext` repart en `disconnected`) : `AuthGate` réaffiche la barrière tant que `status !== 'connected'`, mais le champ d'`AccessGateScreen` est **prérempli** (prop `initialIdentifier`) — jamais un champ nu et vide. Régressions gardées par `src/modules/auth/features/{barriere-acces-identifiant,identifiant-resolution}.feature` (@ui) — d'autant plus utiles que le flux de barrière est **désactivé** dans les tests @e2e (`__FESTIPOD_ACCESS_GATE_DISABLED__`), donc invisible à cette couche. - 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/data-layer/knowledge_context-internals.md b/.project/concepts/data-layer/knowledge_context-internals.md index b0474a5..c050314 100644 --- a/.project/concepts/data-layer/knowledge_context-internals.md +++ b/.project/concepts/data-layer/knowledge_context-internals.md @@ -11,7 +11,7 @@ Comportements non évidents de `src/shared/context/FestipodDataContext.tsx` à c ## Résolution du `currentUser` (mode NG) 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é). +- L'objet `currentUser` (le profil affiché) est, lui, résolu par `users.find(u => normalizeIdentifier(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. ## Lecture = `watchShape` (surface SDK), plus de machinerie bespoke @@ -65,7 +65,7 @@ Le `@id` d'un événement **est** son NURI de document (`did:ng:o:[:v: { - if (!GATE_DISABLED && status === 'connected' && username && route.page === 'welcome') { + if (!GATE_DISABLED && status === 'connected' && identifier && route.page === 'welcome') { navigate('/home'); } - }, [status, username, route.page, navigate]); + }, [status, identifier, route.page, navigate]); // Gate explicitly disabled (no-gate build / @e2e harness) → straight to app. if (GATE_DISABLED) { @@ -47,22 +47,40 @@ export function AuthGate({ children }: { children: ReactNode }) { } // 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. + // "Entrer" records the identifier (persisted to localStorage AND written into + // the `?id=` URL param, which is what actually survives the broker redirect + // across the partitioned frontier) and, if the wallet isn't open yet, triggers + // the connect. // // On return (reload / broker round-trip) the identifier is already stored, so // we PREFILL the field with it (`initialIdentifier`) — the user never sees a // bare empty prompt they must re-type. It is captured ONCE, at first access. - if (status !== 'connected' || !username) { - const onEnter = (identifier: string) => { - login(identifier); + if (status !== 'connected' || !identifier) { + const onEnter = (entered: string) => { + login(entered); + // Carry the identifier across the broker frontier via the URL. localStorage + // is partitioned by top-level site, so the value written here (127.0.0.1) + // is NOT what the app reads inside the broker iframe (nextgraph.net). The + // `@ng-org/web` redirect embeds the FULL app URL (query included) in the + // broker `o=`, which is reloaded in the iframe — so writing the normalized + // id into `?id=` BEFORE connect() makes it travel. `history.replaceState` + // (not push) keeps a single history entry. See AccountContext resolution. + if (typeof window !== 'undefined') { + try { + const url = new URL(window.location.href); + url.searchParams.set('id', normalizeIdentifier(entered)); + window.history.replaceState(window.history.state, '', url.toString()); + } catch { + /* URL construction can't fail for a real page URL; ignore defensively */ + } + } if (status !== 'connected') connect(); }; return ( ); diff --git a/src/modules/auth/features/identifiant-resolution.feature b/src/modules/auth/features/identifiant-resolution.feature new file mode 100644 index 0000000..5a01085 --- /dev/null +++ b/src/modules/auth/features/identifiant-resolution.feature @@ -0,0 +1,42 @@ +# language: fr +@AUTH @priority-1 +Fonctionnalité: Résolution de l'identifiant — le param d'URL prime sur localStorage + En tant qu'application relancée dans l'iframe du broker après le round-trip + Je veux résoudre l'identifiant depuis le param d'URL "?id=" + Afin qu'il traverse la frontière top-level↔iframe (que localStorage ne franchit pas) + + # Le flux wallet-partagé fait tourner l'app dans DEUX contextes avec DEUX + # partitions localStorage distinctes (top-level 127.0.0.1 vs iframe + # nextgraph.net). localStorage ne traverse pas la frontière ; le param "?id=" + # embarqué dans le redirect broker (o=) la traverse. AccountContext résout donc + # dans l'ordre : (1) param d'URL "?id=" (source de vérité) ; (2) sinon + # localStorage (préremplissage même-partition). Voir AccountContext + AuthGate. + + @ui + Scénario: Le param d'URL est la source de vérité quand il est présent + Étant donné que localStorage contient l'identifiant "alice" + Et que l'URL porte le param id "bob" + Quand le contexte de compte résout l'identifiant + Alors l'identifiant résolu est "bob" + + @ui + Scénario: Le param d'URL prime même sur une valeur localStorage différente et est persisté + Étant donné que localStorage contient l'identifiant "alice" + Et que l'URL porte le param id "carol" + Quand le contexte de compte résout l'identifiant + Alors l'identifiant résolu est "carol" + Et localStorage contient désormais l'identifiant "carol" + + @ui + Scénario: Sans param d'URL, localStorage sert de repli + Étant donné que localStorage contient l'identifiant "dave" + Et que l'URL ne porte aucun param id + Quand le contexte de compte résout l'identifiant + Alors l'identifiant résolu est "dave" + + @ui + Scénario: Le param d'URL est normalisé (minuscules, @ retiré) + Étant donné que localStorage ne contient aucun identifiant + Et que l'URL porte le param id "@Erin" + Quand le contexte de compte résout l'identifiant + Alors l'identifiant résolu est "erin" diff --git a/src/modules/auth/steps/ui/identifiant-resolution.steps.ts b/src/modules/auth/steps/ui/identifiant-resolution.steps.ts new file mode 100644 index 0000000..1a33a64 --- /dev/null +++ b/src/modules/auth/steps/ui/identifiant-resolution.steps.ts @@ -0,0 +1,111 @@ +/** + * @ui steps for AccountContext identifier resolution. + * + * Guards the cross-frontier fix: the shared-wallet flow runs the app in TWO + * localStorage partitions (top-level 127.0.0.1 vs broker iframe nextgraph.net), + * so localStorage does NOT cross. The `?id=` URL param — embedded in the broker + * redirect `o=` — DOES cross. AccountContext resolution therefore PRIORITIZES the + * URL param over localStorage, and (when present) persists it to localStorage for + * same-partition convenience. Normalization (trim, `@`-strip, lowercase) applies. + * + * These render a tiny probe inside a real AccountProvider (via renderElement), + * having first seeded window.location.search and window.localStorage through the + * happy-dom harness — so the resolution logic runs for real, not mocked. + */ +import { Given, When, Then } from '@cucumber/cucumber'; +import { expect } from 'chai'; +import React from 'react'; +import { + renderElement, + setRenderUrl, + setRenderLocalStorage, + getRenderLocalStorage, +} from '../../../../shared/test-harness/renderHelper'; +import { AccountProvider, useAccount } from '../../../../shared/context/AccountContext'; +import type { FestipodWorld } from '../../../../shared/support/world'; + +const STORAGE_KEY = 'festipod.account.identifier'; + +// Per-scenario intent (kept off the World type via a WeakMap). +interface ResolveState { + storageSeed: string | null; + url: string; + doc: Document | null; +} +const states = new WeakMap(); +function stateFor(world: object): ResolveState { + let s = states.get(world); + if (!s) { + s = { storageSeed: null, url: 'http://localhost/', doc: null }; + states.set(world, s); + } + return s; +} + +// Probe: renders the resolved identifier so the DOM can be asserted. +function IdentifierProbe(): React.ReactElement { + const { identifier } = useAccount(); + return React.createElement('div', { 'data-testid': 'resolved-identifier' }, identifier ?? ''); +} + +Given( + 'localStorage contient l\'identifiant {string}', + function (this: FestipodWorld, value: string) { + stateFor(this).storageSeed = value; + }, +); + +Given('localStorage ne contient aucun identifiant', function (this: FestipodWorld) { + stateFor(this).storageSeed = null; +}); + +Given('l\'URL porte le param id {string}', function (this: FestipodWorld, id: string) { + const s = stateFor(this); + const url = new URL('http://localhost/'); + url.searchParams.set('id', id); + s.url = url.toString(); +}); + +Given('l\'URL ne porte aucun param id', function (this: FestipodWorld) { + stateFor(this).url = 'http://localhost/'; +}); + +When('le contexte de compte résout l\'identifiant', async function (this: FestipodWorld) { + const s = stateFor(this); + // Seed the happy-dom window (URL + localStorage) BEFORE mounting the provider, + // so the provider's init-time resolution reads exactly this state. + await setRenderUrl(s.url); + await setRenderLocalStorage(STORAGE_KEY, s.storageSeed); + s.doc = await renderElement( + React.createElement(AccountProvider, null, React.createElement(IdentifierProbe)), + ); +}); + +function resolved(world: object): string { + const s = stateFor(world); + expect(s.doc, 'The probe should be rendered').to.not.be.null; + const el = s.doc!.querySelector('[data-testid="resolved-identifier"]'); + expect(el, 'The resolved-identifier probe should be present').to.not.be.null; + return el!.textContent ?? ''; +} + +Then('l\'identifiant résolu est {string}', function (this: FestipodWorld, expected: string) { + expect(resolved(this)).to.equal(expected); +}); + +Then( + 'localStorage contient désormais l\'identifiant {string}', + async function (this: FestipodWorld, expected: string) { + // The URL-param → localStorage persistence runs in a mount useEffect, which + // React flushes AFTER the render's first microtask. Yield a few macrotask + // ticks (bounded, no polling of any live resource) so the effect has run + // before asserting — otherwise the read races the effect and flakes. + let stored: string | null = null; + for (let i = 0; i < 10; i++) { + stored = await getRenderLocalStorage(STORAGE_KEY); + if (stored === expected) break; + await new Promise((r) => setTimeout(r, 0)); + } + expect(stored).to.equal(expected); + }, +); diff --git a/src/modules/event/steps/data/isolation.steps.ts b/src/modules/event/steps/data/isolation.steps.ts index 3e10fd7..433fe60 100644 --- a/src/modules/event/steps/data/isolation.steps.ts +++ b/src/modules/event/steps/data/isolation.steps.ts @@ -43,7 +43,7 @@ When('une identité fraîche B arrive sur le même wallet partagé', { timeout: const ctx = this.page!.context(); const bPage = await ctx.newPage(); await bPage.addInitScript((u: string) => { - try { window.localStorage.setItem('festipod.account.username', u); } catch { /* opaque */ } + try { window.localStorage.setItem('festipod.account.identifier', u); } catch { /* opaque */ } }, bId); await bPage.addInitScript(() => { (globalThis as Record).__FESTIPOD_ACCESS_GATE_DISABLED__ = true; diff --git a/src/modules/event/steps/data/reconnexion.steps.ts b/src/modules/event/steps/data/reconnexion.steps.ts index ace164c..a94026b 100644 --- a/src/modules/event/steps/data/reconnexion.steps.ts +++ b/src/modules/event/steps/data/reconnexion.steps.ts @@ -33,7 +33,7 @@ When('une page fraîche pour la MÊME identité A recharge sur le même wallet', const ctx = this.page!.context(); const freshPage = await ctx.newPage(); await freshPage.addInitScript((u: string) => { - try { window.localStorage.setItem('festipod.account.username', u); } catch { /* opaque */ } + try { window.localStorage.setItem('festipod.account.identifier', u); } catch { /* opaque */ } }, aIdentifier); await freshPage.addInitScript(() => { (globalThis as Record).__FESTIPOD_ACCESS_GATE_DISABLED__ = true; diff --git a/src/shared/context/AccountContext.tsx b/src/shared/context/AccountContext.tsx index 86fddcb..c4960be 100644 --- a/src/shared/context/AccountContext.tsx +++ b/src/shared/context/AccountContext.tsx @@ -6,9 +6,21 @@ * 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. + * not a Festipod display name): it is normalized (trimmed, `@`-stripped, + * lowercased) and carried across the broker round-trip. + * + * IDENTIFIER RESOLUTION — the `?id=` URL param is the SOURCE OF TRUTH. + * The shared-wallet flow runs the app in TWO contexts with TWO separate + * localStorage partitions: the top-level page (127.0.0.1) and the broker + * iframe (nextgraph.net) — the browser partitions storage by top-level site, + * so a value written top-level is NOT the value the iframe reads. localStorage + * cannot cross that boundary. But the `@ng-org/web` redirect embeds the FULL + * app URL (query included) in the broker `o=`, which is reloaded in the iframe + * — so a URL param DOES cross. Hence resolution priority: + * (1) `?id=` in the URL — wins whenever present (crosses the frontier); + * (2) else localStorage — same-partition convenience / prefill only. + * When the param is present it also gets persisted to localStorage (same + * partition, convenience) so a plain reload without the param still prefills. * * `login()` / `logout()` here only read/write that identifier in localStorage; * they NEVER call NextGraph (ng.session_stop / wallet_close) — the shared wallet @@ -16,8 +28,8 @@ * * 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. + * are keyed on. It 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). @@ -26,8 +38,8 @@ import { createContext, useContext, useState, useCallback, useMemo, useEffect, type ReactNode } from 'react'; // 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. +// Festipod identifier handle; `normalizeIdentifier` (the handle → id mapping) is +// the app's own choice. See decision_2026-06-17_eventually-library. import { accounts } from '@ng-eventually/client'; // 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 @@ -35,21 +47,40 @@ import { accounts } from '@ng-eventually/client'; // 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 SDK's default key differs; we pin ours explicitly → no behavior change). -const STORAGE_KEY = 'festipod.account.username'; +// Festipod localStorage key for the current identifier (same-partition +// prefill/convenience only — never the cross-frontier carrier; that's the URL +// param). Changed from the historical 'festipod.account.username' → any +// pre-existing stored "logins" under the old key are dropped (acceptable: this +// is a stopgap test env; the URL param carries identity anyway). +const STORAGE_KEY = 'festipod.account.identifier'; -/** 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(); +/** Name of the URL param that carries the identifier across the broker frontier. */ +const ID_PARAM = 'id'; + +/** Normalise an identifier handle into the identity id the SDK is given. */ +export function normalizeIdentifier(identifier: string | null | undefined): string { + return (identifier ?? '').trim().replace(/^@+/, '').toLowerCase(); +} + +/** Read the `?id=` URL param (source of truth), normalized. Null when absent. */ +function identifierFromUrl(): string | null { + if (typeof window === 'undefined') return null; + try { + const raw = new URLSearchParams(window.location.search).get(ID_PARAM); + if (raw == null) return null; + const norm = normalizeIdentifier(raw); + return norm.length > 0 ? norm : null; + } catch { + return null; + } } export interface AccountContextValue { /** App-level identity (the perceived "login"). null = not connected. */ - username: string | null; - /** Faux login — persists the username. No NextGraph call. */ - login: (username: string) => void; - /** Faux logout — clears the username only. No NextGraph call. */ + identifier: string | null; + /** Faux login — persists the identifier. No NextGraph call. */ + login: (identifier: string) => void; + /** Faux logout — clears the identifier only. No NextGraph call. */ logout: () => void; } @@ -60,38 +91,54 @@ function makeStore(): accounts.IdentityStore { } const AccountContext = createContext({ - username: null, + identifier: null, login: () => {}, logout: () => {}, }); export function AccountProvider({ children }: { children: ReactNode }) { const store = useMemo(() => makeStore(), []); - const [username, setUsername] = useState(() => store.get()); + // Resolution priority at init: (1) URL param `?id=` (source of truth, crosses + // the top-level↔iframe frontier), then (2) localStorage (same-partition + // convenience). The URL param wins whenever present. + const [identifier, setIdentifier] = useState(() => { + return identifierFromUrl() ?? store.get(); + }); + + // If the URL param is present, it is authoritative: persist it to localStorage + // (same partition, convenience for a subsequent plain reload without the param) + // so the store and the resolved identity agree. Runs once at mount. + useEffect(() => { + const fromUrl = identifierFromUrl(); + if (fromUrl && fromUrl !== store.get()) { + store.set(fromUrl); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // 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 the username - // handle into the identity id everything else uses. + // may see; the app performs no access check of its own. Normalize the + // identifier handle into the identity id everything else uses. useEffect(() => { - setCurrentUser(username ? normalizeUsername(username) : null); - }, [username]); + setCurrentUser(identifier ? normalizeIdentifier(identifier) : null); + }, [identifier]); const login = useCallback((name: string) => { // 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); + const next = store.set(normalizeIdentifier(name)); + if (next) setIdentifier(next); }, [store]); const logout = useCallback(() => { store.clear(); - setUsername(null); + setIdentifier(null); }, [store]); return ( - + {children} ); diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index 8eabba1..79762be 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -29,7 +29,7 @@ import { seedFriendships, } from '../data/seedData'; import { useNextGraph } from './NextGraphContext'; -import { useAccount, normalizeUsername } from './AccountContext'; +import { useAccount, normalizeIdentifier } from './AccountContext'; // 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'; @@ -151,7 +151,7 @@ function buildQueries( // ============================================================================ function useLocalData(empty?: boolean): FestipodDataContextValue { - const { username } = useAccount(); + const { identifier } = useAccount(); const [selectedEventId, setSelectedEventId] = useState(empty ? '' : 'event-1'); const [selectedUserId, setSelectedUserId] = useState(''); @@ -161,10 +161,10 @@ function useLocalData(empty?: boolean): FestipodDataContextValue { const meetingPoints = empty ? [] : seedMeetingPoints; const friendships = empty ? [] : seedFriendships; - // Resolve current user from the chosen account username; fall back to the + // Resolve current user from the chosen account identifier; fall back to the // demo default so @ui tests and standalone dev keep working unchanged. - const accountUser = username - ? users.find(u => normalizeUsername(u.username) === normalizeUsername(username)) + const accountUser = identifier + ? users.find(u => normalizeIdentifier(u.username) === normalizeIdentifier(identifier)) : undefined; const currentUserId = empty ? '' : (accountUser?.id ?? CURRENT_USER_ID); const currentUser = users.find(u => u.id === currentUserId); @@ -223,7 +223,7 @@ function useLocalData(empty?: boolean): FestipodDataContextValue { function useNgData(): FestipodDataContextValue { const { session } = useNextGraph(); - const { username } = useAccount(); + const { identifier } = useAccount(); // The app speaks ONLY in logical scopes — it holds no store id and builds no // `did:ng:${…}` NURI. It creates ONE document PER ENTITY in its scope // (`createEntityDoc(scope)`, the SDK create) and READS via the SDK's reactive, @@ -345,11 +345,11 @@ function useNgData(): FestipodDataContextValue { const prevOwnerRef = useRef(undefined); useEffect(() => { if (prevOwnerRef.current === undefined) { - prevOwnerRef.current = username; + prevOwnerRef.current = identifier; return; } - if (prevOwnerRef.current === username) return; - prevOwnerRef.current = username; + if (prevOwnerRef.current === identifier) return; + prevOwnerRef.current = identifier; // Fresh session for the new identity: reset the emulated isolation state and // the owned-events set. `watchShape` re-resolves reads for the new identity on // its own (scope re-resolution keyed on `getCurrentUser()`). @@ -362,7 +362,7 @@ function useNgData(): FestipodDataContextValue { setPendingRemoveIds(new Set()); resetCaps(); resetRegistryCache(); - }, [username]); + }, [identifier]); // OPTION B — the set of event docs the CURRENT identity OWNS (its own public // event docs). Each such NURI IS the event `@id` (writeEntity uses the doc NURI @@ -377,11 +377,11 @@ function useNgData(): FestipodDataContextValue { // `createEvent` appends freshly-created events directly. This is NOT a read path // (it feeds no `events`/`users`/`participations`), only the owner-count derivation. useEffect(() => { - if (!ready || !username) return; + if (!ready || !identifier) return; let cancelled = false; (async () => { try { - const myPublic = await listMyEntityDocs(username, 'public'); + const myPublic = await listMyEntityDocs(identifier, 'public'); if (cancelled) return; setOwnedEventIds(prev => [...new Set([...prev, ...myPublic])]); } catch (err) { @@ -389,7 +389,7 @@ function useNgData(): FestipodDataContextValue { } })(); return () => { cancelled = true; }; - }, [ready, username]); + }, [ready, identifier]); // Not in SHEX shapes yet const [meetingPoints, setMeetingPoints] = useState([]); @@ -431,31 +431,31 @@ function useNgData(): FestipodDataContextValue { // Synced AND empty → a real empty wallet. Seed once. hasTriedAutoSeed.current = true; console.log('[FestipodData] Dev auto-seed: wallet empty (synced), bootstrapping…'); - bootstrapWallet(false, createEntityDoc, username || undefined) + bootstrapWallet(false, createEntityDoc, identifier || undefined) .catch(err => console.error('[FestipodData] Auto-seed failed:', err)); // The reactive `watchShape` reads pick the seeded per-entity docs up on their // own (each createEntityDoc appends to the scope index → the container-index // subscription re-resolves → the new docs enter the read). No registerDoc/relist. - }, [ready, readReady, events.length, users.length, username]); + }, [ready, readReady, events.length, users.length, identifier]); // --- Derived --- - // Resolve current user from the chosen account username (the perceived login); - // fall back to the legacy default while the account layer hydrates. + // Resolve current user from the chosen account identifier (the perceived + // login); fall back to the legacy default while the account layer hydrates. const currentUser = - (username ? users.find(u => normalizeUsername(u.username) === normalizeUsername(username)) : undefined) + (identifier ? users.find(u => normalizeIdentifier(u.username) === normalizeIdentifier(identifier)) : undefined) || users.find(u => u.username === '@mariedupont') || users[0]; // The current user's PRINCIPAL. When logged in, this is a STABLE - // username-derived id (`urn:festipod:user:`) — available - // IMMEDIATELY (no dependency on the protected profile read, which can lag) and - // INVARIANT (it never flips from a fallback to the profile IRI mid-session, - // which would desync a participation written under one value from a check under - // the other). It is the SAME principal the SDK identity (`setCurrentUser`) and - // the cap owner derive from the username, so participations keyed on it are - // consistent with reads and isolation. Falls back to the read profile's IRI only - // when there is no login (dev/demo). + // identifier-derived id (`urn:festipod:user:`) — + // available IMMEDIATELY (no dependency on the protected profile read, which can + // lag) and INVARIANT (it never flips from a fallback to the profile IRI + // mid-session, which would desync a participation written under one value from + // a check under the other). It is the SAME principal the SDK identity + // (`setCurrentUser`) and the cap owner derive from the identifier, so + // participations keyed on it are consistent with reads and isolation. Falls + // back to the read profile's IRI only when there is no login (dev/demo). const currentUserId = - (username ? `urn:festipod:user:${normalizeUsername(username)}` : (currentUser?.id || '')); + (identifier ? `urn:festipod:user:${normalizeIdentifier(identifier)}` : (currentUser?.id || '')); const selectedEvent = events.find(e => e.id === selectedEventId); const selectedUser = users.find(u => u.id === selectedUserId); @@ -574,25 +574,25 @@ function useNgData(): FestipodDataContextValue { useEffect(() => { if (!ready || !currentUserId) return; // 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 => { + // opened with `normalizeIdentifier(owner)`, and the reader identity is set via + // `setCurrentUser(normalizeIdentifier(identifier))`. 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 idKeyOf = (userIri: string): string | undefined => { const u = users.find(x => x.id === userIri); - return u?.username ? normalizeUsername(u.username) : undefined; + return u?.username ? normalizeIdentifier(u.username) : undefined; }; - const selfKey = username ? normalizeUsername(username) : usernameOf(currentUserId); + const selfKey = identifier ? normalizeIdentifier(identifier) : idKeyOf(currentUserId); if (!selfKey) return; const myPeers = friendships .filter(f => f.userId === currentUserId || f.friendId === currentUserId) .map(f => (f.userId === currentUserId ? f.friendId : f.userId)) - .map(usernameOf) + .map(idKeyOf) .filter((k): k is string => !!k); declareConnections(myPeers, selfKey); - }, [ready, friendships, currentUserId, users, username]); + }, [ready, friendships, currentUserId, users, identifier]); const queries = buildQueries( events, users, participations, meetingPoints, friendships, currentUserId, @@ -612,11 +612,11 @@ function useNgData(): FestipodDataContextValue { const createEvent = useCallback(async (event: Omit): Promise => { console.log('[FestipodData] createEvent (NG):', event.title); - // Owner principal = the account username (what setCurrentUser declares). The + // Owner principal = the account identifier (what setCurrentUser declares). The // SDK create returns THIS entity's OWN public document and declares its // ReadCap policy (public → world-readable). Fall back to a generic account // label when no login is present (dev/demo). - const owner = username || currentUserId || 'anon'; + const owner = identifier || currentUserId || 'anon'; // Create the event's OWN document in the PUBLIC scope (one doc per entity), // then WRITE the event RDF DIRECTLY into that document (writeEntity) — not via // the scope-coupled `ngSet.add`, which can't write into a not-yet-subscribed @@ -664,7 +664,7 @@ function useNgData(): FestipodDataContextValue { ).catch(err => console.error('[FestipodData] submit event to index failed:', err)); } return { ...event, id: addedEvent?.["@id"] || `ng-pending-${Date.now()}` }; - }, [currentUserId, username]); + }, [currentUserId, identifier]); const updateEvent = useCallback(async (id: string, updates: Partial) => { console.log('[FestipodData] updateEvent (NG):', id, updates); @@ -701,17 +701,17 @@ function useNgData(): FestipodDataContextValue { // The reactive participation set can lag a just-written participation, so a // second join checking only the set would write a DUPLICATE (breaking "exactly // one participation"). The broker query sees the real state regardless of lag. - const already = await countUserParticipations(username || uid || 'anon', eventId, uid).catch(() => 0); + const already = await countUserParticipations(identifier || uid || 'anon', eventId, uid).catch(() => 0); if (already > 0) { console.log('[FestipodData] Already participating (broker-confirmed), skipping'); return; } // 1) Persist the Participation as its OWN document in the PROTECTED scope - // (one doc per entity). Owner = the account username (setCurrentUser key). + // (one doc per entity). Owner = the account identifier (setCurrentUser key). // Its NURI is appended to the protected scope index, which // `watchShape('protected')` subscribes → the participation enters the // reactive read on the push. - const owner = username || uid || 'anon'; + const owner = identifier || uid || 'anon'; const partGraph = await createEntityDoc(owner, 'protected'); // WRITE the participation RDF DIRECTLY into its own document (writeEntity) — // not via the scope-coupled `ngSet.add` (can't write a not-yet-subscribed @@ -766,7 +766,7 @@ function useNgData(): FestipodDataContextValue { } catch (err) { console.error('[FestipodData] joinEvent inbox/notify failed:', err); } - }, [events, currentUserId, username]); + }, [events, currentUserId, identifier]); const leaveEvent = useCallback(async (eventId: string, userId?: string) => { const uid = userId || currentUserId; @@ -838,7 +838,7 @@ function useNgData(): FestipodDataContextValue { // DELETE pushes → the reactive read drops it (`isParticipating` reflects it). // The count itself follows the owner's materialization of the leave marker // (reactive, cross-session). - }, [participations, events, currentUserId, username]); + }, [participations, events, currentUserId, identifier]); const addMeetingPoint = useCallback((mp: Omit) => { setMeetingPoints(prev => [...prev, { ...mp, id: `ng-mp-${Date.now()}` }]); @@ -876,12 +876,12 @@ function useNgData(): FestipodDataContextValue { // the window where the auto-seed effect could also fire on a still-empty read). hasTriedAutoSeed.current = true; const walletHasData = events.length > 0 || users.length > 0; - const result = await bootstrapWallet(walletHasData, createEntityDoc, username || undefined); + const result = await bootstrapWallet(walletHasData, createEntityDoc, identifier || undefined); // The seeded per-entity docs are appended to their scope indices, which // `watchShape` subscribes → they enter the reactive reads on the push. No // manual registration / re-list. return result; - }, [events.length, users.length, username]); + }, [events.length, users.length, identifier]); return { currentUserId, currentUser, diff --git a/src/shared/support/hooks.ts b/src/shared/support/hooks.ts index 690330f..2e81cb7 100644 --- a/src/shared/support/hooks.ts +++ b/src/shared/support/hooks.ts @@ -10,16 +10,16 @@ import { pool } from './browserPool'; setDefaultTimeout(90000); // PER-SCENARIO FRESH VIRTUAL WALLET (T03.k). The shim keys each emulated account -// (its own private virtual wallet) by the NORMALIZED app-level username read from -// localStorage['festipod.account.username'] on the harness origin. When every -// @data scenario logs in as the SAME fixed user, that ONE virtual wallet +// (its own private virtual wallet) by the NORMALIZED app-level identifier read +// from localStorage['festipod.account.identifier'] on the harness origin. When +// every @data scenario logs in as the SAME fixed user, that ONE virtual wallet // accumulates every doc any prior scenario/run ever wrote → per-doc anchored // reads fan out over hundreds of docs → 90s timeouts. Giving each scenario a -// UNIQUE username hands it a FRESH, EMPTY virtual wallet, so reads stay O(what +// UNIQUE identifier hands it a FRESH, EMPTY virtual wallet, so reads stay O(what // THIS scenario provisions) and are fast + independent. A monotonic counter + // per-run nonce guarantees uniqueness within and across runs; it normalizes to // itself (lowercase, `@`-free) and is disjoint from the reserved `@index` -// account (whose shim key uses a sentinel prefix `normalizeUsername` can't emit). +// account (whose shim key uses a sentinel prefix `normalizeIdentifier` can't emit). const RUN_NONCE = Date.now().toString(36) + Math.random().toString(36).slice(2, 6); let scenarioSeq = 0; function freshScenarioUsername(): string { @@ -581,17 +581,17 @@ Before({ timeout: 60000 }, async function (this: FestipodWorld, scenario) { this.page = await newWalletPageResilient(); // FRESH VIRTUAL WALLET per scenario (see freshScenarioUsername above). Set a - // UNIQUE app-level username into localStorage['festipod.account.username'] on - // EVERY origin (the init script runs in each frame before its scripts do — + // UNIQUE app-level identifier into localStorage['festipod.account.identifier'] + // 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 - // IdentityStore.get() then reads THIS fresh username, so `if (!username) + // IdentityStore.get() then reads THIS fresh identifier, so `if (!identifier) // 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. const freshUser = freshScenarioUsername(); (this as any).freshUser = freshUser; await this.page.addInitScript((u: string) => { - try { window.localStorage.setItem('festipod.account.username', u); } catch { /* opaque origin */ } + try { window.localStorage.setItem('festipod.account.identifier', u); } catch { /* opaque origin */ } }, freshUser); // Capture console for debugging AND collect into the World so smoke diff --git a/src/shared/test-harness/harness-ng.tsx b/src/shared/test-harness/harness-ng.tsx index 5f735a0..260cb32 100644 --- a/src/shared/test-harness/harness-ng.tsx +++ b/src/shared/test-harness/harness-ng.tsx @@ -28,7 +28,7 @@ import { FpParticipationShapeType, } from '../shapes/orm/festipodShapes.shapeTypes'; import type { FpEvent, FpUserProfile, FpParticipation } from '../shapes/orm/festipodShapes.typings'; -import { normalizeUsername } from '../context/AccountContext'; +import { normalizeIdentifier } from '../context/AccountContext'; // ============================================================================ // App — uses real providers (same tree as the real app) @@ -58,10 +58,10 @@ function DataHarnessNG() { * AccountProvider effect) and the current user can read their own protected * entities. Mirrors the real app's post-login state. */ function HarnessLogin() { - const { username, login } = useAccount(); + const { identifier, login } = useAccount(); useEffect(() => { - if (!username) login(DEFAULT_HARNESS_USER); - }, [username, login]); + if (!identifier) login(DEFAULT_HARNESS_USER); + }, [identifier, login]); return null; } @@ -181,11 +181,11 @@ function ConnectedHarness() { * `prevOwnerRef` reset. Returns the normalized id now in effect. */ switchIdentity(identifier: string) { accountRef.current.login(identifier); - return normalizeUsername(identifier); + return normalizeIdentifier(identifier); }, /** The current app-level identifier (localStorage-backed). */ currentIdentifier() { - return accountRef.current.username; + return accountRef.current.identifier; }, /** Titles of the events the CURRENT user PARTICIPATES in — exactly what the * HOME screen shows (`getUserEvents(currentUserId)`). Used by the @@ -367,7 +367,7 @@ function ConnectedHarness() { // and the sanctioned non-hanging enumeration. Falls back to the fan-out // only when no login is present (dev/demo). let currentUser = ''; - try { currentUser = window.localStorage.getItem('festipod.account.username') || ''; } catch { /* opaque origin */ } + try { currentUser = window.localStorage.getItem('festipod.account.identifier') || ''; } catch { /* opaque origin */ } const protectedDocs = currentUser ? await reg.listMyEntityDocs(currentUser, 'protected') : await reg.listEntityDocs('protected'); @@ -671,10 +671,10 @@ function ConnectedHarness() { console.error('[PROBE] publishPublicEventAs: publisher doc=' + doc); // Deposit AS the current identity: the inbox guard binds `from` to the // CURRENT user and rejects a spoofed `from`. So make the publisher the - // current identity (its normalized-username key = the cap-owner key), + // current identity (its normalized-identifier key = the cap-owner key), // then submit WITHOUT a spoofed explicit `from` — the SDK stamps the // current identity itself (anonymous submission also allowed). - setCurrentUser(normalizeUsername(publisher)); + setCurrentUser(normalizeIdentifier(publisher)); console.error('[PROBE] publishPublicEventAs: submitEventToIndex…'); await disc.submitEventToIndex({ doc, id: doc, title }, getCurrentUser()); console.error('[PROBE] publishPublicEventAs: submitted OK'); @@ -686,7 +686,7 @@ function ConnectedHarness() { // The discoverer account exists but is NOT connected to the publisher. // Become the discoverer identity (reads the world-readable public index). await reg.ensureAccount(discoverer); - setCurrentUser(normalizeUsername(discoverer)); + setCurrentUser(normalizeIdentifier(discoverer)); reg.resetRegistryCache(); // Read the GLOBAL INDEX (not a cross-account fan-out) to discover. The // submit deposit needs a moment to land in the broker's queryable graph diff --git a/src/shared/test-harness/renderHelper.tsx b/src/shared/test-harness/renderHelper.tsx index 8444ebb..7e090fa 100644 --- a/src/shared/test-harness/renderHelper.tsx +++ b/src/shared/test-harness/renderHelper.tsx @@ -153,6 +153,37 @@ function defaultPathFor(registryPath: string): string { return path.replace(/\/+$/, '') || '/'; } +/** + * Set the happy-dom window's URL (so `window.location.search` reflects a given + * query string) BEFORE a subsequent render. Used to drive context that resolves + * state from the URL — e.g. AccountContext reads the `?id=` param. Idempotent: + * installs the DOM globals first if needed. + */ +export async function setRenderUrl(url: string): Promise { + await ensureDomGlobals(); + if (!window) throw new Error('DOM globals not installed'); + // happy-dom exposes navigation via the Location setter; assigning href updates + // location.search/pathname synchronously (no real navigation in happy-dom). + (window.location as unknown as { href: string }).href = url; +} + +/** Read a localStorage value on the happy-dom window (null if absent/unavailable). */ +export async function getRenderLocalStorage(key: string): Promise { + await ensureDomGlobals(); + if (!window) throw new Error('DOM globals not installed'); + try { return window.localStorage.getItem(key); } catch { return null; } +} + +/** Set/clear a localStorage value on the happy-dom window (seed same-partition prefill). */ +export async function setRenderLocalStorage(key: string, value: string | null): Promise { + await ensureDomGlobals(); + if (!window) throw new Error('DOM globals not installed'); + try { + if (value == null) window.localStorage.removeItem(key); + else window.localStorage.setItem(key, value); + } catch { /* opaque origin — non-persisting, fine for the assertion path */ } +} + export function unmountRender(): void { if (root) { root.unmount(); diff --git a/src/shared/utils/ngBootstrap.ts b/src/shared/utils/ngBootstrap.ts index 90a3e85..9889e7e 100644 --- a/src/shared/utils/ngBootstrap.ts +++ b/src/shared/utils/ngBootstrap.ts @@ -11,7 +11,7 @@ * them to the live subscription set (reactivity). */ -import { normalizeUsername } from '../context/AccountContext'; +import { normalizeIdentifier } from '../context/AccountContext'; import { seedEvents, seedUsers, @@ -68,7 +68,7 @@ export async function bootstrapWallet( // with no product meaning. One account (the current user) owns them; each entity // is still ITS OWN document (per-document isolation unchanged — only the cap // OWNER is shared). Falls back to the fixture username when no login is present. - const seedOwner = owner ?? (seedUsers[0] ? normalizeUsername(seedUsers[0].username) : 'seed'); + const seedOwner = owner ?? (seedUsers[0] ? normalizeIdentifier(seedUsers[0].username) : 'seed'); // SEED FOOTPRINT (perf). Each entity is its OWN document, and each `docCreate` // is a SERIAL ~2s broker round-trip (the verifier serializes creations — they diff --git a/src/shared/utils/storeRegistry.ts b/src/shared/utils/storeRegistry.ts index da2b817..53775ac 100644 --- a/src/shared/utils/storeRegistry.ts +++ b/src/shared/utils/storeRegistry.ts @@ -1,7 +1,7 @@ /** * storeRegistry (Festipod glue) — the lib owns placement; the app maps * entity → scope. This file keeps ONLY the Festipod domain mapping (entity kind - * → scope) and injects the consumer wiring the lib needs (session + username + * → scope) and injects the consumer wiring the lib needs (session + identifier * normalization) via `configureStoreRegistry(...)`. The app re-exports the lib * surface so existing callers stay unchanged. */ @@ -12,7 +12,7 @@ import { } from '@ng-eventually/client'; import { configureStoreRegistry, getCaps } from '@ng-eventually/client/polyfill'; import { sessionPromise } from './ngSession'; -import { normalizeUsername } from '../context/AccountContext'; +import { normalizeIdentifier } from '../context/AccountContext'; export type Scope = 'public' | 'protected' | 'private'; @@ -51,8 +51,8 @@ configureStoreRegistry({ publicStoreId: session.public_store_id, }; }, - // The app maps its username handle to the identity id the lib keys on. - normalizeId: normalizeUsername, + // The app maps its identifier handle to the identity id the lib keys on. + normalizeId: normalizeIdentifier, // Anti-fork bounded retry (real broker): on a fresh page over the persistent // wallet (reconnection under the SAME identity) the shim may not be synced when // the first read fires → 0 rows. Without this, the registry would provision a @@ -88,13 +88,13 @@ export const { * - protected → owner reads now; connections granted later (a separate grant) * - private → owner only * The owner always holds the WRITE cap (so only the owner may update the doc once - * the guard is active). `owner` = the account username (the principal the app + * the guard is active). `owner` = the account identifier (the principal the app * sets via `setCurrentUser`). */ -export async function createEntityDoc(username: string, scope: Scope): Promise { - const entityNuri = await libStoreRegistry.createEntityDoc(username, scope); +export async function createEntityDoc(identifier: string, scope: Scope): Promise { + const entityNuri = await libStoreRegistry.createEntityDoc(identifier, scope); // Declare the cap policy for the freshly-created entity document. `owner` is - // the account username (principal). This is what makes ReadCap ACTIVE. - getCaps().open(entityNuri, scope, normalizeUsername(username)); + // the account identifier (principal). This is what makes ReadCap ACTIVE. + getCaps().open(entityNuri, scope, normalizeIdentifier(identifier)); return entityNuri; }