Probe a protected ENTITY, not the protected store, in the connections scenario

The scenario reads "an account not connected to another does not read its
protected ENTITY, then reads it after connecting", but the probe was reading
`did🆖${protected_store_id}` — the STORE document — and writing its test
entities straight into it.

Under an ACL that shortcut was harmless. Under key possession it is wrong, and
for a reason the model states outright: sharing a store capability would hand
over everything the store contains, present and future. The unit of sharing is
the document. `declareConnections` therefore shares the keys of entity
documents, the store is not one of them, and the reader legitimately saw
nothing. The code was right; the probe was standing in the store for an entity.
Writing several entities into a store-level document also broke this repo's own
one-document-per-entity rule.

The probe now creates a real protected entity document, writes the entity there,
and mounts its subscription on THAT document.

A second defect surfaced while fixing the first: `connect()` asserted both
directions from the READER's session, but a capability can only be shared by
whoever holds it, and `capFor` answers for the connected identity alone — so the
owner-side call returned early having shared nothing. Each direction is now
asserted from its own session, and the reader drains its inbox afterwards.

The reader is a genuine second identity (per-run identifiers give it its own
account, stores, inbox and keyring), not the same one in disguise — a test that
passes because the state is unreal proves nothing. Checked by breaking it on
purpose: without `connect` it fails with `expected +0 to equal 1`.

Also recorded, and worth knowing before writing another probe: `resetCaps()`
clears the "a capability was issued" flag, which disarms the read filter
entirely — it has to run BEFORE the first mint, or reads go straight through and
the reader sees everything.

