feat!: retirer readUnion de la surface publiee, et lire un document par requete ancree
readUnion etait N requetes ancrees plus une politique de lot : une lecture qui echoue avalee en reponse vide, un document dont on ne detient pas la cle ecarte au lieu d'etre refuse, un regroupement en sacs de proprietes, et le filtrage du prefixe reserve. Rien en amont ne repond a cette composition, et son annotation citait sparql_query -- une citation exacte attachee a une affirmation fausse. Une composition ne s'aligne pas sur une fonction. Ce qui reste est ce qu'il y avait dessous : docs.sparqlQuery ancre sur un document, qui va deja chercher la cle servie par le magasin public -- donc une reference nue suffit toujours pour lire un document public, sans rien changer a useShapeQuery. Lire plusieurs documents, c'est cet appel une fois par document, et la tolerance a une lecture qui echoue revient a l'appelant, la ou l'amont la laisse : une requete ancree sur un depot que le verifier ne detient pas rejette. Cote src, l'unique lecture interne restante devient read-document.ts : un document, une requete ancree, precedee de l'ouverture du depot -- barriere que index-deposit consulte pour distinguer une absence d'un depot non synchronise. Le harnais e2e et l'exemple composent la leur, avec leur catch visible au point d'appel. Version majeure : 1.0.0-dev.8.
This commit is contained in:
@@ -40,8 +40,8 @@ import { insertObject } from "../src/surface/orm";
|
||||
import type { InsertObjectLike } from "../src/model/types";
|
||||
import type { BaseType, ShapeType } from "@ng-org/shex-orm";
|
||||
import { post, postToDocument, read as readInbox } from "../src/surface/inbox";
|
||||
import { readUnion } from "../src/surface/read-model";
|
||||
import { sparqlUpdate } from "../src/surface/docs";
|
||||
import { readDocumentSubjects } from "../src/surface/read-document";
|
||||
import { sparqlQuery, sparqlUpdate } from "../src/surface/docs";
|
||||
import type { Nuri } from "../src/model/types";
|
||||
|
||||
/**
|
||||
@@ -242,7 +242,7 @@ function makeFakeNg() {
|
||||
if (query.includes(`<${SHIM}:contains>`)) {
|
||||
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:contains`).map((q) => ({ e: { value: q.o } })) } };
|
||||
}
|
||||
// Anchored per-doc read (readUnion `SELECT ?s ?p ?o`) — the document's content.
|
||||
// The anchored per-document read (`SELECT ?s ?p ?o`) — the document's content.
|
||||
return {
|
||||
results: {
|
||||
bindings: quads
|
||||
@@ -281,12 +281,25 @@ async function write(doc: Nuri, p: string, o: string): Promise<void> {
|
||||
await sparqlUpdate(SESSION.sessionId, `INSERT DATA { <${doc}> <${p}> "${o}" }`, doc, "test");
|
||||
}
|
||||
|
||||
/** The values `p` carries in the documents `docs`, as the current holder reads them. */
|
||||
async function readValues(docs: Nuri[], p: string): Promise<string[]> {
|
||||
const subjects = await readUnion(docs);
|
||||
/**
|
||||
* The values `p` carries in `doc`, as the current holder reads them.
|
||||
*
|
||||
* ONE document, one anchored query - what `readUnion` did per entry before it was
|
||||
* removed in `1.0.0-dev.8`. The difference that shows up all over this file: a document
|
||||
* the holder may NOT reach used to be dropped from the batch and read as `[]`, and now
|
||||
* the read is REFUSED. Both say "you cannot read this"; only the second says it out
|
||||
* loud, which is why {@link expectRefused} exists rather than another `toEqual([])`.
|
||||
*/
|
||||
async function readValues(doc: Nuri, p: string): Promise<string[]> {
|
||||
const subjects = await readDocumentSubjects(doc);
|
||||
return subjects.flatMap((s) => s.props[p] ?? []);
|
||||
}
|
||||
|
||||
/** Assert the current holder is REFUSED `doc`: the answer to "I hold no cap for this". */
|
||||
async function expectRefused(doc: Nuri, p: string): Promise<void> {
|
||||
await expect(readValues(doc, p)).rejects.toThrow(/does not hold this document/i);
|
||||
}
|
||||
|
||||
/**
|
||||
* Alice's world: a protected document holding a secret, and a public document that
|
||||
* REFERS to it by bare NURI.
|
||||
@@ -326,13 +339,13 @@ test("Bob: reads the public document, sees the reference, and cannot read throug
|
||||
// store, so the store serves him its cap — he never received a key from anyone.
|
||||
|
||||
// He reads the public document and finds the reference.
|
||||
const refs = await readValues([pubDoc], REFERS_TO);
|
||||
const refs = await readValues(pubDoc, REFERS_TO);
|
||||
const ref = referenceFoundIn(refs);
|
||||
expect(ref).toBe(protDoc); // he can NAME Alice's protected document
|
||||
|
||||
// …and that is all it gets him: no cap, no read. Publication is NOT recursive.
|
||||
expect(hasCap(ref)).toBe(false);
|
||||
expect(await readValues([ref], SECRET)).toEqual([]);
|
||||
await expectRefused(ref, SECRET);
|
||||
});
|
||||
|
||||
test("Charlie: same public document, same reference — and he reads through it", async () => {
|
||||
@@ -348,10 +361,10 @@ test("Charlie: same public document, same reference — and he reads through it"
|
||||
setCurrentUser("charlie");
|
||||
await readInbox(CHARLIE_INBOX); // processing the inbox files the cap
|
||||
|
||||
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
|
||||
const ref = referenceFoundIn(await readValues(pubDoc, REFERS_TO));
|
||||
expect(ref).toBe(protDoc);
|
||||
expect(hasCap(ref)).toBe(true);
|
||||
expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]);
|
||||
expect(await readValues(ref, SECRET)).toEqual(["the-protected-content"]);
|
||||
});
|
||||
|
||||
test("the ONLY difference between Bob and Charlie is each of them holds", async () => {
|
||||
@@ -363,13 +376,18 @@ test("the ONLY difference between Bob and Charlie is each of them holds", async
|
||||
await share(protDoc, "charlie");
|
||||
|
||||
setCurrentUser("bob");
|
||||
const bobSees = await readValues([protDoc], SECRET);
|
||||
// Obtained under BOB, so what is compared below is each holder's own answer to the
|
||||
// same question. His is a refusal; hers is the content.
|
||||
const bobIsRefused = await readValues(protDoc, SECRET).then(
|
||||
() => false,
|
||||
() => true,
|
||||
);
|
||||
|
||||
setCurrentUser("charlie");
|
||||
await readInbox(CHARLIE_INBOX);
|
||||
const charlieSees = await readValues([protDoc], SECRET);
|
||||
const charlieSees = await readValues(protDoc, SECRET);
|
||||
|
||||
expect(bobSees).toEqual([]);
|
||||
expect(bobIsRefused).toBe(true);
|
||||
expect(charlieSees).toEqual(["the-protected-content"]);
|
||||
});
|
||||
|
||||
@@ -381,10 +399,10 @@ test("dynamic: a cap delivered to Bob's inbox makes the refused document readabl
|
||||
const BOB_INBOX = await userInbox("bob", "protected");
|
||||
|
||||
setCurrentUser("bob");
|
||||
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
|
||||
const ref = referenceFoundIn(await readValues(pubDoc, REFERS_TO));
|
||||
|
||||
// Before: named, unreadable.
|
||||
expect(await readValues([ref], SECRET)).toEqual([]);
|
||||
await expectRefused(ref, SECRET);
|
||||
|
||||
// A reader that re-reads whenever what it holds changes — this is exactly what
|
||||
// `watchShape` wires internally, played here on an ad-hoc read.
|
||||
@@ -392,7 +410,13 @@ test("dynamic: a cap delivered to Bob's inbox makes the refused document readabl
|
||||
let latest: string[] = [];
|
||||
const unsub = getCaps().onChange(() => {
|
||||
reread += 1;
|
||||
void readValues([ref], SECRET).then((v) => (latest = v));
|
||||
// While the cap is still missing the read is REFUSED rather than empty, and a reader
|
||||
// keeps what it had - which is what any real view does, and what makes `latest`
|
||||
// meaningful when the cap finally lands.
|
||||
void readValues(ref, SECRET).then(
|
||||
(v) => (latest = v),
|
||||
() => {},
|
||||
);
|
||||
});
|
||||
|
||||
// Alice delivers the cap. Bob's client processes his inbox — the only thing that
|
||||
@@ -410,7 +434,7 @@ test("dynamic: a cap delivered to Bob's inbox makes the refused document readabl
|
||||
// …and the read that was empty now yields the content.
|
||||
expect(hasCap(ref)).toBe(true);
|
||||
expect(latest).toEqual(["the-protected-content"]);
|
||||
expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]);
|
||||
expect(await readValues(ref, SECRET)).toEqual(["the-protected-content"]);
|
||||
unsub();
|
||||
});
|
||||
|
||||
@@ -420,14 +444,27 @@ test("dynamic: a cap delivered to Bob's inbox makes the refused document readabl
|
||||
// (`PublicRepoLinkV0`, `engine/net/src/types.rs:5098`) — so the same value transmitted
|
||||
// (a bare reference) yields a different outcome depending on the store, and never
|
||||
// because a key travelled.
|
||||
//
|
||||
// It is also the property `readUnion`'s removal rests on. `readUnion([doc])` was how a
|
||||
// stranger reached a public index from its reference alone; what actually did the work
|
||||
// was the anchored `docs.sparqlQuery` underneath, which fetches the public store's key
|
||||
// at its own door (`surface/docs.ts`). This test exercises exactly that path with no
|
||||
// batch above it, which is why the key fetch is asserted here rather than assumed.
|
||||
test("a bare reference is enough for a PUBLIC document, and not for a protected one", async () => {
|
||||
inject();
|
||||
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
|
||||
|
||||
setCurrentUser("bob");
|
||||
// Bob has been given nothing but the two NURIs.
|
||||
expect((await readValues([pubDoc], REFERS_TO)).length).toBe(1);
|
||||
expect(await readValues([protDoc], SECRET)).toEqual([]);
|
||||
// Bob has been given nothing but the two NURIs — no key, for either of them.
|
||||
expect(getCaps().isEnforcing()).toBe(true);
|
||||
expect(hasCap(pubDoc)).toBe(false);
|
||||
expect(hasCap(protDoc)).toBe(false);
|
||||
|
||||
expect((await readValues(pubDoc, REFERS_TO)).length).toBe(1);
|
||||
// …and the key he now holds for it came from the DOCUMENT, on the way through the
|
||||
// anchored read. Nothing in this file handed it to him.
|
||||
expect(hasCap(pubDoc)).toBe(true);
|
||||
await expectRefused(protDoc, SECRET);
|
||||
|
||||
// And what he obtained for the public one is a READ grant, not a write right: a
|
||||
// public store serves its read cap, no store hands out the write cap.
|
||||
@@ -449,7 +486,7 @@ test("a Link is APPLIED durably: the cap survives with the inbox emptied", async
|
||||
// Bob connects: the library restores + drains, with nothing asked of the app.
|
||||
setCurrentUser("bob");
|
||||
await connectedUser();
|
||||
expect(await readValues([protDoc], SECRET)).toEqual(["the-protected-content"]);
|
||||
expect(await readValues(protDoc, SECRET)).toEqual(["the-protected-content"]);
|
||||
|
||||
// Now EMPTY the inbox — as a consumed queue would be — and drop every in-memory
|
||||
// cap, then re-arm the emulation so the boundary is actually in force again.
|
||||
@@ -468,7 +505,7 @@ test("a Link is APPLIED durably: the cap survives with the inbox emptied", async
|
||||
// Connecting restores it — from the User branch, since the inbox has nothing left.
|
||||
await connectedUser();
|
||||
expect(hasCap(protDoc)).toBe(true);
|
||||
expect(await readValues([protDoc], SECRET)).toEqual(["the-protected-content"]);
|
||||
expect(await readValues(protDoc, SECRET)).toEqual(["the-protected-content"]);
|
||||
});
|
||||
|
||||
test("connecting a user that does not exist provisions nothing", async () => {
|
||||
@@ -494,7 +531,7 @@ test("a deposit is addressed to an inbox, never to a document", async () => {
|
||||
await expect(post(protDoc, { payload: { x: 1 }, ts: 1 })).rejects.toThrow(/not an inbox/i);
|
||||
|
||||
setCurrentUser("alice");
|
||||
expect(await readValues([protDoc], SECRET)).toEqual(["alice's own"]); // untouched
|
||||
expect(await readValues(protDoc, SECRET)).toEqual(["alice's own"]); // untouched
|
||||
});
|
||||
|
||||
// REGRESSION (second adversarial pass). The write guard reads ownership from the store
|
||||
@@ -538,10 +575,10 @@ test("the owner writes to her own public note, even after opening it from its re
|
||||
setCurrentUser("bob");
|
||||
await createEntityDoc("bob", "private"); // re-arms the emulation
|
||||
setCurrentUser("alice");
|
||||
await readValues([pubDoc], SECRET); // this is what files the served cap
|
||||
await readValues(pubDoc, SECRET); // this is what files the served cap
|
||||
|
||||
await write(pubDoc, SECRET, "v2"); // must not throw
|
||||
expect((await readValues([pubDoc], SECRET)).includes("v2")).toBe(true);
|
||||
expect((await readValues(pubDoc, SECRET)).includes("v2")).toBe(true);
|
||||
});
|
||||
|
||||
// Direction 2 — TOO LAX. A cap received in an inbox let its recipient WRITE into the
|
||||
@@ -557,11 +594,11 @@ test("a cap received in an inbox reads, and does NOT write", async () => {
|
||||
|
||||
setCurrentUser("bob");
|
||||
await readInbox(BOB_INBOX);
|
||||
expect(await readValues([protDoc], SECRET)).toEqual(["alice's own"]); // he reads it
|
||||
expect(await readValues(protDoc, SECRET)).toEqual(["alice's own"]); // he reads it
|
||||
await expect(write(protDoc, SECRET, "bob was here")).rejects.toThrow(/WRITE cap/i);
|
||||
|
||||
setCurrentUser("alice");
|
||||
expect(await readValues([protDoc], SECRET)).toEqual(["alice's own"]); // untouched
|
||||
expect(await readValues(protDoc, SECRET)).toEqual(["alice's own"]); // untouched
|
||||
});
|
||||
|
||||
// PER-DOCUMENT INBOXES. Upstream a repo carries `inbox: Option<PrivKey>` and its
|
||||
@@ -726,18 +763,37 @@ test("concurrent opens by a NON-owner are all refused, and leave no residue", as
|
||||
expect(await documentInboxAddress(doc)).toBe(aliceInbox);
|
||||
});
|
||||
|
||||
test("the inbox address is machinery: it never surfaces as the document's data", async () => {
|
||||
test("the inbox address is machinery: a SUBJECT beside the data, never mixed into it", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const doc = await createEntityDoc("alice", "public");
|
||||
await write(doc, SECRET, "s1");
|
||||
await openDocumentInbox(doc);
|
||||
|
||||
// The consumer read returns the entity's properties and nothing of the compartment
|
||||
// that carries the address — the Header branch is beside the content, not in it.
|
||||
const subjects = await readUnion([doc]);
|
||||
// Both halves are asserted, because `readUnion`'s removal changed which one an
|
||||
// APPLICATION sees. The compartment is a distinct reserved SUBJECT, so it is never
|
||||
// mixed into the entity's properties whichever read is used - that is the property
|
||||
// that protects the data, and it is structural.
|
||||
const subjects = await readDocumentSubjects(doc);
|
||||
const props = subjects[0]?.props ?? {};
|
||||
expect(Object.keys(props)).toEqual([SECRET]);
|
||||
|
||||
// The filter that DROPS it, however, lives in this library's internal read. A raw
|
||||
// anchored `docs.sparqlQuery` - which is what an application composes now that
|
||||
// `readUnion` is gone - returns the Header-branch rows too. That is not a gain of
|
||||
// rights: the two registers an entity document carries (`inboxAddress`,
|
||||
// `exposedReadCap`) are the compartment meant for what any reader may see, and the
|
||||
// cap-bearing `inboxCap` register lives in its owner's PRIVATE store, not here.
|
||||
const raw = await sparqlQuery(
|
||||
SESSION.sessionId,
|
||||
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
|
||||
undefined,
|
||||
doc,
|
||||
"test",
|
||||
);
|
||||
const rawSubjects = (raw as { results: { bindings: Array<{ s: { value: string } }> } })
|
||||
.results.bindings.map((b) => b.s.value);
|
||||
expect(rawSubjects).toContain(`${SHIM}:headerBranch`);
|
||||
});
|
||||
|
||||
// CONNECTING APPLIES WHAT WAS DEPOSITED — and the honest scope of that claim.
|
||||
@@ -775,7 +831,7 @@ test("connecting applies the Links waiting for me, and leaves consumer deposits
|
||||
await connectedUser();
|
||||
|
||||
expect(hasCap(carolDoc)).toBe(true); // the Link was applied
|
||||
expect(await readValues([protDoc], SECRET)).toEqual([]); // (protDoc holds no secret here)
|
||||
expect(await readValues(protDoc, SECRET)).toEqual([]); // (protDoc holds no secret here)
|
||||
const left = await readInbox(docInbox);
|
||||
expect(left.map((d) => d.payload)).toEqual([{ onTheDocument: true }]); // consumer data stays
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user