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
+74 -27
View File
@@ -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=<value>` 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<AccountContextValue>({
username: null,
identifier: null,
login: () => {},
logout: () => {},
});
export function AccountProvider({ children }: { children: ReactNode }) {
const store = useMemo(() => makeStore(), []);
const [username, setUsername] = useState<string | null>(() => 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<string | null>(() => {
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 (
<AccountContext.Provider value={{ username, login, logout }}>
<AccountContext.Provider value={{ identifier, login, logout }}>
{children}
</AccountContext.Provider>
);
+48 -48
View File
@@ -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<string>(empty ? '' : 'event-1');
const [selectedUserId, setSelectedUserId] = useState<string>('');
@@ -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<string | null | undefined>(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<FpMeetingPointData[]>([]);
@@ -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:<normalized-username>`) — 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:<normalized-identifier>`) —
// 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<FpEventData, 'id'>): Promise<FpEventData> => {
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<FpEventData>) => {
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<FpMeetingPointData, 'id'>) => {
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,
+9 -9
View File
@@ -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
+10 -10
View File
@@ -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
+31
View File
@@ -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<void> {
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<string | null> {
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<void> {
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();
+2 -2
View File
@@ -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
+9 -9
View File
@@ -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<string> {
const entityNuri = await libStoreRegistry.createEntityDoc(username, scope);
export async function createEntityDoc(identifier: string, scope: Scope): Promise<string> {
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;
}