9951cd5223
Add a generic discovery-index surface: submitToIndex(ref) deposits a reference into the index document's inbox; readIndex() returns the materialized entries. A reserved special account (@index) owns the index document; deposits flow through the emulated inbox and are materialized by the emulated curator (the dedup/ moderation point). This replaces cross-account fan-out as the discovery path and is more faithful to the target (a single owned index fed via its inbox). Generic (the consumer supplies the reference to index). 79 tests pass; tsc rc=0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
371 lines
22 KiB
Markdown
371 lines
22 KiB
Markdown
# How this library emulates mature NextGraph on ONE shared wallet
|
||
|
||
The consumer writes against `@ng-eventually/client` **as if** NextGraph already
|
||
shipped per-entity documents in public/protected/private stores, capabilities and
|
||
inboxes. It hasn't (see [`nextgraph-current-state.md`](./nextgraph-current-state.md)).
|
||
This file is the lib's own engineering doctrine on how it fabricates that mature
|
||
face on top of **one single shared wallet / broker**. Everything here is
|
||
polyfill-era and disappears at migration ([`migration-guide.md`](./migration-guide.md)).
|
||
|
||
## The premise: one shared wallet, everything readable
|
||
|
||
Current NextGraph has **no cross-wallet read** (`OpenRepo` is a TODO at
|
||
`engine/verifier/src/verifier.rs:1423`; a foreign NURI raises `RepoNotFound`; a
|
||
session only holds its own 3 stores in `self.repos`). So "each user their own
|
||
wallet" is blocked at the root — no data ever crosses the boundary between two
|
||
wallets.
|
||
|
||
The lib's answer: **everyone opens the same wallet**. NextGraph sees a single
|
||
identity → **everything is physically readable**. "Multi-user" becomes an
|
||
application fiction the lib maintains. On top of that one wallet the lib rebuilds,
|
||
by emulation, the per-user stores + capabilities + inbox the consumer codes
|
||
against.
|
||
|
||
## Two axes, never conflate them (store ≠ document)
|
||
|
||
The single most load-bearing distinction. Two **orthogonal** axes the
|
||
terminology historically fused:
|
||
|
||
- **Axis A — which native STORE?** A wallet has 3: `private_store_id`,
|
||
`protected_store_id`, `public_store_id`. Historic origin of "mono-store /
|
||
multi-store" (use 1 store vs the 3).
|
||
- **Axis B — how many DOCUMENTS in a store?** A store contains documents; the
|
||
**document (= repo = `@graph`) is the sharing + rights boundary**. The ReadCap
|
||
— hence **isolation** — is **PER-DOCUMENT**.
|
||
|
||
**`docCreate(sessionId, "Graph", "data:graph", "store", undefined)` → the shared
|
||
wallet's PRIVATE store.** The trailing `store` arg left `undefined` targets the
|
||
private store (this is what `store-registry.ts`'s `createDoc()` does). So every
|
||
document the shim creates physically lives in ONE store (private), and the
|
||
`public|protected|private` scope is a **LOGICAL LABEL** tracked in RDF by the
|
||
shim — **not** a NextGraph store. Therefore what a consumer's "multi-store" flag
|
||
switches on is really **multi-DOCUMENT with logical scope labels**, never
|
||
multi-store. Do not read `Scope` (`types.ts`) as a physical store — it is the
|
||
logical label the registry attaches.
|
||
|
||
> Why `undefined` and not a real store? Because `doc_create` **cannot target a
|
||
> non-private native store** today: `StoreRepo` is not JS-constructible (verified
|
||
> — see the parked `getNativeStore` note in
|
||
> [`migration-guide.md`](./migration-guide.md)). The private store is reachable
|
||
> because it opens without `RepoNotFound`.
|
||
|
||
## The shared-wallet shim (`store-registry.ts`)
|
||
|
||
Emulates the target infrastructure — where each user owns their own
|
||
public/protected/private stores — on top of one shared wallet.
|
||
|
||
- **One document per (account × scope)** inside the shared wallet, created via the
|
||
`docs.docCreate` primitive. The `scope` (`public|protected|private`) is a
|
||
logical attribute tracked here, not a physical store.
|
||
- **The `sharedWalletShim`** is the mapping `account → its 3 scope-document
|
||
NURIs`, persisted as RDF in the shared wallet's **private store** (the anchor,
|
||
always known from the session: `RegistrySession.privateStoreId`). That makes
|
||
login **cross-device**: another device opening the same wallet reads the same
|
||
shim and finds the same accounts. It is the account→document **trust root** —
|
||
which is why every untrusted value that reaches its SPARQL is escaped (see
|
||
SPARQL hardening below).
|
||
- **Per-entity documents + per-scope index.** `createEntityDoc(username, scope)`
|
||
makes a dedicated document for ONE entity (mirrors the target, where each entity
|
||
is its own document/repo with a future inbox) and appends its NURI to the
|
||
account's **scope index document** — the index doc plays the role of the future
|
||
**store-container** (it lists the entity-document NURIs "in" that scope).
|
||
`listEntityDocs(scope)` unions the contained NURIs across all accounts — the
|
||
read fan-out. Use the returned NURIs as `useShape(shape, { graphs })`.
|
||
- **GENERIC by construction.** The registry knows only the three native scopes,
|
||
**zero** application entity kind. The consumer maps its entities to a scope and
|
||
injects the session + username normalization via `configureStoreRegistry({
|
||
getSession, normalizeUser })` (`polyfill.ts`).
|
||
|
||
The `store≠document` two axes materialize here directly: the registry moves along
|
||
axis B (more documents = more isolation), never axis A (it always writes into the
|
||
one private store via `docCreate(..., undefined)`).
|
||
|
||
### SDK-shaped scope resolvers — the consumer holds NO store-id
|
||
|
||
The consumer must never construct a `did:ng:${store_id}` NURI itself: physical
|
||
placement is the lib's job (the whole point of the SDK boundary). Two resolvers
|
||
turn a **logical scope** into an **opaque graph NURI** without exposing any
|
||
store-id:
|
||
|
||
- **`resolveScopeGraph(scope)`** — the graph where the current session writes
|
||
entities of `scope`, and whose repo `useShape` subscribes to read them back.
|
||
Use the returned value as BOTH the read scope (`useShape(shape, nuri)`) and the
|
||
`@graph` write target. Placement lives HERE (Axis A): `private` → the private
|
||
native store; `public` + `protected` → the **protected** native store, because
|
||
`doc_create`/ORM cannot target a non-private/protected native store today (SDK
|
||
blocker, [`migration-guide.md`](./migration-guide.md)). At migration each scope
|
||
resolves to the user's REAL per-scope store — the change is in this function,
|
||
the consumer is unchanged.
|
||
- **`resolveInboxAnchor()`** — the anchor where emulated inbox deposits land
|
||
(today the shared wallet's private store — a real repo NURI, required because
|
||
the broker rejects a `urn:` anchor). At migration it becomes the host's native
|
||
inbox NURI.
|
||
|
||
Both resolve the native store ids from the **injected session**
|
||
(`RegistrySession.protectedStoreId` / `publicStoreId`, alongside the existing
|
||
`privateStoreId` anchor). The consumer hands the whole session to the lib at the
|
||
ONE injection point (`configureStoreRegistry({ getSession })`) — that is wiring,
|
||
not placement logic; everything else in the consumer speaks only in scopes. If
|
||
the session omits `protectedStoreId`, the non-private scopes fall back to the
|
||
private store rather than emit a broken NURI.
|
||
|
||
## `RepoNotFound` and the `orm_start_graph` scope rule
|
||
|
||
A hard constraint inherited from the SDK: to read **and** write entities through
|
||
the ORM, the store's repo must be **explicitly opened** in the verifier's
|
||
`self.repos` HashMap. `orm_start_graph` with a store's NURI opens that repo;
|
||
without it, `orm_frontend_update` fails with `RepoNotFound`.
|
||
|
||
- **Scope** for `useShape`: the store NURI, e.g. `did:ng:${privateStoreId}` (or,
|
||
in the consumer, a per-user store once that migration happens).
|
||
- **`@graph`** (write target): the same store NURI.
|
||
- **Never use `did:ng:i` as a scope.** It subscribes to the user's whole site via
|
||
a special code path (`NuriTargetV0::UserSite`) that **does not open individual
|
||
repos** → breaks every write with `RepoNotFound`.
|
||
|
||
Both the private and the protected native stores were verified to open the same
|
||
way for ORM+SPARQL (round-trip probe, no `RepoNotFound`). The original arbitration
|
||
is preserved in [`decisions/private-store-nuri-scope.md`](./decisions/private-store-nuri-scope.md).
|
||
|
||
## The `@ng-org` double-proxy `DataCloneError` constraint
|
||
|
||
**Validated hard constraint, not a style choice.** `docs.ts` calls the **real
|
||
injected `ng`** (`getConfig().ng`) DIRECTLY — never the public `ng` proxy
|
||
(`makeNg` in `ng-proxy.ts`).
|
||
|
||
`@ng-org/web`'s `ng` is already an **iframe-RPC proxy** (postMessage marshaling,
|
||
see [`nextgraph-current-state.md`](./nextgraph-current-state.md) § integration).
|
||
Wrapping it in the lib's own JS `Proxy` (double proxy) breaks `doc_create`'s
|
||
postMessage marshaling → `DataCloneError: function ... could not be cloned`.
|
||
Reaching the real `ng` held in the config avoids the double-proxy. This was
|
||
verified: routing the shim's `doc_create`/SPARQL through the public proxy turned
|
||
4 multistore scenarios red; it was reverted. The integration boundary is:
|
||
|
||
- **Through the lib's public proxy** (validated): `useShape` (ORM + ReadCap
|
||
filter), `init`/`initNg`, `login`.
|
||
- **Through the real injected `ng`** (`docs.ts` primitives): `doc_create` + all
|
||
shim/inbox SPARQL.
|
||
|
||
`docs.ts` therefore imports **no** `@ng-org` package and must **not** import from
|
||
`./ng-proxy`.
|
||
|
||
## Emulated ReadCap — per document (`caps.ts` + `read-filter.ts`)
|
||
|
||
In the target the broker only delivers documents the wallet holds a **ReadCap**
|
||
for, so `useShape` already returns an authorized subset. Here (single shared
|
||
wallet, everything readable) the lib reproduces that with a read-filtered VIEW:
|
||
|
||
- **`CapRegistry` (`caps.ts`)** models ReadCaps as faithfully as a data layer
|
||
can. The access UNIT is the **document = repo NURI** (an item's `@graph`),
|
||
**never the item** — because in `nextgraph-rs` a store is just a container repo
|
||
and holding its cap does NOT grant the repos it references (no store-level read
|
||
inheritance; verified). So the registry is **purely per-document**:
|
||
`grantRead` / `grantWrite` / `makePublic` / `open(doc, scope, owner)` /
|
||
`canRead` / `canWrite` / `governsRead` / `hasReadPolicy`. The consumer performs
|
||
the *acts* of granting (create-public, grant-to-a-connection…) exactly as it
|
||
will in the target; the lib injects no policy.
|
||
- **`read-filter.ts`** — `makeReadFilteredView` wraps the reactive set in a
|
||
`Proxy`: iteration / `size` / `forEach` are filtered by
|
||
`caps.canRead(item['@graph'], user)`; everything else (`add`, `delete`, `has`,
|
||
`getById`…) forwards to the target, preserving writes and reactivity. An item
|
||
with no `@graph`, or in a document under no cap policy, is KEPT (the filter only
|
||
restricts documents that *declare* a cap — no regression on ungoverned data).
|
||
`filterReadable` is the pure variant.
|
||
- **`useShape` (`use-shape.ts`)** applies the view **only if
|
||
`caps.hasReadPolicy()`** — otherwise it passes the real set through unchanged
|
||
(no regression when the consumer declares no caps).
|
||
|
||
In a mono-store layout (every item in one repo) this is all-or-nothing on that
|
||
document — exactly the native behaviour, and why fine-grained isolation requires
|
||
one document per entity (axis B).
|
||
|
||
### Making the ReadCap ACTIVE — current user + connection-driven grants
|
||
|
||
The filter only discriminates once the consumer (a) tells the SDK **who is
|
||
reading** and (b) declares the access policy on the documents. Both are plain SDK
|
||
calls; the consumer never touches the registry internals:
|
||
|
||
- **`setCurrentUser(id)` (`polyfill.ts`)** — the SDK's "current identity" call.
|
||
`useShape`'s filtered view reads it lazily, so the delivered subset always
|
||
reflects the identity in effect at read time. Until it is set, the filter has no
|
||
principal and (per `canRead(doc, null)`) only public documents pass — which is
|
||
why isolation stayed **dormant** while the consumer never made this call.
|
||
- **`getCaps().open(doc, scope, owner)`** — declares a document's policy when the
|
||
consumer creates it: `public` → world-readable; `protected`/`private` → owner
|
||
reads, owner holds the write cap. `open` now also **remembers** `(scope, owner)`
|
||
per document so a later connection-driven grant can find the protected ones.
|
||
- **`declareConnections(connections)` (`polyfill.ts`)** — the SDK-shaped
|
||
**protected sharing act**. The consumer hands its social graph (a `Connections`:
|
||
who-is-connected-to-whom) and the SDK issues, for every **protected** document,
|
||
that document's read cap to the owner's direct connections
|
||
(`CapRegistry.grantReadToConnections`). Public docs stay world-readable; private
|
||
docs stay owner-only. Re-callable whenever the graph changes; additive and
|
||
idempotent. The consumer passes only principals — no document NURI, no store id.
|
||
|
||
The result is the target's discrimination reproduced end-to-end: **private** →
|
||
owner; **protected** → owner + connections; **public** → all. Proven in
|
||
`test/isolation-active.test.ts` (an unconnected principal is denied a protected
|
||
document, granted it after `declareConnections`, and reads the public document
|
||
throughout).
|
||
|
||
### Write-guard coverage (honest scope)
|
||
|
||
The emulated write guard (`ng-proxy.ts`, `sparql_update` override) enforces the
|
||
per-document write cap **on the public `ng` proxy only**. In practice the
|
||
consumer's write paths (`docs.sparqlUpdate`, ORM `ngSet`) call the **real injected
|
||
`ng` directly** — never the public proxy — for the validated `DataCloneError`
|
||
reason above. So the guard is **best-effort**: it fires for any write routed
|
||
through the public proxy, but the consumer's real write paths bypass it and are
|
||
**not** guarded today. This is a deliberate, recorded limitation of the emulation
|
||
(the write guard becomes effective only when the broker/verifier enforces caps
|
||
natively at migration); the READ side is what makes isolation observably active.
|
||
|
||
### Emulated ReadCap ≠ application isolation — they COEXIST
|
||
|
||
`isolation.ts` is a **separate, deliberately non-merged** axis:
|
||
|
||
| | ReadCap (`caps.ts` + `read-filter.ts`) | isolation (`isolation.ts`) |
|
||
|---|---|---|
|
||
| Unit | the DOCUMENT (`@graph` = repo) | the ITEM / record |
|
||
| Question | does the principal HOLD this doc's read cap? | given WHO is connected to WHOM, may this principal see it? |
|
||
| Models | NextGraph's native capability delivery (broker-enforced) | an application social-visibility policy, above the doc layer |
|
||
| Grants | explicit, per-document (`grantRead` / `makePublic`) | implicit, from the connection graph + item scope |
|
||
|
||
`isolation.ts` honors a visibility matrix (public = everyone; protected = owner +
|
||
direct connections; private = owner only) with **pure** functions — no NextGraph,
|
||
no React, zero domain. The consumer injects the connection graph (`Connections`)
|
||
and the `ownerOf`/`scopeOf` accessors. The connection-derived `protected`
|
||
visibility has no equivalent in the per-document cap model, so the two are not
|
||
redundant. Each is a removable scaffold that disappears against a different piece
|
||
of real infra (caps → native ReadCaps; isolation → real per-account social graph
|
||
+ per-account wallets).
|
||
|
||
## Emulated inbox + curator (`inbox.ts`)
|
||
|
||
Current NextGraph does not expose the inbox to the JS SDK (verifier has no
|
||
`InboxPost` arm; no wasm sealing helper — see
|
||
[`nextgraph-current-state.md`](./nextgraph-current-state.md) § Inbox). Rather than
|
||
fork the broker ([`fork-inbox-fallback.md`](./fork-inbox-fallback.md)), the lib
|
||
**emulates** the inbox on the shared wallet:
|
||
|
||
- **Target vs polyfill.** Target: `post` seals a reference into the owner's native
|
||
inbox (`ng.inbox_post_link(...)`) and a **separate curator** materializes
|
||
deposits into the owned document. Here, everything is readable, so both sides are
|
||
emulated in-lib.
|
||
- **`post(targetInbox, opts)`** appends a deposit `{ from, payload, ts }` as RDF
|
||
into the inbox DOCUMENT (in the shared wallet) via `docs.sparqlUpdate`. Each
|
||
deposit is a unique RDF subject → concurrent deposits don't collide. `from` is
|
||
optional: pass `null` for an ANONYMOUS deposit; omit it to default to the
|
||
current polyfill user (`getCurrentUser`). This reproduces the protocol's
|
||
"identified if known, anonymous otherwise" — though the emulation stores
|
||
`from = null` as *absence of a triple*, it does not provide the target's
|
||
**crypto** anonymity (`from = None` sealed), which only a native inbox would.
|
||
- **`read` / `materialize` (alias)** play the **emulated CURATOR**: they read the
|
||
deposits back via `docs.sparqlQuery`, JSON-parse each payload, sort by `ts`.
|
||
- **`watch(targetInbox, onDeposits, { intervalMs })`** is the emulated watcher: it
|
||
polls `read` and fires when the deposit count changes (the polyfill has no
|
||
reactive inbox subscription). Fires once immediately; returns an unsubscribe.
|
||
|
||
GENERIC: the module knows no domain — the consumer supplies the inbox document
|
||
NURI and interprets `payload`. At migration `post` becomes the native
|
||
`inbox_post_link` and the read side moves to a **separate curator package** (see
|
||
the deferred global-index note in the top-level README and
|
||
[`decisions/discovery-model.md`](./decisions/discovery-model.md)). The inbox +
|
||
watcher is the ONE deposit/materialization mechanism reused for BOTH meeting-point
|
||
registration AND submission to a discovery index — same `post` API, same watcher.
|
||
|
||
## Emulated discovery index + special account (`discovery.ts`)
|
||
|
||
Discovery is a **surface on top of the inbox**, not a new primitive. **Access ≠
|
||
discovery**: a public entity is world-readable *with its NURI*; the discovery
|
||
index is how a client learns that NURI **exists** without holding a connection
|
||
to its creator (see [`decisions/discovery-model.md`](./decisions/discovery-model.md)).
|
||
The model is: ONE global index = an **owned document** (public read), **fed via
|
||
ITS inbox**, **materialized by a curator**. Nobody writes the index directly — a
|
||
creator DEPOSITS a reference into the index's inbox; the curator ingests deposits
|
||
into entries. Materialization is the natural **dedup / moderation point**.
|
||
|
||
- **The special account (polyfill owner).** "Who owns the global index" is
|
||
undecided in the target (NextGraph is mono-user with no global data — a
|
||
singleton app is the only glimpsed path). So the polyfill parks ownership on a
|
||
**RESERVED SPECIAL ACCOUNT** in the shim — `INDEX_ACCOUNT = "@index"`. It is a
|
||
normal shim account (so its 3 scope documents are created on first sight like
|
||
any other), but never a real user; it only HOSTS the index document. Its
|
||
`public` scope document IS the index document, and its inbox receives the
|
||
deposits — a **stable NURI**: every client opening the same shared wallet
|
||
resolves the same account → same document, so all clients read/write ONE
|
||
shared index.
|
||
- **`submitToIndex(ref, opts?)`** — the SDK act "make this discoverable".
|
||
Deposits `ref` into the index document's inbox via `inbox.post`. `from` follows
|
||
the inbox convention (anonymous when `null`). `ref` is **opaque** here — the
|
||
consumer serializes whatever locates the entity (e.g. an entity document NURI +
|
||
discovery metadata).
|
||
- **`readIndex()`** — the EMULATED CURATOR. Reads every submission, **dedups by
|
||
serialized `ref`** (the moderation point: a duplicate submission surfaces
|
||
once), returns entries sorted by `ts`. `watchIndex(onEntries, opts?)` is the
|
||
emulated watcher (polls `readIndex`).
|
||
|
||
**This REPLACES the cross-account fan-out** (`store-registry.ts`
|
||
`listEntityDocs('public')` / `resolveReadGraphs`) as the app-facing discovery
|
||
path: the app submits public entities to the index and reads the index, instead
|
||
of fanning out over every account's public documents. The fan-out survives only
|
||
as an **internal lib fallback** — kept for the per-scope listing it also powers
|
||
(e.g. `resolveReadGraphs`), never the app's discovery route.
|
||
|
||
GENERIC: `discovery.ts` knows no application domain — the consumer defines the
|
||
`ref` shape and its meaning. At migration the special account disappears:
|
||
ownership moves to the decided global-index owner, `submitToIndex` becomes the
|
||
native `inbox_post_link` on the index's inbox, and `readIndex` queries the real
|
||
materialized index document. The consumer surface (`submitToIndex` / `readIndex`)
|
||
is designed to survive that swap unchanged.
|
||
|
||
## Emulated write guard (`ng-proxy.ts`)
|
||
|
||
The public `ng` proxy overrides `sparql_update` to enforce an emulated **write
|
||
cap**: a write is refused unless the current user holds the target document's
|
||
WRITE cap. Passthrough (no regression) unless a WRITE policy exists AND that
|
||
specific document (the `anchor` arg) is governed by it — ungoverned docs (the
|
||
mono-store default, no cap declared) flow through unchanged. Mirrors the target
|
||
broker/verifier, which refuses a write without the document's write cap.
|
||
|
||
## Faux login (`accounts.ts`)
|
||
|
||
The real NextGraph login (redirect to the broker, opening the single SHARED
|
||
wallet) is perceived as a **technical access barrier**, not a login (see login
|
||
flow in [`decisions/shared-wallet-login-flow.md`](./decisions/shared-wallet-login-flow.md)).
|
||
THIS layer is the **perceived** login:
|
||
|
||
- The user picks a **username** (no password — declarative), persisted in
|
||
`localStorage` so the "session" survives reloads and lands on the same account
|
||
when the shared wallet re-opens.
|
||
- `login()` / `logout()` are **FAUX**: they only read/write the username in
|
||
storage. They must **NEVER** call NextGraph (no `session_stop` / `wallet_close`)
|
||
— the shared wallet stays open underneath. The real logout lives elsewhere
|
||
(hidden in the consumer's settings/debug), because it forces a new redirect.
|
||
- **Framework-agnostic**: no React, no DOM beyond an optional injected
|
||
`AccountStorage` (a `window.localStorage`, a test fake, or `null` for SSR). The
|
||
React `Context`/`Provider` stays in the consumer. `normalizeUsername`
|
||
(case-insensitive, optional leading `@` stripped, trimmed) is the pure
|
||
normalizer, reusable as the shim key normalizer.
|
||
|
||
## SPARQL injection hardening (`sparql.ts`)
|
||
|
||
Every module that builds SPARQL by interpolation (inbox, store-registry) routes
|
||
untrusted values through `sparql.ts` first, because a `"` closes a literal and a
|
||
`>` closes an IRI, letting an injected value wreck the shim graph (the account →
|
||
document trust root):
|
||
|
||
- **`escapeLiteral`** — for LITERAL position (`"..."`): escapes backslash,
|
||
double-quote, C0 whitespace. Lossless (literals legitimately carry arbitrary
|
||
text — JSON payloads, display names).
|
||
- **`escapeIri`** — for UNTRUSTED values embedded into an IRI (`<PREFIX:${…}>`,
|
||
e.g. a username minted into an account-subject IRI): percent-encodes every
|
||
IRI-hostile character so any username (spaces, unicode, punctuation) stays
|
||
usable while breakout is impossible.
|
||
- **`assertNuri`** — for trusted-SHAPED NURIs coming back from `ng`
|
||
(`did:ng:...`): validates and throws on IRI-breaking chars rather than emitting
|
||
a malformed/injected query.
|
||
|
||
These are re-exported from `@ng-eventually/client` so the consumer reuses the same
|
||
escaping when it builds SPARQL.
|