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
+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;
}
};