feat(data): activate isolation — declare identity + connections to the SDK

Festipod performs the domain acts that make isolation real: AccountContext
declares the current identity at login/change; FestipodDataContext declares its
connections (friendships) to the data SDK. Reads then discriminate by scope
through the SDK (private→owner, protected→owner+connections, public→all) — no
app-side filtering, no store ids, no awareness that isolation is emulated. New
@data scenario proves an unconnected account can't read another's protected
entity but can after connecting; public stays visible. @data 21/21.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-03 23:59:03 +02:00
parent 619b94ac0e
commit 337a1e000d
9 changed files with 184 additions and 2 deletions
+14 -1
View File
@@ -21,12 +21,17 @@
* (the @ui render harness wraps screens without this provider).
*/
import { createContext, useContext, useState, useCallback, useMemo, type ReactNode } from 'react';
import { createContext, useContext, useState, useCallback, useMemo, useEffect, type ReactNode } from 'react';
// Thin React wrapper over the lib's framework-agnostic accounts core (T01.c):
// AccountStore (localStorage-backed faux login) + normalizeUsername. This file
// keeps ONLY the React Context/Provider glue; the login/logout/normalize logic
// lives in the lib. See decision_2026-06-17_eventually-library.
import { accounts } from '@ng-eventually/client';
// Declare the current identity to the SDK: the app tells NextGraph WHO is
// reading, so the SDK returns only the data this identity is authorized to see
// (isolation is the SDK's job — see knowledge_trust-model). This is the SDK's
// "current 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 lib's default key differs; we pin ours explicitly → no behavior change).
@@ -57,6 +62,14 @@ export function AccountProvider({ children }: { children: ReactNode }) {
const store = useMemo(() => makeStore(), []);
const [username, setUsername] = useState<string | null>(() => store.get());
// 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 so the id
// matches the same principal key everything else uses.
useEffect(() => {
setCurrentUser(username ? accounts.normalizeUsername(username) : null);
}, [username]);
const login = useCallback((name: string) => {
const next = store.login(name);
if (next) setUsername(next);
@@ -26,6 +26,8 @@ import {
import { useNextGraph } from './NextGraphContext';
import { useAccount, normalizeUsername } from './AccountContext';
import { applyIsolation } from '../utils/isolation';
import { isolation } from '@ng-eventually/client';
import { declareConnections } from '@ng-eventually/client/polyfill';
import { resolveScopeGraph, listEntityDocs } from '../utils/storeRegistry';
import { useShapeWithDefaults, type ShapeScope } from '../hooks/useShapeWithDefaults';
import {
@@ -429,6 +431,18 @@ function useNgData(): FestipodDataContextValue {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ready, hostedEventIds.join('|')]);
// Protected-sharing act: hand the SDK the current CONNECTIONS graph so it lets
// an owner's direct connections read that owner's PROTECTED entities (public =
// all; private = owner only). The app knows its connections (friendships — a
// domain fact) and declares them to the SDK; the SDK owns the enforcement. No
// store id, no document NURI crosses here — a pure domain graph.
useEffect(() => {
if (!ready) return;
declareConnections(
isolation.connectionsFromLinks(friendships.map(f => ({ a: f.userId, b: f.friendId }))),
);
}, [ready, friendships]);
// Isolation (staging realism): the app honors the matrix in connected mode —
// participations/connections narrowed to self + connections. See isolation.ts.
const isolated = applyIsolation(
+34 -1
View File
@@ -13,7 +13,8 @@ import { NextGraphProvider, useNextGraph } from '../context/NextGraphContext';
import { FestipodDataProvider, useFestipodData } from '../context/FestipodDataContext';
// useShape routed through the lib (SDK-identical surface); caps from /polyfill.
import { useShape, docs, inbox as docsInbox } from '@ng-eventually/client';
import { getCaps, setCurrentUser, resetCaps } from '@ng-eventually/client/polyfill';
import { getCaps, getCurrentUser, setCurrentUser, resetCaps, declareConnections } from '@ng-eventually/client/polyfill';
import { isolation as ngIsolation } from '@ng-eventually/client';
import { hostInboxNuri as regInboxNuri } from '../data/registration';
import type { DeepSignalSet } from '@ng-eventually/client';
// doc_create goes through the lib's `docs` primitive (T01.a): it calls the REAL
@@ -280,6 +281,38 @@ function ConnectedHarness() {
setCurrentUser(user);
},
// --- PROTECTED + connections isolation (T03.b) ----------------------
// Prove, through the SDK's ReadCap filter on the REAL ORM set, that a
// PROTECTED document owned by `owner` is:
// - hidden from an UNCONNECTED principal (only owner reads it);
// - revealed once the app declares the connection owner↔reader;
// - a PUBLIC document stays readable throughout (regardless of caps).
// Uses `getCaps().open(doc, scope, owner)` exactly as the app wrapper
// (storeRegistry.createEntityDoc) does; the protected participations
// document is governed, and a separate makePublic'd doc models a public
// entity. <FilterProbe> exposes the read-filtered VIEW over the protected
// participations doc. `connect` calls the SDK's declareConnections — the
// app's domain sharing act — never touches a doc NURI or the registry.
governProtected(owner: string, reader: string) {
resetCaps();
// The protected participations document (owner-only read at first).
getCaps().open(protectedNuri!, 'protected', owner);
// A public entity document — readable by anyone regardless of caps.
getCaps().makePublic('did:ng:o:public-probe');
setCurrentUser(reader);
setFilterActive(true);
},
/** Declare the owner↔reader connection to the SDK (domain sharing act).
* The SDK then issues the protected doc's read cap to the connection. */
connect(a: string, b: string) {
declareConnections(ngIsolation.connectionsFromLinks([{ a, b }]));
},
/** Does the CURRENT user read the public entity document — through the
* SDK's own cap check — regardless of the protected caps? */
canReadPublicProbe() {
return getCaps().canRead('did:ng:o:public-probe', getCurrentUser());
},
// --- Stopgap multi-store validation (see brief_2026-06-15_shared-wallet-shim) ---
/**