fix(auth): porter l'identité par param d'URL (?id=), pas localStorage; renommer username→identifier

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=<identifiant> (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) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-13 11:55:09 +02:00
parent 13da2d9e03
commit c7e924abe7
14 changed files with 370 additions and 120 deletions
+28 -10
View File
@@ -15,7 +15,7 @@
import { useEffect, type ReactNode } from 'react';
import { useNextGraph } from '../shared/context/NextGraphContext';
import { useAccount } from '../shared/context/AccountContext';
import { useAccount, normalizeIdentifier } from '../shared/context/AccountContext';
import { AccessGateScreen } from '../modules/auth/screens/AccessGateScreen';
import { useRouter, useNavigate } from './router';
@@ -27,7 +27,7 @@ const GATE_DISABLED = globalThis.__FESTIPOD_ACCESS_GATE_DISABLED__ === true;
export function AuthGate({ children }: { children: ReactNode }) {
const { status, error, connect } = useNextGraph();
const { username, login } = useAccount();
const { identifier, login } = useAccount();
const { route } = useRouter();
const navigate = useNavigate();
@@ -36,10 +36,10 @@ export function AuthGate({ children }: { children: ReactNode }) {
// round-trip), so on return the app can land on '/' with a session already
// open; the removed ConnexionScreen used to do this navigate on login.
useEffect(() => {
if (!GATE_DISABLED && status === 'connected' && 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 (
<AccessGateScreen
status={status}
error={error}
initialIdentifier={username ?? ''}
initialIdentifier={identifier ?? ''}
onEnter={onEnter}
/>
);