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:
+28
-10
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
@@ -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<object, ResolveState>();
|
||||
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);
|
||||
},
|
||||
);
|
||||
@@ -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<string, unknown>).__FESTIPOD_ACCESS_GATE_DISABLED__ = true;
|
||||
|
||||
@@ -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<string, unknown>).__FESTIPOD_ACCESS_GATE_DISABLED__ = true;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user