Isolation was dormant (no current identity ever set). Now: setCurrentUser records who is reading; declareConnections(neighborsOf) grants each protected document's read cap to owner + connections. Reads discriminate through the ReadCap filter: private→owner, protected→owner+connections, public→all. Generic (the consumer injects identity + connections). Write-guard coverage limits documented honestly in docs/simulation.md (real write paths bypass the JS proxy; full enforcement awaits native caps). isolation-active.test.ts proves the protected+connections path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
19 KiB
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).
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).
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
undefinedand not a real store? Becausedoc_createcannot target a non-private native store today:StoreRepois not JS-constructible (verified — see the parkedgetNativeStorenote inmigration-guide.md). The private store is reachable because it opens withoutRepoNotFound.
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.docCreateprimitive. Thescope(public|protected|private) is a logical attribute tracked here, not a physical store. - The
sharedWalletShimis the mappingaccount → 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 asuseShape(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 ofscope, and whose repouseShapesubscribes to read them back. Use the returned value as BOTH the read scope (useShape(shape, nuri)) and the@graphwrite target. Placement lives HERE (Axis A):private→ the private native store;public+protected→ the protected native store, becausedoc_create/ORM cannot target a non-private/protected native store today (SDK blocker,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 aurn: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:ias 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 withRepoNotFound.
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.
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 § 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.tsprimitives):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 innextgraph-rsa 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—makeReadFilteredViewwraps the reactive set in aProxy: iteration /size/forEachare filtered bycaps.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).filterReadableis the pure variant.useShape(use-shape.ts) applies the view only ifcaps.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 (percanRead(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.opennow 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 (aConnections: 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 § Inbox). Rather than
fork the broker (fork-inbox-fallback.md), the lib
emulates the inbox on the shared wallet:
- Target vs polyfill. Target:
postseals 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) viadocs.sparqlUpdate. Each deposit is a unique RDF subject → concurrent deposits don't collide.fromis optional: passnullfor 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 storesfrom = nullas absence of a triple, it does not provide the target's crypto anonymity (from = Nonesealed), which only a native inbox would.read/materialize(alias) play the emulated CURATOR: they read the deposits back viadocs.sparqlQuery, JSON-parse each payload, sort byts.watch(targetInbox, onDeposits, { intervalMs })is the emulated watcher: it pollsreadand 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). 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 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).
THIS layer is the perceived login:
- The user picks a username (no password — declarative), persisted in
localStorageso 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 (nosession_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(awindow.localStorage, a test fake, ornullfor SSR). The ReactContext/Providerstays 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 fromng(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.