From 337a1e000dd33d1cb146706cfafe8140297e2f03 Mon Sep 17 00:00:00 2001 From: Sylvain Duchesne Date: Fri, 3 Jul 2026 23:59:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(data):=20activate=20isolation=20=E2=80=94?= =?UTF-8?q?=20declare=20identity=20+=20connections=20to=20the=20SDK?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .project/concepts/app-architecture/_debt.md | 5 ++ .project/concepts/app-security/_debt.md | 5 ++ .project/concepts/bdd-testing/_debt.md | 9 +++ .project/concepts/functional-domain/_debt.md | 9 +++ .../features/protected-connections.feature | 19 +++++ .../steps/data/protected-connections.steps.ts | 75 +++++++++++++++++++ src/shared/context/AccountContext.tsx | 15 +++- src/shared/context/FestipodDataContext.tsx | 14 ++++ src/shared/test-harness/harness-ng.tsx | 35 ++++++++- 9 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 .project/concepts/bdd-testing/_debt.md create mode 100644 .project/concepts/functional-domain/_debt.md create mode 100644 src/modules/workshop/features/protected-connections.feature create mode 100644 src/modules/workshop/steps/data/protected-connections.steps.ts diff --git a/.project/concepts/app-architecture/_debt.md b/.project/concepts/app-architecture/_debt.md index 1ee887e..71e68be 100644 --- a/.project/concepts/app-architecture/_debt.md +++ b/.project/concepts/app-architecture/_debt.md @@ -7,3 +7,8 @@ - **why**: FestipodDataContext route les entités par scope via le SDK et NextGraphContext ne surface plus les store-ids. La structure (provider stack, invariant d'imports module→shared, app shell) est inchangée — touches incidentes, pas d'évolution architecturale. - **files**: src/shared/context/NextGraphContext.tsx, src/shared/context/FestipodDataContext.tsx - **verify (leaves à relire)**: aucune — knowledge_app-shell.md / knowledge_module-structure.md inchangés. Bloc à supprimer après relecture confirmatoire. + +## Block: T03.b — AccountContext déclare l'identité courante au SDK +- **why**: `AccountProvider` appelle désormais `setCurrentUser(normalizeUsername(username))` au login / au changement de compte (effet sur `username`) — appel d'IDENTITÉ SDK, pas une règle d'accès applicative. Structure (provider stack, invariant d'imports) inchangée : touche incidente sur le glue React. +- **files**: src/shared/context/AccountContext.tsx +- **verify (leaves à relire)**: aucune — knowledge_app-shell.md inchangé. Bloc à supprimer après relecture confirmatoire. diff --git a/.project/concepts/app-security/_debt.md b/.project/concepts/app-security/_debt.md index be6e470..a192095 100644 --- a/.project/concepts/app-security/_debt.md +++ b/.project/concepts/app-security/_debt.md @@ -7,3 +7,8 @@ - **why**: `NextGraphContext` ne journalise/expose plus les trois store-ids dans le contexte app (routés uniquement au point d'injection SDK). Renforce la doctrine "isolation déléguée au SDK, l'app ne manipule pas de store physique" — ne l'invalide pas. - **files**: src/shared/context/NextGraphContext.tsx - **verify (leaves à relire)**: knowledge_trust-model.md (confirmer : confiance dans le SDK, aucune manipulation de store côté app). Aucun changement de contenu attendu — relecture confirmatoire. + +## Block: T03.b — isolation ACTIVE (identité courante + acte de partage des connexions) +- **why**: L'app déclare désormais au SDK (a) l'IDENTITÉ courante au login (`AccountContext` → `setCurrentUser`) et (b) son graphe de CONNEXIONS (`FestipodDataContext` → `declareConnections` sur les friendships) — les deux actes DOMAINE qui rendent le filtre du SDK discriminant (private→propriétaire, protected→propriétaire+connexions, public→tous). L'app ne porte toujours AUCUNE règle d'accès elle-même ; elle affiche ce que le SDK laisse passer. Renforce knowledge_trust-model — ne l'invalide pas (l'app fournit juste au SDK le « qui lit » + « qui est connecté à qui » qui manquaient pour que la délégation soit effective). Write-guard : best-effort (chemins d'écriture réels passent par le vrai `ng`, non gardés) — couverture documentée côté lib (docs/simulation.md), PAS dans Festipod. +- **files**: src/shared/context/AccountContext.tsx, src/shared/context/FestipodDataContext.tsx +- **verify (leaves à relire)**: knowledge_trust-model.md — confirmer « isolation déléguée au SDK, aucune logique d'autorisation côté écran/contexte ». Nuance à vérifier : le contexte fournit maintenant identité + connexions au SDK (ce n'est pas un filtre applicatif, c'est le câblage domaine→SDK). Relecture confirmatoire. diff --git a/.project/concepts/bdd-testing/_debt.md b/.project/concepts/bdd-testing/_debt.md new file mode 100644 index 0000000..2852ef0 --- /dev/null +++ b/.project/concepts/bdd-testing/_debt.md @@ -0,0 +1,9 @@ +# Doc-debt — bdd-testing + +> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file. +> One block = one "big change": `why` + `files` + `verify` (leaves to review). + +## Block: T03.b — scénario @data « isolation protégée par connexions » +- **why**: Nouveau scénario @data (workshop) prouvant l'isolation ACTIVE via le SDK contre le vrai broker : un compte non connecté ne lit pas l'entité PROTÉGÉE d'un autre, la lit après `declareConnections`, lit la PUBLIQUE toujours. Nouveaux hooks harness (`governProtected`, `connect`, `canReadPublicProbe`) réutilisant `` sur le vrai set ORM. Suivent le contrat @data (mutation/persistance broker) — pas de nouvelle couche, pas de vestige source-grep. +- **files**: src/shared/test-harness/harness-ng.tsx, src/modules/workshop/features/protected-connections.feature, src/modules/workshop/steps/data/protected-connections.steps.ts +- **verify (leaves à relire)**: aucune — rule_test-layer-contracts.md / knowledge_data-layer-broker.md inchangés (scénario conforme au contrat @data). Bloc à supprimer après relecture confirmatoire. diff --git a/.project/concepts/functional-domain/_debt.md b/.project/concepts/functional-domain/_debt.md new file mode 100644 index 0000000..dcb9797 --- /dev/null +++ b/.project/concepts/functional-domain/_debt.md @@ -0,0 +1,9 @@ +# Doc-debt — functional-domain + +> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file. +> One block = one "big change": `why` + `files` + `verify` (leaves to review). + +## Block: T03.b — l'isolation par périmètre est désormais ACTIVE (confirmatoire) +- **why**: Le modèle produit public/protected/private (knowledge_data-scopes-and-discovery) devient effectivement appliqué : protected = propriétaire + connexions, public = tous, private = propriétaire. Le fait DOMAINE (les connexions) est déclaré au SDK par l'app ; le contenu doctrinal du périmètre est inchangé (le code converge vers la doctrine, ne l'invalide pas). Le fichier .feature ne fait que valider ce modèle. +- **files**: src/modules/workshop/features/protected-connections.feature +- **verify (leaves à relire)**: knowledge_data-scopes-and-discovery.md — confirmer que le triptyque public/protected/private + "connexions bilatérales" reste exact (aucun changement attendu). Bloc à supprimer après relecture confirmatoire. diff --git a/src/modules/workshop/features/protected-connections.feature b/src/modules/workshop/features/protected-connections.feature new file mode 100644 index 0000000..d279238 --- /dev/null +++ b/src/modules/workshop/features/protected-connections.feature @@ -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" diff --git a/src/modules/workshop/steps/data/protected-connections.steps.ts b/src/modules/workshop/steps/data/protected-connections.steps.ts new file mode 100644 index 0000000..e5d70bf --- /dev/null +++ b/src/modules/workshop/steps/data/protected-connections.steps.ts @@ -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 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); +}); diff --git a/src/shared/context/AccountContext.tsx b/src/shared/context/AccountContext.tsx index 66f50a1..0ba2353 100644 --- a/src/shared/context/AccountContext.tsx +++ b/src/shared/context/AccountContext.tsx @@ -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(() => 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); diff --git a/src/shared/context/FestipodDataContext.tsx b/src/shared/context/FestipodDataContext.tsx index 7faa6ae..1ee0305 100644 --- a/src/shared/context/FestipodDataContext.tsx +++ b/src/shared/context/FestipodDataContext.tsx @@ -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( diff --git a/src/shared/test-harness/harness-ng.tsx b/src/shared/test-harness/harness-ng.tsx index 7cfd234..41d9b2e 100644 --- a/src/shared/test-harness/harness-ng.tsx +++ b/src/shared/test-harness/harness-ng.tsx @@ -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. 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) --- /**