fix(data): reset the read set + emulated caps on identity switch (isolation)

The shared-wallet stopgap keeps ONE React tree across a faux-logout + re-login
under a different identifier (AccountContext.login only rewrites a localStorage
id; AuthGate never remounts, no page reload). FestipodDataContext's by-need read
set accumulates the current identity's scope docs and was never reset on identity
change, so the PREVIOUS identity's PROTECTED docs (its participations) survived in
the new identity's read set and leaked through the union read — the in-memory cap
gate can't filter a doc it doesn't govern this session. Symptom: user B saw A's
participation, and A's event surfaced on B's home (home = getUserEvents(currentUserId)).

Treat every identifier change as a fresh session: a ref-guarded useEffect([username])
clears publicDocs/protectedDocs, resetCaps(), resetRegistryCache(), then bumps the
read tick so the listing effect rebuilds the set bounded to the new identity.
Isolation stays per-document/emulated; the reset only drops cross-identity carryover.
Documented in knowledge_context-internals.

Validated (@data, real broker): after an A→B switch, B does not participate and
does not read A's participation; protected-isolation/read-filter/auth scenarios pass.
tsc + build green; lib untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-06 17:29:18 +02:00
parent 3dfd549af3
commit 6ceec5e161
2 changed files with 39 additions and 1 deletions
+33 -1
View File
@@ -29,7 +29,8 @@ import { useAccount, normalizeUsername } 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';
import { listMyEntityDocs, createEntityDoc } from '../utils/storeRegistry';
import { listMyEntityDocs, createEntityDoc, resetRegistryCache } from '../utils/storeRegistry';
import { resetCaps } from '@ng-eventually/client/polyfill';
import { submitEventToIndex, readDiscoveredEvents } from '../data/discovery';
import { readEntities } from '../data/readEntities';
import { writeEntity, updateEntityField, ENTITY_TYPE, str, int, flt, bool, iri } from '../data/entityWrites';
@@ -253,6 +254,37 @@ function useNgData(): FestipodDataContextValue {
setReadTick(t => t + 1);
}, []);
// IDENTITY SWITCH = FRESH SESSION (isolation). The by-need read set
// (publicDocs/protectedDocs) ACCUMULATES the current identity's own scope docs
// (`listMyEntityDocs(username, …)`) so a just-created doc isn't dropped before
// the re-list. But the shared-wallet stopgap keeps ONE React tree across a faux
// logout + re-login under a DIFFERENT identifier (no page reload — see
// AuthGate/AccountContext), so without a reset the PREVIOUS identity's PROTECTED
// docs (its participations) survive in the new identity's read set and leak
// through the union read: the cap gate cannot filter them when the cap registry
// does not govern that doc THIS session (a doc persisted in a prior run, or a
// fresh load where caps are empty). Treat every identity change as a fresh
// session: drop the accumulated read set (the listing effect rebuilds it bounded
// to the NEW identity), and reset the emulated caps + registry cache so nothing
// from the old identity lingers. Ref-guarded so it fires only on a real change,
// not on the first mount (empty sets already).
const prevOwnerRef = useRef<string | null | undefined>(undefined);
useEffect(() => {
if (prevOwnerRef.current === undefined) {
prevOwnerRef.current = username;
return;
}
if (prevOwnerRef.current === username) return;
prevOwnerRef.current = username;
// Fresh session for the new identity: clear the previous identity's read set
// and the emulated isolation state, then let the listing effect rebuild.
setPublicDocs([]);
setProtectedDocs([]);
resetCaps();
resetRegistryCache();
setReadTick(t => t + 1);
}, [username]);
// Resolve the by-need doc NURIs — READ BY NEED, never an all-accounts fan-out
// (the OLD `listEntityDocs('public'|'protected')` enumerated EVERY account and
// tried to open/sync other accounts' unsynced docs → HANG ~75s; see