Ng eventually #1

Open
Sylvain wants to merge 110 commits from ng-eventually into main
14 changed files with 370 additions and 120 deletions
Showing only changes of commit c7e924abe7 - Show all commits
@@ -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=<identifiant>` (`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
@@ -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:<identifiant-normalisé>`, disponible immédiatement (sans dépendre de la lecture du profil protégé) et invariant sur la session — c'est la même clé que `setCurrentUser`, le cap owner et le compte shim (cf. [[rule_document-per-entity]], corollaire d'identité). Pièges restants :
- L'objet `currentUser` (le profil affiché) est, lui, résolu par `users.find(u => normalizeUsername(u.username) === identifiant)` avec **fallback** `@mariedupont` puis `users[0]` — un fallback silencieux si l'identifiant ne correspond à aucun profil (l'identifiant est un id d'espace, pas forcément le `username` d'un profil seedé).
- 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:<repo>[:v:<ove
Le jeu de lecture par besoin (`publicDocs`/`protectedDocs`) **accumule** les docs de scope de l'identité courante (pour ne pas perdre un doc juste créé avant la re-liste). Or le stopgap wallet-partagé garde **un seul arbre React** au travers d'un faux-logout + re-login sous un **autre identifiant** (pas de rechargement — `AccountContext.login` ne fait que réécrire l'identifiant en localStorage, `AuthGate` ne remonte rien). Sans réinitialisation, **les docs PROTECTED de l'identité précédente (ses participations) survivent dans le jeu de lecture de la nouvelle identité et fuient** via la lecture union : le cap gate ne peut pas les filtrer quand le registre de caps (en mémoire) ne gouverne pas ce doc *cette* session (doc persisté d'un run antérieur, ou chargement frais où les caps sont vides). Symptôme observé : un utilisateur B voyait la participation de A (et l'événement de A apparaissait sur l'**accueil** de B, car l'accueil = `getUserEvents(currentUserId)`, cf. concept `app-architecture`).
**Règle** : traiter **tout changement d'identifiant** comme une session fraîche — un `useEffect([username])` (ref-gardé pour ne pas tirer au premier mount) vide `publicDocs`/`protectedDocs`, appelle `resetCaps()` + `resetRegistryCache()`, puis bump le read tick ; l'effet de listing reconstruit le jeu **borné à la nouvelle identité**. L'isolation reste par-document/émulée (concept `app-security`, [[knowledge_trust-model]]) ; ce reset ne fait que supprimer le report d'état inter-identités.
**Règle** : traiter **tout changement d'identifiant** comme une session fraîche — un `useEffect([identifier])` (ref-gardé pour ne pas tirer au premier mount) vide `publicDocs`/`protectedDocs`, appelle `resetCaps()` + `resetRegistryCache()`, puis bump le read tick ; l'effet de listing reconstruit le jeu **borné à la nouvelle identité**. L'isolation reste par-document/émulée (concept `app-security`, [[knowledge_trust-model]]) ; ce reset ne fait que supprimer le report d'état inter-identités.
**Mécanisme confirmé empiriquement (2026-07-07)** : le leak se reproduit UNIQUEMENT quand DEUX conditions coïncident — (a) le jeu de lecture porte encore le doc PROTECTED de A au travers du switch (pas de reset), ET (b) le registre de caps en mémoire ne gouverne pas ce doc (`resetCaps()` déjà tiré / caps vides pour un doc persisté d'une session antérieure au reload). Alors la participation de A traverse la lecture union de B (le filtre par-document n'a aucun cap à vérifier). Avec le reset ci-dessus tiré, `setProtectedDocs([])` retire le doc de A du jeu de lecture de B AVANT que la lecture cap-less ne l'expose → plus de fuite quel que soit l'état des caps. **Régression gardée** par le scénario `@data` « Une identité fraîche ne voit pas la participation d'une autre » (event/isolation-deux-identites.feature) : A crée E + s'y inscrit, B (page fraîche sur le même wallet, identifiant distinct) n'a NI E sur son accueil (`getUserEvents(B)`), NI `isParticipating(E,B)`, ET ne lit AUCUNE participation portant le principal de A. Le symptôme historique « B voit “Je participe” » survenait surtout quand B **réutilisait un identifiant déjà employé par A** (même principal normalisé) sur un wallet **bloaté** (docs persistés d'un run antérieur, caps vides).
+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}
/>
);
@@ -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-leveliframe (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;
+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;
}