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
+74 -8
View File
@@ -6,7 +6,14 @@ separate:
| Import | Surface |
|---|---|
| `@ng-eventually/client` | The same signature as the SDK — `ng`, `useShape`, `inbox` (+ types). A drop-in for `@ng-org/web` / `@ng-org/orm`; as NextGraph matures it resolves to the real SDK (build alias removed) with no code change. |
| `@ng-eventually/client/polyfill` | The only non-SDK surface — `configure`, `setCurrentUser`, and capability helpers (`getCaps`, `grantRead`, `canRead`/`canWrite`). It falls away as NextGraph matures. |
| `@ng-eventually/client/polyfill` | The only non-SDK surface — `configure`, `setCurrentUser`, and the capability surface (`capFor`, `shareCap`, `getCaps`). It falls away as NextGraph matures. |
> **Reading is key possession, and the isolation here is still fake.** The cap
> surface has the shape of the real model — you hold a document's `ReadCap` or you
> do not read it, and there is no authorization list anywhere — but nothing is
> encrypted yet and several read paths bypass the guard entirely. Nothing this
> library does may be described as "anonymous" or "private" until per-document
> encryption lands (P1b).
```ts
// bootstrap (the only non-SDK call) — inject the real SDK
@@ -36,13 +43,72 @@ What the polyfill adds on top of the real SDK (each emulated for now, native as
NextGraph matures):
- Shared-wallet identity (one wallet for everyone; the current identity id is
relayed to the SDK).
- Capability enforcement — a read filter + write guard over emulated grants
attached to documents; the app declares a document's read policy and issues
directed read grants.
- Anticipated methods (inbox `post`, capability ops) with their future-SDK shapes,
- Capability emulation — per-identity **cap possession** (`capFor`) and a read filter
over it: you read the documents whose cap you hold. Creating a document files its
cap; receiving one is an inbox deposit. There is no authorization list.
- Anticipated methods (inbox `post`, `shareCap`) with their future-SDK shapes,
emulated for now.
Generic: no application domain. The consumer application injects its shapes and
performs the acts of granting access. The relationship concept ("who is connected
to whom") is the consumer application's own — the client exposes only directed
per-document read grants.
performs the acts of sharing. The relationship concept ("who is connected to whom")
is the consumer application's own — the client exposes only "share this one
document's cap to that inbox".
### The cap surface in three calls
```ts
import { capFor, shareCap, getCaps } from "@ng-eventually/client/polyfill";
import { storeRegistry } from "@ng-eventually/client";
// Creating a document records its cap and you hold it — nothing to declare.
const doc = await storeRegistry.createEntityDoc(myId, "protected");
capFor(doc); // → `${doc}:r:…` — you hold it
// Share it with one recipient, addressed by their inbox. They need no "receive"
// operation: their existing inbox.watch absorbs it.
await shareCap(capFor(doc)!, theirInbox);
// Publishing is TWO acts: place the data in your public store, and circulate its
// LINK. There is no discovery — you cannot be found, you can only be reached — so
// the link has to travel: into an inbox, or into a document the reader already
// holds. The bare NURI would name the document without opening it.
const link = getCaps().publishRepoLink(publicDoc);
await shareCap(link, theirInbox);
```
The one invariant to keep in mind: **you never derive a cap from a bare reference.**
You look it up in what you hold, or you were given it. A `did:ng:o:…` without `:r:`
names a document and grants nothing.
### The types carry that invariant
`Nuri` and `ReadCap` are **template literal types**, not `string` aliases:
```ts
type Nuri = `did:ng:${string}`
type ReadCap = `did:ng:${string}:r:${string}`
```
They are still strings — assignable to `string`, JSON-serializable, no wrapper — but
the distinction is checked. A `ReadCap` goes wherever a `Nuri` is expected (a cap
*is* a NURI with the key inside); the reverse does not compile:
```ts
await shareCap(doc, theirInbox); // ✗ Argument of type '`did:ng:${string}`' is not
// assignable to '`did:ng:${string}:r:${string}`'
```
A string that comes from outside your code — storage, a URL, JSON, a form — is a
plain `string`. **Narrow it, do not cast it**: a cast re-opens exactly the confusion
the types close.
```ts
import { isNuri, hasReadCap } from "@ng-eventually/client";
const saved = localStorage.getItem("cap");
if (saved && hasReadCap(saved)) await shareCap(saved, theirInbox); // ✓ narrowed
```
The runtime guards remain regardless — a JavaScript caller never meets the compiler,
and a cast bypasses it — so passing a bare reference where a cap belongs throws with
a message that says so.
+11 -5
View File
@@ -202,11 +202,18 @@ Data is isolated **per document (repo)**, and each document lives in a **scope**
| Scope | Read | Write |
|---|---|---|
| **Private** | Owner only | Owner only |
| **Protected** | Owner + explicit grant holders | Owner + permissioned collaborators |
| **Public** | Everyone (no capability needed) | **Owner only** |
| **Protected** | Owner + whoever the owner delivered the cap to | Owner + permissioned collaborators |
| **Public** | Whoever has the URL (the repo link) | **Owner only** |
Consequences a consumer must internalize:
- **Reading is key possession, never an authorization list.** You hold a document's
`ReadCap` (`…:r:{cap}`) or you do not read it — there is no "may X read Y?" to ask,
here or upstream. A cap-less `did:ng:o:…` **names** a document without granting
anything, which is what lets public content point at private content without
disclosing it. Caps reach you two ways: creating a document files its own, and
someone delivering one to your inbox (`shareCap`). Nothing derives a cap from a
bare reference.
- **Isolation is per-document, not per-store.** Holding a store's cap does **not**
grant read on the documents it contains — each document has its own ReadCap. Fine-
grained isolation therefore means **one document per entity**
@@ -263,10 +270,9 @@ from the reactive contract:
for a **single already-opened document**; it is the per-entity **fan-out** that is
unfit today.
2. **Inbox and discovery index use polling watchers.** The inbox is emulated
2. **The inbox uses a polling watcher.** The inbox is emulated
(`AppRequestCommandV0::InboxPost` has no verifier arm today; no wasm helper seals a
deposit), so `inbox.watch` ([`../src/inbox.ts`](../src/inbox.ts)) and
`discovery.watchIndex` ([`../src/discovery.ts`](../src/discovery.ts)) **poll** via
deposit), so `inbox.watch` ([`../src/inbox.ts`](../src/inbox.ts)) **polls** via
`setInterval` (default 1s) instead of subscribing. The finished contract is push
(the broker already routes the inbox natively); these become subscriptions when the
sealed-inbox path (`inbox_post_link`) lands.
+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";
+2 -2
View File
@@ -6,7 +6,7 @@
* document scoped to another identity. When it does (identity B reading identity
* A's doc), the leak is invisible in the data — it looks like a normal read. This
* probe makes it VISIBLE: every real read/write is logged, prefixed by the ACTIVE
* identity (the discriminating virtual identity, NOT the constant physical wallet
* identity (the discriminating virtual identity, NOT the constant physical user
* id), so replaying the scenario shows the exact line where a doc is accessed
* under the wrong identity.
*
@@ -54,7 +54,7 @@ export function enabled(): boolean {
/**
* The identity to prefix an access line with: the ACTIVE virtual identity
* (`getCurrentUser`) — the account/space the operation is scoped under, which is
* the discriminating signal for the isolation leak. NOT the physical wallet id
* the discriminating signal for the isolation leak. NOT the physical user id
* (shared, constant → useless). `(none)` when no identity is set yet (startup).
* Exported so every other polyfill-layer log site (store-registry, inbox,
* outbox-log, …) shares the exact same identity resolution as the access log,
+230 -101
View File
@@ -1,117 +1,249 @@
/**
* Capability emulation — generic, with no domain rules. It models NextGraph
* ReadCaps (and write caps) as a data layer can.
* Capability emulation — key POSSESSION, not an authorization list.
*
* In NextGraph a ReadCap is possession of a document's (repo's) read key: the
* broker only delivers documents the wallet holds a cap for. The access unit is
* therefore the document = repo, identified here by its NURI — the `@graph` an
* item lives in, rather than the item. (A store is just a container repo, and
* holding a store's cap does not grant the repos it references — each document
* carries its own cap — so this registry is purely per-document, with no
* store-level inheritance.)
* In NextGraph a ReadCap **is** the document's read key: whoever holds it reads,
* and there is no read-ACL anywhere. This module emulates that shape (see
* `docs/briefs/2026-07-27-p1a-cap-surface.md`), which means it answers exactly one
* question — *do I hold this document's cap?* — and cannot answer "may principal P
* read document D", because the real model cannot either.
*
* Sharing here is DIRECTED: a grant issues one grantee the read cap of one
* document (`grantRead(doc, granteeId)`). Whether two identities are "connected"
* — and therefore whether such a grant should be issued — is an application
* concept the consumer owns; this layer only records the resulting per-document
* grants. At migration this whole layer disappears: the broker/verifier enforces
* the real caps and `useShape` returns only authorized documents.
* ── Where caps come from — and why this is NOT "a keyring" ────────────────
* There is no keyring object in NextGraph, and calling this one invited a wrong
* mental model: that some single place holds every key. It does not. Upstream the
* caps of a user are in **two** places, by origin (see
* `docs/readcap-and-nuri-model.md` §4quater/§4quinquies):
*
* - documents the user CREATED → `AddRepo { read_cap }` on the **Store branch**
* of the store they live in — one such branch per store;
* - caps RECEIVED for someone else's documents → `AddLink { read_cap }` on the
* **User branch** of the private store.
*
* The wallet itself holds exactly one key per user: the private store's read cap,
* from which everything else is reached. Hence the invariant:
*
* > You do not derive a cap from a bare reference. You look it up in what you
* > hold — or you were given it.
*
* This class is the in-memory record of what the connected holder currently holds:
* upstream's local user storage, not a durable register. The durable ones are
* emulated in `store-registry.ts` (`fileOwnCaps` for created documents, `addLink` /
* `readLinks` for received ones), and `connect.ts` restores from them.
*
* One record PER holder, since one shared wallet hosts every identity. Switching
* identity therefore SWITCHES records; it never wipes one (a wipe would make
* durability a lie and bring per-session re-declaration back under another name).
*
* ── Sharing ───────────────────────────────────────────────────────────────
* Not here: the unit of sharing is the document and the recipient is an INBOX, so
* sharing is `inbox.shareCap(cap, toInbox)` — a **Link** deposit — and receiving is
* the recipient processing their inbox. Handing over a store's cap is NOT the
* gesture: it would give away everything that store contains, present and future.
*
* ── What this module does NOT do ──────────────────────────────────────────
* Enforce. The shape is right after P1a; the isolation is still fake. Per-document
* encryption and closing the read paths that bypass the guard (`docs.sparqlQuery`,
* the inbox, `store-registry`, `subscribe`, `open-repo`) are P1b. Nothing may be
* claimed "anonymous" or "private" until then. The write caps below are likewise
* decorative — the guard they feed (`ng-proxy`) is bypassed by every internal
* writer; they are left as-is and belong to P1b.
*/
import type { Nuri, PrincipalId, Scope } from "./types";
import { hasReadCap, mintCap, targetOf } from "./nuri";
import type { Nuri, PrincipalId, ReadCap, Scope } from "./types";
/** The map key of the anonymous holder (no identity established yet). */
const ANONYMOUS = "";
/**
* Who holds the read/write cap of each document. The consumer populates it via
* cap operations (make-public, directed grant…) exactly as it will in the
* target; this layer enforces possession generically, with no policy of its own.
*/
export class CapRegistry {
/** doc NURI → principals holding its READ cap. */
private readers = new Map<Nuri, Set<PrincipalId>>();
/** doc NURI → principals holding its WRITE cap. */
/** holder → the caps they hold, indexed by the cap-less NURI. */
private heldByHolder = new Map<string, Map<Nuri, ReadCap>>();
/**
* Documents published as a shareable repo link (`RepoLinkV0`) — the emulated
* public store. This is NOT a read grant: a published document is read by
* whoever HOLDS the link, exactly like §5 of the brief says ("whoever has the
* URL reads the content"), and holding it means having received it. The set
* exists so the library can refuse to surface a document its holder never
* published (see `discovery.submitToIndex`).
*/
private published = new Set<Nuri>();
/** doc NURI → principals holding its WRITE cap. Decorative until P1b. */
private writers = new Map<Nuri, Set<PrincipalId>>();
/** doc NURIs readable by everyone (public_store reposno cap needed). */
private publicDocs = new Set<Nuri>();
/** doc NURI → its declared (scope, owner), as recorded at {@link open}. Lets
* the consumer re-derive which documents are `protected` and who owns them
* (see {@link protectedDocsOf}) so it can issue directed grants, without
* re-supplying that per-document — it already declared it at open. */
private policy = new Map<Nuri, { scope: Scope; owner: PrincipalId }>();
/** Fired whenever a holder gains a capa cap delivered asynchronously must
* re-trigger the reads that were empty for want of it. */
private listeners = new Set<() => void>();
/** Has any cap been issued at all? Gates the whole emulation (see {@link isEnforcing}). */
private issued = false;
/** Grant `grantee` the READ cap of document `doc` — a directed grant. */
grantRead(doc: Nuri, grantee: PrincipalId): void {
add(this.readers, doc, grantee);
/**
* @param holder resolves WHO is holding — the current identity. Looked up through it on every
* call, so an identity switch switches records with nothing to reset. Defaults to the anonymous holder.
*/
constructor(private readonly holder: () => PrincipalId | null = () => null) {}
// --- what the holder holds ----------------------------------------------
/** What the current holder holds, created on first use. */
private heldCaps(): Map<Nuri, ReadCap> {
const key = this.holder() ?? ANONYMOUS;
let ring = this.heldByHolder.get(key);
if (!ring) this.heldByHolder.set(key, (ring = new Map()));
return ring;
}
/**
* File `cap` among what the current holder holds — the ONE door in, so
* the invariant is carried here rather than by each caller remembering it.
*
* A reference with no `:r:` is REFUSED. `Nuri` and `ReadCap` are both `string`
* (deliberately — the real SDK takes `nuri: String`), so the compiler cannot
* catch a caller passing the naming form where the reading form is meant. Left
* unchecked, that mistake files a bare reference under its own name, `capFor`
* then returns it, and the document reads — turning "naming is not reading" into
* "naming is reading", which is the exact inversion this batch exists to remove.
* The check is cheap and it is the only thing standing between the two.
*
* Returns whether the cap was new.
*/
private file(cap: ReadCap): boolean {
if (!hasReadCap(cap)) {
throw new Error(
"[ng-eventually] caps: expected a ReadCap (a NURI carrying `:r:`), got a bare " +
`reference — naming is not reading, and no cap derives from one: ${JSON.stringify(cap)}`,
);
}
const target = targetOf(cap);
const ring = this.heldCaps();
if (ring.get(target) === cap) return false;
ring.set(target, cap);
this.issued = true;
this.notify();
return true;
}
/**
* The cap of a document I just CREATED, filed among what I hold — the emulated
* `AddRepo { read_cap }`. Idempotent. Returns the cap.
*/
mint(nuri: Nuri): ReadCap {
const cap = mintCap(nuri);
this.file(cap);
return cap;
}
/**
* File a cap I was GIVEN — an inbox deposit of kind `cap`, or a repo link found
* in world-readable content. This is the ONLY way a cap arrives from
* outside: nothing turns a bare reference into a cap.
*
* @throws if `cap` carries no `:r:` — see {@link file}. Passing a bare `Nuri`
* here is the one type confusion that would silently invert the model, and both
* forms are `string`, so it is rejected at runtime instead.
*/
learn(cap: ReadCap): void {
this.file(cap);
}
/**
* Do I hold the cap of `nuri`? Returns it, or `undefined` when I hold
* none — which is the whole answer the model can give. Absorbs the former
* `canRead(doc, principal)`: there is no principal parameter, because there is
* no list to look a principal up in.
*/
capFor(nuri: Nuri): ReadCap | undefined {
return this.heldCaps().get(targetOf(nuri));
}
// --- publication (the public store) -------------------------------------
/**
* Publish `nuri` as a shareable repo link and return it — the upstream
* `RepoLinkV0 { read_cap }`, which whoever receives it can open. The consumer
* puts this link (not the bare NURI) in what it makes discoverable.
*
* NOT recursive: the published document may REFERENCE private documents, and the
* reference grants nothing on what it references — that non-recursiveness is
* what lets a public object point at a private identity without disclosing it.
*/
publishRepoLink(nuri: Nuri): ReadCap {
const target = targetOf(nuri);
this.published.add(target);
return this.mint(target);
}
/** Was `nuri` published as a repo link? (An emitter-side guard, not a right.) */
isPublished(nuri: Nuri): boolean {
return this.published.has(targetOf(nuri));
}
/**
* Record a document the current holder owns in `scope`: its cap lands in their
* what they hold, and a `public` one is additionally published as a repo link. Returns
* the cap (the shareable link when public). Idempotent — the store-registry calls
* it both when creating a document and when listing the holder's own documents
* back, which is how a holder's caps are rebuilt on a fresh session.
*
* Deliberately does NOT touch write caps: those are decorative until P1b, and
* arming their guard here would be enforcement this batch does not do.
*/
open(nuri: Nuri, scope: Scope): ReadCap {
return scope === "public" ? this.publishRepoLink(nuri) : this.mint(nuri);
}
// --- enforcement gate ---------------------------------------------------
/**
* Is the cap emulation in force? False until the first cap is issued, so a
* consumer that never touches caps keeps reading everything (no regression).
* Once ANY cap exists the regime is possession for EVERY holder — including one
* who holds nothing, which is exactly the isolation being emulated.
*/
isEnforcing(): boolean {
return this.issued;
}
// --- change signal ------------------------------------------------------
/**
* Subscribe to changes in what the holder holds. A cap that arrives asynchronously (an inbox
* deposit) must make the views that were empty for want of it re-read; without
* this signal they stay stale until an unrelated change happens to fire.
*/
onChange(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
private notify(): void {
for (const l of this.listeners) {
try {
l();
} catch (error) {
console.error("[caps] change listener threw", error);
}
}
}
// --- write caps (decorative until P1b) ----------------------------------
/** Grant `principal` the WRITE cap of document `doc`. */
grantWrite(doc: Nuri, principal: PrincipalId): void {
add(this.writers, doc, principal);
}
/** Mark `doc` public (readable without a cap — a public_store repo). */
makePublic(doc: Nuri): void {
this.publicDocs.add(doc);
}
/**
* Apply the caps a creator attaches to a fresh document, by scope. Public →
* world-readable; protected/private → only the owner reads. The owner always
* holds the write cap. Further sharing is a separate explicit grant.
*/
open(doc: Nuri, scope: Scope, owner: PrincipalId): void {
if (scope === "public") this.makePublic(doc);
else this.grantRead(doc, owner);
this.grantWrite(doc, owner);
this.policy.set(doc, { scope, owner });
}
/**
* The `protected` documents owned by `owner`, as recorded at {@link open}. The
* consumer uses this to issue directed read grants: it decides who may read an
* owner's protected documents (its own relationship concept) and calls
* {@link grantRead} on each of these documents for each such reader. Public
* documents are already world-readable and private documents stay owner-only,
* so only the protected ones are surfaced here.
*
* This mirrors a native cap operation: in the target, sharing a protected repo
* with another identity issues that identity the repo's ReadCap. Here the
* consumer selects the documents via this accessor and grants the emulated read
* cap on the same unit.
*/
protectedDocsOf(owner: PrincipalId): Nuri[] {
const out: Nuri[] = [];
for (const [doc, { scope, owner: o }] of this.policy) {
if (scope === "protected" && o === owner) out.push(doc);
}
return out;
}
/** Is `doc` under any READ-cap policy? (Undeclared docs are not enforced.) */
governsRead(doc: Nuri): boolean {
return this.publicDocs.has(doc) || this.readers.has(doc);
}
/** Does `principal` hold a READ cap for `doc` (or is `doc` public)? */
canRead(doc: Nuri, principal: PrincipalId | null): boolean {
if (this.publicDocs.has(doc)) return true;
if (principal === null) return false;
return this.readers.get(doc)?.has(principal) ?? false;
const target = targetOf(doc);
let s = this.writers.get(target);
if (!s) this.writers.set(target, (s = new Set()));
s.add(principal);
}
/** Is `doc` under any WRITE-cap policy? */
governsWrite(doc: Nuri): boolean {
return this.writers.has(doc);
return this.writers.has(targetOf(doc));
}
/** Does `principal` hold a WRITE cap for `doc`? */
canWrite(doc: Nuri, principal: PrincipalId | null): boolean {
if (principal === null) return false;
return this.writers.get(doc)?.has(principal) ?? false;
}
/** No READ policy declared → the read filter stays inert (passthrough). */
hasReadPolicy(): boolean {
return this.readers.size > 0 || this.publicDocs.size > 0;
return this.writers.get(targetOf(doc))?.has(principal) ?? false;
}
/** No WRITE policy declared → the write guard stays inert (passthrough). */
@@ -119,16 +251,13 @@ export class CapRegistry {
return this.writers.size > 0;
}
/** Drop every holder's caps and every publication. Tests / a fresh wallet only —
* NOT what an identity change does (that switches heldByHolder, see the header). */
clear(): void {
this.readers.clear();
this.heldByHolder.clear();
this.published.clear();
this.writers.clear();
this.publicDocs.clear();
this.policy.clear();
this.issued = false;
this.notify();
}
}
function add(m: Map<Nuri, Set<PrincipalId>>, doc: Nuri, principal: PrincipalId): void {
let s = m.get(doc);
if (!s) m.set(doc, (s = new Set()));
s.add(principal);
}
+93
View File
@@ -0,0 +1,93 @@
/**
* connect — what the polyfill does when the app connects a virtual user.
*
* ── Processing inboxes is the LIBRARY's job, not the app's ────────────────
* Stated by the PO, 2026-07-30. A consumer must not have to remember to drain its
* inbox for documents shared with it to become readable; forgetting would look
* like "the share did not work" rather than "nobody processed the queue". So the
* moment an identity is connected ({@link setCurrentUser}), this runs.
*
* Two steps, in order, and the order matters:
*
* 1. **Restore** — read the Links already applied (`storeRegistry.readLinks`, the
* emulated `AddLink` records on the User branch of the private store) back into
* what this user holds. This is durable state; it costs one read and needs no inbox.
* 2. **Process** — drain the user's inbox (`inbox.processInbox`), which files any
* new Link durably and puts it among what the user holds.
*
* Restoring first means a reconnecting user can read its shared documents
* immediately, without waiting on the inbox round-trip.
*
* ── Fire-and-forget, on purpose ───────────────────────────────────────────
* `setCurrentUser` is synchronous and every consumer calls it from synchronous
* code. Making it async would push the wait onto the app, which is exactly the
* obligation this removes. So the work runs in the background and announces itself
* through the registry's change signal (`CapRegistry.onChange`), which is what
* `watchShape` already listens to — a view that was empty for want of a cap
* re-reads when the cap lands. {@link connectedUser} is there for a caller that
* genuinely needs to await it (tests, an app that wants a deterministic start).
*
* ── Every inbox, at both levels ───────────────────────────────────────────
* The user's own inbox AND the inbox of every document it opened one on. Upstream
* both are answered by the same place — `AddInboxCap` records on the User branch
* (`engine/repo/src/types.rs:1969`) — so `storeRegistry.myInboxes()` enumerates
* them and this drains each in turn.
*/
import { getCaps, getCurrentUser } from "./polyfill";
import { myInboxes, readLinks, resolveAccount } from "./store-registry";
import { processInbox } from "./inbox";
/** The in-flight connection work, per user key — so two calls do not race. */
const inFlight = new Map<string, Promise<void>>();
/**
* Restore and drain for the connected user. Idempotent per user while in flight.
*
* Tolerant by construction: it runs on every `setCurrentUser`, including in
* contexts where the store registry was never configured (unit tests, an app
* setting the identity before the session resolves). Those simply have nothing to
* restore, and a failure here must never break connecting.
*/
export async function connectedUser(): Promise<void> {
const holder = getCurrentUser();
if (holder === null) return;
const pending = inFlight.get(holder);
if (pending) return pending;
const run = (async (): Promise<void> => {
try {
// Connecting must not PROVISION. `ensureAccount` would create the user on
// first sight, so connecting an identity that does not exist yet would
// silently mint its stores and their caps — arming the whole emulation as a
// background side effect, at a moment nothing controls. An account that does
// not exist has nothing to restore and no inbox to drain.
if ((await resolveAccount(holder)) === null) return;
// 1. Durable first: what this user has already applied.
for (const cap of await readLinks()) getCaps().learn(cap);
// 2. Then the queues: ALL of them — the user's own inbox, plus one per
// document it opened an inbox on. Both levels, as the PO specified, and
// both are answered by the same User-branch record (`AddInboxCap`).
// Sequential rather than parallel: each `processInbox` writes what it
// applies to the SAME private store, and interleaving those writes buys
// nothing on a queue that is nearly always empty.
for (const inbox of await myInboxes()) await processInbox(inbox);
} catch {
// Not configured yet, or offline. Nothing to restore, and connecting must
// not fail because a queue could not be reached — the next connection, or
// an explicit `connectedUser()`, picks it up.
}
})();
inFlight.set(holder, run);
try {
await run;
} finally {
inFlight.delete(holder);
}
}
/** Fire the connection work without awaiting it. Called by `setCurrentUser`. */
export function startConnect(): void {
void connectedUser();
}
-236
View File
@@ -1,236 +0,0 @@
/**
* discovery — a GENERIC discovery-index surface, reusing the ONE deposit +
* materialization mechanism (`inbox.ts`). GENERIC by construction: this module
* knows no application domain (no event, no meeting-point). The consumer submits
* an opaque reference and interprets the entries it reads back.
*
* ── The mechanism (see docs/decisions/discovery-model.md) ─────────────────
* Access and discovery are separate concerns. A public entity is world-readable
* with its NURI; the discovery index is how a client learns that NURI exists
* without holding a grant to read its creator's other documents. There is one
* global index — an owned document (public read), fed via its own inbox. A
* creator deposits a reference into the index's inbox; reading the index folds
* those deposits into entries, deduplicating identical references along the way.
*
* ── The special account (polyfill owner) ──────────────────────────────────
* Ownership of a truly global index is undecided in the real platform, where an
* identity's apps and services see only what that identity shares. The polyfill
* therefore parks ownership on a reserved special account in the shim
* ({@link INDEX_ACCOUNT}). Its `public` scope document is the index document;
* deposits land in that document's inbox (a stable NURI: every client opening the
* same shared wallet resolves the same account, so the same document). This is
* the app-facing discovery path, in place of a cross-account fan-out
* (`store-registry.ts` `listEntityDocs`), which survives only as an internal
* fallback (see {@link readIndex}).
*
* ── Real target vs this emulation ─────────────────────────────────────────
* The intended real shape is: `submitToIndex` seals a reference into the index
* document's own inbox (a future `inbox_post_link`), and reading the index is a
* query on the materialized index document. Here, everything runs in-lib on the
* shared wallet (deposit via `inbox.post`, fold via `inbox.read`). Against real
* NextGraph the special account gives way to the decided global-index owner and
* `readIndex` points at that document; the consumer surface (`submitToIndex` /
* `readIndex`) is designed to survive that change unchanged.
*
* All NextGraph I/O routes through `inbox.ts` (which routes through the `docs`
* primitives, the real injected `ng`), so this module imports no `@ng-org`
* package.
*/
import * as inbox from "./inbox";
import { subscribeDoc } from "./subscribe";
import { ensureRepoOpen } from "./open-repo";
import { ensureAccount, reservedAccount } from "./store-registry";
import { getCaps } from "./polyfill";
import type { Nuri, PrincipalId } from "./types";
/**
* The reserved special account that owns the global discovery index in the
* polyfill. It hosts the index document but is never a real identity. It lives in
* the registry's reserved namespace ({@link reservedAccount}), whose key
* `normalizeId` can never produce, so an id of "index"/"@index" cannot hijack it
* (it normalizes to "index", a disjoint key). Removed against real NextGraph
* (see file header).
*/
export const INDEX_ACCOUNT = reservedAccount("index");
/** One entry as materialized from the discovery index. */
export interface IndexEntry {
/** The reference submitted by a creator (opaque — the consumer interprets it). */
ref: unknown;
/** The submitter, if identified; `null` when the submission was anonymous. */
from: PrincipalId | null;
/** Submission timestamp (ms epoch). */
ts: number;
}
/** Options for {@link submitToIndex}. */
export interface SubmitOptions {
/**
* Who is submitting. Omit for the current identity, or pass `null` for an
* anonymous submission. `from` is bound to the current identity by the inbox
* (naming another identity is rejected as a spoof — see {@link inbox.post}).
*/
from?: PrincipalId | null;
/**
* The NURI of the document being made discoverable. When given, the index
* admits only a public document: one under a non-public (protected/private)
* read policy is refused, so the world-readable index never exposes a governed
* document's NURI. Omit it only for a ref with no addressable document (rare);
* a governed document passes it so the guard can fire.
*/
doc?: Nuri;
/** Optional deposit timestamp (ms epoch). Omitted → `Date.now()`. Passing it
* keeps tests deterministic. */
ts?: number;
}
/**
* Resolve the NURI of the index document — the stable inbox where discovery
* submissions land. The special account owns this document (its `public` scope
* document, a real repo NURI from `docCreate`); deposits go into that document's
* inbox exactly as host-registration deposits go into a host inbox. Because the
* special account lives in the shim (persisted in the shared wallet's private
* store), EVERY client opening the same wallet resolves the same account → the
* same document NURI → ONE shared index for all clients. Distinct from
* host-registration inboxes because it is a distinct document NURI.
*/
async function indexInboxNuri(): Promise<Nuri> {
// Ensure the special account exists (idempotent) so its scope documents are
// created and stably resolvable across clients.
const record = await ensureAccount(INDEX_ACCOUNT);
return record.docPublic;
}
/**
* The NURI of the global discovery-index document (the inbox where submissions
* land). Exposed so a reactive reader ({@link watchShape}) that folds discovery
* into the public read-set can SUBSCRIBE to this document and re-resolve when a
* new public entity is announced. This is exactly {@link watchIndex}'s subscribe
* anchor. Removed against real NextGraph along with the special account.
*/
export async function indexDocNuri(): Promise<Nuri> {
return indexInboxNuri();
}
/**
* Submit a reference to the global discovery index — the SDK act "make this
* discoverable". Deposits `ref` into the index document's inbox via
* {@link inbox.post}; reading the index ({@link readIndex}) folds it into an
* entry. `ref` is opaque here (the consumer serializes whatever a client needs to
* later locate the entity — e.g. an entity document NURI plus discovery metadata).
* `from` follows the inbox convention (anonymous when `null`).
*
* When `opts.doc` names the document being surfaced, a document under a
* non-public read policy (protected/private) is refused: the global index is
* world-readable, so admitting a governed document's NURI would expose it past
* its scope.
*/
export async function submitToIndex(ref: unknown, opts?: SubmitOptions): Promise<void> {
const doc = opts?.doc;
if (doc !== undefined) {
const caps = getCaps();
// A governed doc is submittable ONLY if it is public (anonymous may read it).
if (caps.governsRead(doc) && !caps.canRead(doc, null)) {
throw new Error(
"[ng-eventually] submitToIndex: only PUBLIC documents may be submitted to " +
"the discovery index — a protected/private document must not be surfaced.",
);
}
}
const target = await indexInboxNuri();
await inbox.post(target, {
payload: ref,
...(opts && "from" in opts ? { from: opts.from } : {}),
...(opts?.ts !== undefined ? { ts: opts.ts } : {}),
});
}
/**
* Read the global discovery index. Reads every submission from the index inbox,
* deduplicates by serialized `ref` (a duplicate submission surfaces once — the
* discovery model's moderation point), and returns the entries sorted by `ts`
* ascending. Against real NextGraph this becomes a query on the materialized
* index document.
*/
export async function readIndex(): Promise<IndexEntry[]> {
const target = await indexInboxNuri();
// COLD-START heal (polyfill-era): on a FRESH session over a persistent wallet the
// discovery-index inbox repo is not yet in the verifier's `self.repos`, so the
// anchored `inbox.read` below would resolve an unopened repo and silently return 0
// deposits — the same self-inflicted cold-read gap `readScopeIndex`/`readUnion`
// heal. This is what made the PUBLIC read's discovery fold come back empty on a
// reconnect, so a fresh page's home stayed empty for tens of seconds while the doc
// slowly synced by other means. Open/subscribe the index repo ONCE and await its
// first `State` (the sync barrier) before the anchored read. Idempotent per session;
// no-op with the unit fake ng (no `doc_subscribe`). This is done HERE (a cold direct
// reader) rather than inside `inbox.read`, because `inbox.watch` already holds the
// repo open via its own subscription and must not spawn a second bootstrap open. See
// open-repo.ts.
await ensureRepoOpen(target);
const deposits = await inbox.read(target);
const seen = new Set<string>();
const entries: IndexEntry[] = [];
for (const d of deposits) {
// Dedup on the serialized reference — the materialization moderation point.
const key = JSON.stringify(d.payload ?? null);
if (seen.has(key)) continue;
seen.add(key);
entries.push({ ref: d.payload, from: d.from, ts: d.ts });
}
return entries;
}
/**
* Watch the discovery index — **event-driven, not polled**. Subscribes to the
* index document via {@link subscribeDoc} (the platform's `doc_subscribe` push):
* `onEntries` fires once on the initial state push and again on every subsequent
* change to the index document — a local submission OR a broker-synced remote one.
* Returns an unsubscribe. (Deduplication is applied on each read.)
*
* The `intervalMs` option is accepted for signature compatibility but IGNORED:
* there is no polling. The index is a single document, so this is immune to the
* ORM fan-out hang (see {@link subscribeDoc}).
*/
export function watchIndex(
onEntries: (entries: IndexEntry[]) => void,
_opts?: { intervalMs?: number },
): () => void {
let stopped = false;
let lastCount = -1;
let unsubscribe: (() => void) | null = null;
const refresh = async (): Promise<void> => {
if (stopped) return;
try {
const entries = await readIndex();
if (!stopped && entries.length !== lastCount) {
lastCount = entries.length;
onEntries(entries);
}
} catch (error) {
console.error("[discovery] watchIndex read failed:", error);
}
};
// The index document NURI is resolved async (ensureAccount); subscribe once it
// is known. The initial State push fires the first read (onEntries fires once),
// each later Patch a re-read.
void (async () => {
try {
const anchor = await indexInboxNuri();
if (stopped) return;
unsubscribe = subscribeDoc(anchor, () => void refresh());
} catch (error) {
console.error("[discovery] watchIndex subscribe failed:", error);
}
})();
return () => {
stopped = true;
if (unsubscribe) {
unsubscribe();
unsubscribe = null;
}
};
}
+42
View File
@@ -15,6 +15,8 @@
import { getConfig } from "./polyfill";
import { logAccess, enabled as accessLogEnabled } from "./access-log";
import { isNuri } from "./nuri";
import { assertMayReach } from "./reach";
import type { Nuri } from "./types";
// The low common point for ALL document access: every read in the SDK routes
@@ -50,6 +52,15 @@ export async function docCreate(
): Promise<Nuri> {
const { ng } = getConfig();
const nuri = await ng.doc_create(sessionId, crdt, cls, dest, store);
// The BROKER boundary. `ng` is a permissive property bag (`NgLike`), so what
// comes back is `any` and this function's `Promise<Nuri>` would otherwise be an
// unchecked promise — every typed NURI downstream rests on it. Validate once,
// here, rather than let a non-reference propagate as a document.
if (typeof nuri !== "string" || !isNuri(nuri)) {
throw new Error(
`[ng-eventually] docCreate: the broker returned something that is not a NextGraph reference: ${JSON.stringify(nuri)}`,
);
}
// A container creation is a WRITE; the NURI only exists after the call.
logAccess("WRITE", nuri, "docCreate");
return nuri;
@@ -68,11 +79,38 @@ export async function sparqlUpdate(
label = "sparqlUpdate",
): Promise<void> {
const { ng } = getConfig();
// The boundary: a write may only touch what the connected virtual user reaches.
if (anchor !== undefined) assertMayReach(anchor, "docs.sparqlUpdate");
// `label` is a lib-internal access-log tag, NOT forwarded to `ng`.
logAccess("WRITE", anchor ?? "(no anchor)", label);
return ng.sparql_update(sessionId, query, anchor);
}
/**
* Deposit into ANOTHER virtual user's inbox — the one write that legitimately
* crosses the boundary, and therefore the one that skips {@link assertMayReach}.
*
* Why this is a separate primitive rather than a flag: depositing is not "a write
* that happens to be allowed", it is a different act. You cannot read the inbox you
* deposit into, you hold no cap for it, and you get nothing back — upstream it is an
* anonymous sealed box. Naming the exception makes it greppable and keeps
* {@link sparqlUpdate} free of a bypass that would otherwise be reusable for
* anything.
*
* The recipient's ownership of the inbox is what bounds this: `inbox.post` is the
* only caller, and reading is guarded separately (`inbox.read`).
*/
export async function depositInto(
sessionId: string,
query: string,
targetInbox: Nuri,
label = "deposit",
): Promise<void> {
const { ng } = getConfig();
logAccess("WRITE", targetInbox, label, " (cross-user deposit)");
return ng.sparql_update(sessionId, query, targetInbox);
}
/**
* Run a SPARQL SELECT/CONSTRUCT/ASK query → the raw SDK result.
*
@@ -87,6 +125,10 @@ export async function sparqlQuery(
label = "sparqlQuery",
): Promise<unknown> {
const { ng } = getConfig();
// The boundary: an ANCHORED read may only touch what the connected virtual user
// reaches. An anchorless query spans the local union — a different problem (it is
// O(wallet size), and the read path never uses it), not one this guard can bound.
if (anchor !== undefined) assertMayReach(anchor, "docs.sparqlQuery");
// `label` is a lib-internal access-log tag, NOT forwarded to `ng`.
const result = await ng.sparql_query(sessionId, query, base, anchor);
// Log AFTER the read so the row count (a strong leak signal: a doc rendering
+151 -7
View File
@@ -24,11 +24,13 @@
* never `makeNg`), so this module imports no `@ng-org` package.
*/
import { sparqlUpdate, sparqlQuery } from "./docs";
import { depositInto, sparqlQuery } from "./docs";
import { subscribeDoc } from "./subscribe";
import { ensureRepoOpen } from "./open-repo";
import { getCurrentUser, getStoreRegistryDeps } from "./polyfill";
import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./polyfill";
import { addLink, isOwnInbox } from "./store-registry";
import { escapeLiteral } from "./sparql";
import { hasReadCap } from "./nuri";
import {
accessLogPrefix,
enabled as accessLogEnabled,
@@ -36,7 +38,7 @@ import {
logStage,
shortNuri,
} from "./access-log";
import type { Nuri, PrincipalId } from "./types";
import type { Nuri, PrincipalId, ReadCap } from "./types";
// --- deposit model --------------------------------------------------------
@@ -172,7 +174,8 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void>
<${P.payload}> "${payloadLiteral}" ;
<${P.ts}> "${ts}"${fromTriple} .
}`;
await sparqlUpdate(sid, update, targetInbox, "deposit");
// A deposit crosses the boundary on purpose — see docs.depositInto.
await depositInto(sid, update, targetInbox, "deposit");
// Domain-level diagnostic (on top of docs.ts's generic access-path WRITE log):
// who deposited WHAT into which inbox — the decoded payload, not just the
// triple-write. Gated by the same access-log flag; skip the JSON work when off.
@@ -186,6 +189,93 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void>
}
}
// --- cap delivery ---------------------------------------------------------
/**
* A **Link** — the deposit that carries a ReadCap. The word is upstream's, and it
* is the same one at all three stages: `InboxMsgContent::Link` is the message
* (`engine/net/src/types.rs:4249-4261`, declared but payload-less so far),
* `AddLink { read_cap }` is where the recipient files it (`repo/types.rs:1934-1950`),
* `RemoveLink` withdraws it. So giving access is: deposit a Link, and on connection
* the recipient processes their inbox and files it.
*
* It travels the SAME channel as any other deposit, which is why key ROTATION needs
* no special case on the surface — a re-delivered cap is just another Link.
*/
const LINK_KIND = "urn:ng-eventually:inbox:link";
/** Links observed during the last read of an inbox, awaiting durable filing. */
const seenByInbox = new Map<Nuri, ReadCap[]>();
function capsSeenIn(inbox: Nuri): ReadCap[] {
return seenByInbox.get(inbox) ?? [];
}
/** The cap a deposit carries, if it is a Link rather than consumer data. */
function capOfPayload(payload: unknown): ReadCap | null {
const p = payload as { kind?: unknown; cap?: unknown } | null;
if (!p || typeof p !== "object" || p.kind !== LINK_KIND) return null;
return typeof p.cap === "string" && hasReadCap(p.cap) ? p.cap : null;
}
/**
* Share ONE document's read cap with ONE recipient, addressed by their inbox.
*
* The unit of sharing is the DOCUMENT: never hand over a store's cap, which would
* give away everything the store contains, present and future. The recipient needs
* no dedicated operation to receive it — the cap arrives as a deposit that their
* existing {@link watch} absorbs into what they hold (see {@link read}).
*
* Reaching several recipients means calling this once per inbox, which is what the
* real model does too: each delivery is sealed to one recipient.
*
* Upstream this path is a GAP, not a disagreement: the field exists
* (`ContactDetails.read_cap`) but its message construction is `unimplemented!()`
* and the receiver discards the cap. The shape is right; the implementation is
* absent, so we emulate it meanwhile.
*/
export async function shareCap(cap: ReadCap, toInbox: Nuri): Promise<void> {
if (!hasReadCap(cap)) {
throw new Error(
"[ng-eventually] inbox.shareCap: expected a ReadCap (a NURI carrying `:r:`), " +
`got a bare reference — naming is not reading: ${JSON.stringify(cap)}`,
);
}
await post(toInbox, { payload: { kind: LINK_KIND, cap } });
}
// --- the read guard ------------------------------------------------------
/**
* Refuse to READ an inbox that is not the current wallet's.
*
* Depositing into someone else's inbox is the one legitimate cross-wallet act (it
* is how a link reaches another wallet at all — see {@link post} / {@link shareCap});
* READING one is not, and it is not symmetric with it. Since caps travel as
* deposits, an unguarded read let anyone who knew an inbox NURI collect the caps
* addressed to its owner, which defeats directed sharing entirely.
*
* Anonymous owns no inbox, so it can read none — an identity has to be established
* first. At migration this disappears: an inbox is sealed to its owner's key, and
* the guard is the cryptography.
*/
async function assertOwnInbox(targetInbox: Nuri, op: string): Promise<void> {
if (getCurrentUser() === null) {
throw new Error(
`[ng-eventually] inbox.${op}: no identity is set, so no inbox belongs to this ` +
"session — call setCurrentUser() first. Depositing (post/shareCap) stays open.",
);
}
if (!(await isOwnInbox(targetInbox))) {
throw new Error(
`[ng-eventually] inbox.${op}: refusing to read an inbox that does not belong to ` +
"the connected wallet. You may DEPOSIT into anyone's inbox; you may only READ " +
"your own — otherwise the caps addressed to its owner would be collectable by " +
`whoever knows its NURI: ${JSON.stringify(targetInbox)}`,
);
}
}
// --- read --------------------------------------------------------------
/**
@@ -194,8 +284,15 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void>
* it processes the inbox; here this read stands in for that until the
* sealed-inbox path is available. The consumer interprets each deposit's
* `payload`.
*
* Cap deliveries ({@link shareCap}) are applied inline and NOT returned: they land
* in what the current holder holds, like the verifier applying a queued message.
* That is why receiving a cap needs no dedicated operation — a consumer already
* watching its inbox gets them, and the resulting change re-triggers the
* reads that were empty for want of that cap.
*/
export async function read(targetInbox: Nuri): Promise<Deposit[]> {
await assertOwnInbox(targetInbox, "read");
const sid = await sessionId();
// NOTE: cold-start repo opening is done by the COLD DIRECT readers that need it
// (e.g. `discovery.readIndex` → `ensureInboxRepoOpen`), NOT here — `inbox.watch`
@@ -230,6 +327,23 @@ export async function read(targetInbox: Nuri): Promise<Deposit[]> {
deposits.push({ from: fromValue ? fromValue : null, payload, ts });
}
deposits.sort((a, b) => a.ts - b.ts);
// Links are infrastructure, not consumer data: they never reach the caller. They
// are only KEPT here (in memory, for this session) — FILING them durably is
// `processInbox`'s job, because reading an inbox must not quietly write to a
// user's store. Filing fires the registry's change signal, which is what makes a
// view that was empty for want of that cap re-read instead of staying stale.
const delivered: Deposit[] = [];
const links: ReadCap[] = [];
for (const d of deposits) {
const cap = capOfPayload(d.payload);
if (cap) {
getCaps().learn(cap);
links.push(cap);
continue;
}
delivered.push(d);
}
if (links.length > 0) seenByInbox.set(targetInbox, links);
// Domain-level diagnostic (on top of docs.ts's generic access-path READ log
// of raw triple-rows): how many DEPOSITS were found, and the decoded data of
// each — the exact visibility needed to trace materialization at the owner
@@ -239,9 +353,12 @@ export async function read(targetInbox: Nuri): Promise<Deposit[]> {
"READ",
targetInbox,
"inbox materialize",
" → " + deposits.length + " message(s)",
" → " + delivered.length + " message(s)" +
(deposits.length !== delivered.length
? " (+" + (deposits.length - delivered.length) + " cap deliver(y/ies) absorbed)"
: ""),
);
for (const d of deposits) {
for (const d of delivered) {
logAccess(
"READ",
targetInbox,
@@ -250,7 +367,7 @@ export async function read(targetInbox: Nuri): Promise<Deposit[]> {
);
}
}
return deposits;
return delivered;
}
/** Alias for {@link read} — the name that reads as "process the inbox now". */
@@ -282,10 +399,35 @@ export async function readSynced(targetInbox: Nuri): Promise<Deposit[]> {
// (from the read() this wraps) follow right after, so a live session shows
// the whole owner-reconnect sequence together.
logStage("READSYNCED " + shortNuri(targetInbox) + " (cold, barrier-gated)");
await assertOwnInbox(targetInbox, "readSynced");
await ensureRepoOpen(targetInbox);
return read(targetInbox);
}
/**
* PROCESS an inbox: read it, and **apply** what it contains.
*
* Applying a {@link shareCap} Link means filing it durably — `storeRegistry.addLink`,
* the emulated `AddLink { read_cap }` on the User branch of the private store — so
* the cap survives the session. Upstream this is what a verifier does when it
* processes queued messages: an inbox is a **queue you consume**, not a store you
* re-read. Re-reading an inbox every session to recover caps is using a queue as a
* database, and it is the thing this replaces.
*
* Idempotent: `addLink` ignores a Link it already holds, so processing twice (a
* second tab, a reconnect) costs nothing. Returns the consumer deposits, exactly as
* {@link read} does — Links are never surfaced.
*/
export async function processInbox(targetInbox: Nuri): Promise<Deposit[]> {
const deposits = await readSynced(targetInbox);
// `readSynced` already put every Link in memory for this session; now make
// them durable. Reading the raw deposits again would mean re-parsing, so the caps
// are taken from what the read just observed.
for (const cap of capsSeenIn(targetInbox)) await addLink(cap);
seenByInbox.delete(targetInbox);
return deposits;
}
/**
* Subscription over an inbox — **event-driven, not polled**. Subscribes to the
* inbox document via {@link subscribeDoc} (the platform's `doc_subscribe` push):
@@ -341,6 +483,8 @@ export function watch(
// Subscribe to the inbox document: the initial State push fires the first read
// (so onDeposits fires once immediately, as before), each later Patch a re-read.
// The ownership guard runs inside `read`, so a watch on someone else's inbox
// yields nothing but logged refusals rather than their deposits.
const unsubscribe = subscribeDoc(targetInbox, () => void refresh());
return () => {
stopped = true;
+9 -2
View File
@@ -17,8 +17,6 @@ export { watchShape } from "./watch-shape";
export type { ShapeQuery, ShapeObservable } from "./watch-shape";
export { init, initNg } from "./lifecycle";
export * as inbox from "./inbox";
export * as discovery from "./discovery";
export type { IndexEntry, SubmitOptions } from "./discovery";
export * as docs from "./docs";
export { subscribeDoc, subscribeDocs, docChangeType } from "./subscribe";
export type { DocChange, DocChangeType, Unsubscribe } from "./subscribe";
@@ -35,6 +33,15 @@ export type { AccountStorage } from "./accounts";
// validate trusted-shaped NURIs before embedding them in an IRI.
export { escapeLiteral, escapeIri, assertNuri } from "./sparql";
// NURI type guards — the doors through which an app's own `string` (read back
// from storage, a URL, JSON, a form) becomes a typed `Nuri` or `ReadCap`. `Nuri`
// and `ReadCap` are template literal types, so an app that narrows with these
// gets the same compile-time distinction the library uses internally — in
// particular, it cannot pass a bare reference where a cap is required. Narrow
// with these rather than casting: a cast re-opens exactly the confusion the
// types exist to close.
export { isNuri, hasReadCap } from "./nuri";
// SDK type re-exports — so the app imports these from @ng-eventually/client too,
// not from @ng-org. `export type` is ERASED at build, so this adds NO runtime
// @ng-org import to the lib (no risk of a duplicate SDK copy in the bundle).
+118
View File
@@ -0,0 +1,118 @@
/**
* NURI primitives — the cap-less / cap-bearing distinction, kept as ONE object.
*
* Upstream a NURI is a single type, `NuriV0 { target, access }`: a cap-less NURI
* simply has an empty `access`. `did:ng:` is the URI SCHEME prefix (inboxes,
* branches and overlays all carry it) — it does NOT mean "without cap". The
* discriminant is the `:r:` segment:
*
* did:ng:o:{doc}:v:{overlay} — names, does NOT read (a {@link Nuri})
* did:ng:o:{doc}:v:{overlay}:r:{cap} — names AND reads (a {@link ReadCap})
*
* ── Why `:r:` and not `:k:` ────────────────────────────────────────────────
* Reported by NextGraph's developer and verified in the source: a **ReadCap** is
* `r:{base64url(serde_bare(ObjectRef))}` — `BlockRef::readcap_nuri()`,
* `engine/repo/src/types.rs:518-521` — where id AND key are serialized together
* into ONE opaque segment. The `:k:` forms are a different thing: they belong to
* **objects, files and commits** (`j:{id}:k:{key}`, `c:{id}:k:{key}`, `:510`/`:514`),
* where id and key are two separate segments. This library used `:k:` until
* 2026-07-30; it was the wrong letter *and* the wrong structure.
*
* These helpers are INTERNAL to the library. The parsed form {@link parseNuri}
* mirrors `NuriV0 { target, access }` 1:1 but never surfaces in the SDK-identical
* entry's signatures — the real SDK takes plain `String`s and enforces at runtime,
* through cryptography, so no branded type and no parsed struct leaks outward.
*
* ── The stand-in key (deliberately NOT a secret) ───────────────────────────
* This library is deliberately insecure (see docs/vision.md). The only question it
* can answer is **do I hold this document's cap, or not** — so the key value is the
* constant `OK`, which says exactly that and pretends nothing more. What identifies
* the document is the NURI the key is attached to; the value carries no information.
* Real per-document encryption is P1b's job, and it replaces this one constant.
* Until then, possession is a SHAPE, not a protection.
*/
import type { Nuri, ReadCap } from "./types";
/** The URI scheme prefix every NextGraph reference carries. */
const SCHEME = "did:ng:";
/** The segment that turns a naming NURI into a reading one — upstream's ReadCap
* encoding (`readcap_nuri`), NOT the `:k:` used for objects/files/commits. */
const CAP_SEGMENT = ":r:";
/**
* Is this string a NextGraph reference at all? A **type guard**: it is the door
* through which an untrusted `string` — a SPARQL binding, an ORM `@graph`, a value
* an app read back from storage or a URL — becomes a {@link Nuri}. Exported from
* the SDK entry so a consumer narrows its own strings the same way, rather than
* casting.
*/
export function isNuri(s: string): s is Nuri {
return s.startsWith(SCHEME);
}
/**
* Does this reference carry a read cap (a `:r:` segment)? A **type guard**: the
* ONLY narrowing from a bare string (or a {@link Nuri}) to a {@link ReadCap}.
* Nothing else may produce a `ReadCap` from a reference that carries no key —
* that would be deriving a cap from a bare reference, which the model forbids.
*/
export function hasReadCap(s: string): s is ReadCap {
return isNuri(s) && s.includes(CAP_SEGMENT);
}
/**
* The cap-less form of a reference — what it NAMES, with any cap stripped.
*
* The one internal cast of this module, and it is load-bearing: `slice` returns
* `string`, yet slicing a `did:ng:…` at the `:r:` boundary can only yield a
* `did:ng:…` — which the compiler cannot know. Keeping the cast HERE, in the
* primitive that defines the contract, is what lets every caller stay typed with
* no cast of its own.
*/
export function targetOf(nuri: Nuri): Nuri {
const i = nuri.indexOf(CAP_SEGMENT);
return i === -1 ? nuri : (nuri.slice(0, i) as Nuri);
}
/**
* The parsed form — a 1:1 mirror of upstream `NuriV0 { target, access }`, where a
* cap-less NURI has no `readCap`. Library-internal (see the module header).
*/
export function parseNuri(nuri: Nuri): { target: Nuri; readCap?: ReadCap } {
return hasReadCap(nuri) ? { target: targetOf(nuri), readCap: nuri } : { target: nuri };
}
/**
* The stand-in cap value. A CONSTANT, on purpose.
*
* Upstream this segment carries `base64url(serde_bare(ObjectRef))` — the block id
* and its key serialized together. Here it carries `OK`.
*
* The only question this library can answer today is **do I hold this document's
* cap, or not** — a boolean. An earlier version derived a per-document digest,
* which looked like a key and was not one: it invited the reader to believe
* something was protected, and it made "the key is reproducible" a subtlety to
* explain rather than a fact you can see. `OK` says what it is — a presence
* marker. The document a cap opens is already identified by the NURI it is
* attached to, so the value carries no information anyway.
*
* P1b replaces this single constant with a real key. Nothing else has to change:
* every path already reads a cap rather than recomputing one.
*/
const STAND_IN_CAP = "OK";
/**
* Build the cap-bearing form of `nuri` — `{target}:r:OK`. Passing an already
* cap-bearing reference yields the same value.
*
* This is INTERNAL: nothing on the library's surface turns a bare reference into a
* cap, because that is not how the model works — you look a cap up in what you
* hold, or you were given it (see `caps.ts`).
*
* No cast needed on the way out: the compiler derives `` `did:ng:…:r:…` `` from the
* template itself, which is exactly the {@link ReadCap} shape.
*/
export function mintCap(nuri: Nuri): ReadCap {
return `${targetOf(nuri)}${CAP_SEGMENT}${STAND_IN_CAP}`;
}
+29 -4
View File
@@ -3,7 +3,7 @@
*
* ── The cold-start defect this heals ──────────────────────────────────────
* The anchored read path (`read-model.ts` `readDoc`, `store-registry.ts`
* `readScopeIndex`) assumes the target repo is already in the verifier's
* `readUserStore`) assumes the target repo is already in the verifier's
* `self.repos` — true within the session that CREATED the doc (every `doc_create`
* opens it), but FALSE on a FRESH session over the same persistent wallet
* (reconnection / new page / re-login). On that fresh session nothing has opened
@@ -13,7 +13,7 @@
*
* The circularity that made this self-inflicted: `doc_subscribe` WOULD open the
* repo, but the reactive layer only subscribes AFTER the listing produced NURIs —
* and the listing (`readScopeIndex`) is itself an anchored read of a not-yet-open
* and the listing (`readUserStore`) is itself an anchored read of a not-yet-open
* index repo → 0 rows → nothing to subscribe → nothing ever opens. Verified fix
* (adversarial pass): on a fresh session, `doc_subscribe(<docNuri>)` THEN the
* anchored re-read returns the data. So we OPEN the repo before the anchored read.
@@ -59,8 +59,9 @@
* resolves a same-session repo directly. Polyfill-era, removed with the shim.
*/
import { mustNotAttempt } from "./reach";
import { getConfig, getStoreRegistryDeps } from "./polyfill";
import { subscribeDoc, type Unsubscribe } from "./subscribe";
import { subscribePhysicalDoc, type Unsubscribe } from "./subscribe";
import { logStage, shortNuri } from "./access-log";
import type { Nuri } from "./types";
@@ -165,6 +166,27 @@ async function syncSession(): Promise<void> {
*/
export async function ensureRepoOpen(nuri: Nuri): Promise<void> {
if (!nuri) return;
// RULE 2 — do not even attempt. Opening a repo IS an access: it subscribes and
// pulls its state. A user that holds no cap for it has no business asking.
// (`ensurePhysicalRepoOpen` is the machinery's door — see physical.ts.)
if (mustNotAttempt(nuri)) return;
return openRepoUnguarded(nuri);
}
/**
* Open a repo as the PHYSICAL user — the shim's own documents (store-root,
* doc-shim). The machinery's counterpart to {@link ensureRepoOpen}: resolving
* WHICH documents a virtual user owns cannot itself be confined to that user.
* See `physical.ts` for why this is a separate function and not an exemption.
*
* Never exported from the package.
*/
export async function ensurePhysicalRepoOpen(nuri: Nuri): Promise<void> {
if (!nuri) return;
return openRepoUnguarded(nuri);
}
async function openRepoUnguarded(nuri: Nuri): Promise<void> {
// Drop the registry if the session changed (in-page re-login → fresh verifier).
await syncSession();
if (opened.has(nuri)) return;
@@ -214,7 +236,10 @@ export async function ensureRepoOpen(nuri: Nuri): Promise<void> {
// is the whole point. We wait for the FIRST `State` event specifically (the
// barrier), NOT any push: the platform pushes `TabInfo` before `State`, and
// resolving on `TabInfo` would return before the real sync barrier.
const unsub = subscribeDoc(nuri, (_r, type) => {
// Unguarded on purpose: the caller already decided. `ensureRepoOpen` applied
// rule 2 above; `ensurePhysicalRepoOpen` is the machinery's door and is not
// subject to the boundary at all (see physical.ts).
const unsub = subscribePhysicalDoc(nuri, (_r, type) => {
if (type === "State") onState();
});
held.set(nuri, unsub);
+103
View File
@@ -0,0 +1,103 @@
/**
* physical — the polyfill's OWN machinery, operating on the PHYSICAL user.
*
* ── Two levels, two APIs, and only one of them is the app's ───────────────
* NextGraph sees exactly one user: the physical one, whose wallet everybody opens.
* On top of it the library fabricates **virtual users** — what the consumer calls
* an identity. Those are two different levels, and conflating them is how a
* boundary gets a hole in it:
*
* | | Level | Who calls it | Guarded |
* |---|---|---|---|
* | `docs.*`, `subscribeDoc` | the **virtual user** | the consumer app, and the library on the user's behalf | YES — confined to the connected user (`reach.ts`) |
* | this module | the **physical user** | the library's own machinery, and nothing else | no — it *is* the machinery the boundary is built on |
*
* **Nothing here is exported from the package.** `index.ts` must never re-export
* this module: an app holding these functions could read any document of any
* virtual user, which is precisely the boundary they exist below.
*
* ── Why a separate module rather than exemptions ──────────────────────────
* The store-root pointer and the doc-shim — the index of virtual users — cannot be
* subject to the boundary: resolving *which* documents a virtual user owns is what
* makes virtual users exist at all. An earlier version handled that with a list of
* exempt NURIs consulted by the guard. Separating the FUNCTIONS is stronger: the
* machinery does not call the guarded primitive and get waved through, it calls a
* different primitive that was never guarded. There is no exemption list to widen,
* to get wrong, or to infer.
*
* The rule for deciding which side a call belongs to:
*
* > Does this operate on the index of virtual users (the shim), or on the content
* > of one virtual user? The first is machinery; everything else is the user's,
* > and is confined.
*
* A virtual user's own stores, its inbox and its documents are the user's — they go
* through `docs.*` and are guarded, even though the library is what calls them.
*
* At migration this module disappears with the shim: there is no physical/virtual
* split once each user opens their own wallet.
*/
import { getConfig } from "./polyfill";
import { logAccess } from "./access-log";
import { isNuri } from "./nuri";
import type { Nuri } from "./types";
/**
* Create a document as the PHYSICAL user — the shim's own documents (the doc-shim,
* a virtual user's store documents at provisioning time, an inbox document).
*
* Creation is the one operation with no boundary to check: the document does not
* exist yet, so nobody can hold its cap. What matters is who is credited with it
* afterwards, which the caller decides by filing the cap among the caps that holder holds.
*/
export async function physicalCreate(
sessionId: string,
crdt = "Graph",
cls = "data:graph",
dest = "store",
store?: unknown,
): Promise<Nuri> {
const { ng } = getConfig();
const nuri = await ng.doc_create(sessionId, crdt, cls, dest, store);
if (typeof nuri !== "string" || !isNuri(nuri)) {
throw new Error(
`[ng-eventually] physicalCreate: the broker returned something that is not a NextGraph reference: ${JSON.stringify(nuri)}`,
);
}
logAccess("WRITE", nuri, "physicalCreate");
return nuri;
}
/**
* Read as the PHYSICAL user — for the shim only (the store-root pointer, the
* doc-shim's account records).
*
* Unguarded by design: this is how the library learns which documents a virtual
* user owns, so it cannot itself depend on knowing that. Do not reach for it to
* read a virtual user's content — that is `docs.sparqlQuery`, which is confined.
*/
export async function physicalQuery(
sessionId: string,
query: string,
base: string | undefined,
anchor: Nuri,
label = "physicalQuery",
): Promise<unknown> {
const { ng } = getConfig();
const result = await ng.sparql_query(sessionId, query, base, anchor);
logAccess("READ", anchor, label, " (physical)");
return result;
}
/** Write as the PHYSICAL user — the shim's own records. See {@link physicalQuery}. */
export async function physicalUpdate(
sessionId: string,
query: string,
anchor: Nuri,
label = "physicalUpdate",
): Promise<void> {
const { ng } = getConfig();
logAccess("WRITE", anchor, label, " (physical)");
return ng.sparql_update(sessionId, query, anchor);
}
+68 -10
View File
@@ -8,11 +8,12 @@
* here is removed at migration.
*/
import type { NgLike, UseShapeLike, PrincipalId } from "./types";
import type { NgLike, UseShapeLike, Nuri, PrincipalId, ReadCap } from "./types";
import type { RegistrySession } from "./store-registry";
import { CapRegistry } from "./caps";
import { setAccessLog } from "./access-log";
import { inspectOutbox } from "./outbox-log";
import { startConnect } from "./connect";
/**
* Consumer-injected dependencies of the storeRegistry (polyfill-era). The
@@ -70,9 +71,30 @@ type ResolvedRegistryDeps = Required<
Pick<StoreRegistryDeps, "getSession" | "normalizeId" | "pointerGuard">
>;
let registryDeps: ResolvedRegistryDeps | null = null;
/** The emulated ReadCap/WriteCap registry. Empty until the app declares caps;
* while it has no read policy the read filter passes through (no regression). */
let caps = new CapRegistry();
/**
* The map key of the current identity — deliberately NOT the raw id.
*
* A virtual user IS a shim account, and the shim keys accounts by the
* consumer-injected `normalizeId` ("@Alice" and "alice" are ONE account, with one
* set of scope documents). This record must key the same way, or a consumer that
* spells its own id differently between two calls gets a SECOND record and stops
* reading its own documents — the caps are filed under one spelling and looked up
* under the other. Falls back to the raw id while the registry deps are not yet
* configured (nothing can be filed before that anyway).
*/
function capsHolder(): PrincipalId | null {
if (currentUser === null) return null;
return registryDeps ? registryDeps.normalizeId(currentUser) : currentUser;
}
/**
* The emulated cap registry — one record PER identity (per virtual user),
* resolved through {@link capsHolder} on every call. So switching identity
* SWITCHES heldByHolder (nothing to reset, nothing wiped); see `caps.ts`. Empty until
* the first cap is issued, and while it is empty the read filter passes through
* (no regression).
*/
let caps = new CapRegistry(capsHolder);
export function configure(c: EventuallyConfig): void {
cfg = c;
@@ -147,25 +169,61 @@ export function resetStoreRegistry(): void {
* who is acting. Passing `null` clears it (no identity yet, e.g. during startup).
*/
export function setCurrentUser(id: PrincipalId | null): void {
const changed = currentUser !== id;
currentUser = id;
// Connecting a user is what triggers inbox processing — the library's job, not
// the app's. Fire-and-forget: this setter is synchronous and every consumer calls
// it from synchronous code, so the work announces itself through the cap
// registry's change signal instead of making callers await. See `connect.ts`.
//
// Gated on the registry being configured, and that is not a test convenience: an
// identity set before the session resolves has nothing to restore and no inbox to
// reach, so firing would be I/O that can only fail. The consumer's real sequence
// is `configureStoreRegistry` then `setCurrentUser`; anything else can call
// `connectedUser()` explicitly.
if (changed && id !== null && registryDeps !== null) startConnect();
}
export function getCurrentUser(): PrincipalId | null {
return currentUser;
}
/** The emulated cap registry — the app opens a document's read policy and issues
* directed read grants on it (as it will via real cap operations in the target).
* The read filter consults it. */
/** The emulated cap registry — what the current identity holds, plus the emulated
* public store. The read filter and the read-model consult it. */
export function getCaps(): CapRegistry {
return caps;
}
/** Reset all emulated caps (mainly for tests / fresh sessions). */
/**
* Do I hold the cap of `nuri`? — the held-caps lookup, the ONLY way a cap is
* obtained besides being given one. Returns `undefined` when what I hold has none;
* that is the whole answer the model can give (there is no "may P read D?").
*
* Shorthand for `getCaps().capFor(nuri)`, exposed because it is the surface the
* consumer actually uses.
*/
export function capFor(nuri: Nuri): ReadCap | undefined {
return caps.capFor(nuri);
}
/**
* Drop EVERY holder's caps (tests / a fresh wallet). This is **not** what an identity
* change does: switching identity switches heldByHolder, it never wipes one — if it
* wiped, durability would be a lie and per-session re-declaration would come back
* under another name. Nothing in the library calls this on `setCurrentUser`.
*/
export function resetCaps(): void {
caps = new CapRegistry();
// Clear IN PLACE rather than rebuilding: whoever subscribed to the registry's
// change signal (`watchShape`) stays subscribed to the live instance instead of
// silently holding a listener on an orphaned one.
caps.clear();
}
// Cap surface — polyfill-era (caps are emulated now; native at migration).
// Re-exported here so the whole polyfill API lives under /polyfill.
// Re-exported here so the whole polyfill API lives under /polyfill. `shareCap`
// lives in `inbox.ts` because sharing IS an inbox deposit (upstream: a sealed
// message carrying the cap), but it is surfaced here so the cap vocabulary stays
// on the polyfill side of the boundary rather than in the SDK-identical entry.
export { CapRegistry } from "./caps";
export { shareCap } from "./inbox";
export { connectedUser } from "./connect";
+133
View File
@@ -0,0 +1,133 @@
/**
* reach — may the CONNECTED virtual user touch this document at all?
*
* The one predicate every path to `ng` consults, so the boundary is decided in a
* single place instead of being re-argued at each call site.
*
* ── The boundary ──────────────────────────────────────────────────────────
* A virtual user must simulate the boundary of the future single-user wallet:
* every access function is confined to the user currently connected
* (`setCurrentUser`), and no cross-user access is permitted. Otherwise the
* consumer is coded against a reach that will never exist — the same failure mode
* as an ACL where the real model is key possession, one level down.
*
* Two ways a document is legitimately reachable, and no others:
*
* 1. **You hold its cap.** Either because you created it (the store refiles the
* cap) or because someone delivered it to you. This is the whole of the
* access model, so it is the whole of the predicate.
* 2. **It is declared INFRASTRUCTURE.** A short, explicitly-registered list —
* never inferred from the shape of a NURI, because an inferred exemption is
* a hole. See {@link declareInfrastructure}.
*
* ── What may be exempt, and why so little ─────────────────────────────────
* > The only reads/writes not confined to a virtual user are those that make
* > multi-user operation possible at all. Nothing common — only the indexing
* > mechanisms that make the virtual users work.
*
* The test an exemption must pass: *does removing it stop the virtual users from
* functioning, or does it merely stop users from seeing each other's content?*
* Only the first qualifies. The shim passes (remove it and no user is resolvable
* at all); a shared index of user content does not (remove it and every user still
* works — you simply have to be given links).
*
* Depositing into another user's inbox is NOT handled here: it is a write to a
* document you do not hold, and it is legitimate — the only channel by which a
* link crosses from one user to another, hence the bootstrap of the whole
* reachability graph. It is allowed at the inbox surface, which is where the
* asymmetry (deposit yes, read no) is expressed.
*
* At migration this module disappears: the boundary becomes the wallet itself.
*/
import { getCaps } from "./polyfill";
import { targetOf } from "./nuri";
import type { Nuri } from "./types";
/**
* NURIs of the polyfill's own scaffolding, registered as they are resolved.
*
* Explicit registration rather than pattern-matching: the store-root and the
* doc-shim are exempt because they ARE the index of virtual users, not because
* they look a certain way. A NURI is in here because some code path put it here,
* knowing what it was.
*/
const infrastructure = new Set<Nuri>();
/**
* Register `nuri` as scaffolding that the boundary does not apply to. Called by
* the store-registry as it resolves the store-root pointer and the doc-shim —
* the only two documents that qualify, because without them no virtual user can
* be resolved at all.
*
* Deliberately NOT exported from the package: nothing outside the library may
* widen the exemption list.
*/
export function declareInfrastructure(nuri: Nuri): void {
infrastructure.add(nuri);
}
/** Is `nuri` registered scaffolding? */
export function isInfrastructure(nuri: Nuri): boolean {
return infrastructure.has(nuri);
}
/** Forget every declared exemption (tests / a fresh wallet). */
export function resetInfrastructure(): void {
infrastructure.clear();
}
/**
* Do we POSSESS the cap of `nuri`? Not "does this string carry one" — a caller may
* legitimately be holding the bare form and possess the cap elsewhere, which is the
* normal case: NURIs travel bare through content and indexes, while the cap sits in
* what the user holds. Possession is what decides; the shape of the reference the
* caller happens to have in hand decides nothing.
*
* `targetOf` first, so a cap-bearing reference and its bare form answer alike.
*
* Inert until the first cap exists (`caps.isEnforcing()`), so a consumer that never
* touches caps keeps working. Once ANY cap has been issued the boundary applies to
* every user, including one holding nothing: that is the isolation.
*/
export function mayReach(nuri: Nuri): boolean {
const caps = getCaps();
if (!caps.isEnforcing()) return true;
const target = targetOf(nuri);
return isInfrastructure(target) || caps.capFor(target) !== undefined;
}
/**
* **Rule 1 — authorization**, at the PASSAGE POINTS (`docs.*`, `subscribe`).
*
* Nothing reaches `ng` unless the connected user possesses the document's cap. This
* is the guard: it fires on a request that should never have been made, and its job
* is to make sure the attempt fails rather than succeeds quietly.
*
* Deliberately duplicated with rule 2 below — see {@link mustNotAttempt}. Two rules,
* two places, one criterion: a lapse in either is caught by the other.
*/
export function assertMayReach(nuri: Nuri, op: string): void {
if (mayReach(nuri)) return;
throw new Error(
`[ng-eventually] ${op}: refused — the connected user does not hold this document's ` +
"cap. Naming a document does not grant access to it: a cap is looked up in what " +
`you hold, or it was delivered to you. ${JSON.stringify(nuri)}`,
);
}
/**
* **Rule 2 — do not even attempt**, at the CALLERS (`read-model`, `open-repo`,
* `subscribe`'s callers…).
*
* A reader that does not hold a document's cap must not issue the operation at all.
* Not attempting and being refused are different things: the first is a caller that
* knows what it holds, the second is one that hoped and got caught. Only the first
* is the model — upstream you cannot even address a repo you have no cap for.
*
* Practically it also stops the library from asking the broker for documents it has
* no business asking about, which is work, noise, and a leak of intent.
*/
export function mustNotAttempt(nuri: Nuri): boolean {
return !mayReach(nuri);
}
+29 -28
View File
@@ -1,47 +1,52 @@
/**
* Read filter — the polyfill of capability-based read access.
*
* In the target, the broker only delivers documents the user holds a **ReadCap**
* for, so `useShape` already returns an authorized subset. Here (single shared
* In the target, the broker only delivers documents the holder has the **ReadCap**
* of, so `useShape` already returns an authorized subset. Here (single shared
* wallet, everything readable) we reproduce that with a read-filtered VIEW over
* the reactive set: it keeps only items whose **document** (its `@graph` = the
* repo it lives in) the current user may read, per the {@link CapRegistry}.
* repo it lives in) is in what the current holder holds, per the
* {@link CapRegistry}.
*
* Faithful to NextGraph: the access unit is the DOCUMENT, not the item. In a
* mono-store layout (every item in one repo) the filter is therefore all-or-
* nothing on that document — which is exactly the native behavior, and why
* fine-grained isolation requires one document per entity. Removed at migration.
*
* Note there is no `user` parameter anywhere below, and that is the point: reading
* is key possession, so the only question askable is "do I hold this document's
* cap?". "May principal P read document D?" is an ACL question the real model
* cannot answer either. Which holder's caps are consulted follows the identity the
* registry resolves, so the view reflects the holder in effect at read time.
*/
import type { CapRegistry } from "./caps";
import type { PrincipalId } from "./types";
import { isNuri } from "./nuri";
import type { Nuri } from "./types";
/** The document (repo NURI) an item lives in — its `@graph`. */
function docOf(item: unknown): string | null {
/** The document (repo NURI) an item lives in — its `@graph`. The ORM boundary:
* `@graph` is an untyped value on a property bag, so it is narrowed here rather
* than cast. Anything that is not a NextGraph reference names no document. */
function docOf(item: unknown): Nuri | null {
const g = (item as Record<string, unknown> | null)?.["@graph"];
return typeof g === "string" ? g : null;
return typeof g === "string" && isNuri(g) ? g : null;
}
/**
* May `user` read this item? An item with no `@graph`, or in a document under no
* cap policy, is KEPT (the filter only restricts documents that DECLARE a cap —
* mirrors the prior behavior and keeps ungoverned data flowing).
* Do I hold this item's document? An item with no `@graph` is KEPT (it names no
* document, so there is no cap to hold). Everything else needs the cap: a bare
* reference names without reading.
*/
function readable(item: unknown, caps: CapRegistry, user: PrincipalId | null): boolean {
function readable(item: unknown, caps: CapRegistry): boolean {
const doc = docOf(item);
if (doc === null) return true;
if (!caps.governsRead(doc)) return true;
return caps.canRead(doc, user);
return caps.capFor(doc) !== undefined;
}
/** Pure: keep only the items the user may read. */
export function filterReadable<T>(
items: Iterable<T>,
caps: CapRegistry,
user: PrincipalId | null,
): T[] {
/** Pure: keep only the items whose document the current holder holds. */
export function filterReadable<T>(items: Iterable<T>, caps: CapRegistry): T[] {
const out: T[] = [];
for (const item of items) if (readable(item, caps, user)) out.push(item);
for (const item of items) if (readable(item, caps)) out.push(item);
return out;
}
@@ -49,15 +54,11 @@ export function filterReadable<T>(
* A read-filtered VIEW over a reactive set (a `DeepSignalSet`, or any Set-like).
* Iteration / `size` / `forEach` yield only readable items; everything else
* (`add`, `delete`, `has`, `getById`, …) forwards to the target, so writes and
* the underlying reactivity are preserved. The current user is read lazily (via
* `getUser`) so the view reflects the user in effect at read time.
* the underlying reactivity are preserved. What the holder holds is consulted lazily, so the
* view reflects the holder in effect at read time.
*/
export function makeReadFilteredView<S extends object>(
set: S,
caps: CapRegistry,
getUser: () => PrincipalId | null,
): S {
const keep = (item: unknown): boolean => readable(item, caps, getUser());
export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry): S {
const keep = (item: unknown): boolean => readable(item, caps);
return new Proxy(set, {
get(target, prop, receiver) {
if (prop === Symbol.iterator) {
+14 -9
View File
@@ -42,7 +42,8 @@
*/
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./polyfill";
import { getCaps, getStoreRegistryDeps } from "./polyfill";
import { mustNotAttempt } from "./reach";
import { ensureReposOpen } from "./open-repo";
import { assertNuri } from "./sparql";
import type { Nuri } from "./types";
@@ -140,29 +141,33 @@ export async function readUnion(docs: Nuri[]): Promise<UnionSubject[]> {
const unique = [...new Set(docs.filter(Boolean))];
if (unique.length === 0) return [];
// RULE 2 — do not even attempt. Drop the documents whose cap this user does not
// hold BEFORE opening or reading anything: upstream you cannot address a repo you
// have no cap for, so asking about one is not "a read that will be refused", it is
// a read that has no meaning. (The passage points enforce rule 1 regardless — see
// reach.ts — so a lapse here is caught, not exploited.)
const reachable = unique.filter((d) => !mustNotAttempt(d));
// COLD-START heal (polyfill-era): on a fresh session over a persistent wallet the
// target repos are not yet in `self.repos`, so an anchored read would return 0
// rows. Open/subscribe each repo ONCE (idempotent, per session) and await its
// initial-state push before the anchored reads. No-op once opened / when the
// injected `ng` has no `doc_subscribe` (unit fake). See open-repo.ts.
await ensureReposOpen(unique);
await ensureReposOpen(reachable);
// One anchored query per doc, in parallel, tolerant (a bad doc yields []).
const perDoc = await Promise.all(
unique.map(async (d) => ({ doc: assertNuri(d), rows: await readDoc(sid, d) })),
reachable.map(async (d) => ({ doc: assertNuri(d), rows: await readDoc(sid, d) })),
);
// Cap gate (defence-in-depth). A doc whose read policy the current user may not
// satisfy is dropped. Isolation holds both by construction (the app only resolves
// docs it is entitled to) and by filter here. Generic: the lib owns the cap
// registry; a doc under no policy (`!governsRead`) flows through unchanged. In this
// Possession gate, kept as defence in depth behind rule 2 above: `reachable`
// already excluded these, so this loop should never drop anything. In this
// polyfill each subject IRI is its own document NURI, so the cap key is the doc NURI.
const caps = getCaps();
const user = getCurrentUser();
const bySubject = new Map<string, UnionSubject>();
for (const { doc, rows } of perDoc) {
if (caps.governsRead(doc) && !caps.canRead(doc, user)) continue;
if (caps.isEnforcing() && caps.capFor(doc) === undefined) continue;
// Anchored to `doc`, so every row belongs to `doc`; the subject is the doc NURI
// (writeEntity invariant). Pin subject/graph to the doc NURI (the anchor), which
// is stable regardless of the repo_graph_name overlay suffix the store carries.
+6 -1
View File
@@ -86,8 +86,13 @@ export function escapeIri(value: string): string {
* should never carry IRI-breaking characters; if one does, we throw rather than
* emit a query that could be malformed or injected. Returns the value unchanged
* so it can be used inline: `<${assertNuri(doc)}>`.
*
* Generic in its argument so the caller's type flows THROUGH: passing a `Nuri`
* gives back a `Nuri`, not a widened `string`. This function checks characters,
* not the `did:ng:` shape (it legitimately accepts `urn:…` IRIs too), so it must
* not be the thing that mints a `Nuri` — that is {@link isNuri}'s job.
*/
export function assertNuri(nuri: string): string {
export function assertNuri<T extends string>(nuri: T): T {
if (typeof nuri !== "string" || nuri.length === 0) {
throw new Error(`[sparql] invalid NURI (empty): ${JSON.stringify(nuri)}`);
}
+487 -140
View File
@@ -59,15 +59,30 @@
* `ng`), so this module imports **no** `@ng-org` package.
*/
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
import { getStoreRegistryDeps } from "./polyfill";
import { ensureRepoOpen } from "./open-repo";
import { sparqlUpdate, sparqlQuery } from "./docs";
import { physicalCreate, physicalQuery, physicalUpdate } from "./physical";
import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./polyfill";
import { ensureRepoOpen, ensurePhysicalRepoOpen } from "./open-repo";
import { escapeLiteral, escapeIri, assertNuri } from "./sparql";
import { hasReadCap, isNuri, mintCap } from "./nuri";
import { accessLogPrefix, logStage, shortNuri } from "./access-log";
import type { Nuri, Scope } from "./types";
import type { Nuri, ReadCap, Scope } from "./types";
// --- sharedWalletShim model ----------------------------------------------
/**
* A NURI as read back from the shim, where `""` means "absent or corrupt".
*
* The empty case is NOT new — `canonicalDoc` has always returned `""` for a missing
* field, and callers have always had to test for it — but with {@link Nuri} typed it
* stops hiding inside a `string`. It is kept confined to the shim-reading functions
* below: `AccountRecord` still promises real NURIs, because a record with an empty
* scope document is a corrupt record, not a valid state to spread through the API.
* Tightening that (reject the record rather than let it flow) is a change of
* behaviour and belongs to its own lot — see `recordFromRows`.
*/
type MaybeNuri = Nuri | "";
/** One account's three scope-document NURIs, as recorded in the shim. */
export interface AccountRecord {
id: string;
@@ -84,11 +99,47 @@ const P = {
docProtected: `${SHIM}:docProtected`,
docPrivate: `${SHIM}:docPrivate`,
contains: `${SHIM}:contains`, // scope-index → entity document NURI
docInbox: `${SHIM}:docInbox`, // account → ITS OWN inbox document
link: `${SHIM}:link`, // user branch → a ReadCap received for an EXTERNAL document
readCap: `${SHIM}:readCap`, // store branch → the ReadCap of a document IN this store
inboxCap: `${SHIM}:inboxCap`, // user branch → an inbox this user may READ
} as const;
// Fixed subject of the per-(account×scope) index document. The index doc plays
// the role of the future store-container: it lists the NURIs of the entity
// documents (one per entity) that live "in" that scope.
const INDEX_SUBJECT = `${SHIM}:index`;
const MAIN_BRANCH_SUBJECT = `${SHIM}:index`;
/**
* Fixed subject of the **User branch** emulation, inside a user's PRIVATE store
* document. Upstream, `AddLink { read_cap }` is committed to the User branch of the
* private store — *"so that a user can share with all its device a new Link they
* received"*, and *"only external repos are accepted"* (`engine/repo/src/types.rs:1934-1950`).
* That is where a cap received from someone else durably lives.
*
* We have no branches, so the compartment is a distinct SUBJECT in the same
* document, kept separate from `MAIN_BRANCH_SUBJECT` (which emulates the store's Main
* branch, the `ldp:contains` listing). Two compartments, two subjects — the
* separation upstream makes with two branches.
*/
const USER_BRANCH_SUBJECT = `${SHIM}:userBranch`;
/**
* Fixed subject of the **Store branch** emulation, inside a user's store document.
*
* `doc_create` upstream writes TWICE (`engine/verifier/src/request_processor.rs:697-710`):
* `ldp:contains` on the store's **Main** branch — the listing — and
* `AddRepo { read_cap }` on its **Store** branch — the key. Two branches, two
* purposes, deliberately separate; replaying the Store branch is what reloads a
* store's documents WITH their caps (`AddRepo::verify` → `load_repo_from_read_cap`).
*
* We have no branches, so this is a distinct SUBJECT beside {@link MAIN_BRANCH_SUBJECT}
* in the same document — the same shape already used for {@link USER_BRANCH_SUBJECT}.
*
* **Honest about the emulation**: upstream the Store branch carries NO triples at all
* (`BranchCrdt::None`, `engine/repo/src/types.rs:1420`) — it is a stream of service
* commits. Representing it as RDF is our invention; what is faithful is *that the cap
* is stored beside the document rather than recomputed*, and that the listing and the
* keys stay separate.
*/
const STORE_BRANCH_SUBJECT = `${SHIM}:storeBranch`;
// --- pointer (store-root → doc-shim indirection) --------------------------
//
@@ -178,10 +229,6 @@ async function rootNuri(): Promise<Nuri> {
// --- cache ----------------------------------------------------------------
// In-memory cache of the FULL shim (all accounts), keyed by account key. Set
// only once loadShim() has read every account — used by the all-accounts paths.
let cache: Map<string, AccountRecord> | null = null;
// Per-account cache, keyed by account key. Populated by the TARGETED resolver
// (resolveAccount) and by loadShim(). Independent of `cache` so a single
// targeted resolve never forces a full shim scan. Both are cleared together.
@@ -197,8 +244,9 @@ let shimDocInFlight: Promise<Nuri> | null = null;
/** Reset cache (e.g. after switching the shared wallet). Mostly for tests. */
export function resetRegistryCache(): void {
cache = null;
accountCache.clear();
inboxCache.clear();
inboxInFlight.clear();
shimDocNuri = null;
shimDocInFlight = null;
}
@@ -231,7 +279,7 @@ function bindingValue(row: Record<string, { value: string }>, key: string): stri
* bindings (the cross-product of the duplicate values). Picking `rows[0]` is
* NON-DETERMINISTIC (binding order is not stable across sessions), so the session
* that WROTE an entity into one docPublic and a later fresh page that RESOLVED a
* DIFFERENT docPublic would disagree → the anchored `readScopeIndex` returns 0 →
* DIFFERENT docPublic would disagree → the anchored `readUserStore` returns 0 →
* the home reads empty. When both happen to pick the same doc, it "works".
*
* The fix: for each scope field, collect EVERY distinct value across the bindings
@@ -247,12 +295,15 @@ function bindingValue(row: Record<string, { value: string }>, key: string): stri
* stay robust against the residue of PAST forks already persisted in a wallet, and
* to reconcile a benign pointer fork the same content-addressed way.
*/
function canonicalDoc(rows: Array<Record<string, { value: string }>>, key: string): Nuri {
let chosen = "";
function canonicalDoc(rows: Array<Record<string, { value: string }>>, key: string): MaybeNuri {
let chosen: MaybeNuri = "";
const distinct = new Set<string>();
for (const row of rows) {
const v = bindingValue(row, key);
if (!v) continue;
// The SPARQL boundary: a binding is an untrusted string. Narrowing here (rather
// than casting) also discards a value that is not a NextGraph reference at all —
// shim corruption that used to flow straight through as a "document NURI".
if (!v || !isNuri(v)) continue;
distinct.add(v);
if (chosen === "" || v < chosen) chosen = v;
}
@@ -277,11 +328,17 @@ function recordFromRows(
const v = bindingValue(row, "id");
if (v) { id = v; break; }
}
// The ONE place the `""`-for-corrupt case is absorbed. `AccountRecord` promises
// real NURIs; a shim missing a scope document yields `""` here, exactly as it
// always has, and the cast records that this is a KNOWN gap rather than a proven
// invariant. Callers already test for the empty value (e.g. `watchShape` skips a
// falsy container). Rejecting such a record outright would be the right fix and is
// a behaviour change — its own lot, not this one.
return {
id: id || fallbackId,
docPublic: canonicalDoc(rows, "docPublic"),
docProtected: canonicalDoc(rows, "docProtected"),
docPrivate: canonicalDoc(rows, "docPrivate"),
docPublic: canonicalDoc(rows, "docPublic") as Nuri,
docProtected: canonicalDoc(rows, "docProtected") as Nuri,
docPrivate: canonicalDoc(rows, "docPrivate") as Nuri,
};
}
@@ -307,14 +364,14 @@ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms
* canonical (lexicographically-smallest) doc-shim NURI — content-addressed and
* stable, so every device converges on the SAME doc-shim.
*/
async function resolvePointer(): Promise<Nuri> {
async function resolvePointer(): Promise<MaybeNuri> {
const s = await session();
const root = await rootNuri();
// COLD-START heal: open the store-root repo before the anchored read, so a fresh
// wallet whose store-root isn't yet in `self.repos` resolves instead of throwing
// `RepoNotFound`. Idempotent; a no-op with the unit fake ng. The store-root has no
// barrier, so this open cannot make the read authoritative — the guard below does.
await ensureRepoOpen(root);
await ensurePhysicalRepoOpen(root);
const query = `
SELECT ?shimDoc WHERE {
GRAPH <${assertNuri(root)}> {
@@ -335,7 +392,7 @@ async function resolvePointer(): Promise<Nuri> {
let step = baseMs;
for (let i = 0; i < attempts; i++) {
try {
const result = await sparqlQuery(s.sessionId, query, undefined, root, "resolvePointer");
const result = await physicalQuery(s.sessionId, query, undefined, root, "resolvePointer");
const doc = canonicalDoc(readBindings(result), "shimDoc");
if (doc) {
logStage("resolvePointer → 1 target: " + shortNuri(doc));
@@ -359,7 +416,7 @@ async function resolvePointer(): Promise<Nuri> {
async function writePointer(doc: Nuri): Promise<void> {
const s = await session();
const root = await rootNuri();
await ensureRepoOpen(root);
await ensurePhysicalRepoOpen(root);
const update = `
INSERT DATA {
GRAPH <${assertNuri(root)}> {
@@ -367,7 +424,7 @@ async function writePointer(doc: Nuri): Promise<void> {
}
}`;
try {
await sparqlUpdate(s.sessionId, update, root, "writePointer");
await physicalUpdate(s.sessionId, update, root, "writePointer");
} catch (error) {
console.error(accessLogPrefix() + " writePointer failed:", error);
}
@@ -378,7 +435,7 @@ async function createDoc(): Promise<Nuri> {
const s = await session();
// crdt="Graph" (RDF/SPARQL/ORM), class="data:graph", destination="store",
// store_repo=undefined → shared wallet's private store.
return docCreate(s.sessionId, "Graph", "data:graph", "store", undefined);
return physicalCreate(s.sessionId);
}
/**
@@ -406,7 +463,7 @@ async function resolveShimDoc(): Promise<Nuri> {
if (existing) {
// Open the doc-shim through its first-`State` barrier BEFORE any account read,
// so a cold 0 on the doc-shim is authoritative (genuinely absent), not sync-lag.
await ensureRepoOpen(existing);
await ensurePhysicalRepoOpen(existing);
shimDocNuri = existing;
logStage("resolveShimDoc → " + shortNuri(existing));
return existing;
@@ -416,7 +473,7 @@ async function resolveShimDoc(): Promise<Nuri> {
// the pointer, then open (no-op barrier for a just-created repo).
const doc = await createDoc();
await writePointer(doc);
await ensureRepoOpen(doc);
await ensurePhysicalRepoOpen(doc);
shimDocNuri = doc;
logStage("resolveShimDoc → " + shortNuri(doc));
return doc;
@@ -432,52 +489,6 @@ async function resolveShimDoc(): Promise<Nuri> {
// --- shim load / account bootstrap ----------------------------------------
/** Load all accounts from the shim (the doc-shim) into the cache. */
export async function loadShim(): Promise<Map<string, AccountRecord>> {
if (cache) return cache;
const s = await session();
const doc = await resolveShimDoc();
const query = `
SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
?acc a <${P.type}> ;
<${P.id}> ?id ;
<${P.docPublic}> ?docPublic ;
<${P.docProtected}> ?docProtected ;
<${P.docPrivate}> ?docPrivate .
}`;
const map = new Map<string, AccountRecord>();
// The doc-shim is opened (first-`State` barrier) by resolveShimDoc, so this read is
// authoritative.
await ensureRepoOpen(doc);
try {
const result = await sparqlQuery(s.sessionId, query, undefined, doc, "loadShim");
// Group ALL bindings by account key first, then pick the CANONICAL doc per
// scope (see recordFromRows / canonicalDoc). A single account subject may carry
// duplicate scope-doc values (fork residue) → several bindings; grouping +
// canonical selection makes loadShim resolve the SAME doc the targeted
// resolveAccount does, so full-scan and hot-path readers never disagree.
const byKey = new Map<string, Array<Record<string, { value: string }>>>();
for (const row of readBindings(result)) {
const id = bindingValue(row, "id");
if (!id) continue;
const key = accountKey(id);
const bucket = byKey.get(key) ?? [];
bucket.push(row);
byKey.set(key, bucket);
}
for (const [key, rows] of byKey) {
const record = recordFromRows(rows, rows[0] ? bindingValue(rows[0], "id") : key);
map.set(key, record);
// Feed the per-account cache too, so a subsequent targeted resolve is free.
accountCache.set(key, record);
}
} catch (error) {
console.error(accessLogPrefix() + " loadShim failed:", error);
}
cache = map;
return map;
}
/**
* Resolve ONE account by its shim key with a BOUNDED query — O(1), independent
* of the number of accounts in the shim. This is the HOT-PATH lookup: it hits
@@ -516,7 +527,7 @@ export async function resolveAccount(id: string): Promise<AccountRecord | null>
<${P.docPrivate}> ?docPrivate .
}`;
try {
const result = await sparqlQuery(s.sessionId, query, undefined, doc, "resolveAccount");
const result = await physicalQuery(s.sessionId, query, undefined, doc, "resolveAccount");
const rows = readBindings(result);
if (rows.length === 0) {
logStage("resolveAccount(" + key + ") → null");
@@ -536,11 +547,6 @@ export async function resolveAccount(id: string): Promise<AccountRecord | null>
}
}
/** All known accounts (from the shim). */
export async function allAccounts(): Promise<AccountRecord[]> {
return [...(await loadShim()).values()];
}
/** Persist one AccountRecord into the doc-shim (anchored default-graph write, the
* canonical always-safe shape — same convention as createEntityDoc). */
async function writeRecord(doc: Nuri, record: AccountRecord): Promise<void> {
@@ -560,7 +566,7 @@ async function writeRecord(doc: Nuri, record: AccountRecord): Promise<void> {
<${P.docPrivate}> "${escapeLiteral(record.docPrivate)}" .
}`;
try {
await sparqlUpdate(s.sessionId, update, doc, "writeRecord");
await physicalUpdate(s.sessionId, update, doc, "writeRecord");
} catch (error) {
console.error(accessLogPrefix() + " writeRecord persist failed:", error);
}
@@ -594,7 +600,10 @@ export async function ensureAccount(id: string): Promise<AccountRecord> {
const key = accountKey(id);
// A completed provision/resolve is cached → no query, no fork risk.
const cached = accountCache.get(key);
if (cached) return cached;
if (cached) {
fileOwnStructure(id, cached);
return cached;
}
// A concurrent provision for the SAME account is already running → await it,
// instead of racing a second (forking) provision. This is the anti-fork guard.
const pending = ensureInFlight.get(key);
@@ -609,7 +618,10 @@ export async function ensureAccount(id: string): Promise<AccountRecord> {
// absent — not sync-lag. No account-level retry: the store-root ambiguity that
// forced the old provisionRetry loop is gone once the read moves behind the barrier.
const existing = await resolveAccount(id);
if (existing) return existing;
if (existing) {
fileOwnStructure(id, existing);
return existing;
}
const doc = await resolveShimDoc();
const [docPublic, docProtected, docPrivate] = await Promise.all([
@@ -623,7 +635,7 @@ export async function ensureAccount(id: string): Promise<AccountRecord> {
// Feed the per-account cache, and the full-shim cache if it is already loaded
// (so allAccounts / the fan-out see the freshly-created account too).
accountCache.set(key, record);
cache?.set(key, record);
fileOwnStructure(id, record);
return record;
})();
@@ -638,7 +650,7 @@ export async function ensureAccount(id: string): Promise<AccountRecord> {
// --- resolvers ------------------------------------------------------------
/** The index document NURI of an account for a scope (the store-container). */
function indexDocOf(record: AccountRecord, scope: Scope): Nuri {
function storeOf(record: AccountRecord, scope: Scope): Nuri {
return scope === "public"
? record.docPublic
: scope === "protected"
@@ -653,13 +665,7 @@ function indexDocOf(record: AccountRecord, scope: Scope): Nuri {
*/
export async function resolveWriteGraph(id: string, scope: Scope): Promise<Nuri> {
const record = await ensureAccount(id);
return indexDocOf(record, scope);
}
/** NURIs of every account's document for `scope` (read fan-out). */
export async function resolveReadGraphs(scope: Scope): Promise<Nuri[]> {
const accounts = await allAccounts();
return accounts.map((a) => indexDocOf(a, scope));
return storeOf(record, scope);
}
// --- SDK-shaped scope resolvers (no store-id ever leaves the lib) ----------
@@ -699,29 +705,170 @@ export async function resolveScopeGraph(scope: Scope): Promise<Nuri> {
}
/**
* The reserved account that OWNS the shared registration-inbox document. Like the
* discovery index's special account, it lives in the reserved namespace (no user
* can produce this key) and only HOSTS a document — its `public` scope document is
* the inbox anchor. Disappears at migration (native per-document inboxes).
* In-flight `walletInbox` resolutions, keyed by account key — so concurrent callers
* for the SAME wallet share ONE resolve-or-create instead of racing two documents
* into existence (mirrors {@link ensureInFlight}).
*/
const INBOX_ANCHOR_ACCOUNT = reservedAccount("inbox");
const inboxInFlight = new Map<string, Promise<Nuri>>();
/** Resolved wallet inboxes, keyed by account key. Cleared with the registry cache. */
const inboxCache = new Map<string, Nuri>();
/**
* The inbox anchor NURI for the current session (where emulated inbox deposits
* physically land). SDK-shaped: the consumer never resolves a store itself.
* The NURI of a virtual user's OWN inbox — where deposits addressed to that
* identity land, ReadCaps among them.
*
* This is a DEDICATED inbox DOCUMENT (a reserved account's public scope document —
* a real repo NURI from `docCreate`, stable across clients via the shim), NOT the
* shared wallet's private-store root. Reason (perf + hygiene): the shim (the
* account→document trust root) is scanned on every `loadShim`; routing every inbox
* deposit into that SAME graph bloats it without bound (thousands of deposit triples
* across sessions). A separate inbox document keeps the shim graph small and the
* deposits isolated. At migration this becomes the host's native per-document inbox
* and the resolution moves here.
* ── Why a wallet owns an inbox, and why that is load-bearing ───────────────
* You cannot discover in NextGraph; you can only follow links. So a link crosses
* from one wallet to another through exactly one channel: a deposit into the
* recipient's inbox. That makes the inbox the **bootstrap of the whole
* reachability graph** rather than a side feature — and it is why an inbox has to
* BELONG to someone. Before this existed, an inbox was any NURI a caller passed,
* so "read the inbox" meant "read anyone's inbox", and since P1a routes caps
* through it, reading someone else's collected the caps addressed to them.
*
* Created on first sight and stable thereafter. Recorded in the doc-shim under its
* own predicate, read by its OWN query rather than added to the account SELECT: an
* account record written before this existed must keep resolving, which it would
* not if the fixed account pattern grew a fourth required field.
*
* Concurrency-safe (see {@link inboxInFlight}), and a fork is reconciled the same
* content-addressed way as everything else ({@link canonicalDoc}).
*
* At migration this becomes the identity's native inbox and the resolution moves
* here — the consumer-facing act (deposit to an inbox, process my own) is unchanged.
*/
export async function resolveInboxAnchor(): Promise<Nuri> {
const record = await ensureAccount(INBOX_ANCHOR_ACCOUNT);
return record.docPublic;
export async function walletInbox(id: string): Promise<Nuri> {
const key = accountKey(id);
const cached = inboxCache.get(key);
if (cached) {
fileOwnInbox(id, cached);
return cached;
}
const pending = inboxInFlight.get(key);
if (pending) return pending;
const p = (async (): Promise<Nuri> => {
const s = await session();
const shimDoc = await resolveShimDoc();
await ensureAccount(id); // the account must exist before it can own an inbox
const subj = accountSubject(id);
try {
// The doc-shim is machinery: this reads WHICH inbox a virtual user owns,
// which is exactly the kind of question that cannot be confined to that user.
const res = await physicalQuery(
s.sessionId,
`SELECT ?d WHERE { <${subj}> <${P.docInbox}> ?d }`,
undefined,
shimDoc,
"walletInbox",
);
const existing = canonicalDoc(readBindings(res), "d");
if (existing) {
inboxCache.set(key, existing);
fileOwnInbox(id, existing);
return existing;
}
} catch (error) {
console.error(accessLogPrefix() + " walletInbox read failed:", error);
}
const doc = await createDoc();
fileOwnInbox(id, doc);
try {
await physicalUpdate(
s.sessionId,
`INSERT DATA { <${subj}> <${P.docInbox}> "${escapeLiteral(doc)}" }`,
shimDoc,
"walletInbox",
);
} catch (error) {
console.error(accessLogPrefix() + " walletInbox persist failed:", error);
}
inboxCache.set(key, doc);
logStage("walletInbox(" + key + ") → " + shortNuri(doc));
return doc;
})();
inboxInFlight.set(key, p);
try {
return await p;
} finally {
inboxInFlight.delete(key);
}
}
/**
* Does `nuri` belong to the CURRENT wallet as one of its inboxes? The predicate the
* inbox read guard consults (`inbox.ts`). Anonymous holds no inbox, so it is false
* for everyone until an identity is set.
*/
export async function isOwnInbox(nuri: Nuri): Promise<boolean> {
const holder = getCurrentUser();
if (holder === null) return false;
if ((await walletInbox(holder)) === nuri) return true;
// …and the inbox of any document this user opened one on (the emulated
// `AddInboxCap` records on its User branch).
return (await readInboxCapPairs()).some((p) => p.inbox === nuri);
}
// --- the cap side of a user's store ----------------------------------
/**
* File the caps of documents the CURRENT holder owns into what they hold — the
* emulated `AddRepo { read_cap }`.
*
* Upstream, creating a document commits an `AddRepo { read_cap }` into a typed
* branch of the store, and that branch — listing the store's documents, each with
* its read key — carries the owner's caps. Here the per-(account × scope) index
* document plays the store-container role, so it carries the caps too: a
* document appended to it on creation, or read back from it on a later session,
* puts its cap in the owner's hands with nothing for the consumer to do. That is
* what makes the invariant hold both ways — you never derive a cap from a bare
* reference, and yet a document's own creator is never locked out of it.
*
* Scoped to the current holder ON PURPOSE: another account's documents are listed
* by the cross-account fan-out (`listEntityDocs`), and those caps are emphatically
* not ours to hold. `id` is compared through the shim key, so it matches however
* the consumer spells the identity.
*/
function holdOwnCap(id: string, scope: Scope, doc: Nuri, cap: ReadCap): void {
const holder = getCurrentUser();
if (holder === null || accountKey(holder) !== accountKey(id)) return;
const caps = getCaps();
// `learn(cap)`, not `open(doc, scope)` — the cap must be the SAME value that was
// written to the Store branch, not a second one minted from the NURI. They agree
// today only because the stand-in value is a constant; with a real key (P1b) a
// second mint would produce a DIFFERENT key and the document would be unreadable
// by the very session that created it. Mint once, store it, hold that one.
caps.learn(cap);
// Publication is a registry fact, not a stored one, so it is applied separately.
if (scope === "public") caps.publishRepoLink(doc);
}
/**
* File the caps of the documents a virtual user owns BY BEING one: its three
* stores, and its inbox. They are as much its documents as any entity it creates,
* and without them it cannot even list its own content — the boundary would lock a
* user out of itself.
*
* Scoped to the current holder, like {@link holdOwnCap}: another user's stores are
* emphatically not ours to hold.
*/
function fileOwnStructure(id: string, record: AccountRecord): void {
const holder = getCurrentUser();
if (holder === null || accountKey(holder) !== accountKey(id)) return;
const caps = getCaps();
if (record.docPublic) caps.open(record.docPublic, "public");
if (record.docProtected) caps.open(record.docProtected, "protected");
if (record.docPrivate) caps.open(record.docPrivate, "private");
}
/** Same, for the user's own inbox — it is its document, and it must be able to
* read it. Depositing into someone else's needs no cap (see `docs.depositInto`). */
function fileOwnInbox(id: string, inbox: Nuri): void {
const holder = getCurrentUser();
if (holder === null || accountKey(holder) !== accountKey(id)) return;
getCaps().open(inbox, "private");
}
// --- per-entity documents + per-scope index -------------------------------
@@ -730,11 +877,14 @@ export async function resolveInboxAnchor(): Promise<Nuri> {
* Create a dedicated document for ONE entity — mirrors the target, where each
* such entity is its own document/repo (addressable, future inbox). The new
* document's NURI is appended to the account's scope index document (the
* store-container). Returns the entity document NURI (use it as `@graph`).
* store-container). Returns the entity document NURI (use it as `@graph`) — a
* CAP-LESS reference, exactly like `doc_create` upstream: it names the document,
* it does not carry its key. The key goes to what the creator holds (see
* {@link holdOwnCap}), which is where you look it up.
*/
export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
const record = await ensureAccount(id);
const indexDoc = indexDocOf(record, scope);
const indexDoc = storeOf(record, scope);
const entityNuri = await createDoc();
const s = await session();
try {
@@ -742,24 +892,70 @@ export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
s.sessionId,
// NO explicit `GRAPH <…>` wrapper: write the anchored DEFAULT graph (the
// `indexDoc` anchor scopes it) — the CANONICAL, always-safe shape the
// anchored default-graph read queries (readScopeIndex below, same as
// anchored default-graph read queries (readUserStore below, same as
// read-model.ts). Not a round-trip necessity on the current broker: the e2e
// harness (`packages/client/e2e/`) verified an anchored `GRAPH <plainNuri>`
// write ALSO round-trips here (same repo graph, no phantom graph); no-GRAPH
// is kept as a simplicity/safety convention. entityNuri is a NURI stored as
// a literal → escapeLiteral.
`INSERT DATA { <${INDEX_SUBJECT}> <${P.contains}> "${escapeLiteral(entityNuri)}" }`,
`INSERT DATA { <${MAIN_BRANCH_SUBJECT}> <${P.contains}> "${escapeLiteral(entityNuri)}" }`,
indexDoc,
"createEntityDoc",
);
} catch (error) {
console.error(accessLogPrefix() + " createEntityDoc index append failed:", error);
}
// The second write: `AddRepo { read_cap }` on the Store branch. A separate
// statement, not a second triple in the one above, because upstream these are two
// commits on two branches — and because the cap must be recoverable even if the
// listing write failed.
//
// One literal suffices: a ReadCap CARRIES its document (`targetOf`), so storing the
// cap stores the pair.
const cap = mintCap(entityNuri);
try {
await sparqlUpdate(
s.sessionId,
`INSERT DATA { <${STORE_BRANCH_SUBJECT}> <${P.readCap}> "${escapeLiteral(cap)}" }`,
indexDoc,
"createEntityDoc:addRepo",
);
} catch (error) {
console.error(accessLogPrefix() + " createEntityDoc cap append failed:", error);
}
// …and the creator holds THAT cap for this session.
holdOwnCap(id, scope, entityNuri, cap);
return entityNuri;
}
/**
* The ReadCaps recorded on a store's Store branch — its documents, each with its
* key. The emulated replay of `AddRepo`, and the reason a fresh session recovers
* what it owns without recomputing anything.
*/
async function readStoreCaps(storeDoc: Nuri): Promise<ReadCap[]> {
const s = await session();
const out: ReadCap[] = [];
try {
const res = await sparqlQuery(
s.sessionId,
`SELECT ?c WHERE { <${STORE_BRANCH_SUBJECT}> <${P.readCap}> ?c }`,
undefined,
storeDoc,
"readStoreCaps",
);
for (const row of readBindings(res)) {
const v = bindingValue(row, "c");
if (v && hasReadCap(v)) out.push(v);
}
} catch (error) {
console.error(accessLogPrefix() + " readStoreCaps failed:", error);
}
return out;
}
/** Read the entity-document NURIs contained in ONE scope index document. */
async function readScopeIndex(indexDoc: Nuri): Promise<Nuri[]> {
async function readUserStore(indexDoc: Nuri): Promise<Nuri[]> {
const s = await session();
const out: Nuri[] = [];
// COLD-START heal (polyfill-era): on a fresh session over a persistent wallet the
@@ -775,39 +971,21 @@ async function readScopeIndex(indexDoc: Nuri): Promise<Nuri[]> {
s.sessionId,
// NO explicit `GRAPH <…>` clause — read the anchored DEFAULT graph (see
// the note in createEntityDoc). The `indexDoc` anchor scopes the query.
`SELECT ?e WHERE { <${INDEX_SUBJECT}> <${P.contains}> ?e }`,
`SELECT ?e WHERE { <${MAIN_BRANCH_SUBJECT}> <${P.contains}> ?e }`,
undefined,
indexDoc,
"readScopeIndex",
"readUserStore",
);
for (const row of readBindings(res)) {
// SPARQL boundary again (see canonicalDoc): narrow, do not cast — a stored
// value that is not a NextGraph reference is not an entity document.
const v = bindingValue(row, "e");
if (v) out.push(v);
if (v && isNuri(v)) out.push(v);
}
} catch (error) {
console.error(accessLogPrefix() + " readScopeIndex failed:", error);
}
logStage("readScopeIndex(" + shortNuri(indexDoc) + ") → " + out.length + " entities");
return out;
}
/**
* Every entity document NURI of `scope`, across all accounts — the read
* fan-out for per-entity scopes. Reads each account's scope index document and
* unions the contained NURIs. Use as `useShape(shape, { graphs })`.
*
* NOTE (read-by-need): this ALL-ACCOUNTS fan-out contradicts the read-by-need
* model (docs/read-model.md) — it opens/syncs other accounts' possibly-unsynced
* docs, which HANGS. Prefer {@link listMyEntityDocs} (my own account's scope
* docs) for "my entities", and the discovery index for "all public events".
* Retained for callers that legitimately need every account (tests).
*/
export async function listEntityDocs(scope: Scope): Promise<Nuri[]> {
const accounts = await allAccounts();
const out: Nuri[] = [];
for (const a of accounts) {
out.push(...(await readScopeIndex(indexDocOf(a, scope))));
console.error(accessLogPrefix() + " readUserStore failed:", error);
}
logStage("readUserStore(" + shortNuri(indexDoc) + ") → " + out.length + " entities");
return out;
}
@@ -820,9 +998,9 @@ export async function listEntityDocs(scope: Scope): Promise<Nuri[]> {
* here). Idempotent via `ensureAccount`'s cache. At migration this becomes the
* user's real per-scope store NURI (the container the store itself provides).
*/
export async function scopeIndexDoc(id: string, scope: Scope): Promise<Nuri> {
export async function userStoreDoc(id: string, scope: Scope): Promise<Nuri> {
const record = await ensureAccount(id);
return indexDocOf(record, scope);
return storeOf(record, scope);
}
/**
@@ -833,7 +1011,176 @@ export async function scopeIndexDoc(id: string, scope: Scope): Promise<Nuri> {
* another account's unsynced docs. This is the helper a consumer application uses
* for its own my-entities path, instead of the all-accounts `listEntityDocs`.
*/
/**
* The inbox of a document this user owns — resolved, and created on first ask.
*
* Upstream a repo carries `inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`):
* an inbox is a keypair on the document, whose PRIVATE half its owner holds. That
* half is recorded with `AddInboxCap { repo_id, overlay, priv_key }` — *"into the
* user branch, so that a user can share with all its device"*
* (`engine/repo/src/types.rs:1969-1981`), the same branch that carries `AddLink`.
* So "which inboxes may I read" is answered by the User branch, and that is what
* this emulates.
*
* Lazy on purpose: creating an inbox document for every entity up front would
* double every `createEntityDoc` for inboxes most documents never receive anything
* in. Upstream the keypair is cheap; here an inbox is a document, so it is minted
* when first asked for.
*
* Only for documents this user holds — you cannot open an inbox on someone else's
* document, you can only deposit into it.
*/
export async function documentInbox(doc: Nuri): Promise<Nuri> {
const holder = getCurrentUser();
if (holder === null) throw new Error("[ng-eventually] documentInbox: no identity is set");
const known = (await readInboxCapsFor(doc)) ?? null;
if (known) return known;
const inbox = await createDoc();
const s = await session();
const record = await ensureAccount(holder);
const store = record.docPrivate;
getCaps().open(inbox, "private"); // its owner holds it, like any document of theirs
if (store) {
try {
await sparqlUpdate(
s.sessionId,
`INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> "${escapeLiteral(doc + " " + inbox)}" }`,
store,
"documentInbox",
);
} catch (error) {
console.error(accessLogPrefix() + " documentInbox persist failed:", error);
}
}
return inbox;
}
/** The `(document, inbox)` pairs recorded on this user's User branch. */
async function readInboxCapPairs(): Promise<Array<{ doc: Nuri; inbox: Nuri }>> {
const holder = getCurrentUser();
if (holder === null) return [];
const record = await resolveAccount(holder);
const store = record?.docPrivate;
if (!store) return [];
const s = await session();
const out: Array<{ doc: Nuri; inbox: Nuri }> = [];
try {
const res = await sparqlQuery(
s.sessionId,
`SELECT ?c WHERE { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> ?c }`,
undefined,
store,
"readInboxCaps",
);
for (const row of readBindings(res)) {
const [doc, inbox] = bindingValue(row, "c").split(" ");
if (doc && inbox && isNuri(doc) && isNuri(inbox)) out.push({ doc, inbox });
}
} catch (error) {
console.error(accessLogPrefix() + " readInboxCaps failed:", error);
}
return out;
}
/** The inbox recorded for one document, if this user opened one. */
async function readInboxCapsFor(doc: Nuri): Promise<Nuri | undefined> {
return (await readInboxCapPairs()).find((p) => p.doc === doc)?.inbox;
}
/**
* Every inbox this user may READ: its own, plus one per document it opened an
* inbox on. What `connect.connectedUser` drains, and what `isOwnInbox` answers from.
*/
export async function myInboxes(): Promise<Nuri[]> {
const holder = getCurrentUser();
if (holder === null) return [];
const out: Nuri[] = [];
if ((await resolveAccount(holder)) !== null) out.push(await walletInbox(holder));
for (const { inbox } of await readInboxCapPairs()) out.push(inbox);
return out;
}
/**
* File a cap received for someone ELSE's document — the emulated
* `AddLink { read_cap }` on the User branch of the current user's private store.
*
* This is what makes a received cap DURABLE. Before it, a shared document survived
* only by re-reading the inbox every session, which uses a queue as a database:
* upstream an inbox is consumed, and processing a message *applies* it. Applying a
* Link means writing it here.
*
* Idempotent — re-applying the same Link is a no-op, so re-processing an inbox
* (a second tab, a reconnect) costs nothing.
*/
export async function addLink(cap: ReadCap): Promise<void> {
const holder = getCurrentUser();
if (holder === null) return;
const record = await ensureAccount(holder);
const store = record.docPrivate;
if (!store) return;
if ((await readLinks()).includes(cap)) return;
const s = await session();
try {
await sparqlUpdate(
s.sessionId,
`INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.link}> "${escapeLiteral(cap)}" }`,
store,
"addLink",
);
} catch (error) {
console.error(accessLogPrefix() + " addLink failed:", error);
}
}
/**
* The caps this user has received and applied — the User branch read back. Called
* at connection to restore what was shared with them, without touching any inbox.
*/
export async function readLinks(): Promise<ReadCap[]> {
const holder = getCurrentUser();
if (holder === null) return [];
const record = await ensureAccount(holder);
const store = record.docPrivate;
if (!store) return [];
const s = await session();
const out: ReadCap[] = [];
await ensureRepoOpen(store);
try {
const res = await sparqlQuery(
s.sessionId,
`SELECT ?c WHERE { <${USER_BRANCH_SUBJECT}> <${P.link}> ?c }`,
undefined,
store,
"readLinks",
);
for (const row of readBindings(res)) {
const v = bindingValue(row, "c");
if (v && hasReadCap(v)) out.push(v);
}
} catch (error) {
console.error(accessLogPrefix() + " readLinks failed:", error);
}
return out;
}
export async function listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]> {
const record = await ensureAccount(id);
return readScopeIndex(indexDocOf(record, scope));
const store = storeOf(record, scope);
const docs = await readUserStore(store);
// Recover the caps by READING the Store branch, never by recomputing them from
// the NURIs — that is the whole point of storing them. A fresh session gets back
// exactly what was recorded, and the day the stand-in value becomes a real key
// (P1b) this path needs no change at all.
//
// Scoped to the current holder: another user's store caps are not ours to hold.
const holder = getCurrentUser();
if (holder !== null && accountKey(holder) === accountKey(id)) {
const caps = getCaps();
for (const cap of await readStoreCaps(store)) caps.learn(cap);
// A `public` store's documents are also published links — the publication fact
// lives in the registry, not in the store, so it is re-applied here.
if (scope === "public") for (const d of docs) caps.publishRepoLink(d);
}
return docs;
}
+22
View File
@@ -34,6 +34,7 @@
*/
import { getConfig, getStoreRegistryDeps } from "./polyfill";
import { assertMayReach } from "./reach";
import type { Nuri } from "./types";
/**
@@ -103,6 +104,27 @@ async function sessionId(): Promise<string> {
export function subscribeDoc(
nuri: Nuri,
onChange: (r: DocChange, type: DocChangeType) => void,
): Unsubscribe {
// RULE 1 — a subscription IS an access: the push carries the document's state.
// Guarding the read paths while leaving this open would be a door beside the gate.
assertMayReach(nuri, "subscribeDoc");
return subscribeDocUnguarded(nuri, onChange);
}
/**
* Subscribe as the PHYSICAL user — the shim's own documents. The machinery's
* counterpart to {@link subscribeDoc}; never exported from the package.
*/
export function subscribePhysicalDoc(
nuri: Nuri,
onChange: (r: DocChange, type: DocChangeType) => void,
): Unsubscribe {
return subscribeDocUnguarded(nuri, onChange);
}
function subscribeDocUnguarded(
nuri: Nuri,
onChange: (r: DocChange, type: DocChangeType) => void,
): Unsubscribe {
const { ng } = getConfig();
let stopped = false;
+30 -2
View File
@@ -2,8 +2,36 @@
* Generic, NextGraph-shaped types. ZERO application domain.
*/
/** A NextGraph URI (document / store / inbox). */
export type Nuri = string;
/**
* A NextGraph URI (document / store / inbox) in its **cap-less** form — it NAMES
* and locates, it does not grant the right to read: `did:ng:o:{doc}:v:{overlay}`.
* `did:ng:` is the URI scheme prefix, not a "without cap" marker; the discriminant
* is the `:r:` segment (see {@link ReadCap}).
*/
export type Nuri = `did:ng:${string}`;
/**
* A NextGraph URI that carries the document's read cap — `…:r:{cap}`. It NAMES
* *and* READS: reading is key possession, never an authorization list. This is the
* upstream name (`ReadCap`).
*
* ── Why a template literal type, and not a branded one ────────────────────
* Both this and {@link Nuri} are **still strings** — assignable to `string`,
* JSON-serializable, no wrapper object — so nothing has to be *un*-typed when the
* real SDK arrives and takes `nuri: String`. What the template buys is the one
* direction that matters: a `ReadCap` is freely usable wherever a `Nuri` is
* expected (a cap IS a NURI with the key inside — upstream's single `NuriV0`),
* while a bare `Nuri` passed where a `ReadCap` is required is a **compile error**.
* That confusion, left to runtime, silently turns "naming is not reading" into
* "naming is reading" — the exact inversion this model exists to remove.
*
* It constrains the consumer's code the same way, which is the point: an app that
* reads a cap back from storage, a URL or JSON gets a `string` and must pass it
* through {@link isNuri} / {@link hasReadCap} (exported from the SDK entry) to use
* it — a validation it should be doing anyway. The runtime guards stay regardless:
* a JavaScript consumer bypasses the compiler entirely.
*/
export type ReadCap = `did:ng:${string}:r:${string}`;
/** NextGraph-native store scopes. The *mapping* of entities to scopes is the
* consumer's concern; this layer only knows the three scopes exist. */
+8 -8
View File
@@ -1,17 +1,17 @@
/**
* Wrapped `useShape`: same signature as `@ng-org/orm`. When a read-cap policy is
* declared, the returned set is a read-filtered VIEW (only items in documents the
* current user holds a ReadCap for); otherwise it passes the real set through
* unchanged. At migration the filtering disappears — the broker only delivers
* authorized documents.
* Wrapped `useShape`: same signature as `@ng-org/orm`. Once the cap emulation is
* in force, the returned set is a read-filtered VIEW (only items in documents the
* current holder has the ReadCap of); before the first cap is issued it passes the
* real set through unchanged. At migration the filtering disappears — the broker
* only delivers documents whose cap the wallet holds.
*/
import { getConfig, getCurrentUser, getCaps } from "./polyfill";
import { getConfig, getCaps } from "./polyfill";
import { makeReadFilteredView } from "./read-filter";
export function useShape(shapeType: unknown, scope: unknown): unknown {
const set = getConfig().useShape(shapeType, scope) as object;
const caps = getCaps();
if (!caps.hasReadPolicy()) return set; // no policy configured → passthrough
return makeReadFilteredView(set, caps, getCurrentUser);
if (!caps.isEnforcing()) return set; // no cap issued yet → passthrough
return makeReadFilteredView(set, caps);
}
+60 -58
View File
@@ -15,11 +15,13 @@
* scope still syncing reads `{ data: [], isPending: true, isSuccess: false }`.
*
* ── What the observable OWNS (the whole read pipeline) ─────────────────────
* 1. Resolve the logical scope → the doc set: the current identity's per-entity
* docs for that scope (`storeRegistry.listMyEntityDocs`), PLUS — for `public`
* only — the discovery index folded in (`discovery.readIndex`), so the app
* never orchestrates discovery to read. Faithful to the future
* `useShape(shape, 'public')`.
* 1. Resolve the logical scope → the doc set: the CURRENT wallet's own per-entity
* documents for that scope (`storeRegistry.listMyEntityDocs`), and nothing
* else. There is no "everything public" to fold in — **you cannot discover,
* you can only follow links** (see docs/readcap-and-nuri-model.md §4ter-bis),
* and a link reaches you through an inbox or through a document you already
* hold. A document whose cap you were given is read by NAMING it
* (`readModel.readUnion`), not by turning up in a scope you never put it in.
* 2. Open the docs (`ensureReposOpen`) — this AWAITS the sync BARRIER (first
* `State` per doc, `getSyncState` → `synced`, or `timed-out` on the bounded
* fallback). `isPending` holds until the barrier is reached for the current
@@ -31,12 +33,15 @@
* ShapeType, not from any application concept.
*
* ── Reactivity WITHOUT polling (no `setInterval`) ──────────────────────────
* Reactivity is push-only (rule no-broker-polling): `subscribeDoc` on every doc in
* the current set re-runs `readUnion` on any push. The set is DYNAMIC (creating an
* entity appends a NURI to the scope-index doc; announcing a public entity appends
* to the discovery index), so we ALSO subscribe to the scope-index document (and,
* for `public`, the discovery-index document): a push there re-RESOLVES the scope
* and re-keys the subscribed set. Subscriptions are idempotent — an already-followed
* Reactivity is push-only (rule no-broker-polling). It has TWO sources: document
* pushes, and the KEYRING — a cap that arrives asynchronously (an inbox delivery
* absorbed by the consumer's `inbox.watch`) makes documents readable that were not,
* so `CapRegistry.onChange` re-reads. Without that, a view stays stale until an
* unrelated push happens to fire. On the document side, `subscribeDoc` on every doc
* in the current set re-runs `readUnion` on any push. The set is DYNAMIC (creating an
* entity appends a NURI to the scope-index doc), so we ALSO subscribe to the
* scope-index document: a push there re-RESOLVES the scope and re-keys the
* subscribed set. Subscriptions are idempotent — an already-followed
* doc is not re-subscribed. Everything reuses `subscribe.ts` / `open-repo.ts`; no
* parallel channel.
*
@@ -46,12 +51,11 @@
* `isError` fires ONLY on a real thrown exception in the pipeline.
*/
import { getCurrentUser } from "./polyfill";
import { getCaps, getCurrentUser } from "./polyfill";
import { ensureReposOpen, getSyncState } from "./open-repo";
import { readUnion, type UnionSubject } from "./read-model";
import { subscribeDoc, type Unsubscribe } from "./subscribe";
import { listMyEntityDocs, scopeIndexDoc } from "./store-registry";
import { readIndex, indexDocNuri } from "./discovery";
import { listMyEntityDocs, userStoreDoc } from "./store-registry";
import type { Nuri, Scope } from "./types";
/**
@@ -183,6 +187,12 @@ export function watchShape<T = UnionSubject>(
// Container subscriptions (scope-index doc; discovery-index doc for `public`) —
// a push here means the doc SET may have changed → re-resolve.
const containerSubs = new Map<Nuri, Unsubscribe>();
// Unsubscribe from the held-caps change signal (see the subscription in `start`).
let capsUnsub: (() => void) | null = null;
// True while `resolveDocs` runs. Folding a repo link files a cap, which fires the
// held-caps signal; the resolution in progress already accounts for it, so the
// signal is ignored during that window instead of restarting the cycle.
let resolving = false;
// Monotonic token so a slow in-flight refresh cannot clobber a newer one.
let refreshToken = 0;
@@ -201,64 +211,43 @@ export function watchShape<T = UnionSubject>(
emit();
}
/** Extract candidate document NURIs from an opaque discovery `ref` — every
* string, recursively, that looks like a NextGraph doc NURI (`did:ng:`). Generic:
* the app puts the entity doc NURI inside the ref it submits; we fold those docs
* into the read-set so the app need not orchestrate discovery. Non-NURI refs
* contribute nothing (and readUnion+shape-filter drop anything irrelevant). */
function nurisFromRef(ref: unknown, out: Set<Nuri>): void {
if (typeof ref === "string") {
if (ref.startsWith("did:ng:")) out.add(ref);
return;
}
if (Array.isArray(ref)) {
for (const v of ref) nurisFromRef(v, out);
return;
}
if (ref && typeof ref === "object") {
for (const v of Object.values(ref)) nurisFromRef(v, out);
}
}
/** Resolve the logical scope → the current doc set (my entity docs + discovery
* fold for `public`). Tolerant: a resolution failure yields whatever resolved. */
/** Resolve the logical scope → the current doc set: the CURRENT wallet's own
* entity documents for that scope, and nothing else. Tolerant: a resolution
* failure yields whatever resolved.
*
* There is no "everything public" to fold in. You cannot discover; you can only
* follow links, and a link reaches you through an inbox or through a document
* you already hold — never through a shared index. A document someone gave you
* the cap for is read by naming it (`readModel.readUnion`), not by appearing in
* a scope you did not put it in. */
async function resolveDocs(): Promise<Nuri[]> {
const user = getCurrentUser();
const set = new Set<Nuri>();
if (user) {
try {
for (const d of await listMyEntityDocs(user, scope)) set.add(d);
} catch (error) {
console.error("[watch-shape] listMyEntityDocs failed", error);
}
}
if (scope === "public") {
try {
for (const e of await readIndex()) nurisFromRef(e.ref, set);
} catch (error) {
console.error("[watch-shape] discovery readIndex failed", error);
resolving = true;
try {
if (user) {
try {
for (const d of await listMyEntityDocs(user, scope)) set.add(d);
} catch (error) {
console.error("[watch-shape] listMyEntityDocs failed", error);
}
}
} finally {
resolving = false;
}
return [...set];
}
/** Subscribe to the CONTAINER documents (scope-index; discovery-index for public)
* so a change to the doc SET re-resolves. Idempotent per NURI. */
/** Subscribe to the CONTAINER document (the scope index) so a change to the doc
* SET re-resolves. Idempotent per NURI. */
async function ensureContainerSubs(): Promise<void> {
const containers: Nuri[] = [];
const user = getCurrentUser();
if (user) {
try {
containers.push(await scopeIndexDoc(user, scope));
containers.push(await userStoreDoc(user, scope));
} catch (error) {
console.error("[watch-shape] scopeIndexDoc failed", error);
}
}
if (scope === "public") {
try {
containers.push(await indexDocNuri());
} catch (error) {
console.error("[watch-shape] indexDocNuri failed", error);
console.error("[watch-shape] userStoreDoc failed", error);
}
}
for (const c of containers) {
@@ -341,6 +330,15 @@ export function watchShape<T = UnionSubject>(
function start(): void {
if (started) return;
started = true;
// A cap that arrives ASYNCHRONOUSLY (an inbox deposit absorbed by the
// consumer's `inbox.watch`) makes documents readable that were not. Without
// this the view would stay stale until some unrelated push happened to fire —
// so re-read whenever they change. This is the delivery channel key
// ROTATION uses too, which is why keeping an access needs no subscription
// obligation on the consumer's side.
capsUnsub = getCaps().onChange(() => {
if (!resolving) void refresh();
});
void refresh();
}
@@ -372,6 +370,10 @@ export function watchShape<T = UnionSubject>(
}
docSubs.clear();
containerSubs.clear();
if (capsUnsub) {
capsUnsub();
capsUnsub = null;
}
started = false;
}
};
+21 -4
View File
@@ -28,6 +28,8 @@ import {
resetConfig,
setCurrentUser,
getCurrentUser,
resetCaps,
connectedUser,
} from "../src/polyfill";
// ---------------------------------------------------------------------------
@@ -97,6 +99,17 @@ afterAll(() => {
// Tests
// ---------------------------------------------------------------------------
// Two process-wide things bite this suite, which only wants to watch the log:
// - the cap registry: once ANY cap exists the reach guard applies to every reader;
// - connecting a user does WORK (restore + drain its inbox, see connect.ts), which
// both logs and files caps, asynchronously.
// So: let any in-flight connection finish, THEN clear. Awaiting rather than hoping
// is what makes this deterministic — `setCurrentUser` is fire-and-forget by design.
beforeEach(async () => {
await connectedUser();
resetCaps();
});
describe("access-log: OFF by default", () => {
beforeEach(() => {
// Force the env var OFF for these tests, regardless of the shell environment.
@@ -147,8 +160,10 @@ describe("access-log: OFF by default", () => {
});
describe("access-log: ON via configure({ debugAccessLog: true })", () => {
beforeEach(() => {
beforeEach(async () => {
setCurrentUser("alice");
await connectedUser(); // drain the connection work before counting log lines
resetCaps();
});
it("sparqlQuery emits a READ line with identity, nuri, label, and row-count", async () => {
@@ -262,9 +277,11 @@ describe("access-log: identity follows setCurrentUser", () => {
} finally {
restore();
}
expect(lines.length).toBe(2);
expect(lines[0]).toMatch(/^\[first-user\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
expect(lines[1]).toMatch(/^\[second-user\]\[polyfill\] /);
// Only this test's own lines: connecting a user legitimately logs its own reads.
const mine = lines.filter((l) => l.includes("step1") || l.includes("step2"));
expect(mine.length).toBe(2);
expect(mine[0]).toMatch(/^\[first-user\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
expect(mine[1]).toMatch(/^\[second-user\]\[polyfill\] /);
});
it("prefix is (none) when no identity is set", async () => {
+2 -2
View File
@@ -23,7 +23,6 @@ import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
import {
ensureAccount,
resolveAccount,
loadShim,
resetRegistryCache,
} from "../src/store-registry";
import type { RegistrySession } from "../src/store-registry";
@@ -225,7 +224,8 @@ describe("deterministic resolution over a doc-shim corrupted by fork residue", (
const r1 = await resolveAccount("dupuser");
resetRegistryCache();
const r2 = await resolveAccount("dupuser");
const viaShim = (await loadShim()).get("dupuser");
resetRegistryCache();
const viaShim = await resolveAccount("dupuser");
// Canonical = lexicographically smallest → "did:ng:o:pub-a".
expect(r1?.docPublic).toBe("did:ng:o:pub-a");
+144 -62
View File
@@ -1,83 +1,165 @@
/**
* caps.test.ts — the cap surface as KEY POSSESSION.
*
* What these prove is a SHAPE, not a protection (the library is deliberately
* insecure until P1b): the only question the registry can answer is "do I hold
* this document's cap?", there is no principal to look up in a list, and no
* function turns a bare reference into a cap.
*/
import { test, expect } from "bun:test";
import { CapRegistry } from "../src/caps";
import { hasReadCap, targetOf } from "../src/nuri";
import type { ReadCap } from "../src/types";
test("public documents are readable by anyone, even anonymous", () => {
const caps = new CapRegistry();
caps.open("did:ng:o:pub", "public", "alice");
expect(caps.canRead("did:ng:o:pub", null)).toBe(true);
expect(caps.canRead("did:ng:o:pub", "bob")).toBe(true);
/** A registry whose holder the test drives. */
function registry(initial: string | null = "alice") {
let holder = initial;
const caps = new CapRegistry(() => holder);
return { caps, become: (id: string | null) => (holder = id) };
}
test("a cap NAMES and READS; the bare reference only names", () => {
const { caps } = registry();
const doc = "did:ng:o:doc1:v:overlay";
// Before anything: naming a document tells you nothing about reading it.
expect(caps.capFor(doc)).toBeUndefined();
const cap = caps.mint(doc);
expect(hasReadCap(cap)).toBe(true); // carries `:r:`
expect(hasReadCap(doc)).toBe(false);
expect(targetOf(cap)).toBe(doc); // same object, key inside
expect(caps.capFor(doc)).toBe(cap);
// Looking the cap up by the cap-bearing form resolves the same document.
expect(caps.capFor(cap)).toBe(cap);
});
test("protected documents: owner + explicitly granted principals only", () => {
const caps = new CapRegistry();
caps.open("did:ng:o:prot", "protected", "alice");
expect(caps.canRead("did:ng:o:prot", "alice")).toBe(true);
expect(caps.canRead("did:ng:o:prot", "bob")).toBe(false);
caps.grantRead("did:ng:o:prot", "bob"); // a directed grant issues bob the read cap
expect(caps.canRead("did:ng:o:prot", "bob")).toBe(true);
test("no cap is derivable from a bare reference — you look it up or you were given it", () => {
const { caps } = registry();
caps.mint("did:ng:o:mine");
// A document that never entered the held caps stays unreadable, however well-formed
// its reference is. There is no `grantRead`, and no principal to name.
expect(caps.capFor("did:ng:o:someone-else")).toBeUndefined();
});
test("private documents: owner only", () => {
const caps = new CapRegistry();
caps.open("did:ng:o:priv", "private", "alice");
expect(caps.canRead("did:ng:o:priv", "alice")).toBe(true);
expect(caps.canRead("did:ng:o:priv", "bob")).toBe(false);
expect(caps.canRead("did:ng:o:priv", null)).toBe(false);
// Passing the naming form where the reading form is meant is now a COMPILE error
// (`ReadCap` is a template literal type). The runtime refusal still has to hold,
// because a JavaScript consumer — or a cap read back from storage, a URL or JSON
// and cast rather than narrowed — never meets the compiler. The `as` below is
// exactly that consumer: it is how the mistake reaches the library at all.
// Unchecked, it would file a bare reference as its own cap and make the document
// read — the exact inversion this batch removes.
test("learn REFUSES a bare reference, even when the compiler was bypassed", () => {
const { caps } = registry();
const bare = "did:ng:o:someone-elses-doc" as ReadCap; // a JS consumer / an unchecked cast
expect(() => caps.learn(bare)).toThrow(/naming is not reading|bare reference/i);
expect(caps.capFor("did:ng:o:someone-elses-doc")).toBeUndefined(); // nothing was filed
expect(caps.isEnforcing()).toBe(false); // and nothing was issued
});
test("protectedDocsOf surfaces an owner's protected documents for directed grants", () => {
const caps = new CapRegistry();
caps.open("did:ng:o:prot1", "protected", "alice");
caps.open("did:ng:o:prot2", "protected", "alice");
caps.open("did:ng:o:pub", "public", "alice"); // not protected → excluded
caps.open("did:ng:o:priv", "private", "alice"); // not protected → excluded
caps.open("did:ng:o:bob", "protected", "bob"); // other owner → excluded
expect(caps.protectedDocsOf("alice").sort()).toEqual([
"did:ng:o:prot1",
"did:ng:o:prot2",
]);
expect(caps.protectedDocsOf("bob")).toEqual(["did:ng:o:bob"]);
expect(caps.protectedDocsOf("carol")).toEqual([]);
// A directed grant on one of them makes the reader read that doc only.
caps.grantRead("did:ng:o:prot1", "carol");
expect(caps.canRead("did:ng:o:prot1", "carol")).toBe(true);
expect(caps.canRead("did:ng:o:prot2", "carol")).toBe(false);
test("holding one document's cap grants nothing on another (no inheritance)", () => {
const { caps } = registry();
caps.mint("did:ng:o:doc1");
expect(caps.capFor("did:ng:o:doc1")).toBeDefined();
expect(caps.capFor("did:ng:o:doc2")).toBeUndefined(); // separate repo, separate cap
});
test("write is restricted to write-cap holders; the creator always holds it", () => {
const caps = new CapRegistry();
caps.open("did:ng:o:pub", "public", "alice");
expect(caps.canWrite("did:ng:o:pub", "alice")).toBe(true);
expect(caps.canWrite("did:ng:o:pub", "bob")).toBe(false);
expect(caps.canWrite("did:ng:o:pub", null)).toBe(false);
test("one set of held caps PER holder: switching identity switches heldByHolder, it does not wipe", () => {
const { caps, become } = registry("alice");
const doc = "did:ng:o:alice-doc";
const cap = caps.mint(doc);
become("bob");
expect(caps.capFor(doc)).toBeUndefined(); // bob holds nothing of alice's
become("alice");
expect(caps.capFor(doc)).toBe(cap); // …and alice did not lose hers
});
test("holding a document's cap does NOT grant another document (no inheritance)", () => {
const caps = new CapRegistry();
caps.grantRead("did:ng:o:doc1", "alice");
expect(caps.canRead("did:ng:o:doc1", "alice")).toBe(true);
expect(caps.canRead("did:ng:o:doc2", "alice")).toBe(false); // separate repo, separate cap
test("a cap received (learn) reads, exactly like one minted", () => {
const alice = registry("alice");
const doc = "did:ng:o:shared";
const cap = alice.caps.mint(doc);
const bob = registry("bob");
expect(bob.caps.capFor(doc)).toBeUndefined();
bob.caps.learn(cap); // delivered to bob's inbox, absorbed
expect(bob.caps.capFor(doc)).toBe(cap);
});
test("governsRead / hasReadPolicy distinguish governed from ungoverned documents", () => {
const caps = new CapRegistry();
expect(caps.hasReadPolicy()).toBe(false);
caps.grantRead("did:ng:o:doc1", "alice");
expect(caps.hasReadPolicy()).toBe(true);
expect(caps.governsRead("did:ng:o:doc1")).toBe(true);
expect(caps.governsRead("did:ng:o:unknown")).toBe(false); // not declared → not enforced
test("publishRepoLink returns a cap-bearing link; reading it still means HOLDING it", () => {
const { caps, become } = registry("alice");
const doc = "did:ng:o:public-doc";
const link = caps.publishRepoLink(doc);
expect(hasReadCap(link)).toBe(true);
expect(targetOf(link)).toBe(doc);
expect(caps.isPublished(doc)).toBe(true);
expect(caps.isPublished("did:ng:o:other")).toBe(false);
// Publication is not a world-wide read grant: whoever HAS the URL reads it.
become("bob");
expect(caps.capFor(doc)).toBeUndefined();
caps.learn(link); // bob received the link (e.g. from the discovery index)
expect(caps.capFor(doc)).toBe(link);
});
test("governsWrite / hasWritePolicy distinguish governed from ungoverned documents", () => {
const caps = new CapRegistry();
test("open(): a public document is published as a link, a private one is not", () => {
const { caps } = registry();
const pub = caps.open("did:ng:o:pub", "public");
const prot = caps.open("did:ng:o:prot", "protected");
const priv = caps.open("did:ng:o:priv", "private");
expect(caps.isPublished("did:ng:o:pub")).toBe(true);
expect(caps.isPublished("did:ng:o:prot")).toBe(false);
expect(caps.isPublished("did:ng:o:priv")).toBe(false);
// All three are readable BY THEIR OWNER — a creator is never locked out.
for (const [doc, cap] of [["did:ng:o:pub", pub], ["did:ng:o:prot", prot], ["did:ng:o:priv", priv]] as const) {
expect(caps.capFor(doc)).toBe(cap);
}
});
test("open() is idempotent — re-listing my own documents refiles the same caps", () => {
const { caps } = registry();
const first = caps.open("did:ng:o:doc", "protected");
let fired = 0;
caps.onChange(() => (fired += 1));
expect(caps.open("did:ng:o:doc", "protected")).toBe(first);
expect(fired).toBe(0); // nothing changed → no spurious re-read
});
test("isEnforcing is false until the first cap exists, then holds for every holder", () => {
const { caps, become } = registry("alice");
expect(caps.isEnforcing()).toBe(false);
caps.mint("did:ng:o:doc1");
expect(caps.isEnforcing()).toBe(true);
// …including for a holder whose own holds nothing: that IS the isolation.
become("bob");
expect(caps.isEnforcing()).toBe(true);
expect(caps.capFor("did:ng:o:doc1")).toBeUndefined();
});
test("a cap arriving fires the change signal — an asynchronous delivery must re-trigger reads", () => {
const { caps } = registry();
let fired = 0;
const unsub = caps.onChange(() => (fired += 1));
caps.learn(caps.mint("did:ng:o:doc1")); // mint fires once; the learn is a no-op
expect(fired).toBe(1);
unsub();
caps.mint("did:ng:o:doc2");
expect(fired).toBe(1); // unsubscribed
});
test("write is restricted to write-cap holders (decorative until P1b)", () => {
const { caps } = registry();
expect(caps.hasWritePolicy()).toBe(false);
caps.open("did:ng:o:doc1", "private", "alice"); // owner gets the write cap
caps.grantWrite("did:ng:o:doc", "alice");
expect(caps.hasWritePolicy()).toBe(true);
expect(caps.governsWrite("did:ng:o:doc1")).toBe(true);
expect(caps.governsWrite("did:ng:o:doc")).toBe(true);
expect(caps.governsWrite("did:ng:o:unknown")).toBe(false); // not declared → not enforced
// A public doc grants read to all but its write cap is still owner-only.
const pub = new CapRegistry();
pub.open("did:ng:o:pub", "public", "alice");
expect(pub.hasWritePolicy()).toBe(true);
expect(pub.governsWrite("did:ng:o:pub")).toBe(true);
expect(caps.canWrite("did:ng:o:doc", "alice")).toBe(true);
expect(caps.canWrite("did:ng:o:doc", "bob")).toBe(false);
expect(caps.canWrite("did:ng:o:doc", null)).toBe(false);
});
+13 -1
View File
@@ -13,7 +13,7 @@
*
* The heal: `resolveAccount`/`loadShim`/`ensureAccount` call `ensureRepoOpen(anchor)`
* (open-repo.ts, via `doc_subscribe` + first-`State` barrier) before touching the
* shim — the same open-before-read guard `readScopeIndex` already applies to its
* shim — the same open-before-read guard `readUserStore` already applies to its
* index doc. This suite models a fake `ng` where the anchor throws `RepoNotFound`
* UNTIL it has been `doc_subscribe`-d, and asserts the registry provisions cleanly.
*
@@ -28,7 +28,10 @@ import {
configureStoreRegistry,
resetStoreRegistry,
resetConfig,
resetCaps,
setCurrentUser,
} from "../src/polyfill";
import { resetInfrastructure } from "../src/reach";
const SESSION = { sessionId: "sid-cold", privateStoreId: "PRIV-COLD" };
const ANCHOR = `did:ng:${SESSION.privateStoreId}`;
@@ -38,11 +41,20 @@ afterAll(() => {
resetStoreRegistry();
resetRegistryCache();
resetOpenedRepos();
resetCaps();
resetInfrastructure();
setCurrentUser(null);
});
// The reach guard is process-wide and so is the cap registry: once ANY cap exists
// the boundary applies to every reader. A suite that declares none must therefore
// start from an empty one, or it inherits another suite's enforcement.
beforeEach(() => {
resetRegistryCache();
resetOpenedRepos();
resetCaps();
resetInfrastructure();
setCurrentUser(null);
});
interface Quad { g: string; s: string; p: string; o: string }
@@ -0,0 +1,424 @@
/**
* 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 what what they hold holds. There is
* no authorization list anywhere, and nobody was named to the registry.
*/
import { test, expect, mock, afterAll } from "bun:test";
import { createEntityDoc, documentInbox, resetRegistryCache, walletInbox } from "../src/store-registry";
import type { RegistrySession } from "../src/store-registry";
import {
configure,
configureStoreRegistry,
resetStoreRegistry,
resetConfig,
capFor,
getCaps,
resetCaps,
setCurrentUser,
shareCap,
connectedUser,
} from "../src/polyfill";
import { post, read as readInbox } from "../src/inbox";
import { readUnion } from "../src/read-model";
import { sparqlUpdate } from "../src/docs";
import type { Nuri } from "../src/types";
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;
const 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: 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 } })) } };
}
// 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 } })) } };
}
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. Returns what each actor could plausibly come to hold.
*/
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);
const pubLink = capFor(pubDoc)!; // the shareable repo link of the public doc
const protCap = capFor(protDoc)!; // the cap Alice may hand to whoever she chooses
return { protDoc, pubDoc, pubLink, protCap };
}
/** 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, pubLink } = await aliceSetsUpHerDocuments();
setCurrentUser("bob");
// Bob was given the public document's link — "whoever has the URL reads it".
getCaps().learn(pubLink);
// 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(capFor(ref)).toBeUndefined();
expect(await readValues([ref], SECRET)).toEqual([]);
});
test("Charlie: same public document, same reference — and he reads through it", async () => {
inject();
const { protDoc, pubDoc, pubLink, protCap } = await aliceSetsUpHerDocuments();
const CHARLIE_INBOX = await walletInbox("charlie");
// 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 shareCap(protCap, CHARLIE_INBOX);
setCurrentUser("charlie");
getCaps().learn(pubLink);
await readInbox(CHARLIE_INBOX); // processing the inbox files the cap
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
expect(ref).toBe(protDoc);
expect(capFor(ref)).toBe(protCap);
expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]);
});
test("the ONLY difference between Bob and Charlie is what what they hold holds", async () => {
inject();
const { protDoc, pubLink, protCap } = await aliceSetsUpHerDocuments();
const CHARLIE_INBOX = await walletInbox("charlie");
setCurrentUser("alice");
await shareCap(protCap, CHARLIE_INBOX);
setCurrentUser("bob");
getCaps().learn(pubLink);
const bobSees = await readValues([protDoc], SECRET);
setCurrentUser("charlie");
getCaps().learn(pubLink);
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 { pubDoc, pubLink, protCap } = await aliceSetsUpHerDocuments();
const BOB_INBOX = await walletInbox("bob");
setCurrentUser("bob");
getCaps().learn(pubLink);
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 shareCap(protCap, BOB_INBOX);
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(capFor(ref)).toBe(protCap);
expect(latest).toEqual(["the-protected-content"]);
expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]);
unsub();
});
test("a bare reference to the PUBLIC document is not enough either — the link is", async () => {
inject();
const { pubDoc, pubLink } = await aliceSetsUpHerDocuments();
setCurrentUser("bob");
// Bob knows the public document's NURI but was never given its link.
expect(await readValues([pubDoc], REFERS_TO)).toEqual([]);
getCaps().learn(pubLink);
expect((await readValues([pubDoc], REFERS_TO)).length).toBe(1);
});
// 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, protCap } = await aliceSetsUpHerDocuments();
const bobInbox = await walletInbox("bob");
setCurrentUser("alice");
await shareCap(protCap, bobInbox);
// 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");
expect(await readValues([protDoc], SECRET)).toEqual([]); // bob holds nothing yet
// Connecting restores it — from the User branch, since the inbox has nothing left.
await connectedUser();
expect(capFor(protDoc)).toBe(protCap);
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);
});
// 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 docInbox = await documentInbox(doc);
expect(docInbox).not.toBe(await walletInbox("alice"));
// Bob deposits into the document's inbox — the cross-user act, open to all.
setCurrentUser("bob");
await post(docInbox, { payload: { joining: true }, ts: 1 });
// …and cannot read it back: depositing grants nothing.
await expect(readInbox(docInbox)).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(docInbox);
expect(deposits.map((d) => d.payload)).toEqual([{ joining: true }]);
});
test("connecting drains BOTH levels: the user's inbox and its documents'", async () => {
inject();
setCurrentUser("alice");
const protDoc = await createEntityDoc("alice", "protected");
const pubDoc = await createEntityDoc("alice", "public");
const docInbox = await documentInbox(pubDoc);
const aliceInbox = await walletInbox("alice");
// Two deposits, one at each level, both made by someone else.
setCurrentUser("carol");
const carolDoc = await createEntityDoc("carol", "protected");
await shareCap(capFor(carolDoc)!, aliceInbox); // a Link, to alice herself
await post(docInbox, { payload: { onTheDocument: true }, ts: 2 });
// Alice connects: one call, both queues.
setCurrentUser("alice");
await connectedUser();
expect(capFor(carolDoc)).toBeDefined(); // 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
});
-333
View File
@@ -1,333 +0,0 @@
import { test, expect, mock, beforeEach, afterAll } from "bun:test";
import { submitToIndex, readIndex, watchIndex, INDEX_ACCOUNT } from "../src/discovery";
import type { IndexEntry } from "../src/discovery";
import {
configure,
configureStoreRegistry,
resetStoreRegistry,
resetConfig,
setCurrentUser,
getCaps,
resetCaps,
} from "../src/polyfill";
import { resetRegistryCache, ensureAccount } from "../src/store-registry";
import type { RegistrySession } from "../src/store-registry";
// discovery.ts submits to / reads from a global index owned by a RESERVED
// SPECIAL ACCOUNT (@index) in the shim. This suite injects one fake `ng` that
// emulates BOTH the shim SPARQL (ensureAccount('@index') → doc_create ×3 +
// shim INSERT/SELECT) AND the inbox SPARQL (deposit INSERT + read SELECT), over
// a single in-memory quad store. Restore un-configured state at the end.
afterAll(() => {
resetConfig();
resetStoreRegistry();
setCurrentUser(null);
resetCaps();
});
test("throws a clear error when configureStoreRegistry() was not called", async () => {
resetStoreRegistry();
resetRegistryCache();
await expect(submitToIndex({ ref: 1 })).rejects.toThrow(
/configureStoreRegistry\(\) must be called before use/,
);
});
interface Quad { g: string; s: string; p: string; o: string }
const SHIM = "urn:ng-eventually:shim";
const INBOX = "urn:ng-eventually:inbox";
/** 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 and the inbox SPARQL.
function makeFakeNg() {
const quads: Quad[] = [];
let docCounter = 0;
// Reactive subscriptions (see inbox.test.ts): doc_subscribe registers a
// callback per anchor + fires an initial push; sparql_update pushes a Patch to
// that anchor's subscribers, so discovery.watchIndex (now event-driven) works
// without a timer.
const subs = new Map<string, Set<(r: unknown) => void>>();
const doc_subscribe = mock(async (nuri: string, _sid: unknown, cb: (r: unknown) => void) => {
let set = subs.get(nuri);
if (!set) {
set = new Set();
subs.set(nuri, set);
}
set.add(cb);
queueMicrotask(() => cb({ V0: { State: { doc: nuri } } }));
return () => set!.delete(cb);
});
const pushTo = (anchor: string): void => {
for (const cb of subs.get(anchor) ?? []) cb({ V0: { Patch: { doc: anchor } } });
};
const doc_create = mock(async (..._a: unknown[]) => `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;
// TWO shapes coexist: the shim account write STILL uses `GRAPH <${priv}>`
// (the private-store repo's graph name equals the plain store NURI → it
// round-trips; key by that GRAPH IRI). The inbox deposit write has NO
// explicit GRAPH — the real broker keys it by the ANCHORED repo's default
// graph (repo_graph_name(id, overlay)); key it by the ANCHOR arg (a[2]).
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 after = body.slice(body.indexOf(sm[0]) + sm[0].length);
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
while ((m = pairRe.exec(after)) !== null) {
// `a` → an rdf:type marker; the two type IRIs the modules use differ, so
// pick by which body we're in (deposit vs account) — harmless if wrong,
// the SELECT filters by the real predicates below.
const isDeposit = query.includes(`${INBOX}:Deposit`);
const p = m[1] ?? (isDeposit ? `${INBOX}:Deposit` : `${SHIM}:Account`);
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
quads.push({ g, s, p, o });
}
pushTo(g); // local-push to the written graph's subscribers
return undefined;
});
const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[3] as string | undefined;
// Pointer SELECT: `<shim:root> <shim:shimDoc> ?shimDoc` in the store-root graph.
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 } };
}
// Shim account SELECT (anchored to the doc-shim, no GRAPH wrapper). Two shapes:
// the full scan (`?acc a <Account>`) and the TARGETED bounded resolve (`<subj> a
// <Account>`) — honour that subject filter so the bounded query is O(1)/exact.
if (query.includes(`<${SHIM}:id>`)) {
const subjM = query.match(new RegExp(`<([^>]+)>\\s+a\\s+<${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 (?payload ?ts ?from).
if (query.includes(`<${INBOX}:payload>`)) {
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (q.p === `${INBOX}:Deposit`) {
if (!bySubject.has(q.s)) bySubject.set(q.s, {});
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 } };
}
// Entity-index SELECT (shim contains) — unused here.
return { results: { bindings: [] } };
});
return { doc_create, doc_subscribe, sparql_update, sparql_query, _quads: quads };
}
const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" };
function inject() {
const ng = makeFakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => SESSION,
normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
});
resetRegistryCache();
setCurrentUser(null);
return ng;
}
let fake: ReturnType<typeof makeFakeNg>;
beforeEach(() => {
fake = inject();
});
test("submitToIndex creates the @index special account on first sight (3 docs)", async () => {
await submitToIndex({ nuri: "did:ng:o:event1", title: "Concert" });
// ensureAccount('@index') created its 3 scope docs + 1 doc-shim (first login).
expect(fake.doc_create).toHaveBeenCalledTimes(4);
// The deposit landed in the @index public document (its inbox).
const depositCall = fake.sparql_update.mock.calls.find((c) =>
(c[1] as string).includes(`${INBOX}:Deposit`),
)!;
expect(depositCall, "a deposit INSERT was issued").not.toBeUndefined();
expect(depositCall[2]).toMatch(/^did:ng:o:doc/); // the index document NURI
});
test("submit → read round-trips the reference as an index entry", async () => {
setCurrentUser("alice"); // `from` is bound to the current identity
const ref = { nuri: "did:ng:o:event1", title: "Concert au parc" };
await submitToIndex(ref, { from: "alice", ts: 100 });
const entries = await readIndex();
expect(entries).toHaveLength(1);
expect(entries[0]).toEqual({ ref, from: "alice", ts: 100 } as IndexEntry);
});
test("a reference submitted by A is discovered by a NON-connected reader via the index", async () => {
// A submits (identified). No connection is ever declared. A separate reader
// materializes the SAME index (same special account → same document) and sees
// the reference — discovery is via the index, not any direct fan-out/link.
setCurrentUser("alice");
const ref = { nuri: "did:ng:o:evA", title: "Public event by A" };
await submitToIndex(ref, { ts: 100 });
// Reader B: a fresh cache, never connected to A, reads the index.
resetRegistryCache();
setCurrentUser("bob");
const entries = await readIndex();
const refs = entries.map((e) => e.ref);
expect(refs).toContainEqual(ref);
expect(entries.find((e) => JSON.stringify(e.ref) === JSON.stringify(ref))!.from).toBe("alice");
});
test("readIndex deduplicates identical references (materialization moderation point)", async () => {
const ref = { nuri: "did:ng:o:dup", title: "Twice" };
// Anonymous submissions (dedup keys on the ref, not the submitter).
await submitToIndex(ref, { from: null, ts: 100 });
await submitToIndex(ref, { from: null, ts: 200 }); // duplicate reference
const entries = await readIndex();
expect(entries).toHaveLength(1); // surfaced once
});
test("from: null makes an anonymous submission", async () => {
await submitToIndex({ nuri: "did:ng:o:anon" }, { from: null, ts: 100 });
const entries = await readIndex();
expect(entries[0]!.from).toBeNull();
});
// (d) PUBLIC-ONLY: a protected/private document must NOT be submittable to the
// world-readable discovery index; a public (or ungoverned) document is fine.
test("(d) submitToIndex refuses a PROTECTED/PRIVATE document (public-only)", async () => {
resetCaps();
// A PROTECTED and a PRIVATE governed document, and a PUBLIC one.
getCaps().open("did:ng:o:prot", "protected", "alice");
getCaps().open("did:ng:o:priv", "private", "alice");
getCaps().open("did:ng:o:pub", "public", "alice");
// Submitting the protected doc's NURI is REJECTED.
await expect(
submitToIndex({ nuri: "did:ng:o:prot" }, { from: null, doc: "did:ng:o:prot" }),
).rejects.toThrow(/PUBLIC|public-only|protected\/private/i);
// Private too.
await expect(
submitToIndex({ nuri: "did:ng:o:priv" }, { from: null, doc: "did:ng:o:priv" }),
).rejects.toThrow(/PUBLIC|public-only|protected\/private/i);
// The PUBLIC document passes.
await submitToIndex({ nuri: "did:ng:o:pub" }, { from: null, doc: "did:ng:o:pub", ts: 1 });
const entries = await readIndex();
expect(entries.map((e) => (e.ref as { nuri: string }).nuri)).toEqual(["did:ng:o:pub"]);
resetCaps();
});
test("INDEX_ACCOUNT lives in the reserved namespace (no typed id can equal it)", () => {
// The index account occupies a key no consumer input can produce: it is prefixed
// with a NUL control char, which a user cannot type into an id field and
// which no `normalizeId` output (a typeable value) contains. So it is
// disjoint from the keys "index" / "@index" a hostile user would submit.
expect(INDEX_ACCOUNT.startsWith("\u0000")).toBe(true); // unreachable-by-typing sentinel
expect(INDEX_ACCOUNT).not.toBe("index");
expect(INDEX_ACCOUNT).not.toBe("@index");
});
test("a user named 'index'/'@index' does NOT resolve to the index account's document", async () => {
// The discovery index lives on INDEX_ACCOUNT. A hostile (or unlucky) user who
// registers as "index" or "@index" normalizes to key "index" — which must be
// a DISJOINT key from the reserved index account, so they get their own
// documents and cannot hijack / read-write the global index document.
const indexRecord = await ensureAccount(INDEX_ACCOUNT);
// A real user "index" — same normalized form as "@index".
const userIndex = await ensureAccount("index");
expect(userIndex.docPublic).not.toBe(indexRecord.docPublic);
expect(userIndex.docProtected).not.toBe(indexRecord.docProtected);
expect(userIndex.docPrivate).not.toBe(indexRecord.docPrivate);
// "@index" must land on the SAME account as "index" (both normalize to
// "index") — and still NOT on the reserved index account.
const userAtIndex = await ensureAccount("@index");
expect(userAtIndex.docPublic).toBe(userIndex.docPublic);
expect(userAtIndex.docPublic).not.toBe(indexRecord.docPublic);
});
test("watchIndex fires immediately then when a submission arrives", async () => {
const seen: IndexEntry[][] = [];
const stop = watchIndex((e) => seen.push(e), { intervalMs: 5 });
await new Promise((r) => setTimeout(r, 20));
expect(seen.length).toBeGreaterThanOrEqual(1);
expect(seen[seen.length - 1]).toEqual([]);
await submitToIndex({ nuri: "did:ng:o:watched" }, { from: null, ts: 1 });
await new Promise((r) => setTimeout(r, 20));
const last = seen[seen.length - 1]!;
expect(last.map((e) => (e.ref as any).nuri)).toContain("did:ng:o:watched");
stop();
const countAfterStop = seen.length;
await submitToIndex({ nuri: "did:ng:o:after" }, { from: null, ts: 2 });
await new Promise((r) => setTimeout(r, 20));
expect(seen.length).toBe(countAfterStop);
});
+9 -2
View File
@@ -1,5 +1,12 @@
import { test, expect, mock } from "bun:test";
import { test, expect, mock, beforeEach } from "bun:test";
import { docCreate, sparqlUpdate, sparqlQuery } from "../src/docs";
// The reach guard is process-wide: once ANY cap exists it applies to every reader.
// This suite declares none, so it must not inherit another suite's enforcement.
beforeEach(() => {
resetCaps();
setCurrentUser(null);
});
import * as ngProxy from "../src/ng-proxy";
// NOTE ORDER: the "not configured → throw" case MUST run before any configure()
@@ -18,7 +25,7 @@ test("throws a clear error when configure() was not called", async () => {
});
// From here on, a fake real `ng` is injected via configure().
import { configure } from "../src/polyfill";
import { configure, resetCaps, setCurrentUser } from "../src/polyfill";
function fakeNg() {
return {
+11 -4
View File
@@ -1,5 +1,6 @@
import { test, expect, mock, beforeEach, afterAll } from "bun:test";
import { post, read, materialize, watch } from "../src/inbox";
import { walletInbox, resetRegistryCache } from "../src/store-registry";
import type { Deposit } from "../src/inbox";
import {
configure,
@@ -147,7 +148,8 @@ function makeFakeNg() {
}
const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" };
const TARGET = "did:ng:o:host-inbox";
/** Resolved per test: an inbox BELONGS to a wallet, and only its owner may read it. */
let TARGET: `did:ng:${string}`;
function inject() {
const ng = makeFakeNg();
@@ -159,15 +161,20 @@ function inject() {
}
let fake: ReturnType<typeof makeFakeNg>;
beforeEach(() => {
beforeEach(async () => {
fake = inject();
resetRegistryCache();
setCurrentUser("alice");
TARGET = await walletInbox("alice");
});
test("post writes via the real injected ng.sparql_update (not makeNg), scoped to the inbox", async () => {
setCurrentUser("alice"); // `from` is bound to the current identity
// Count from HERE: resolving this wallet's own inbox already wrote to the shim.
const before = fake.sparql_update.mock.calls.length;
await post(TARGET, { from: "alice", payload: { kind: "join" }, ts: 100 });
expect(fake.sparql_update).toHaveBeenCalledTimes(1);
const call = fake.sparql_update.mock.calls[0]!;
expect(fake.sparql_update.mock.calls.length).toBe(before + 1);
const call = fake.sparql_update.mock.calls[before]!;
expect(call[0]).toBe("sid-1"); // sessionId from the injected session
expect(call[2]).toBe(TARGET); // anchored to the target inbox
// The write targets the anchored DEFAULT graph — NO explicit `GRAPH <…>`
+360 -77
View File
@@ -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
});
+10 -8
View File
@@ -8,9 +8,11 @@ import {
setCurrentUser,
} from "../src/polyfill";
// This suite injects a fake `ng` via configure() and declares write caps. Reset
// both after each test so the docs.test.ts "not configured" guard still holds
// and no cap policy leaks into another suite.
// This suite injects a fake `ng` via configure() and declares WRITE caps —
// which stay an authorization list on purpose: only READING is key possession
// (P1a). The write axis is decorative until P1b (every internal writer bypasses
// this proxy). Reset after each test so the docs.test.ts "not configured" guard
// still holds and no cap leaks into another suite.
afterEach(() => {
resetConfig();
resetCaps();
@@ -40,7 +42,7 @@ test("write guard: passthrough when NO write policy is declared (no regression)"
test("write guard: passthrough for an UNGOVERNED doc even when a policy exists elsewhere", async () => {
const ng = inject();
getCaps().open("did:ng:o:other", "private", "alice"); // policy on another doc
getCaps().grantWrite("did:ng:o:other", "alice"); // policy on another doc
setCurrentUser("bob");
const proxy = makeNg();
await proxy.sparql_update("sid", UPDATE, DOC); // DOC itself is ungoverned
@@ -49,7 +51,7 @@ test("write guard: passthrough for an UNGOVERNED doc even when a policy exists e
test("write guard: REJECTS when the doc is governed and the user lacks the write cap", async () => {
const ng = inject();
getCaps().open(DOC, "private", "alice"); // alice holds write cap
getCaps().grantWrite(DOC, "alice"); // alice holds the write cap
setCurrentUser("bob"); // bob does not
const proxy = makeNg();
await expect(proxy.sparql_update("sid", UPDATE, DOC)).rejects.toThrow(
@@ -60,7 +62,7 @@ test("write guard: REJECTS when the doc is governed and the user lacks the write
test("write guard: REJECTS an anonymous (null) user on a governed doc", async () => {
const ng = inject();
getCaps().open(DOC, "public", "alice");
getCaps().grantWrite(DOC, "alice");
setCurrentUser(null);
const proxy = makeNg();
await expect(proxy.sparql_update("sid", UPDATE, DOC)).rejects.toThrow(
@@ -71,7 +73,7 @@ test("write guard: REJECTS an anonymous (null) user on a governed doc", async ()
test("write guard: ALLOWS the write-cap holder", async () => {
const ng = inject();
getCaps().open(DOC, "private", "alice");
getCaps().grantWrite(DOC, "alice");
setCurrentUser("alice"); // owner always holds the write cap
const proxy = makeNg();
await proxy.sparql_update("sid", UPDATE, DOC);
@@ -80,7 +82,7 @@ test("write guard: ALLOWS the write-cap holder", async () => {
test("write guard: passthrough when anchor is omitted (cannot scope the guard)", async () => {
const ng = inject();
getCaps().open(DOC, "private", "alice");
getCaps().grantWrite(DOC, "alice");
setCurrentUser("bob");
const proxy = makeNg();
await proxy.sparql_update("sid", "INSERT DATA {}"); // no anchor → passthrough
+15 -1
View File
@@ -20,7 +20,15 @@
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
import { ensureRepoOpen, ensureReposOpen, resetOpenedRepos } from "../src/open-repo";
import { readUnion } from "../src/read-model";
import { configure, configureStoreRegistry, resetStoreRegistry, resetConfig } from "../src/polyfill";
import {
configure,
configureStoreRegistry,
resetStoreRegistry,
resetConfig,
resetCaps,
setCurrentUser,
} from "../src/polyfill";
import { resetInfrastructure } from "../src/reach";
import { resetRegistryCache } from "../src/store-registry";
afterAll(() => {
@@ -30,9 +38,15 @@ afterAll(() => {
resetOpenedRepos();
});
// The reach guard and the cap registry are process-wide: once ANY cap exists the
// boundary applies to every reader. A suite that declares none must start from an
// empty one, or it inherits another suite's enforcement.
beforeEach(() => {
resetOpenedRepos();
resetRegistryCache();
resetCaps();
resetInfrastructure();
setCurrentUser(null);
});
const SESSION = { sessionId: "sid-or", privateStoreId: "PRIV-OR" };
+219
View File
@@ -0,0 +1,219 @@
/**
* reach.test.ts — the virtual user boundary, at the passage points.
*
* A virtual user must simulate the boundary of the future single-user wallet: the
* access functions are confined to the user currently connected, and no cross-user
* access is permitted. Before this, `docs.sparqlQuery`/`sparqlUpdate` — both
* exported from the SDK entry — reached ANY document of ANY identity given a
* session id and a NURI.
*
* The one act that legitimately crosses: DEPOSITING into someone's inbox. It is
* how a link travels between users at all, and it gives the depositor nothing back.
*/
import { test, expect, mock, afterAll } from "bun:test";
import { sparqlQuery, sparqlUpdate, depositInto } from "../src/docs";
import { createEntityDoc, resetRegistryCache, walletInbox } from "../src/store-registry";
import type { RegistrySession } from "../src/store-registry";
import {
configure,
configureStoreRegistry,
resetStoreRegistry,
resetConfig,
resetCaps,
setCurrentUser,
} from "../src/polyfill";
import { mayReach, mustNotAttempt } from "../src/reach";
import { hasReadCap } from "../src/nuri";
afterAll(() => {
resetConfig();
resetStoreRegistry();
resetCaps();
setCurrentUser(null);
});
const SESSION: RegistrySession = { sessionId: "sid-reach", privateStoreId: "PRIV-REACH" };
function inject() {
let n = 0;
const quads: Array<{ g: string; s: string; p: string; o: string }> = [];
const ng = {
doc_create: mock(async () => `did:ng:o:reach${++n}`),
sparql_update: mock(async (...a: unknown[]) => {
quads.push({ g: String(a[2]), s: "", p: "", o: String(a[1]) });
return undefined;
}),
sparql_query: mock(async () => ({ results: { bindings: [] } })),
};
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
resetRegistryCache();
resetCaps();
setCurrentUser(null);
return { ng, quads };
}
const READ = "SELECT ?s ?p ?o WHERE { ?s ?p ?o }";
test("the guard is inert until the first cap exists (no regression for a cap-free consumer)", async () => {
const { ng } = inject();
// Nothing has been created, so no cap has been issued: everything flows.
expect(mayReach("did:ng:o:anything")).toBe(true);
await sparqlQuery(SESSION.sessionId, READ, undefined, "did:ng:o:anything");
expect(ng.sparql_query).toHaveBeenCalledTimes(1);
});
test("once caps exist, a document outside the connected user's reach is refused — read AND write", async () => {
inject();
setCurrentUser("alice");
const mine = await createEntityDoc("alice", "private");
// Mine: reachable.
expect(mayReach(mine)).toBe(true);
await sparqlQuery(SESSION.sessionId, READ, undefined, mine);
// A well-formed NURI I hold nothing for: named, unreachable. Both directions.
const theirs = "did:ng:o:someone-elses-doc" as const;
expect(mayReach(theirs)).toBe(false);
await expect(sparqlQuery(SESSION.sessionId, READ, undefined, theirs)).rejects.toThrow(
/does not hold this document.s cap/i,
);
await expect(
sparqlUpdate(SESSION.sessionId, "INSERT DATA { <a> <b> \"c\" }", theirs),
).rejects.toThrow(/does not hold this document.s cap/i);
});
test("the boundary follows the connected user — one user's document is another's forbidden NURI", async () => {
inject();
setCurrentUser("alice");
const aliceDoc = await createEntityDoc("alice", "private");
setCurrentUser("bob");
const bobDoc = await createEntityDoc("bob", "private");
expect(mayReach(bobDoc)).toBe(true);
expect(mayReach(aliceDoc)).toBe(false); // bob is connected
await expect(sparqlQuery(SESSION.sessionId, READ, undefined, aliceDoc)).rejects.toThrow();
setCurrentUser("alice");
expect(mayReach(aliceDoc)).toBe(true);
expect(mayReach(bobDoc)).toBe(false);
});
test("a user reaches its OWN stores and inbox — the boundary must not lock it out of itself", async () => {
inject();
setCurrentUser("alice");
await createEntityDoc("alice", "protected"); // provisions alice's account
const inbox = await walletInbox("alice");
expect(mayReach(inbox)).toBe(true);
await sparqlQuery(SESSION.sessionId, READ, undefined, inbox);
// …and not another user's inbox.
setCurrentUser("bob");
expect(mayReach(inbox)).toBe(false);
});
test("DEPOSITING into another user's inbox crosses the boundary, and gives nothing back", async () => {
const { ng } = inject();
setCurrentUser("bob");
const bobInbox = await walletInbox("bob");
setCurrentUser("alice");
await createEntityDoc("alice", "private"); // alice now holds caps → guard is armed
expect(mayReach(bobInbox)).toBe(false); // she holds no cap for it
// The deposit goes through anyway — it is the one legitimate cross-user act.
const before = ng.sparql_update.mock.calls.length;
await depositInto(SESSION.sessionId, 'INSERT DATA { <a> <b> "c" }', bobInbox);
expect(ng.sparql_update.mock.calls.length).toBe(before + 1);
// …and it grants her nothing: she still cannot read that inbox.
expect(mayReach(bobInbox)).toBe(false);
await expect(sparqlQuery(SESSION.sessionId, READ, undefined, bobInbox)).rejects.toThrow(
/does not hold this document.s cap/i,
);
});
test("the shim is reached by the MACHINERY, not by an exemption in the boundary", async () => {
inject();
setCurrentUser("alice");
await createEntityDoc("alice", "private"); // arms the emulation, resolves the shim
// The store-root and the doc-shim are NOT reachable through the virtual-user
// surface — there is no exemption list any more. The machinery reaches them
// through its own primitives (`physical.ts`), which the boundary never sees and
// which are never exported from the package.
expect(mayReach(`did:ng:${SESSION.privateStoreId}`)).toBe(false);
await expect(
sparqlQuery(SESSION.sessionId, READ, undefined, `did:ng:${SESSION.privateStoreId}`),
).rejects.toThrow(/does not hold this document's cap/i);
// …yet the registry works, because it never asked through that door.
const doc = await createEntityDoc("alice", "protected");
expect(mayReach(doc)).toBe(true);
});
// The two rules are deliberately redundant, and this is what that buys.
test("rule 1 and rule 2 are independent — the guard still holds if a caller forgets to check", async () => {
inject();
setCurrentUser("alice");
await createEntityDoc("alice", "private"); // arms the emulation
const theirs = "did:ng:o:not-mine" as const;
// RULE 2 — a caller that checks first simply does not issue the operation.
expect(mustNotAttempt(theirs)).toBe(true);
// RULE 1 — and a caller that does NOT check is refused anyway. This is the whole
// point of implementing the same criterion in two places: rule 2 is where the
// model lives (you cannot address what you hold no cap for), rule 1 is what makes
// a lapse in rule 2 fail loudly instead of quietly succeeding.
await expect(sparqlQuery(SESSION.sessionId, READ, undefined, theirs)).rejects.toThrow(
/does not hold this document's cap/i,
);
});
// Possession decides, not the shape of the reference the caller happens to hold.
test("a BARE reference is reachable when the cap is possessed elsewhere", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "private");
// `doc` is the bare form — it carries no cap — yet alice possesses that cap, so
// reaching it is legitimate. Manipulating a bare NURI is normal: references travel
// bare through content and indexes while the cap sits in what the user holds.
expect(hasReadCap(doc)).toBe(false);
expect(mayReach(doc)).toBe(true);
await sparqlQuery(SESSION.sessionId, READ, undefined, doc);
// The cap-bearing form of the same document answers alike.
expect(mayReach(`${doc}:r:OK`)).toBe(true);
// And bob, holding neither, cannot reach it in either form.
setCurrentUser("bob");
expect(mayReach(doc)).toBe(false);
expect(mayReach(`${doc}:r:OK`)).toBe(false);
});
// The whole point of splitting the machinery out: one API is the app's, the other
// must never be. A regression here is silent and total — an app holding the
// machinery reaches every virtual user's documents.
test("the machinery is NOT part of the package's public surface", async () => {
const entry: Record<string, unknown> = await import("../src/index");
const polyfill: Record<string, unknown> = await import("../src/polyfill");
for (const surface of [entry, polyfill]) {
for (const name of Object.keys(surface)) {
expect(name).not.toMatch(/^physical/);
}
}
// Named explicitly, so adding one and forgetting the rule fails here.
for (const forbidden of ["physicalQuery", "physicalUpdate", "physicalCreate", "subscribePhysicalDoc"]) {
expect(entry[forbidden]).toBeUndefined();
expect(polyfill[forbidden]).toBeUndefined();
}
// The cross-account fan-out is gone from the registry entirely.
const registry = entry.storeRegistry as Record<string, unknown>;
for (const gone of ["listEntityDocs", "resolveReadGraphs", "allAccounts", "loadShim"]) {
expect(registry[gone]).toBeUndefined();
}
});
+55 -30
View File
@@ -3,57 +3,82 @@ import { filterReadable, makeReadFilteredView } from "../src/read-filter";
import { CapRegistry } from "../src/caps";
// The access unit is the DOCUMENT (an item's `@graph` = the repo it lives in),
// not the item. Items here carry `@graph`; caps are granted per document.
// not the item. Items here carry `@graph`; each holder holds caps per document.
interface Item { id: string; "@graph"?: string }
const PRIV: Item = { id: "a", "@graph": "did:ng:o:alice" }; // alice's doc
const PUB: Item = { id: "p", "@graph": "did:ng:o:public" }; // public doc
const UNGOV: Item = { id: "n", "@graph": "did:ng:o:other" }; // doc under no policy
const NOGRAPH: Item = { id: "x" }; // no document → kept
const MINE: Item = { id: "a", "@graph": "did:ng:o:alice" }; // alice's doc
const LINKED: Item = { id: "p", "@graph": "did:ng:o:public" }; // a published doc
const FOREIGN: Item = { id: "n", "@graph": "did:ng:o:other" }; // no cap held
const NOGRAPH: Item = { id: "x" }; // names no document
function caps(): CapRegistry {
const c = new CapRegistry();
c.grantRead("did:ng:o:alice", "alice");
c.makePublic("did:ng:o:public");
return c;
/** A registry whose holder the test drives; alice created one doc and published one. */
function setup(initial: string | null = "alice") {
let holder = initial;
const caps = new CapRegistry(() => holder);
const before = holder;
holder = "alice";
caps.mint("did:ng:o:alice");
const link = caps.publishRepoLink("did:ng:o:public");
holder = before;
return { caps, link, become: (id: string | null) => (holder = id) };
}
test("filterReadable keeps public, cap-held, ungoverned and graphless items", () => {
const items = [PRIV, PUB, UNGOV, NOGRAPH];
expect(filterReadable(items, caps(), "alice").map(i => (i as Item).id)).toEqual(["a", "p", "n", "x"]);
expect(filterReadable(items, caps(), "bob").map(i => (i as Item).id)).toEqual(["p", "n", "x"]);
expect(filterReadable(items, caps(), null).map(i => (i as Item).id)).toEqual(["p", "n", "x"]);
test("filterReadable keeps only documents whose cap is held; a graphless item names none", () => {
const items = [MINE, LINKED, FOREIGN, NOGRAPH];
const { caps, become } = setup("alice");
expect(filterReadable(items, caps).map((i) => i.id)).toEqual(["a", "p", "x"]);
// bob holds nothing — including the published doc, until he receives its link.
become("bob");
expect(filterReadable(items, caps).map((i) => i.id)).toEqual(["x"]);
});
test("makeReadFilteredView filters iteration/size, reflects the current user", () => {
const set = new Set<Item>([PRIV, PUB, UNGOV, NOGRAPH]);
let user: string | null = "bob";
const view = makeReadFilteredView(set, caps(), () => user);
test("a bare reference yields nothing — naming is not reading", () => {
const { caps } = setup("alice");
// `did:ng:o:other` is perfectly well-formed and perfectly unreadable.
expect(filterReadable([FOREIGN], caps)).toEqual([]);
});
expect([...view].map(i => i.id)).toEqual(["p", "n", "x"]);
test("receiving the repo link is what opens a published document", () => {
const { caps, link, become } = setup("alice");
become("bob");
expect(filterReadable([LINKED], caps)).toEqual([]);
caps.learn(link);
expect(filterReadable([LINKED], caps).map((i) => i.id)).toEqual(["p"]);
});
test("makeReadFilteredView filters iteration/size, and follows the holder in effect", () => {
const set = new Set<Item>([MINE, LINKED, FOREIGN, NOGRAPH]);
const { caps, become } = setup("bob");
const view = makeReadFilteredView(set, caps);
expect([...view].map((i) => i.id)).toEqual(["x"]);
expect(view.size).toBe(1);
become("alice"); // the held caps are read lazily → the view updates without rewrapping
expect([...view].map((i) => i.id)).toEqual(["a", "p", "x"]);
expect(view.size).toBe(3);
user = "alice"; // read lazily → view updates without rewrapping
expect([...view].map(i => i.id)).toEqual(["a", "p", "n", "x"]);
expect(view.size).toBe(4);
});
test("makeReadFilteredView forwards mutations and membership to the target", () => {
const set = new Set<Item>([PUB]);
const view = makeReadFilteredView(set, caps(), () => "bob");
const set = new Set<Item>([LINKED]);
const { caps } = setup("alice");
const view = makeReadFilteredView(set, caps);
const C: Item = { id: "c", "@graph": "did:ng:o:public" };
view.add(C);
expect(set.has(C)).toBe(true); // mutation reached the real set
expect([...view].map(i => i.id)).toEqual(["p", "c"]);
expect([...view].map((i) => i.id)).toEqual(["p", "c"]);
view.delete(C);
expect(set.has(C)).toBe(false);
});
test("forEach is filtered too", () => {
const set = new Set<Item>([PRIV, PUB]);
const set = new Set<Item>([MINE, LINKED]);
const seen: string[] = [];
makeReadFilteredView(set, caps(), () => "bob").forEach((i) => seen.push((i as Item).id));
expect(seen).toEqual(["p"]);
const { caps, become } = setup("alice");
become("bob");
makeReadFilteredView(set, caps).forEach((i) => seen.push((i as Item).id));
expect(seen).toEqual([]);
});
+42 -2
View File
@@ -1,6 +1,21 @@
import { test, expect, mock } from "bun:test";
import { test, expect, mock, afterAll } from "bun:test";
import { readUnion } from "../src/read-model";
import { configure, configureStoreRegistry } from "../src/polyfill";
import type { Nuri } from "../src/types";
import {
configure,
configureStoreRegistry,
getCaps,
resetCaps,
setCurrentUser,
} from "../src/polyfill";
// The cap registry is process-wide, so each inject() starts from an empty one:
// once ANY cap exists the possession gate is in force for every reader, and a
// suite that never declares caps must not inherit another suite's.
afterAll(() => {
resetCaps();
setCurrentUser(null);
});
// A fake `ng` whose sparql_query answers the ANCHORED per-doc query (SELECT ?s ?p ?o
// WHERE { ?s ?p ?o }, anchor = the doc NURI) with ONLY that doc's triples. There is
@@ -31,6 +46,8 @@ function fakeNgWith(triplesByDoc: Record<string, Array<[string, string]>>) {
function inject(triplesByDoc: Record<string, Array<[string, string]>>) {
const ng = fakeNgWith(triplesByDoc);
resetCaps();
setCurrentUser(null);
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
@@ -103,3 +120,26 @@ test("a doc that fails to read is skipped, not aborting the batch", async () =>
// The bad doc failed its read but the good one still lists.
expect(subjects.map((s) => s.subject)).toEqual(["did:ng:o:ok"]);
});
// The possession gate, at the read-model's own level: once ANY cap exists, a doc
// whose cap is not in what the current holder holds is dropped — however well its
// NURI resolves. Before the first cap the gate is inert (no regression).
test("readUnion drops a doc whose cap the holder does not hold", async () => {
inject({
"did:ng:o:mine": [[TYPE, `${FP}Event`], [`${FP}title`, "mine"]],
"did:ng:o:theirs": [[TYPE, `${FP}Event`], [`${FP}title`, "theirs"]],
});
const both: Nuri[] = ["did:ng:o:mine", "did:ng:o:theirs"];
// Inert: no cap issued yet → everything flows through.
expect((await readUnion(both)).map((s) => s.subject).sort()).toEqual(both);
// One cap issued → possession is now the rule for every document.
setCurrentUser("alice");
getCaps().mint("did:ng:o:mine");
expect((await readUnion(both)).map((s) => s.subject)).toEqual(["did:ng:o:mine"]);
// …and for every holder: bob holds nothing, so bob reads nothing.
setCurrentUser("bob");
expect(await readUnion(both)).toEqual([]);
});
+21 -45
View File
@@ -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 () => {
+18
View File
@@ -30,6 +30,7 @@ import {
configureStoreRegistry,
resetStoreRegistry,
resetConfig,
resetCaps,
setCurrentUser,
} from "../src/polyfill";
import { resetRegistryCache, createEntityDoc } from "../src/store-registry";
@@ -156,6 +157,18 @@ function makeFake(opts?: { holdState?: boolean }) {
}));
return { results: { bindings } };
}
if (query.includes("<urn:ng-eventually:shim:inboxCap>")) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:inboxCap")
.map((q) => ({ c: { value: q.o } }));
return { results: { bindings } };
}
if (query.includes("<urn:ng-eventually:shim:readCap>")) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:readCap")
.map((q) => ({ c: { value: q.o } }));
return { results: { bindings } };
}
if (query.includes("<urn:ng-eventually:shim:contains>")) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains")
@@ -214,6 +227,7 @@ function inject(ng: ReturnType<typeof makeFake>) {
});
resetRegistryCache();
resetOpenedRepos();
resetCaps();
}
// Insert a triple straight into a doc's graph in the fake store (no push).
@@ -242,6 +256,9 @@ afterAll(() => {
resetStoreRegistry();
resetRegistryCache();
resetOpenedRepos();
// The cap registry is process-wide: leaving caps behind would put the possession
// gate in force for a suite that never declares any.
resetCaps();
});
describe("watchShape", () => {
@@ -369,4 +386,5 @@ describe("watchShape", () => {
expect(snap.data.length).toBe(1);
unsub();
});
});