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:
Sylvain Duchesne
2026-08-03 11:22:01 +02:00
parent 6f0d0586e2
commit ae9c32e271
51 changed files with 4245 additions and 1543 deletions
+23 -36
View File
@@ -251,33 +251,6 @@ async function main(): Promise<void> {
check("post as another principal is rejected; self + anon allowed", r.spoofRejected && r.selfOk && r.anonOk, `spoof=${r.spoofRejected} self=${r.selfOk} anon=${r.anonOk}`);
});
// ── discovery index ─────────────────────────────────────────────────────
console.log("\n── discovery index ──");
await step("discovery submit → read", async () => {
const ref = { doc: "did:ng:o:some-public-doc", title: "t" };
const r = await sdk<any>(frame, "discoverySubmitRead", ref);
const refs = (r.entries || []).map((e: any) => JSON.stringify(e.ref));
check("submitToIndex then readIndex returns the entry", refs.includes(JSON.stringify(ref)), `entries=${r.entries.length}`);
});
await step("discovery watchIndex fires reactively", async () => {
await sdk(frame, "discoveryWatchStart");
await frame.waitForFunction(() => (window as any).__sdk.discoveryWatchState().fires >= 1, { timeout: 20000 });
const base = await sdkGet<any>(frame, "discoveryWatchState");
await sdk(frame, "discoverySubmit", { doc: "did:ng:o:doc2", title: "t2", n: Date.now() });
await frame.waitForFunction(
(b) => (window as any).__sdk.discoveryWatchState().fires > (b as number),
base.fires,
{ timeout: 20000 },
);
const after = await sdkGet<any>(frame, "discoveryWatchState");
check("watchIndex fires on a new submission", after.fires > base.fires, `fires=${after.fires}`);
await sdk(frame, "discoveryWatchStop");
});
await step("reserved @index account isolation", async () => {
const r = await sdk<any>(frame, "discoveryIndexIsolation");
check("user '@index' resolves disjoint from the reserved index owner", r.disjoint === true, `disjoint=${r.disjoint}`);
});
// ── store-registry ──────────────────────────────────────────────────────
console.log("\n── store-registry ──");
await step("ensureAccount idempotent", async () => {
@@ -337,17 +310,31 @@ async function main(): Promise<void> {
// ── caps / read-filter (in-memory cap model) ────────────────────────────
console.log("\n── caps / read-filter (in-memory cap model) ──");
await step("read-filter: protected hidden from stranger", async () => {
await step("read-filter: you read what your keyring holds, nothing else", async () => {
const r = await sdk<any>(frame, "capsReadFilter");
const ownerSeesProt = r.ownerView.includes("protected-item");
const strangerHiddenProt = !r.strangerView.includes("protected-item");
const bothSeePublic = r.ownerView.includes("public-item") && r.strangerView.includes("public-item");
const bothSeeUngoverned = r.ownerView.includes("ungoverned-item") && r.strangerView.includes("ungoverned-item");
check("owner reads protected; stranger does not; public+ungoverned visible to both", ownerSeesProt && strangerHiddenProt && bothSeePublic && bothSeeUngoverned, `owner=${JSON.stringify(r.ownerView)} stranger=${JSON.stringify(r.strangerView)}`);
// The owner reads the documents whose caps their keyring holds — and NOT the
// one it does not, even though its NURI is right there in the set.
const ownerReadsHeld =
r.ownerView.includes("protected-item") && r.ownerView.includes("public-item");
const ownerMissesUnheld = !r.ownerView.includes("unheld-item");
// A stranger holds nothing at all — a bare reference names without reading.
const strangerReadsNothing = r.strangerView.length === 0;
// …until the repo link of the PUBLISHED document reaches them.
const linkOpensPublic =
r.strangerWithLinkView.length === 1 && r.strangerWithLinkView.includes("public-item");
check(
"owner reads held docs only; stranger reads nothing; the repo link opens the published one",
ownerReadsHeld && ownerMissesUnheld && strangerReadsNothing && linkOpensPublic,
`owner=${JSON.stringify(r.ownerView)} stranger=${JSON.stringify(r.strangerView)} withLink=${JSON.stringify(r.strangerWithLinkView)}`,
);
});
await step("read-filter: directed grant reveals the doc", async () => {
const r = await sdk<any>(frame, "capsDirectedGrant");
check("grantRead reveals the protected doc to the grantee", r.before === 0 && r.after === 1, `before=${r.before} after=${r.after}`);
await step("shareCap: a cap delivered to an inbox reveals the doc", async () => {
const r = await sdk<any>(frame, "capsShareCap");
check(
"shareCap → inbox processed → the shared doc becomes readable, and the delivery is not surfaced",
r.before === 0 && r.after === 1 && r.surfacedDeposits === 0,
`before=${r.before} after=${r.after} surfaced=${r.surfacedDeposits}`,
);
});
// ── accounts (IdentityStore) ────────────────────────────────────────────
+81 -71
View File
@@ -15,23 +15,48 @@
*/
import { ng as realNg, init as realInit } from "@ng-org/web";
import { configure, configureStoreRegistry, setCurrentUser, getCaps, resetCaps } from "@ng-eventually/client/polyfill";
import {
configure,
configureStoreRegistry,
setCurrentUser,
capFor,
getCaps,
resetCaps,
shareCap,
} from "@ng-eventually/client/polyfill";
import {
docs,
subscribeDoc,
subscribeDocs,
readModel,
inbox,
discovery,
storeRegistry,
useShape as libUseShape,
watchShape,
accounts,
} from "@ng-eventually/client";
import type { ShapeObservable, ShapeQuery } from "@ng-eventually/client";
import { isNuri } from "@ng-eventually/client";
import type { Nuri, ShapeObservable, ShapeQuery } from "@ng-eventually/client";
const { IdentityStore } = accounts;
/**
* The Playwright boundary. Every NURI reaching this harness crosses the bridge as
* a plain `string` (Playwright serializes arguments), so it arrives untyped even
* though the library's `Nuri` is a template literal type. Narrow it here, loudly:
* a test that passes something which is not a NextGraph reference should fail with
* that message, not with a confusing downstream error. Never cast — a cast would
* re-open exactly the confusion the types exist to close.
*/
function asNuri(s: string): Nuri {
if (!isNuri(s)) throw new Error(`[e2e] not a NextGraph reference: ${JSON.stringify(s)}`);
return s;
}
/** Same, for an optional anchor. */
function asAnchor(s?: string): Nuri | undefined {
return s === undefined ? undefined : asNuri(s);
}
// ── The broker session, resolved once the iframe connects ──────────────────
interface BrokerSession {
session_id: string;
@@ -69,7 +94,7 @@ configure({
});
configureStoreRegistry({
// The registry (+ subscribe/inbox/discovery/read-model) reach the session
// The registry (+ subscribe/inbox/read-model) reach the session
// through this. It resolves once the broker connects.
getSession: async () => {
// Read the CURRENT session (mutable): a fresh session (session_stop+session_start
@@ -144,11 +169,11 @@ const identity = new IdentityStore(
},
async sparqlUpdate(query: string, anchor?: string) {
const s = await sessionReady;
return docs.sparqlUpdate(s.session_id, query, anchor);
return docs.sparqlUpdate(s.session_id, query, asAnchor(anchor));
},
async sparqlQuery(query: string, anchor?: string) {
const s = await sessionReady;
return docs.sparqlQuery(s.session_id, query, undefined, anchor);
return docs.sparqlQuery(s.session_id, query, undefined, asAnchor(anchor));
},
/**
* The load-bearing graph-behavior characterization against the REAL broker.
@@ -252,7 +277,7 @@ const identity = new IdentityStore(
*/
async readUnionOverDocs(n: number, includeBad: boolean) {
const s = await sessionReady;
const docNuris: string[] = [];
const docNuris: Nuri[] = [];
for (let i = 0; i < n; i++) {
const d = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
await docs.sparqlUpdate(
@@ -262,20 +287,22 @@ const identity = new IdentityStore(
);
docNuris.push(d);
}
const toRead = includeBad ? [...docNuris, "did:ng:o:definitely-not-a-real-doc-xyz"] : docNuris;
const toRead: Nuri[] = includeBad ? [...docNuris, "did:ng:o:definitely-not-a-real-doc-xyz"] : docNuris;
const subjects = await readModel.readUnion(toRead);
return { docNuris, subjectCount: subjects.length, subjects };
},
/**
* readUnion cap gate: create a doc, mark it protected for owner O, set the
* current user to a DIFFERENT identity, and readUnion → the doc is dropped.
* readUnion possession gate: create a doc as owner O (whose keyring gets its
* cap), then read it as a DIFFERENT identity, which holds nothing → dropped.
* The stranger has the document's NURI in hand throughout: naming is not reading.
*/
async readUnionCapGate() {
const s = await sessionReady;
resetCaps();
const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
await docs.sparqlUpdate(s.session_id, `INSERT DATA { <urn:e2e:cg> <urn:e2e:p> "x" }`, doc);
getCaps().open(doc, "protected", "owner-O");
setCurrentUser("owner-O");
getCaps().open(doc, "protected");
setCurrentUser("someone-else");
const asStranger = await readModel.readUnion([doc]);
setCurrentUser("owner-O");
@@ -308,7 +335,7 @@ const identity = new IdentityStore(
await docs.sparqlUpdate(
s.session_id,
`INSERT DATA { <urn:e2e:sub:${marker}> <urn:e2e:m> "${marker}" }`,
doc,
asNuri(doc),
);
},
subscribeStop(handle: string) {
@@ -396,47 +423,6 @@ const identity = new IdentityStore(
return { spoofRejected: threw, selfOk, anonOk };
},
// ── discovery index ──────────────────────────────────────────────────────
async discoverySubmitRead(ref: unknown) {
setCurrentUser("publisher");
await discovery.submitToIndex(ref);
setCurrentUser(null);
const entries = await discovery.readIndex();
return { entries };
},
_discWatch: { fires: 0, lastLen: -1, unsub: () => {} },
discoveryWatchStart() {
const rec = { fires: 0, lastLen: -1, unsub: () => {} };
(window as any).__sdk._discWatch = rec;
rec.unsub = discovery.watchIndex((entries) => {
rec.fires += 1;
rec.lastLen = entries.length;
});
},
async discoverySubmit(ref: unknown) {
setCurrentUser("publisher2");
await discovery.submitToIndex(ref);
setCurrentUser(null);
},
discoveryWatchState() {
const r = (window as any).__sdk._discWatch;
return { fires: r.fires, lastLen: r.lastLen };
},
discoveryWatchStop() {
(window as any).__sdk._discWatch.unsub();
},
// reserved @index account isolation: a real user named "index"/"@index" resolves
// to a DIFFERENT account than the reserved index owner.
async discoveryIndexIsolation() {
const userIndex = await storeRegistry.ensureAccount("@index");
const reserved = await storeRegistry.ensureAccount(discovery.INDEX_ACCOUNT);
return {
userIndexDoc: userIndex.docPublic,
reservedDoc: reserved.docPublic,
disjoint: userIndex.docPublic !== reserved.docPublic,
};
},
// ── store-registry ───────────────────────────────────────────────────────
async ensureAccountIdempotent(id: string) {
storeRegistry.resetRegistryCache();
@@ -510,7 +496,7 @@ const identity = new IdentityStore(
/**
* RECONNECTION read (phase 2, run in a FRESH session over the SAME wallet). First a
* DIAGNOSTIC raw anchored read with NO open (rawRowCount), then re-resolve the
* account's entity docs of `scope` (listMyEntityDocs → readScopeIndex) and readUnion
* account's entity docs of `scope` (listMyEntityDocs → readUserStore) and readUnion
* them, purely from the persistent wallet — nothing from phase 1's session state
* carries over. The SDK's open-before-read heal (open-repo.ts) opens each repo via
* doc_subscribe before the anchored reads. NB: on the SDK/broker version tested here
@@ -526,7 +512,7 @@ const identity = new IdentityStore(
const s = session ?? (await sessionReady);
let rawRowCount = -1;
try {
const raw: any = await docs.sparqlQuery(s.session_id, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", undefined, entityNuri);
const raw: any = await docs.sparqlQuery(s.session_id, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", undefined, asNuri(entityNuri));
rawRowCount = Array.isArray(raw) ? raw.length : (raw?.results?.bindings?.length ?? 0);
} catch (e: any) {
rawRowCount = -2; // threw (e.g. RepoNotFound / InvalidNuri)
@@ -534,7 +520,7 @@ const identity = new IdentityStore(
storeRegistry.resetRegistryCache();
const listed = await storeRegistry.listMyEntityDocs(id, scope);
const subjects = await readModel.readUnion(listed.length ? listed : [entityNuri]);
const subjects = await readModel.readUnion(listed.length ? listed : [asNuri(entityNuri)]);
const markers: string[] = [];
for (const subj of subjects) {
for (const vals of Object.values(subj.props)) {
@@ -545,7 +531,7 @@ const identity = new IdentityStore(
rawRowCount,
listed,
listedCount: listed.length,
foundEntity: listed.includes(entityNuri),
foundEntity: listed.includes(asNuri(entityNuri)),
subjectCount: subjects.length,
markerPresent: markers.includes(marker),
markers,
@@ -588,7 +574,7 @@ const identity = new IdentityStore(
anchor +
"> { ?acc a <urn:ng-eventually:shim:Account> } }";
try {
const res: any = await docs.sparqlQuery(s.session_id, query, undefined, anchor);
const res: any = await docs.sparqlQuery(s.session_id, query, undefined, asNuri(anchor));
const rows = Array.isArray(res) ? res.length : (res?.results?.bindings?.length ?? 0);
return { threw: false, error: null, rows, anchor };
} catch (e: any) {
@@ -754,39 +740,63 @@ const identity = new IdentityStore(
// The read-filter over the injected useShape Set-like. Boundary note: the
// caps/read-filter are EMULATED in-memory (CapRegistry) — the real broker does
// NOT yet enforce per-doc read caps here (one shared wallet reads everything).
// We test what the SDK enforces: the in-memory read-filtered VIEW.
// We test what the SDK enforces: the in-memory read-filtered VIEW, which after
// P1a is KEY POSSESSION — you read what your keyring holds, nothing else.
capsReadFilter() {
resetCaps();
injectedSetItems = [
{ "@graph": "did:ng:o:protdoc", "@id": "1", v: "protected-item" },
{ "@graph": "did:ng:o:pubdoc", "@id": "2", v: "public-item" },
{ "@graph": "did:ng:o:ungoverned", "@id": "3", v: "ungoverned-item" },
{ "@graph": "did:ng:o:unheld", "@id": "3", v: "unheld-item" },
];
getCaps().open("did:ng:o:protdoc", "protected", "owner-O");
getCaps().makePublic("did:ng:o:pubdoc");
// as owner-O
setCurrentUser("owner-O");
getCaps().open("did:ng:o:protdoc", "protected");
const link = getCaps().publishRepoLink("did:ng:o:pubdoc");
const ownerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
// as a stranger
// A stranger holds nothing — including the PUBLISHED document, until the repo
// link reaches them (§5: whoever has the URL reads the content).
setCurrentUser("stranger");
const strangerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
getCaps().learn(link);
const strangerWithLinkView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
resetCaps();
injectedSetItems = [];
setCurrentUser(null);
return { ownerView, strangerView };
return { ownerView, strangerView, strangerWithLinkView };
},
capsDirectedGrant() {
/**
* Sharing a cap the way the model does it: the owner deposits it into the
* recipient's INBOX, and the recipient processing that inbox absorbs it. No
* "receive" operation exists, and no principal is ever named to the registry.
* Runs against the REAL broker inbox document, so it exercises the whole path.
*/
async capsShareCap() {
const s = await sessionReady;
resetCaps();
injectedSetItems = [{ "@graph": "did:ng:o:sharedoc", "@id": "1", v: "shared-item" }];
getCaps().open("did:ng:o:sharedoc", "protected", "owner-O");
const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
const friendInbox = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
injectedSetItems = [{ "@graph": doc, "@id": "1", v: "shared-item" }];
setCurrentUser("owner-O");
getCaps().open(doc, "protected");
const cap = capFor(doc)!;
setCurrentUser("friend");
const before = [...(libUseShape(null, null) as Iterable<any>)].length;
getCaps().grantRead("did:ng:o:sharedoc", "friend");
setCurrentUser("owner-O");
await shareCap(cap, friendInbox);
setCurrentUser("friend");
const absorbed = await inbox.read(friendInbox); // processing it applies the cap
const after = [...(libUseShape(null, null) as Iterable<any>)].length;
resetCaps();
injectedSetItems = [];
setCurrentUser(null);
return { before, after };
// `absorbed` must be EMPTY: a cap delivery is infrastructure, never surfaced
// to the consumer as a deposit.
return { before, after, surfacedDeposits: absorbed.length };
},
// ── accounts (IdentityStore) ─────────────────────────────────────────────
@@ -849,7 +859,7 @@ const identity = new IdentityStore(
unsub: () => {},
};
(window as any).__sdk._stateProbe = probe;
probe.unsub = subscribeDoc(doc, (resp: any) => {
probe.unsub = subscribeDoc(asNuri(doc), (resp: any) => {
const elapsedMs = Date.now() - probe.startMs;
// AppResponse shape: { V0: { State: … } } | { V0: { Patch: … } } | { V0: { TabInfo: … } } | …
let typeKey = "unknown";