Align the cap emulation on NextGraph's model, and confine it to a virtual user
Two batches, verified against nextgraph-rs throughout. P1a — the capability surface. Reading was an ACL (Map<doc, Set<principal>>), the exact inversion of key possession. It is now possession: `capFor(nuri)` is the only question, there is no principal parameter anywhere, and nothing turns a bare reference into a cap. Sharing is `shareCap(cap, toInbox)`, a Link deposit; receiving needs no operation. `Nuri` and `ReadCap` are template literal types, so passing a bare reference where a cap belongs is a compile error, with runtime guards behind it for JavaScript callers. The virtual user boundary. Every access function is now confined to the connected user, through two rules on one criterion (possession), implemented in two places so a lapse in either is caught by the other: authorization at the passage points, and "do not even attempt" at the callers. The polyfill's own machinery moved to physical.ts — unguarded, never exported — which replaced an exemption list: the machinery no longer gets waved through the guard, it calls something the guard never saw. Removed, as emulating capabilities the target does not have: - discovery.ts and its global index. There is no discovery in NextGraph; you follow links. It also pooled user data across wallets. - the cross-account fan-out (listEntityDocs, resolveReadGraphs, allAccounts, loadShim), which was cross-user enumeration by construction. - resolveInboxAnchor, a single inbox common to every user. Caps are now stored where NextGraph stores them, and read back rather than recomputed: AddRepo on the store's Store branch for documents a user creates, AddLink on its User branch for caps received. Inboxes belong to someone — the user's own, plus one per document — and connecting a user drains them all; that is the library's job, not the app's. Corrections worth recording: a ReadCap is `r:`, not `:k:` (reported by NextGraph's developer, verified in BlockRef::readcap_nuri); received caps DO have a register (AddLink), contrary to what this repo's notes claimed; and "wallet" upstream means keyring — what owns three stores is a user, so the vocabulary follows. The cap value is the constant OK: the only question the emulation answers is whether a cap is held. P1b replaces that one constant with a real key. After this the shape is right and the isolation is still fake. Nothing here may be described as anonymous or private.
This commit is contained in:
@@ -1,31 +1,36 @@
|
||||
/**
|
||||
* ReadCap ACTIVE — end-to-end proof that the emulated SDK enforces per-DOCUMENT
|
||||
* isolation, driven by per-entity documents + DIRECTED read grants.
|
||||
* isolation, driven by per-entity documents + KEY POSSESSION.
|
||||
*
|
||||
* Mirrors what the app does: create an entity document through the REAL registry
|
||||
* (`createEntityDoc`), declare its cap policy via `getCaps().open(doc, scope,
|
||||
* owner)`, set the current identity, and — when the app decides two identities
|
||||
* are related — issue a DIRECTED read grant on each of the owner's protected
|
||||
* documents (`getCaps().grantRead(doc, granteeId)`). Whether identities are
|
||||
* "connected" is the application's own concept: this test plays that role
|
||||
* directly. The read filter then discriminates:
|
||||
* (a) an ungranted principal is denied a PROTECTED doc; granted once the owner
|
||||
* issues a directed grant; PUBLIC readable throughout — via the ACTIVE
|
||||
* ReadCap.
|
||||
* (b) no grant → no protected read (a reader cannot grant itself).
|
||||
* (`createEntityDoc`) — which files its cap in the creator's held caps, the emulated
|
||||
* `AddRepo { read_cap }` — and, when the app decides two identities are related,
|
||||
* SHARE that one document's cap to the other's inbox (`shareCap`). The recipient
|
||||
* needs no dedicated operation: processing their inbox absorbs it.
|
||||
*
|
||||
* What the read filter then shows:
|
||||
* (a) a document nobody shared is unreadable, and stays unreadable for a third
|
||||
* party after a share to someone else — sharing is per-document, per-inbox;
|
||||
* (b) a bare reference grants NOTHING (naming is not reading), while the repo
|
||||
* link of a published document opens it for whoever receives it;
|
||||
* (c) switching identity SWITCHES heldByHolder — it never wipes one.
|
||||
*/
|
||||
import { test, expect, mock, afterAll } from "bun:test";
|
||||
import { createEntityDoc, resetRegistryCache } from "../src/store-registry";
|
||||
import { createEntityDoc, resetRegistryCache, walletInbox, listMyEntityDocs } from "../src/store-registry";
|
||||
import type { RegistrySession } from "../src/store-registry";
|
||||
import type { ReadCap } from "../src/types";
|
||||
import {
|
||||
configure,
|
||||
configureStoreRegistry,
|
||||
resetStoreRegistry,
|
||||
resetConfig,
|
||||
capFor,
|
||||
getCaps,
|
||||
resetCaps,
|
||||
setCurrentUser,
|
||||
shareCap,
|
||||
} from "../src/polyfill";
|
||||
import { read as readInbox } from "../src/inbox";
|
||||
import { filterReadable } from "../src/read-filter";
|
||||
|
||||
afterAll(() => {
|
||||
@@ -36,93 +41,371 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
const SESSION: RegistrySession = { sessionId: "sid", privateStoreId: "PRIV" };
|
||||
const SHIM = "urn:ng-eventually:shim";
|
||||
const INBOX = "urn:ng-eventually:inbox";
|
||||
|
||||
function inject() {
|
||||
let n = 0;
|
||||
const ng = {
|
||||
doc_create: mock(async () => `did:ng:o:doc${++n}`),
|
||||
sparql_update: mock(async () => undefined),
|
||||
sparql_query: mock(async () => ({ results: { bindings: [] } })),
|
||||
};
|
||||
interface Quad { g: string; s: string; p: string; o: string }
|
||||
|
||||
/** Reverse of the lib's escapeLiteral: single left-to-right pass over `\x`. */
|
||||
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` serving BOTH the shim SPARQL and the inbox SPARQL. */
|
||||
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;
|
||||
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
|
||||
let g: string;
|
||||
let body: string;
|
||||
if (gm) {
|
||||
g = gm[1]!;
|
||||
body = gm[2]!;
|
||||
} else {
|
||||
if (!anchor) return undefined;
|
||||
g = anchor;
|
||||
body = 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, 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;
|
||||
// Pointer SELECT (store-root → doc-shim).
|
||||
if (query.includes(`<${SHIM}:shimDoc>`)) {
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor && q.p === `${SHIM}:shimDoc`)
|
||||
.map((q) => ({ shimDoc: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
// Account SELECT.
|
||||
if (query.includes(`<${SHIM}:id>`)) {
|
||||
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
|
||||
const onlySubject = subjM ? subjM[1]! : null;
|
||||
const bySubject = new Map<string, Record<string, string>>();
|
||||
for (const q of quads) {
|
||||
if (q.g !== anchor) continue;
|
||||
if (onlySubject !== null && q.s !== onlySubject) 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);
|
||||
}
|
||||
const 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 ?? "" },
|
||||
}));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
// Inbox deposit SELECT.
|
||||
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);
|
||||
}
|
||||
const 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;
|
||||
});
|
||||
return { results: { bindings } };
|
||||
}
|
||||
// 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 } })) } };
|
||||
}
|
||||
// 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>`)) {
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor && q.p === `${SHIM}:link`)
|
||||
.map((q) => ({ c: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
// Scope-index `contains` SELECT.
|
||||
if (query.includes(`<${SHIM}:contains>`)) {
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor && q.p === `${SHIM}:contains`)
|
||||
.map((q) => ({ e: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
return { results: { bindings: [] } };
|
||||
});
|
||||
|
||||
return { doc_create, sparql_update, sparql_query, _quads: quads };
|
||||
}
|
||||
|
||||
function inject(normalizeId: (id: string) => string = (id) => id.trim()) {
|
||||
const ng = makeFakeNg();
|
||||
configure({ ng: ng as any, useShape: (() => {}) as any });
|
||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
|
||||
configureStoreRegistry({ getSession: async () => SESSION, normalizeId });
|
||||
resetRegistryCache();
|
||||
resetCaps();
|
||||
setCurrentUser(null);
|
||||
return ng;
|
||||
}
|
||||
|
||||
/** The app's relationship concept, played inline: grant `reader` the read cap of
|
||||
* every protected document owned by `owner`. */
|
||||
function grantOwnerProtectedTo(owner: string, reader: string) {
|
||||
for (const doc of getCaps().protectedDocsOf(owner)) getCaps().grantRead(doc, reader);
|
||||
}
|
||||
/** The items an ORM set would carry, one per document. */
|
||||
const item = (doc: string, id: string) => ({ "@graph": doc, "@id": id });
|
||||
/** What the current holder reads out of `items`. */
|
||||
const view = (items: Array<{ "@graph": string; "@id": string }>) =>
|
||||
filterReadable(items, getCaps()).map((i) => i["@id"]).sort();
|
||||
|
||||
test("ReadCap active: a private entity doc created via the real registry is hidden from another principal", async () => {
|
||||
|
||||
test("a created document is readable by its creator and by nobody else", async () => {
|
||||
inject();
|
||||
|
||||
setCurrentUser("alice");
|
||||
const aliceDoc = await createEntityDoc("alice", "private");
|
||||
getCaps().open(aliceDoc, "private", "alice");
|
||||
setCurrentUser("bob");
|
||||
const bobDoc = await createEntityDoc("bob", "private");
|
||||
|
||||
const bobDoc = await createEntityDoc("bob", "public");
|
||||
getCaps().open(bobDoc, "public", "bob");
|
||||
const items = [item(aliceDoc, "a1"), item(bobDoc, "b1")];
|
||||
|
||||
const items = [
|
||||
{ "@graph": aliceDoc, "@id": "a1", label: "alice-private" },
|
||||
{ "@graph": bobDoc, "@id": "b1", label: "bob-public" },
|
||||
];
|
||||
|
||||
expect(filterReadable(items, getCaps(), "bob").map((i) => i["@id"])).toEqual(["b1"]);
|
||||
expect(filterReadable(items, getCaps(), "alice").map((i) => i["@id"]).sort()).toEqual(["a1", "b1"]);
|
||||
expect(filterReadable(items, getCaps(), null).map((i) => i["@id"])).toEqual(["b1"]);
|
||||
expect(getCaps().hasReadPolicy()).toBe(true);
|
||||
setCurrentUser("alice");
|
||||
expect(view(items)).toEqual(["a1"]);
|
||||
setCurrentUser("bob");
|
||||
expect(view(items)).toEqual(["b1"]);
|
||||
setCurrentUser(null);
|
||||
expect(view(items)).toEqual([]); // anonymous holds nothing
|
||||
expect(getCaps().isEnforcing()).toBe(true);
|
||||
});
|
||||
|
||||
// (a) protected hidden while ungranted → revealed after a DIRECTED grant; public
|
||||
// readable regardless — all through the ACTIVE ReadCap.
|
||||
test("(a) PROTECTED doc: hidden ungranted, revealed after a DIRECTED grant, PUBLIC always readable", async () => {
|
||||
// (a) Sharing is per-document AND per-recipient: a share to bob leaves carol out.
|
||||
test("(a) sharing one document's cap to ONE inbox reveals it there, and only there", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const shared = await createEntityDoc("alice", "protected");
|
||||
const kept = await createEntityDoc("alice", "protected");
|
||||
const items = [item(shared, "s1"), item(kept, "k1")];
|
||||
|
||||
const aliceProtected = await createEntityDoc("alice", "protected");
|
||||
getCaps().open(aliceProtected, "protected", "alice");
|
||||
const alicePublic = await createEntityDoc("alice", "public");
|
||||
getCaps().open(alicePublic, "public", "alice");
|
||||
// BEFORE the share: bob reads nothing of alice's.
|
||||
setCurrentUser("bob");
|
||||
expect(view(items)).toEqual([]);
|
||||
|
||||
const items = [
|
||||
{ "@graph": aliceProtected, "@id": "p1" },
|
||||
{ "@graph": alicePublic, "@id": "u1" },
|
||||
];
|
||||
const view = (u: string) => filterReadable(items, getCaps(), u).map((i) => i["@id"]).sort();
|
||||
// The app decides alice↔bob are related: alice shares ONE document's cap into
|
||||
// bob's OWN inbox — the only cross-wallet act there is.
|
||||
const bobInbox = await walletInbox("bob");
|
||||
setCurrentUser("alice");
|
||||
await shareCap(capFor(shared)!, bobInbox);
|
||||
|
||||
// BEFORE any grant: bob sees only the public item.
|
||||
expect(view("bob")).toEqual(["u1"]);
|
||||
expect(view("alice")).toEqual(["p1", "u1"]);
|
||||
// bob processes his inbox — no dedicated "receive" operation exists.
|
||||
setCurrentUser("bob");
|
||||
await readInbox(bobInbox);
|
||||
expect(view(items)).toEqual(["s1"]); // the shared one only — not `kept`
|
||||
|
||||
// The app decides alice↔bob are related and grants bob the read cap of alice's
|
||||
// protected documents.
|
||||
grantOwnerProtectedTo("alice", "bob");
|
||||
|
||||
expect(view("bob")).toEqual(["p1", "u1"]);
|
||||
// A third, ungranted principal still sees only the public one.
|
||||
expect(view("carol")).toEqual(["u1"]);
|
||||
// carol, who was not shared with, still reads nothing.
|
||||
setCurrentUser("carol");
|
||||
await readInbox(await walletInbox("carol"));
|
||||
expect(view(items)).toEqual([]);
|
||||
});
|
||||
|
||||
// (b) An identity gets no protected read until the OWNER issues the grant — a
|
||||
// reader cannot grant itself.
|
||||
test("(b) no directed grant → no protected read", async () => {
|
||||
test("a cap deposit is absorbed, not surfaced as a consumer deposit", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const doc = await createEntityDoc("alice", "protected");
|
||||
const bobInbox = await walletInbox("bob");
|
||||
await shareCap(capFor(doc)!, bobInbox);
|
||||
|
||||
const aliceProtected = await createEntityDoc("alice", "protected");
|
||||
getCaps().open(aliceProtected, "protected", "alice");
|
||||
const items = [{ "@graph": aliceProtected, "@id": "p1" }];
|
||||
const view = (u: string) => filterReadable(items, getCaps(), u).map((i) => i["@id"]);
|
||||
|
||||
// mallory holds no grant on alice's protected doc → denied.
|
||||
expect(view("mallory")).toEqual([]);
|
||||
|
||||
// Granting bob (a different, legitimate reader) leaves mallory denied.
|
||||
grantOwnerProtectedTo("alice", "bob");
|
||||
expect(view("mallory")).toEqual([]);
|
||||
expect(view("bob")).toEqual(["p1"]);
|
||||
setCurrentUser("bob");
|
||||
const deposits = await readInbox(bobInbox);
|
||||
expect(deposits).toEqual([]); // infrastructure, not consumer data
|
||||
expect(capFor(doc)).toBeDefined(); // …but it landed in bob's held caps
|
||||
});
|
||||
|
||||
// (b) A bare reference grants nothing; the repo link of a published document does.
|
||||
test("(b) a bare reference reads nothing; the repo link of a published document opens it", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const pub = await createEntityDoc("alice", "public");
|
||||
const items = [item(pub, "u1")];
|
||||
expect(getCaps().isPublished(pub)).toBe(true);
|
||||
const link = capFor(pub)!;
|
||||
|
||||
// bob HAS the document's bare NURI (it is right there in `items`) and reads nothing.
|
||||
setCurrentUser("bob");
|
||||
expect(view(items)).toEqual([]);
|
||||
|
||||
// Receiving the repo link — what a discovery entry actually carries — opens it.
|
||||
getCaps().learn(link);
|
||||
expect(view(items)).toEqual(["u1"]);
|
||||
});
|
||||
|
||||
// (c) Identity change switches heldByHolder; it does not wipe them.
|
||||
test("(c) switching identity switches heldByHolder — a returning identity keeps its caps", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const doc = await createEntityDoc("alice", "protected");
|
||||
const cap = capFor(doc);
|
||||
expect(cap).toBeDefined();
|
||||
|
||||
setCurrentUser("bob");
|
||||
expect(capFor(doc)).toBeUndefined();
|
||||
|
||||
setCurrentUser("alice");
|
||||
expect(capFor(doc)).toBe(cap!); // durable across the switch — nothing re-declared
|
||||
});
|
||||
|
||||
// A virtual user IS a shim account, and the shim keys accounts through the
|
||||
// consumer's `normalizeId`. The held caps must key the SAME way: otherwise an app
|
||||
// that spells its own identity differently between two calls ("@Alice" at login,
|
||||
// "alice" later) gets a second held caps and stops reading its own documents.
|
||||
test("one held caps per virtual WALLET, not per spelling of its id", async () => {
|
||||
inject((id) => id.trim().replace(/^@+/, "").toLowerCase());
|
||||
|
||||
setCurrentUser("@Alice");
|
||||
const doc = await createEntityDoc("@Alice", "protected");
|
||||
const cap = capFor(doc);
|
||||
expect(cap).toBeDefined();
|
||||
|
||||
// Same account, spelled differently — same shim account, so the same held caps.
|
||||
setCurrentUser("alice");
|
||||
expect(capFor(doc)).toBe(cap!);
|
||||
setCurrentUser(" ALICE ");
|
||||
expect(capFor(doc)).toBe(cap!);
|
||||
|
||||
// A genuinely different account still holds nothing.
|
||||
setCurrentUser("bob");
|
||||
expect(capFor(doc)).toBeUndefined();
|
||||
});
|
||||
|
||||
// THE BREACH P1a OPENED. Caps travel as inbox deposits, so an unguarded inbox read
|
||||
// let anyone who knew an inbox NURI collect the caps addressed to its owner —
|
||||
// defeating directed sharing entirely. Depositing stays open (it is the only way a
|
||||
// link crosses between wallets at all); reading does not.
|
||||
test("an inbox may be DEPOSITED into by anyone, and READ only by its owner", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const secret = await createEntityDoc("alice", "protected");
|
||||
const bobInbox = await walletInbox("bob");
|
||||
|
||||
// Alice deposits into bob's inbox — allowed, and it grants her nothing back.
|
||||
await shareCap(capFor(secret)!, bobInbox);
|
||||
await expect(readInbox(bobInbox)).rejects.toThrow(/does not belong to the connected wallet/i);
|
||||
expect(capFor(secret)).toBeDefined(); // still hers, obviously
|
||||
|
||||
// Mallory knows the NURI of bob's inbox and tries to pocket what is in it.
|
||||
setCurrentUser("mallory");
|
||||
await expect(readInbox(bobInbox)).rejects.toThrow(/does not belong to the connected wallet/i);
|
||||
expect(capFor(secret)).toBeUndefined(); // nothing was absorbed
|
||||
|
||||
// Anonymous owns no inbox at all.
|
||||
setCurrentUser(null);
|
||||
await expect(readInbox(bobInbox)).rejects.toThrow(/no identity is set/i);
|
||||
|
||||
// Bob reads his own, and only then does the cap land.
|
||||
setCurrentUser("bob");
|
||||
await readInbox(bobInbox);
|
||||
expect(capFor(secret)).toBeDefined();
|
||||
});
|
||||
|
||||
test("a fresh session rebuilds the held caps from the scope index (the emulated AddRepo)", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const doc = await createEntityDoc("alice", "protected");
|
||||
const items = [item(doc, "p1")];
|
||||
|
||||
// Simulate a new session over the same wallet: caps are in memory, so they go —
|
||||
// the registry cache too. Only the persisted documents remain.
|
||||
resetCaps();
|
||||
resetRegistryCache();
|
||||
expect(view(items)).toEqual([]);
|
||||
|
||||
// Listing my own documents refiles their caps: this is the store branch that
|
||||
// carries `AddRepo { read_cap }` upstream.
|
||||
const { listMyEntityDocs } = await import("../src/store-registry");
|
||||
expect(await listMyEntityDocs("alice", "protected")).toEqual([doc]);
|
||||
expect(view(items)).toEqual(["p1"]);
|
||||
});
|
||||
|
||||
// The Store branch exists so a cap is READ back, not recomputed. Without this test
|
||||
// the two are indistinguishable: with a stand-in value, re-minting happens to give
|
||||
// the same string. So corrupt the stored cap and check the corruption wins — proof
|
||||
// the value comes from the store, and proof that P1b's real key will too.
|
||||
test("a document's cap is READ from the Store branch, never recomputed", async () => {
|
||||
const ng = inject();
|
||||
setCurrentUser("alice");
|
||||
const doc = await createEntityDoc("alice", "protected");
|
||||
|
||||
// The store recorded `AddRepo { read_cap }` beside the `contains` listing.
|
||||
const stored = ng._quads.filter((q) => q.p === "urn:ng-eventually:shim:readCap");
|
||||
expect(stored.length).toBe(1);
|
||||
expect(stored[0]!.o).toBe(`${doc}:r:OK`);
|
||||
|
||||
// Rewrite it to a DIFFERENT value, then start a fresh session.
|
||||
stored[0]!.o = `${doc}:r:FROM-THE-STORE`;
|
||||
resetCaps();
|
||||
resetRegistryCache();
|
||||
|
||||
expect(await listMyEntityDocs("alice", "protected")).toEqual([doc]);
|
||||
// Recomputing would have produced `:r:OK`; this is what was stored.
|
||||
expect(capFor(doc)).toBe(`${doc}:r:FROM-THE-STORE` as ReadCap);
|
||||
});
|
||||
|
||||
// The listing and the keys are separate upstream (Main vs Store branch), and the
|
||||
// separation has to survive here or a document could be listed without its cap.
|
||||
test("the listing and the caps are two separate records", async () => {
|
||||
const ng = inject();
|
||||
setCurrentUser("alice");
|
||||
await createEntityDoc("alice", "private");
|
||||
|
||||
const subjects = new Set(ng._quads.filter((q) => q.p.startsWith("urn:ng-eventually:shim:")).map((q) => q.s));
|
||||
expect(subjects.has("urn:ng-eventually:shim:index")).toBe(true); // Main branch: contains
|
||||
expect(subjects.has("urn:ng-eventually:shim:storeBranch")).toBe(true); // Store branch: readCap
|
||||
});
|
||||
|
||||
// P1b will make the stand-in value a real, non-derivable key. The moment it does,
|
||||
// any path that mints a SECOND cap instead of using the stored one breaks: the
|
||||
// creator would hold a key that does not open its own document. This pins that the
|
||||
// creation path mints exactly once.
|
||||
test("creation mints the cap ONCE — the stored value is the one held", async () => {
|
||||
const ng = inject();
|
||||
setCurrentUser("alice");
|
||||
const doc = await createEntityDoc("alice", "protected");
|
||||
|
||||
const stored = ng._quads.find((q) => q.p === "urn:ng-eventually:shim:readCap")!;
|
||||
expect(capFor(doc)).toBe(stored.o as ReadCap); // same value, not two mints that agree by luck
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user