Ng eventually #1

Open
Sylvain wants to merge 110 commits from ng-eventually into main
9 changed files with 184 additions and 2 deletions
Showing only changes of commit 337a1e000d - Show all commits
@@ -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.
+5
View File
@@ -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.
+9
View File
@@ -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 `<FilterProbe>` 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.
@@ -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.
@@ -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) ---
/**