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
@@ -0,0 +1,19 @@
# language: fr
@WORKSHOP @priority-1
Fonctionnalité: Isolation protégée par connexions (ng-eventually)
En tant que développeur
Je veux valider, contre le vrai broker, que l'isolation est ACTIVE via le SDK :
un compte ne lit PAS l'entité PROTÉGÉE d'un autre compte tant qu'ils ne sont
pas connectés, la lit une fois qu'ils se connectent, et lit toujours l'entité
PUBLIQUE de cet autre compte le tout appliqué par le SDK (filtre ReadCap +
déclaration de connexions), pas par un filtre applicatif.
@data
Scénario: Un compte non connecté ne lit pas l'entité protégée d'un autre, puis la lit après connexion
Étant donné le wallet contient l'entité protégée du compte "alice"
Et le compte "bob" est courant sans connexion à "alice"
Alors "bob" ne voit aucune entité protégée d'"alice"
Mais "bob" voit l'entité publique d'"alice"
Quand l'app déclare la connexion entre "alice" et "bob"
Alors "bob" voit l'entité protégée d'"alice"
Et "bob" voit toujours l'entité publique d'"alice"
@@ -0,0 +1,75 @@
import { Given, When, Then } from '@cucumber/cucumber';
import { expect } from 'chai';
import type { FestipodWorld } from '../../../../shared/support/world';
// Proves ISOLATION IS ACTIVE through the SDK (not a mere app filter): a PROTECTED
// document owned by `alice` is hidden from an unconnected `bob`, revealed once the
// app declares the alice↔bob connection (declareConnections — the domain sharing
// act), while alice's PUBLIC document stays readable for bob regardless. Runs on
// the REAL ORM set via <FilterProbe> against the broker. The current user + caps +
// connections all drive the SDK's per-document ReadCap filter — see T03.b.
Given('le wallet contient l\'entité protégée du compte {string}', async function (this: FestipodWorld, owner: string) {
// Ensure ≥1 participation lives in the protected participations document.
// joinEvent is idempotent on (event, user), so re-runs don't accumulate.
await this.appFrame!.evaluate(async () => {
const td = (window as any).__testData;
await td.joinEvent('urn:pc:event', 'urn:pc:p1');
await td.joinEvent('urn:pc:event', 'urn:pc:p2');
});
await this.appFrame!.waitForFunction(
() => {
const ps = [...(window as any).__testData.participations];
return ps.some((p: any) => p.user === 'urn:pc:p1') && ps.some((p: any) => p.user === 'urn:pc:p2');
},
null,
{ timeout: 15000 },
);
const total = await this.appFrame!.evaluate(() => [...(window as any).__testData.participations].length);
(this as any).pc = { owner, total };
expect(total, 'the protected document holds participations').to.be.greaterThan(0);
});
Given('le compte {string} est courant sans connexion à {string}', async function (this: FestipodWorld, reader: string, owner: string) {
(this as any).pc = { ...(this as any).pc, reader, owner };
// Govern the protected participations document as `protected` owned by `owner`,
// set `reader` (unconnected) as current — no connection declared yet.
await this.appFrame!.evaluate(
(args: { owner: string; reader: string }) =>
(window as any).__testData.governProtected(args.owner, args.reader),
{ owner, reader },
);
await this.appFrame!.waitForFunction(
() => (window as any).__readFilter?.ready === true,
null,
{ timeout: 15000 },
);
});
Then('{string} ne voit aucune entité protégée d\'{string}', async function (this: FestipodWorld, _reader: string, _owner: string) {
const snap = await this.appFrame!.evaluate(() => (window as any).__readFilter.snapshot());
expect(snap.count, 'an unconnected reader sees none of the protected document').to.equal(0);
});
Then('{string} voit l\'entité publique d\'{string}', async function (this: FestipodWorld, _reader: string, _owner: string) {
const canRead = await this.appFrame!.evaluate(() => (window as any).__testData.canReadPublicProbe());
expect(canRead, 'the public entity is readable regardless of connection').to.equal(true);
});
When('l\'app déclare la connexion entre {string} et {string}', async function (this: FestipodWorld, a: string, b: string) {
await this.appFrame!.evaluate(
(args: { a: string; b: string }) => (window as any).__testData.connect(args.a, args.b),
{ a, b },
);
});
Then('{string} voit l\'entité protégée d\'{string}', async function (this: FestipodWorld, _reader: string, _owner: string) {
const { total } = (this as any).pc;
const snap = await this.appFrame!.evaluate(() => (window as any).__readFilter.snapshot());
expect(snap.count, 'a connected reader sees the whole protected document').to.equal(total);
});
Then('{string} voit toujours l\'entité publique d\'{string}', async function (this: FestipodWorld, _reader: string, _owner: string) {
const canRead = await this.appFrame!.evaluate(() => (window as any).__testData.canReadPublicProbe());
expect(canRead, 'the public entity stays readable after connecting').to.equal(true);
});
+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) ---
/**