tsc 0, @ui 7/7, target scenario green, read-filter not regressed.
This commit is contained in:
Sylvain Duchesne
2026-08-03 14:10:44 +02:00
parent c1817607b4
commit 47af46fd09
7 changed files with 158 additions and 2402 deletions
+1
View File
@@ -6,3 +6,4 @@
## Raw markers (consolidate into blocks, then delete)
- TOUCHED src/modules/event/steps/data/reconnexion.steps.ts @2026-08-03 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/test-harness/harness-ng.tsx @2026-08-03 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/modules/workshop/steps/data/protected-connections.steps.ts @2026-08-03 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
+1
View File
@@ -8,3 +8,4 @@
- TOUCHED src/shared/utils/ngBootstrap.ts @2026-08-03 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/utils/ngSession.ts @2026-08-03 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/context/FestipodDataContext.tsx @2026-08-03 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
- TOUCHED src/shared/shapes/shex/festipodShapes.shex @2026-08-03 (session 0b064e8b-1717-421f-a20e-a4318ad217b1)
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -3,54 +3,70 @@ 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.
// ENTITY 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 probed document is a real per-entity document (`createEntityDoc(owner,
// 'protected')`, rule_document-per-entity), NOT the protected STORE document: the
// unit of sharing is the document, and `declareConnections` hands over entity
// documents' keys. Handing over a store's key would give away everything it holds,
// present and future — the model refuses that, so a store-level probe can only ever
// read 0 after connecting.
//
// `alice` and `bob` are two GENUINELY DISTINCT identities: each is derived from the
// scenario's fresh identifier, so each gets its own account, its own scope stores,
// its own inbox and its own set of held keys. `bob` holds nothing of `alice`'s until
// a key is delivered to its inbox.
/** The scenario's identity for a Gherkin handle ("alice"/"bob") — distinct per
* scenario, so nothing accumulates in the shared test wallet across runs. */
function identityFor(world: FestipodWorld, handle: string): string {
return `${(world as any).freshIdentifier}-${handle}`;
}
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;
// Store-root protected document (governed by governProtected/FilterProbe) —
// RAW path so the participations land in that document, not per-entity docs.
td.rawJoin('urn:pc:event', 'urn:pc:p1');
td.rawJoin('urn:pc:event', 'urn:pc:p2');
});
await this.appFrame!.waitForFunction(
() => {
const ps = [...(window as any).__testData.rawParticipations];
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.rawParticipations].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 },
const ownerId = identityFor(this, owner);
// `owner` creates its OWN protected entity document and writes its entity into
// it — the creator is the one holding the key, no declaration involved.
const { total } = await this.appFrame!.evaluate(
(id: string) => (window as any).__testData.setupProtectedEntity(id),
ownerId,
);
(this as any).pc = { owner: ownerId, total };
expect(total, 'the protected entity document holds an entity').to.be.greaterThan(0);
// <FilterProbe> mounts over that document; wait for the reactive set to carry the
// entity while the OWNER is still the connected identity (it holds the key, so it
// reads its own document). Observes the pushed reactive state — no broker re-read.
await this.appFrame!.waitForFunction(
() => (window as any).__readFilter?.ready === true,
null,
{ timeout: 15000 },
);
await this.appFrame!.waitForFunction(
(expected: number) => (window as any).__readFilter.snapshot().count === expected,
total,
{ timeout: 20000 },
);
});
Given('le compte {string} est courant sans connexion à {string}', async function (this: FestipodWorld, reader: string, owner: string) {
const readerId = identityFor(this, reader);
const ownerId = identityFor(this, owner);
(this as any).pc = { ...(this as any).pc, reader: readerId, owner: ownerId };
// `owner` publishes its public probe and hands the link to `reader`, who becomes
// the connected identity — holding no key of the protected entity document.
await this.appFrame!.evaluate(
(args: { owner: string; reader: string }) =>
(window as any).__testData.governProtected(args.owner, args.reader),
{ owner: ownerId, reader: readerId },
);
});
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);
expect(snap.count, 'an unconnected reader sees none of the protected entity document').to.equal(0);
});
Then('{string} voit l\'entité publique d\'{string}', async function (this: FestipodWorld, _reader: string, _owner: string) {
@@ -61,14 +77,14 @@ Then('{string} voit l\'entité publique d\'{string}', async function (this: Fest
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 },
{ a: identityFor(this, a), b: identityFor(this, 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);
expect(snap.count, 'a connected reader sees the whole protected entity document').to.equal(total);
});
Then('{string} voit toujours l\'entité publique d\'{string}', async function (this: FestipodWorld, _reader: string, _owner: string) {
@@ -22,6 +22,8 @@ fp:Event {
// rdfs:comment "Name of the event host or relay" ;
fp:hostInitials xsd:string ?
// rdfs:comment "Initials of the event host" ;
fp:inbox xsd:string ?
// rdfs:comment "NURI of this event's own inbox, published so that anyone holding the event can deposit into it (an inbox belongs to someone and its address must be given, never derived)" ;
}
fp:UserProfile {
+80 -29
View File
@@ -15,10 +15,13 @@ import { FestipodDataProvider, useFestipodData } from '../context/FestipodDataCo
// useShape routed through the lib (SDK-identical surface); caps from /polyfill.
import { useShape, docs, inbox as docsInbox, isNuri } from '@ng-eventually/client';
import type { Nuri } from '@ng-eventually/client';
import { getCaps, getCurrentUser, setCurrentUser, resetCaps } from '@ng-eventually/client/polyfill';
import { getCaps, getCurrentUser, setCurrentUser, resetCaps, connectedUser } from '@ng-eventually/client/polyfill';
// Relationship is an app concept: directed grants come from the app's own module.
import { declareConnections, resetConnections } from '../utils/connections';
import { hostInboxNuri as regInboxNuri } from '../data/registration';
// The write side of one-document-per-entity: an entity's RDF goes straight into
// its OWN document (rule_document-per-entity), never into a store-level document.
import { writeEntity, ENTITY_TYPE, iri, bool } from '../data/entityWrites';
import type { DeepSignalSet } from '@ng-eventually/client';
// doc_create goes through the lib's `docs` primitive (T01.a): it calls the REAL
// injected `ng` directly (never the public proxy), so postMessage marshaling
@@ -134,9 +137,11 @@ function ConnectedHarness() {
const participations = useShape(FpParticipationShapeType, protectedNuri) as DeepSignalSet<FpParticipation>;
const [bridgeReady, setBridgeReady] = useState(false);
// Read-filter validation: once a ReadCap policy is active, <FilterProbe> mounts
// a useShape that returns the read-filtered VIEW.
const [filterActive, setFilterActive] = useState(false);
// Read-filter validation: <FilterProbe> mounts a useShape over THIS document and
// returns the read-filtered VIEW of it. Which document depends on the probe: the
// store-root one for the mono-store read-filter scenario, a real per-entity
// document for the protected-connections one.
const [filterDoc, setFilterDoc] = useState<Nuri | null>(null);
// Stopgap multi-store validation: a doc created on demand via doc_create,
// mounted into a real useShape({graphs}) by <SmokeProbe>.
const [smokeDoc, setSmokeDoc] = useState<string | null>(null);
@@ -247,13 +252,14 @@ function ConnectedHarness() {
await AD().leaveEvent(eventId, userId);
},
// --- RAW store-root path (workshop ReadCap/connection probes ONLY) -----
// The per-document ReadCap probe scenarios (read-filter, protected-
// connections) govern the STORE-ROOT protected document (`documentNuri` =
// protectedNuri) via <FilterProbe>, so they need participations written
// into THAT document — not the per-entity docs the app path uses. These
// raw helpers write/read the store-root ORM set directly, keeping those
// probes on the exact document they govern.
// --- RAW store-root path (the mono-store read-filter probe ONLY) -------
// `read-filter.feature` states the filter's all-or-nothing behaviour on ONE
// document holding SEVERAL items — the mono-store layout — so it governs the
// STORE-ROOT protected document (`documentNuri` = protectedNuri) via
// <FilterProbe> and needs participations written into THAT document. These
// raw helpers keep that probe on the exact document it governs. Nothing else
// may use them: the app (and the protected-connections probe) writes one
// document per entity (rule_document-per-entity).
get rawParticipations() { return participations; },
rawJoin(eventId: string, userId: string) {
const already = [...participations].some(p => p.event === eventId && p.user === userId);
@@ -574,7 +580,7 @@ function ConnectedHarness() {
setCurrentUser(reader);
getCaps().open(protectedNuri, 'protected');
setCurrentUser(user);
setFilterActive(true);
setFilterDoc(protectedNuri);
},
/** Switch the current user (does the user now hold the document's cap?). */
@@ -584,37 +590,82 @@ function ConnectedHarness() {
// --- 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:
// PROTECTED ENTITY DOCUMENT owned by `owner` is:
// - hidden from an UNCONNECTED principal (only owner holds its key);
// - revealed once the app declares the connection owner↔reader;
// - a PUBLIC document stays readable throughout.
// Each document's key is filed WHILE its owner is the connected identity —
// `getCaps().open(doc, scope)` files under the current holder, exactly as the
// SDK's own `createEntityDoc` does. The PUBLIC probe is published as a repo
// The unit of sharing is the DOCUMENT, and each entity is its own document
// (rule_document-per-entity) — so the probe exercises a real per-entity
// document from `createEntityDoc(owner, 'protected')`, which is exactly what
// `declareConnections` hands over. The PUBLIC probe is published as a repo
// LINK and that link is then handed to the reader: public means "whoever has
// the link reads", not "everyone reads regardless of keys". <FilterProbe>
// exposes the read-filtered VIEW over the protected participations doc.
// exposes the read-filtered VIEW over the owner's protected entity document.
// `connect` calls the app's declareConnections — the domain sharing act.
governProtected(owner: string, reader: string) {
if (!protectedNuri) throw new Error('no protected_store_id in session');
/**
* Create `owner`'s PROTECTED ENTITY document and write its entity into it,
* as `owner` — the creator is the one who holds the key, so possession is
* established by CREATING, not by declaring anything. Mounts <FilterProbe>
* over THAT document. Returns its NURI and how many entities were written
* (the count the reader must end up seeing).
*
* `resetCaps` runs FIRST: it also clears the enforcement flag, so the very
* next mint (this document's) is what arms the read filter.
*/
async setupProtectedEntity(owner: string) {
const reg = await import('../utils/storeRegistry');
resetCaps();
resetConnections(); // clear the app's relationship registry too
setCurrentUser(owner);
// The protected participations document (only its owner holds it at first).
getCaps().open(protectedNuri, 'protected');
const doc = await reg.createEntityDoc(owner, 'protected');
await writeEntity(doc, ENTITY_TYPE.participation, {
event: iri('urn:pc:event'),
user: iri('urn:pc:p1'),
isConfirmed: bool(true),
});
setFilterDoc(doc);
// One entity, one document — so one item is the whole document.
return { doc, total: 1 };
},
/**
* Bring up the UNCONNECTED reader: `owner` publishes the public probe as a
* repo link and hands it to `reader`, who becomes the connected identity.
* No cap of the protected entity document is handed over — that is what the
* connection is for. Runs AFTER `setupProtectedEntity` and deliberately does
* NOT reset caps: the owner's key on its own document must survive.
*/
governProtected(owner: string, reader: string) {
setCurrentUser(owner);
// A public entity document, published as a shareable repo link.
const publicLink = getCaps().publishRepoLink(PUBLIC_PROBE);
setCurrentUser(reader);
// The reader was handed that link — which is all "public" means here.
getCaps().learn(publicLink);
setFilterActive(true);
},
/** Declare a bilateral owner↔reader connection (domain sharing act). Each
* side asserts the other; only a two-sided link makes a session share its
* own protected documents' keys into the neighbour's inbox. */
async connect(a: string, b: string) {
await declareConnections([b], a); // a asserts b
await declareConnections([a], b); // b asserts a → bilateral link materializes
/**
* Declare a bilateral owner↔reader connection (domain sharing act) the way
* two real sessions would: each side asserts from ITS OWN session, because
* sharing a key requires HOLDING it and `capFor` answers for the connected
* identity alone. Reader asserts first (nothing to share yet), then the owner
* asserts back — that second call is the one that finds a two-sided link and
* hands its protected documents' keys to the reader's inbox. Finally the
* reader reconnects and `connectedUser()` drains that inbox, which is where
* the key actually lands among what the reader holds.
*/
async connect(owner: string, reader: string) {
const reg = await import('../utils/storeRegistry');
// The reader's own account + inbox, provisioned from the READER's session
// so what belongs to it is filed under it.
setCurrentUser(reader);
await reg.ensureAccount(reader);
await reg.walletInbox(reader);
await declareConnections([owner], reader); // reader asserts owner
setCurrentUser(owner);
await declareConnections([reader], owner); // bilateral → owner shares its keys
setCurrentUser(reader);
await connectedUser(); // the reader drains its inbox → it now holds the key
},
/** Does the CURRENT user hold the public entity document's key — the only
* question the model can answer — regardless of the protected one? */
@@ -749,7 +800,7 @@ function ConnectedHarness() {
return (
<>
<div id="harness-status">{bridgeReady ? 'READY' : 'LOADING_SHAPES'}</div>
{filterActive && protectedNuri && <FilterProbe documentNuri={protectedNuri} />}
{filterDoc && <FilterProbe documentNuri={filterDoc} />}
{smokeDoc && <SmokeProbe docNuri={smokeDoc} />}
{fanoutGraphs.length > 0 && <FanoutProbe graphs={fanoutGraphs} />}
{protectedActive && protectedNuri && <ProtectedProbe protectedNuri={protectedNuri} />}