737729c9ce
Le nom @ng-eventually/sdk entrait en collision avec le SDK de NextGraph, dont ce paquet est justement un polyfill. Impossible d'écrire « le SDK » sans lever l'ambiguïté à chaque phrase — et le contrat publié, lu par une application, était le pire endroit pour laisser traîner ça. packages/sdk → packages/polyfill, @ng-eventually/sdk → @ng-eventually/polyfill, contract_sdk-surface → contract_polyfill-surface, e2e/sdk-entry.ts → e2e/polyfill-entry.ts, docs/sdk-reference.md → docs/polyfill-reference.md. Les occurrences de « SDK » qui désignent celui de NextGraph restent intactes, y compris les chemins dans nextgraph-rs (sdk/js/orm, sdk/js/web). Le tri s'est fait occurrence par occurrence, pas par substitution. Le contrat énonce désormais son identité en une phrase : « This package is a polyfill of NextGraph's SDK. »
687 lines
31 KiB
TypeScript
687 lines
31 KiB
TypeScript
/**
|
|
* Cross-user access — the scenario that proves the model end to end.
|
|
*
|
|
* Alice owns a PROTECTED document and a PUBLIC one, and the public one carries a
|
|
* REFERENCE to the protected one. Then:
|
|
*
|
|
* - **Bob** has the public document's link. He reads it, sees the reference, and
|
|
* cannot read what it points at. Naming is not reading, and publication is
|
|
* **not recursive**: a public object may point at private content without
|
|
* disclosing it.
|
|
* - **Charlie** has the public document's link AND was given the protected
|
|
* document's cap. Same reference, same path — he reads through it.
|
|
* - **Bob, dynamically**: Alice delivers the cap to Bob's inbox. Processing the
|
|
* inbox files it, which fires the held-caps signal, which re-runs the read — the
|
|
* protected document appears with nothing else happening.
|
|
*
|
|
* The difference between Bob and Charlie is ONLY each of them holds. There is
|
|
* no authorization list anywhere, and nobody was named to the registry.
|
|
*/
|
|
import { getCaps } from "../src/shared-wallet/bootstrap";
|
|
import { test, expect, mock, afterAll } from "bun:test";
|
|
import {
|
|
createEntityDoc,
|
|
resetRegistryCache,
|
|
resolveWriteGraph,
|
|
userInbox,
|
|
} from "../src/shared-wallet/account-registry";
|
|
import { documentInboxAddress, openDocumentInbox } from "../src/emulated-verifier/branch-registers";
|
|
import type { RegistrySession } from "../src/shared-wallet/account-registry";
|
|
import { configure } from "../src/index";
|
|
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
|
|
import { connectedUser } from "../src/emulated-verifier/connect";
|
|
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
|
|
import { share } from "../src/surface/inbox";
|
|
import { post, postToDocument, read as readInbox } from "../src/surface/inbox";
|
|
import { readUnion } from "../src/surface/read-model";
|
|
import { sparqlUpdate } from "../src/surface/docs";
|
|
import type { Nuri } from "../src/model/types";
|
|
|
|
/**
|
|
* Do I hold this document's cap? Possession, asked of the internal registry — the
|
|
* polyfill door stopped publishing this (see `polyfill.ts`), because as an app-facing
|
|
* question it reads like "may I read this?" and a public store's document answers
|
|
* `false` until something has asked for its cap.
|
|
*/
|
|
function hasCap(nuri: Nuri): boolean {
|
|
return getCaps().capFor(nuri) !== undefined;
|
|
}
|
|
|
|
afterAll(() => {
|
|
resetConfig();
|
|
resetStoreRegistry();
|
|
resetCaps();
|
|
setCurrentUser(null);
|
|
});
|
|
|
|
const SESSION: RegistrySession = { sessionId: "sid-x", privateStoreId: "PRIV-X" };
|
|
const SHIM = "urn:ng-eventually:shim";
|
|
const INBOX = "urn:ng-eventually:inbox";
|
|
/** The predicate Alice uses to point from her public doc at her protected one. */
|
|
const REFERS_TO = "urn:e2e:refersTo";
|
|
const SECRET = "urn:e2e:secret";
|
|
|
|
interface Quad { g: string; s: string; p: string; o: string }
|
|
|
|
function unescapeLiteral(s: string): string {
|
|
let out = "";
|
|
for (let i = 0; i < s.length; i++) {
|
|
if (s[i] === "\\" && i + 1 < s.length) {
|
|
const next = s[++i];
|
|
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!;
|
|
} else out += s[i];
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** A stateful fake `ng`: the shim SPARQL, the inbox SPARQL, and the anchored
|
|
* per-doc `?s ?p ?o` read the read-model uses. */
|
|
function makeFakeNg() {
|
|
const quads: Quad[] = [];
|
|
let docCounter = 0;
|
|
|
|
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
|
|
|
|
const sparql_update = mock(async (...a: unknown[]) => {
|
|
const query = a[1] as string;
|
|
const anchor = a[2] as string | undefined;
|
|
if (!anchor) return undefined;
|
|
// `DELETE WHERE { <s> <p> ?var }` — the form the lib uses to REPLACE a value
|
|
// (see docs/decisions/sparql-delete-for-orm-objects.md). Without this arm the
|
|
// fake would treat the delete as an insert and the replacement would silently
|
|
// become an accumulation — the exact bug a replacement exists to prevent.
|
|
const del = query.match(/^\s*DELETE\s+WHERE\s*\{\s*<([^>]+)>\s+<([^>]+)>\s+\?/);
|
|
if (del) {
|
|
const [s0, p0] = [del[1]!, del[2]!];
|
|
for (let i = quads.length - 1; i >= 0; i--) {
|
|
const q = quads[i]!;
|
|
if (q.g === anchor && q.s === s0 && q.p === p0) quads.splice(i, 1);
|
|
}
|
|
return undefined;
|
|
}
|
|
// `INSERT DATA { GRAPH <g> { … } }` — the shape the store-ROOT pointer write uses.
|
|
// Without this arm the first `<…>` in the body is the GRAPH NAME, so the pointer was
|
|
// stored with the graph as its subject and its predicate as its object. The pointer
|
|
// SELECT then found nothing, every cold `resolveShimDoc` forked a NEW shim, and the
|
|
// suite never noticed because the module cache carried the previous answer. Added
|
|
// 2026-08-10; the other fakes had it already.
|
|
const gm = query.match(/GRAPH\s+<([^>]+)>\s*\{([\s\S]*)\}/);
|
|
const body = gm
|
|
? gm[2]!
|
|
: query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
|
|
const sm = body.match(/<([^>]+)>/);
|
|
if (!sm) return undefined;
|
|
const s = sm[1]!;
|
|
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
|
|
let m: RegExpExecArray | null;
|
|
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
|
|
while ((m = pairRe.exec(after)) !== null) {
|
|
const p = m[1] ?? (query.includes(`${INBOX}:Deposit`) ? `${INBOX}:Deposit` : `${SHIM}:Account`);
|
|
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
|
|
quads.push({ g: anchor, s, p, o });
|
|
}
|
|
return undefined;
|
|
});
|
|
|
|
const sparql_query = mock(async (...a: unknown[]) => {
|
|
const query = a[1] as string;
|
|
const anchor = a[3] as string | undefined;
|
|
if (query.includes(`<${SHIM}:shimDoc>`)) {
|
|
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:shimDoc`).map((q) => ({ shimDoc: { value: q.o } })) } };
|
|
}
|
|
if (query.includes(`<${SHIM}:id>`)) {
|
|
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
|
|
const only = subjM ? subjM[1]! : null;
|
|
const bySubject = new Map<string, Record<string, string>>();
|
|
for (const q of quads) {
|
|
if (q.g !== anchor) continue;
|
|
if (only !== null && q.s !== only) continue;
|
|
const rec = bySubject.get(q.s) ?? {};
|
|
if (q.p === `${SHIM}:id`) rec.id = q.o;
|
|
if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o;
|
|
if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o;
|
|
if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o;
|
|
bySubject.set(q.s, rec);
|
|
}
|
|
return {
|
|
results: {
|
|
bindings: [...bySubject.values()].filter((r) => r.id).map((r) => ({
|
|
id: { value: r.id! },
|
|
docPublic: { value: r.docPublic ?? "" },
|
|
docProtected: { value: r.docProtected ?? "" },
|
|
docPrivate: { value: r.docPrivate ?? "" },
|
|
})),
|
|
},
|
|
};
|
|
}
|
|
if (query.includes(`<${INBOX}:payload>`)) {
|
|
const bySubject = new Map<string, Record<string, string>>();
|
|
for (const q of quads) {
|
|
if (q.g !== anchor) continue;
|
|
const rec = bySubject.get(q.s) ?? {};
|
|
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
|
|
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
|
|
if (q.p === `${INBOX}:from`) rec.from = q.o;
|
|
bySubject.set(q.s, rec);
|
|
}
|
|
return {
|
|
results: {
|
|
bindings: [...bySubject.values()]
|
|
.filter((r) => r.payload !== undefined && r.ts !== undefined)
|
|
.map((r) => {
|
|
const row: Record<string, { value: string }> = { payload: { value: r.payload! }, ts: { value: r.ts! } };
|
|
if (r.from !== undefined) row.from = { value: r.from };
|
|
return row;
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
// User-branch `link` SELECT (the emulated AddLink records).
|
|
// User-branch `inboxCap` SELECT (the emulated AddInboxCap records).
|
|
if (query.includes(`<${SHIM}:inboxCap>`)) {
|
|
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxCap`).map((q) => ({ c: { value: q.o } })) } };
|
|
}
|
|
// Header-branch `inboxAddress` SELECT (where to deposit for this document).
|
|
if (query.includes(`<${SHIM}:inboxAddress>`)) {
|
|
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxAddress`).map((q) => ({ a: { value: q.o } })) } };
|
|
}
|
|
// Store-branch `readCap` SELECT (the emulated AddRepo records).
|
|
if (query.includes(`<${SHIM}:readCap>`)) {
|
|
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:readCap`).map((q) => ({ c: { value: q.o } })) } };
|
|
}
|
|
if (query.includes(`<${SHIM}:link>`)) {
|
|
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:link`).map((q) => ({ c: { value: q.o } })) } };
|
|
}
|
|
// Shim `isInbox` SELECT — the emulated "the broker knows this is an inbox". Absent at
|
|
// first, so `isKnownInbox` answered from its in-memory set alone: the durable half was
|
|
// never exercised, which is the very fault this pass was fixing elsewhere.
|
|
if (query.includes(`${SHIM}:isInbox`)) {
|
|
return { results: { bindings: quads
|
|
.filter((q) => q.g === anchor && q.p === `${SHIM}:isInbox`)
|
|
.map((q) => ({ i: { value: q.o } })) } };
|
|
}
|
|
// Shim `docInbox:<scope>` SELECT — WHICH inbox a virtual user owns. Absent until
|
|
// 2026-08-10, so `userInbox` never found a persisted address and answered from the
|
|
// module cache alone: two actors in one JS realm agreed, two SESSIONS would not have.
|
|
// The suite's "a third party resolves another user's inbox" was proving the cache.
|
|
if (query.includes(`${SHIM}:docInbox`)) {
|
|
const pm = query.match(/<(urn:ng-eventually:shim:docInbox:[a-z]+)>/);
|
|
const pred = pm ? pm[1]! : "";
|
|
const sm = query.match(/<([^>]+)>\s+<urn:ng-eventually:shim:docInbox/);
|
|
const subj = sm ? sm[1]! : null;
|
|
return { results: { bindings: quads
|
|
.filter((q) => q.g === anchor && q.p === pred && (subj === null || q.s === subj))
|
|
.map((q) => ({ d: { value: q.o } })) } };
|
|
}
|
|
// Header-branch `exposedReadCap` SELECT — what a PUBLIC store serves to anyone.
|
|
if (query.includes(`<${SHIM}:exposedReadCap>`)) {
|
|
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:exposedReadCap`).map((q) => ({ c: { value: q.o } })) } };
|
|
}
|
|
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.
|
|
return {
|
|
results: {
|
|
bindings: quads
|
|
.filter((q) => q.g === anchor)
|
|
.map((q) => ({ s: { value: q.s }, p: { value: q.p }, o: { value: q.o } })),
|
|
},
|
|
};
|
|
});
|
|
|
|
return { doc_create, sparql_update, sparql_query, _quads: quads };
|
|
}
|
|
|
|
function inject() {
|
|
const ng = makeFakeNg();
|
|
configure({ ng: ng as any, useShape: (() => {}) as any });
|
|
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim().toLowerCase() });
|
|
resetRegistryCache();
|
|
resetCaps();
|
|
setCurrentUser(null);
|
|
return ng;
|
|
}
|
|
|
|
/** Write one triple into `doc`, as the consumer's write path would. */
|
|
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);
|
|
return subjects.flatMap((s) => s.props[p] ?? []);
|
|
}
|
|
|
|
/**
|
|
* Alice's world: a protected document holding a secret, and a public document that
|
|
* REFERS to it by bare NURI.
|
|
*
|
|
* What crosses to the other actors is **the bare reference of the public document and
|
|
* nothing else** — no cap, no link with a key in it. That is the whole discipline of
|
|
* this file: an application circulates references, and if a test had to hand a key
|
|
* across an identity boundary through a JS variable, the feature it claims to prove
|
|
* would have no path in any real application.
|
|
*/
|
|
async function aliceSetsUpHerDocuments() {
|
|
setCurrentUser("alice");
|
|
const protDoc = await createEntityDoc("alice", "protected");
|
|
await write(protDoc, SECRET, "the-protected-content");
|
|
|
|
const pubDoc = await createEntityDoc("alice", "public");
|
|
// The reference is the BARE NURI of the protected document: it names it, and
|
|
// grants nothing. This is the whole point of the scenario.
|
|
await write(pubDoc, REFERS_TO, protDoc);
|
|
|
|
return { protDoc, pubDoc };
|
|
}
|
|
|
|
/** Follow the reference found in the public document — what a reader actually does. */
|
|
function referenceFoundIn(values: string[]): Nuri {
|
|
const ref = values[0];
|
|
expect(ref).toBeDefined();
|
|
return ref as Nuri;
|
|
}
|
|
|
|
test("Bob: reads the public document, sees the reference, and cannot read through it", async () => {
|
|
inject();
|
|
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
|
|
|
|
setCurrentUser("bob");
|
|
// Bob holds the BARE reference and nothing else. The document sits in a public
|
|
// 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 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([]);
|
|
});
|
|
|
|
test("Charlie: same public document, same reference — and he reads through it", async () => {
|
|
inject();
|
|
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
|
|
const CHARLIE_INBOX = await userInbox("charlie", "protected");
|
|
|
|
// Alice decides Charlie may read that ONE document, and delivers its cap to his
|
|
// inbox. She names no principal to the registry; she addresses an inbox.
|
|
setCurrentUser("alice");
|
|
await share(protDoc, "charlie");
|
|
|
|
setCurrentUser("charlie");
|
|
await readInbox(CHARLIE_INBOX); // processing the inbox files the cap
|
|
|
|
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"]);
|
|
});
|
|
|
|
test("the ONLY difference between Bob and Charlie is each of them holds", async () => {
|
|
inject();
|
|
const { protDoc } = await aliceSetsUpHerDocuments();
|
|
const CHARLIE_INBOX = await userInbox("charlie", "protected");
|
|
|
|
setCurrentUser("alice");
|
|
await share(protDoc, "charlie");
|
|
|
|
setCurrentUser("bob");
|
|
const bobSees = await readValues([protDoc], SECRET);
|
|
|
|
setCurrentUser("charlie");
|
|
await readInbox(CHARLIE_INBOX);
|
|
const charlieSees = await readValues([protDoc], SECRET);
|
|
|
|
expect(bobSees).toEqual([]);
|
|
expect(charlieSees).toEqual(["the-protected-content"]);
|
|
});
|
|
|
|
// The dynamic version: Bob is refused, then the cap lands in his inbox and the read
|
|
// that was empty becomes full — with nothing re-declared and nobody re-authorized.
|
|
test("dynamic: a cap delivered to Bob's inbox makes the refused document readable, and signals it", async () => {
|
|
inject();
|
|
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
|
|
const BOB_INBOX = await userInbox("bob", "protected");
|
|
|
|
setCurrentUser("bob");
|
|
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
|
|
|
|
// Before: named, unreadable.
|
|
expect(await readValues([ref], SECRET)).toEqual([]);
|
|
|
|
// A reader that re-reads whenever what it holds changes — this is exactly what
|
|
// `watchShape` wires internally, played here on an ad-hoc read.
|
|
let reread = 0;
|
|
let latest: string[] = [];
|
|
const unsub = getCaps().onChange(() => {
|
|
reread += 1;
|
|
void readValues([ref], SECRET).then((v) => (latest = v));
|
|
});
|
|
|
|
// Alice delivers the cap. Bob's client processes his inbox — the only thing that
|
|
// happens; no "receive" call exists.
|
|
setCurrentUser("alice");
|
|
await share(protDoc, "bob");
|
|
setCurrentUser("bob");
|
|
await readInbox(BOB_INBOX);
|
|
|
|
// Filing the cap fired the signal…
|
|
expect(reread).toBeGreaterThan(0);
|
|
await Promise.resolve();
|
|
await new Promise((r) => setTimeout(r, 0));
|
|
|
|
// …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"]);
|
|
unsub();
|
|
});
|
|
|
|
// The property this whole batch exists for, stated on its own: WHERE a document sits
|
|
// decides whether a bare reference is enough. Upstream a public store's repos are
|
|
// served on the outer overlay and their ReadCap is downloaded from it
|
|
// (`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.
|
|
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([]);
|
|
|
|
// 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.
|
|
await expect(write(pubDoc, SECRET, "bob-was-here")).rejects.toThrow(/public store/i);
|
|
});
|
|
|
|
// THE POINT OF THE LINK: a cap survives because it was APPLIED, not because the
|
|
// inbox is re-read. Upstream, processing an inbox message files it — `AddLink
|
|
// { read_cap }` on the User branch of the private store — and the queue is consumed.
|
|
// Re-reading a queue to recover state is using it as a database.
|
|
test("a Link is APPLIED durably: the cap survives with the inbox emptied", async () => {
|
|
const ng = inject();
|
|
const { protDoc } = await aliceSetsUpHerDocuments();
|
|
const bobInbox = await userInbox("bob", "protected");
|
|
|
|
setCurrentUser("alice");
|
|
await share(protDoc, "bob");
|
|
|
|
// 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"]);
|
|
|
|
// 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.
|
|
for (let k = ng._quads.length - 1; k >= 0; k--) {
|
|
if (ng._quads[k]!.g === bobInbox) ng._quads.splice(k, 1);
|
|
}
|
|
resetCaps();
|
|
setCurrentUser("alice");
|
|
await createEntityDoc("alice", "private"); // re-arms: a cap exists again
|
|
setCurrentUser("bob");
|
|
// Checked SYNCHRONOUSLY, before yielding: `setCurrentUser` fires the connection work
|
|
// itself, and that work is precisely what restores the cap. An awaited check here
|
|
// would be asserting who won a race, not what the library does.
|
|
expect(hasCap(protDoc)).toBe(false); // bob holds nothing yet
|
|
|
|
// 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"]);
|
|
});
|
|
|
|
test("connecting a user that does not exist provisions nothing", async () => {
|
|
inject();
|
|
setCurrentUser("nobody");
|
|
await connectedUser();
|
|
// No account, no stores, no caps — connecting must not create a user as a side
|
|
// effect, or the emulation would arm itself in the background.
|
|
expect(getCaps().isEnforcing()).toBe(false);
|
|
});
|
|
|
|
// REGRESSION (second adversarial pass). `inbox.post` is a published door that skips both
|
|
// guards by design — the deposit is the one write that legitimately crosses. It accepted
|
|
// ANY NURI, so it wrote into a document its caller could not even read. Upstream the
|
|
// confusion cannot arise: a deposit carries an inbox key, not a document reference.
|
|
test("a deposit is addressed to an inbox, never to a document", async () => {
|
|
inject();
|
|
setCurrentUser("alice");
|
|
const protDoc = await createEntityDoc("alice", "protected");
|
|
await write(protDoc, SECRET, "alice's own");
|
|
|
|
setCurrentUser("bob");
|
|
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
|
|
});
|
|
|
|
// REGRESSION (second adversarial pass). The write guard reads ownership from the store
|
|
// index — and the holder's own store document was marked "created by me", so it was
|
|
// writable through the PUBLISHED `docs.sparqlUpdate`. One insert into it and you were
|
|
// the owner of anything you cared to name.
|
|
test("a holder cannot write into their own store index and forge ownership", async () => {
|
|
inject();
|
|
setCurrentUser("alice");
|
|
const protDoc = await createEntityDoc("alice", "protected");
|
|
await write(protDoc, SECRET, "alice's own");
|
|
|
|
setCurrentUser("bob");
|
|
await createEntityDoc("bob", "protected"); // bob has his own stores
|
|
const bobStore = await resolveWriteGraph("bob", "protected");
|
|
await expect(
|
|
sparqlUpdate(SESSION.sessionId, `INSERT DATA { <${SHIM}:index> <${SHIM}:contains> "${protDoc}" }`, bobStore, "forge"),
|
|
).rejects.toThrow(/WRITE cap/i);
|
|
// …and he is still refused the write itself — here by rule 1 (he cannot even reach
|
|
// alice's protected document), which fires before the ownership guard. Both say no.
|
|
await expect(write(protDoc, SECRET, "bob was here")).rejects.toThrow(/refused/i);
|
|
});
|
|
|
|
// WRITING IS OWNERSHIP — the two regressions that replaced the old write guard.
|
|
//
|
|
// It used to ask "was this cap served to me by a public store?", which was wrong in both
|
|
// directions at once. Both are pinned here, because one predicate pushed two ways is
|
|
// exactly how a fix trades one bug for a worse one.
|
|
|
|
// Direction 1 — TOO STRICT. The owner opening her own public note from its reference,
|
|
// before her store has been listed (a deep link, a fresh session), got the "served by a
|
|
// public store" mark on her own document and was refused a write to it.
|
|
test("the owner writes to her own public note, even after opening it from its reference", async () => {
|
|
inject();
|
|
setCurrentUser("alice");
|
|
const pubDoc = await createEntityDoc("alice", "public");
|
|
await write(pubDoc, SECRET, "v1");
|
|
|
|
// She arrives at it the way a deep link would: by reference, with nothing held.
|
|
resetCaps();
|
|
setCurrentUser("bob");
|
|
await createEntityDoc("bob", "private"); // re-arms the emulation
|
|
setCurrentUser("alice");
|
|
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);
|
|
});
|
|
|
|
// Direction 2 — TOO LAX. A cap received in an inbox let its recipient WRITE into the
|
|
// owner's document. Upstream impossible: writing is repo membership, and a Link is
|
|
// "external repos only". An application could have shipped collaborative editing on it.
|
|
test("a cap received in an inbox reads, and does NOT write", async () => {
|
|
inject();
|
|
setCurrentUser("alice");
|
|
const protDoc = await createEntityDoc("alice", "protected");
|
|
await write(protDoc, SECRET, "alice's own");
|
|
const BOB_INBOX = await userInbox("bob", "protected");
|
|
await share(protDoc, "bob");
|
|
|
|
setCurrentUser("bob");
|
|
await readInbox(BOB_INBOX);
|
|
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
|
|
});
|
|
|
|
// PER-DOCUMENT INBOXES. Upstream a repo carries `inbox: Option<PrivKey>` and its
|
|
// owner records the private half with `AddInboxCap` on the User branch — the same
|
|
// branch as `AddLink`. So "which inboxes may I read" has one answer, and connecting
|
|
// drains them all: the user's own, and one per document it opened an inbox on.
|
|
test("a document has its own inbox: anyone deposits, only the owner reads", async () => {
|
|
inject();
|
|
setCurrentUser("alice");
|
|
const doc = await createEntityDoc("alice", "public");
|
|
const aliceInbox = await openDocumentInbox(doc);
|
|
expect(aliceInbox).not.toBe(await userInbox("alice", "protected"));
|
|
|
|
// Bob RESOLVES the address himself, from the BARE reference — the only thing he is
|
|
// handed, and the only thing an application circulates. The document is in a public
|
|
// store, so the store serves him its read cap; the address is not passed to him,
|
|
// because if it had to be there would be no way for an app to get it.
|
|
//
|
|
// The registry cache is dropped first: Bob is another session, and an address he can
|
|
// only find because Alice's session warmed a module map is an address no second browser
|
|
// page would find.
|
|
resetRegistryCache();
|
|
setCurrentUser("bob");
|
|
const bobTarget = await documentInboxAddress(doc);
|
|
expect(bobTarget).toBe(aliceInbox); // …and it is the SAME inbox alice reads
|
|
await post(bobTarget!, { payload: { joining: true }, ts: 1 });
|
|
|
|
// …and he cannot read it back: depositing grants nothing.
|
|
await expect(readInbox(bobTarget!)).rejects.toThrow(/does not belong to the connected wallet/i);
|
|
|
|
// Alice reads her document's inbox, because she opened it.
|
|
setCurrentUser("alice");
|
|
const deposits = await readInbox(aliceInbox);
|
|
expect(deposits.map((d) => d.payload)).toEqual([{ joining: true }]);
|
|
});
|
|
|
|
test("opening an inbox on someone else's document is refused, not silently forked", async () => {
|
|
inject();
|
|
setCurrentUser("alice");
|
|
const doc = await createEntityDoc("alice", "public");
|
|
const aliceInbox = await openDocumentInbox(doc);
|
|
|
|
// Bob can READ the document (it is in a public store) — and reading is not ownership.
|
|
resetRegistryCache(); // another session, not a warmed cache
|
|
setCurrentUser("bob");
|
|
await expect(openDocumentInbox(doc)).rejects.toThrow(/already has an inbox|you may only open an inbox/i);
|
|
// The address he resolves is still alice's, so his deposits reach her.
|
|
expect(await documentInboxAddress(doc)).toBe(aliceInbox);
|
|
});
|
|
|
|
test("a fresh document has NO inbox — one belongs to one document, and only its owner opens it", async () => {
|
|
inject();
|
|
setCurrentUser("alice");
|
|
const doc = await createEntityDoc("alice", "public");
|
|
|
|
// Not "the owner's inbox by default": upstream an inbox belongs to exactly ONE repo
|
|
// (the verifier routes by `inboxes: PubKey → RepoId`), so pointing several documents
|
|
// at one inbox is a relation the model cannot express.
|
|
resetRegistryCache(); // another session, not a warmed cache
|
|
setCurrentUser("bob");
|
|
expect(await documentInboxAddress(doc)).toBeUndefined();
|
|
// …and depositing THROWS rather than vanishing — a lost deposit is the bug this
|
|
// whole path exists to close.
|
|
await expect(postToDocument(doc, { payload: { x: 1 } })).rejects.toThrow(/has no inbox/i);
|
|
});
|
|
|
|
test("opening an inbox publishes ONE address, and re-opening does not accumulate", async () => {
|
|
inject();
|
|
setCurrentUser("alice");
|
|
const doc = await createEntityDoc("alice", "public");
|
|
const dedicated = await openDocumentInbox(doc);
|
|
expect(await openDocumentInbox(doc)).toBe(dedicated); // idempotent
|
|
|
|
resetRegistryCache(); // another session, not a warmed cache
|
|
setCurrentUser("bob");
|
|
expect(await documentInboxAddress(doc)).toBe(dedicated);
|
|
// The deposit reaches the owner, addressed by the document alone.
|
|
await postToDocument(doc, { payload: { signingUp: true } });
|
|
setCurrentUser("alice");
|
|
expect((await readInbox(dedicated)).map((d) => d.payload)).toEqual([{ signingUp: true }]);
|
|
});
|
|
|
|
test("the inbox address is machinery: it never surfaces as the document's data", 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]);
|
|
const props = subjects[0]?.props ?? {};
|
|
expect(Object.keys(props)).toEqual([SECRET]);
|
|
});
|
|
|
|
// CONNECTING APPLIES WHAT WAS DEPOSITED — and the honest scope of that claim.
|
|
//
|
|
// This was called "connecting drains BOTH levels" and asserted nothing about the second.
|
|
// An adversarial review replayed it against a `connectedUser` that drained ONLY the
|
|
// user's own two inboxes: all three assertions still passed. The reason is not a weak
|
|
// test, it is that the second level currently has **no producer**: the one call that
|
|
// deposits a cap is `inbox.share(doc, toUser)`, which resolves `userInbox(toUser,
|
|
// "protected")` (`surface/inbox.ts`) — a USER's inbox, never a document's. Nothing
|
|
// published can address a cap to a document's inbox, so draining one applies nothing and
|
|
// there is nothing to observe.
|
|
//
|
|
// That the drain covers document inboxes is therefore an ANTICIPATION, and a legitimate
|
|
// one: upstream `AddInboxCap` is generic over repos (no `is_store` check,
|
|
// `engine/verifier/src/verifier.rs:1916-1930`) and `InboxMsgContent::Link` exists as a
|
|
// variant. What is NOT legitimate is a test title asserting a property no code exercises.
|
|
// So this test states what it proves; the anticipation is named, not dressed up.
|
|
test("connecting applies the Links waiting for me, and leaves consumer deposits alone", async () => {
|
|
inject();
|
|
setCurrentUser("alice");
|
|
const protDoc = await createEntityDoc("alice", "protected");
|
|
const pubDoc = await createEntityDoc("alice", "public");
|
|
const docInbox = await openDocumentInbox(pubDoc);
|
|
|
|
// Two deposits, one at each level, both made by someone else.
|
|
resetRegistryCache();
|
|
setCurrentUser("carol");
|
|
const carolDoc = await createEntityDoc("carol", "protected");
|
|
await share(carolDoc, "alice"); // a Link, into ALICE's own inbox — the only cap path
|
|
await post(docInbox, { payload: { onTheDocument: true }, ts: 2 }); // consumer data
|
|
|
|
// Alice connects: one call, and she calls nothing to "receive".
|
|
setCurrentUser("alice");
|
|
await connectedUser();
|
|
|
|
expect(hasCap(carolDoc)).toBe(true); // the Link was applied
|
|
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
|
|
});
|
|
|
|
// The same resolution property one level up: a user's own inbox.
|
|
//
|
|
// REGRESSION (2026-08-10, found adversarially). This test used to pass on the module
|
|
// CACHE: `userInbox` keys by (account, scope) regardless of who is asking, so Bob hit the
|
|
// entry Alice had just warmed. Nothing about persistence was exercised — the fake did not
|
|
// even answer the shim query — so in a second SESSION (or a second browser page, which is
|
|
// what the applicative e2e runs) Bob would have got a DIFFERENT inbox, and his deposit
|
|
// would have gone where nobody reads. That is the exact failure this library already paid
|
|
// for once. Dropping the cache between the two actors is what makes it a real test.
|
|
test("a third party resolves another user's inbox, from the shim and not from a cache", async () => {
|
|
inject();
|
|
setCurrentUser("alice");
|
|
const aliceView = await userInbox("alice", "protected");
|
|
|
|
resetRegistryCache(); // Bob is another session: nothing of Alice's is in memory
|
|
setCurrentUser("bob");
|
|
const bobView = await userInbox("alice", "protected");
|
|
expect(bobView).toBe(aliceView);
|
|
});
|