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.