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,14 +1,12 @@
|
||||
import { test, expect, mock, beforeEach, afterAll } from "bun:test";
|
||||
import {
|
||||
ensureAccount,
|
||||
allAccounts,
|
||||
loadShim,
|
||||
resolveWriteGraph,
|
||||
resolveReadGraphs,
|
||||
resolveAccount,
|
||||
listMyEntityDocs,
|
||||
resolveScopeGraph,
|
||||
resolveInboxAnchor,
|
||||
walletInbox,
|
||||
createEntityDoc,
|
||||
listEntityDocs,
|
||||
resetRegistryCache,
|
||||
} from "../src/store-registry";
|
||||
import type { RegistrySession } from "../src/store-registry";
|
||||
@@ -223,19 +221,10 @@ test("ensureAccount de-dupes CONCURRENT provisions (anti-fork): one account, 3 d
|
||||
for (const r of results) expect(r).toEqual(results[0]!);
|
||||
});
|
||||
|
||||
test("loadShim round-trips a persisted account across a cache reset", async () => {
|
||||
await ensureAccount("Bob");
|
||||
resetRegistryCache(); // force a re-read from the fake store
|
||||
const map = await loadShim();
|
||||
const rec = map.get("bob");
|
||||
expect(rec?.id).toBe("Bob");
|
||||
expect(rec?.docPublic).toMatch(/^did:ng:o:doc/);
|
||||
});
|
||||
|
||||
test("resolveWriteGraph returns the per-scope index doc; resolveReadGraphs fans out", async () => {
|
||||
test("resolveWriteGraph returns the per-scope index doc", async () => {
|
||||
const rec = await ensureAccount("Carol");
|
||||
expect(await resolveWriteGraph("carol", "protected")).toBe(rec.docProtected);
|
||||
expect(await resolveReadGraphs("public")).toEqual([rec.docPublic]);
|
||||
});
|
||||
|
||||
test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to the caller)", async () => {
|
||||
@@ -255,14 +244,15 @@ test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to
|
||||
expect(await resolveScopeGraph("private")).toBe("did:ng:PRIV");
|
||||
expect(await resolveScopeGraph("protected")).toBe("did:ng:PROT");
|
||||
expect(await resolveScopeGraph("public")).toBe("did:ng:PROT"); // co-located
|
||||
// The inbox anchor is now a DEDICATED inbox DOCUMENT (a reserved account's
|
||||
// public scope doc, from docCreate) — NOT the private-store root — so inbox
|
||||
// deposits don't bloat the shim graph. It is a real repo NURI and STABLE
|
||||
// across calls (same reserved account → same document).
|
||||
const anchor = await resolveInboxAnchor();
|
||||
expect(anchor).toMatch(/^did:ng:o:doc/);
|
||||
expect(anchor).not.toBe("did:ng:PRIV");
|
||||
expect(await resolveInboxAnchor()).toBe(anchor); // stable
|
||||
// An inbox belongs to ONE virtual user — it is a dedicated document (from
|
||||
// docCreate), not the private-store root, so deposits never bloat the shim graph.
|
||||
// Stable per wallet, and DISJOINT between wallets: reading someone else's inbox
|
||||
// would collect the caps addressed to them (see inbox.ts's read guard).
|
||||
const mine = await walletInbox("@alice");
|
||||
expect(mine).toMatch(/^did:ng:o:doc/);
|
||||
expect(mine).not.toBe("did:ng:PRIV");
|
||||
expect(await walletInbox("@alice")).toBe(mine); // stable
|
||||
expect(await walletInbox("@bob")).not.toBe(mine); // another wallet, another inbox
|
||||
});
|
||||
|
||||
test("resolveScopeGraph falls back to the private store when no protected id is injected", async () => {
|
||||
@@ -272,34 +262,21 @@ test("resolveScopeGraph falls back to the private store when no protected id is
|
||||
expect(await resolveScopeGraph("public")).toBe("did:ng:PRIV");
|
||||
});
|
||||
|
||||
test("createEntityDoc + listEntityDocs round-trip via the per-scope index", async () => {
|
||||
test("createEntityDoc + listMyEntityDocs round-trip via the per-scope index", async () => {
|
||||
const rec = await ensureAccount("Dave");
|
||||
const e1 = await createEntityDoc("dave", "public");
|
||||
const e2 = await createEntityDoc("dave", "public");
|
||||
const other = await createEntityDoc("dave", "protected");
|
||||
// Public listing unions dave's public entities only.
|
||||
const pub = await listEntityDocs("public");
|
||||
const pub = await listMyEntityDocs("dave", "public");
|
||||
expect(pub.sort()).toEqual([e1, e2].sort());
|
||||
const prot = await listEntityDocs("protected");
|
||||
const prot = await listMyEntityDocs("dave", "protected");
|
||||
expect(prot).toEqual([other]);
|
||||
// The index append targets the account's public index doc.
|
||||
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
|
||||
});
|
||||
|
||||
test("listEntityDocs fans out across multiple accounts", async () => {
|
||||
await ensureAccount("Eve");
|
||||
await ensureAccount("Frank");
|
||||
const e = await createEntityDoc("eve", "public");
|
||||
const f = await createEntityDoc("frank", "public");
|
||||
expect((await listEntityDocs("public")).sort()).toEqual([e, f].sort());
|
||||
});
|
||||
|
||||
test("allAccounts reflects every ensured account", async () => {
|
||||
await ensureAccount("Gina");
|
||||
await ensureAccount("Hank");
|
||||
const names = (await allAccounts()).map((a) => a.id).sort();
|
||||
expect(names).toEqual(["Gina", "Hank"]);
|
||||
});
|
||||
|
||||
// --- SPARQL injection hardening (F1) --------------------------------------
|
||||
//
|
||||
@@ -384,12 +361,11 @@ test("injection: a malicious id still round-trips through the shim", async () =>
|
||||
const rec = await ensureAccount(evil);
|
||||
expect(rec.id).toBe(evil);
|
||||
resetRegistryCache();
|
||||
const map = await loadShim();
|
||||
// The stored id came back verbatim (escaping is lossless) under its
|
||||
// normalized key, and exactly ONE account exists (no injected extra subject).
|
||||
const key = evil.trim().replace(/^@+/, "").toLowerCase();
|
||||
expect(map.get(key)?.id).toBe(evil);
|
||||
expect(map.size).toBe(1);
|
||||
// The stored id comes back verbatim (escaping is lossless) when resolved by its
|
||||
// own key — and no injected extra subject answers in its place.
|
||||
const back = await resolveAccount(evil);
|
||||
expect(back?.id).toBe(evil);
|
||||
expect(back?.docPublic).toBe(rec.docPublic);
|
||||
});
|
||||
|
||||
test("normalizeId defaults to trim when not provided", async () => {
|
||||
|
||||
Reference in New Issue
Block a user