docs: own the current-NextGraph-state knowledge + boundary (lib side)
This library presents a mature-NextGraph SDK face to consumers while compensating for the current SDK's gaps via a shared-wallet simulation. It therefore OWNS all current-state + simulation knowledge — moved here out of the Festipod app repo, which must treat this library as a finished SDK. New docs/: - nextgraph-current-state.md — what the current SDK/broker do and don't expose (5 store types, document=repo, per-document ReadCap, inbox not exposed, iframe RPC proxy, mono-user/no-global-data, wallet import constraint). Keeps the nextgraph-rs source pointers. - simulation.md — how the lib emulates the mature behaviour on one shared wallet (shim, store!=document two axes, docCreate→private store, RepoNotFound scope rule, @ng-org double-proxy DataCloneError, emulated ReadCap/inbox/curator). - decisions/ — the current-SDK ADRs (private-store-nuri-scope, sparql-delete, shared-wallet-login, discovery mechanism). - fork-inbox-fallback.md — the Rust-patch/self-host route not taken. - migration-guide.md — the checklist for when real NextGraph matures. README: boundary framing from the lib's side + docs/ index; replaced the stale "scaffold/stubbed" status with the actually-implemented mechanisms per source. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,31 @@ it contains **no application domain** — the consumer injects its shapes and th
|
||||
The name: *eventually* NextGraph will ship these features; until then this layer
|
||||
fills the gap (and nods at eventual consistency / events).
|
||||
|
||||
## The boundary — mature face out, compensation in
|
||||
|
||||
The asymmetry is the whole point. **Consumers write SDK-shaped code as if
|
||||
NextGraph were finished**: per-entity documents in public/protected/private
|
||||
stores, capabilities, inboxes. This library **owns all the current-state
|
||||
NextGraph knowledge and the simulation** that fabricates that mature face — a
|
||||
**shared-wallet** emulation — so the application never sees it. When NextGraph
|
||||
matures, **only this library changes**; the consumer's code does not.
|
||||
|
||||
Docs (this library's own engineering doctrine, under [`docs/`](./docs/)):
|
||||
|
||||
- [`docs/nextgraph-current-state.md`](./docs/nextgraph-current-state.md) — the
|
||||
authoritative reference on what the CURRENT SDK/broker do and do NOT expose
|
||||
(the ground truth every polyfill compensates for).
|
||||
- [`docs/simulation.md`](./docs/simulation.md) — how this lib emulates the mature
|
||||
behaviour on ONE shared wallet (shim, per-document ReadCaps, emulated
|
||||
inbox+curator, write guard, faux login, the two axes, the double-proxy
|
||||
constraint).
|
||||
- [`docs/decisions/`](./docs/decisions/) — historical current-SDK ADRs
|
||||
(private-store scope, SPARQL delete, shared-wallet login, discovery mechanism).
|
||||
- [`docs/fork-inbox-fallback.md`](./docs/fork-inbox-fallback.md) — the Rust-patch /
|
||||
self-host inbox path NOT taken (kept as fallback).
|
||||
- [`docs/migration-guide.md`](./docs/migration-guide.md) — the checklist for when
|
||||
real NextGraph matures.
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Role | At migration |
|
||||
@@ -39,12 +64,37 @@ app code (SDK-shaped) is unchanged.
|
||||
enforces them generically (read filter + write guard). The app *attaches*
|
||||
grants via cap operations — same as it will in the target. No policy is
|
||||
injected.
|
||||
- **Inbox**: the client `inbox.post()` deposits; materialization (the curator)
|
||||
is deferred with the global-index mechanism — see the note above.
|
||||
- **Inbox**: the client `inbox` namespace deposits (`post`) and, in the
|
||||
shared-wallet emulation, also plays the curator inline (`read` / `materialize`
|
||||
/ `watch`). At migration the read side moves to a separate curator package,
|
||||
deferred with the global-index mechanism — see the note above.
|
||||
- **Tests** of the polyfill (against a real broker) live **in this repo**, so
|
||||
the consuming app can test its features against a mocked, clean API.
|
||||
|
||||
## Status
|
||||
|
||||
Scaffold. Mechanisms are stubbed with `TODO(broker)` / `TODO(polyfill)` where
|
||||
real NextGraph wiring is required.
|
||||
**Implemented.** The polyfill mechanisms are wired against a real broker, not
|
||||
stubbed:
|
||||
|
||||
- **Shared-wallet shim** — `store-registry.ts` (`(account, scope) → document
|
||||
NURI`, `createEntityDoc` / `listEntityDocs` + per-scope index, cross-device via
|
||||
the RDF shim anchored in the private store).
|
||||
- **Document / SPARQL primitive** — `docs.ts`, calling the real injected `ng`
|
||||
directly (avoids the `@ng-org` double-proxy `DataCloneError`).
|
||||
- **Emulated ReadCaps** — `caps.ts` (`CapRegistry`, per-document) + read filter
|
||||
`read-filter.ts` (reactive-set `Proxy` view), applied by `use-shape.ts` only
|
||||
when a policy is declared.
|
||||
- **Write guard** — `ng-proxy.ts` (`sparql_update` override, emulated write cap).
|
||||
- **Inbox** — `inbox.ts` (`post` / `read` / `materialize` / `watch`, emulated
|
||||
curator inline).
|
||||
- **Isolation** — `isolation.ts` (pure social-visibility matrix, distinct axis
|
||||
from ReadCaps).
|
||||
- **Accounts** — `accounts.ts` (faux username login, injected storage).
|
||||
- **SPARQL hardening** — `sparql.ts` (`escapeLiteral` / `escapeIri` / `assertNuri`).
|
||||
|
||||
Remaining `TODO` markers are narrow: the shared-wallet credential passthrough in
|
||||
the `login`/`session_start` proxy branch, and the anticipated cap/inbox SDK
|
||||
signatures to reconcile if the official API differs. See
|
||||
[`docs/simulation.md`](./docs/simulation.md) for what each piece does and
|
||||
[`docs/migration-guide.md`](./docs/migration-guide.md) for what is removed at
|
||||
migration.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
# ADR — Discovery mechanism (inbox-fed index, curator, fan-out)
|
||||
|
||||
**Date:** 2026-06-16 · **Status:** mechanism accepted; target owner undecided.
|
||||
Ported here for the **discovery MECHANISM** it defines — the piece this lib
|
||||
realizes (`inbox.ts` post/materialize/watch; `store-registry.ts` fan-out). The
|
||||
product intent (what a consumer *should* surface) is the consumer's concern, not
|
||||
this lib's; only the mechanism is recorded here.
|
||||
|
||||
## Access ≠ discovery
|
||||
|
||||
- **Access**: may I read this document if I hold it? A public entity is
|
||||
world-readable with its NURI.
|
||||
- **Discovery**: how do I learn it exists, in order to read it? ← this ADR.
|
||||
|
||||
## The mechanism
|
||||
|
||||
1. **A single global index**, **fed via ITS inbox**. The creator does **not** edit
|
||||
the index directly: it **deposits a reference into the index's inbox**. The
|
||||
index is an **owned document** (public read), **materialized from its inbox** (a
|
||||
watcher ingests deposits → adds entries).
|
||||
2. **Primary discovery = that global index.**
|
||||
3. **Relational = secondary axis**, overlaid: a connection's participations,
|
||||
markers on the global list. Rests on existing per-item data (protected scope) —
|
||||
no new primitive.
|
||||
|
||||
## The 3-stage frame
|
||||
|
||||
`discovery → synchronization → query`
|
||||
|
||||
1. **Discovery**: the index gives the NURIs of the entity documents.
|
||||
2. **Synchronization**: subscribe to those documents → they **replicate locally**
|
||||
(verifier: `self.repos` + oxigraph dataset).
|
||||
3. **Query**: query what is **now local** (sort, limit, reactivity). **SPARQL/ORM
|
||||
run on the local set only** (`resolve_target_for_sparql` searches `self.repos`)
|
||||
— you cannot query what is not loaded.
|
||||
|
||||
**Corollary:** a reactive query does not replace the index — it runs at stage 3 on
|
||||
the local union that stages 1-2 built. You don't sync what you didn't discover.
|
||||
|
||||
## Why one reused mechanism
|
||||
|
||||
- **No Group store.** The index is **not** open-write: it is an **owned document**
|
||||
(public read) **+ native inbox** (a primitive present on every document). Nobody
|
||||
writes the index but its owner (by materializing inbox deposits). So the model
|
||||
stays "3 stores + Dialog + inboxes, no Group store."
|
||||
- **One mechanism, reused.** The **inbox + materialization watcher** serve BOTH
|
||||
submitting an entity to the index AND registering to a meeting-point — same
|
||||
`inbox.post` API, same handling. This is exactly `inbox.ts` in this lib (`post`
|
||||
/ `read` / `materialize` / `watch`).
|
||||
- **Natural dedup / moderation point:** materialization (inbox → index) is where
|
||||
duplicates are detected / moderated before insertion.
|
||||
|
||||
## Index owner — target model undecided
|
||||
|
||||
The "dedicated service with its own wallet sharing a freely-readable index" was
|
||||
**incorrect**: NextGraph apps and services are **mono-user with no global data**
|
||||
(see [`../nextgraph-current-state.md`](../nextgraph-current-state.md) § Apps &
|
||||
services). The only path glimpsed for a global document is a **singleton app**
|
||||
bound to the developer-user — **not implemented, uncertain**, to explore later.
|
||||
This is why a global-index curator is a **deferred separate package** in this lib
|
||||
(see the top-level README).
|
||||
|
||||
## Polyfill reality (fan-out) vs target (global index)
|
||||
|
||||
What ships in the shared-wallet polyfill today is the **cross-account fan-out over
|
||||
every account's public documents** (`store-registry.ts` `listEntityDocs('public')`
|
||||
/ `resolveReadGraphs`) — one account sees another's public entity **without a
|
||||
connection**. This ADR classified per-account fan-out as a **drift** to be
|
||||
replaced by the single global index; the target (inbox-fed global index) remains
|
||||
valid but the fan-out is the mechanism the shared-wallet staging actually runs on
|
||||
until the global-index owner is decided. Recorded here as mechanism history — the
|
||||
resolution belongs to [`../migration-guide.md`](../migration-guide.md).
|
||||
|
||||
## Alternatives rejected (mechanism)
|
||||
|
||||
- **Open-write index** (creator writes the index directly): required a
|
||||
collaborative document (Group store, SDK-blocked) and exposed the index to
|
||||
corruption. Replaced by inbox deposit + owner materialization.
|
||||
- **Purely relational discovery** (`social_query`): rejected as *primary* (a
|
||||
global list is wanted); kept as a secondary axis.
|
||||
- **No index, direct reactive query**: impossible — SPARQL is local-only (stage 3).
|
||||
@@ -0,0 +1,51 @@
|
||||
# ADR — Use a store NURI as the `useShape` scope AND `@graph`
|
||||
|
||||
**Date:** 2026-03-17 · **Status:** Accepted (partially superseded — see below).
|
||||
Historical decision, ported into this lib because the *insight* still governs how
|
||||
the shim opens repos. Original context: the consuming app.
|
||||
|
||||
> **Partially superseded (2026-07-03).** The private-store-only scope was replaced
|
||||
> for shareable domain entities: they are now scoped AND written to the
|
||||
> **protected** store (`did:ng:${protected_store_id}`), verified to open without
|
||||
> `RepoNotFound`. **The central insight of this ADR still holds** and now applies
|
||||
> to **both** stores: you must open the repo via the store's NURI
|
||||
> (`orm_start_graph`) or you get `RepoNotFound`.
|
||||
|
||||
## Context
|
||||
|
||||
Loading test data updated the in-memory ORM signals (immediate UI) but produced
|
||||
`RepoNotFound` on `doc_create` and `orm_frontend_update`. Data vanished on reload
|
||||
because the SPARQL writes never reached the broker: the verifier's `self.repos`
|
||||
HashMap did not contain the store's repo → `resolve_target()` failed.
|
||||
|
||||
## Options considered
|
||||
|
||||
### A — `did:ng:i` scope + `doc_create` for `@graph`
|
||||
`did:ng:i` is documented as a subscription scope; `doc_create` returns a real
|
||||
NURI. **Against:** `did:ng:i` goes through `NuriTargetV0::UserSite`, which does
|
||||
NOT open individual repos; `doc_create` calls `resolve_target(PrivateStore)`,
|
||||
which requires the repo already in `self.repos` → fails; needs complex retry/timing.
|
||||
|
||||
### B — the store NURI as scope AND `@graph` (chosen)
|
||||
Exact copy of the working `expense-tracker-rdf` example: `orm_start_graph` with
|
||||
the store's NURI opens the repo in `self.repos`; subsequent `orm_frontend_update`
|
||||
finds it. Simple, no retry. **Against:** slightly less flexible than `did:ng:i`
|
||||
(scoped to one store); requires passing the session down to the ORM hook.
|
||||
|
||||
### C — `did:ng:i` scope + reuse an existing entity's `@graph`
|
||||
Works for users who already have data. **Against:** fails for empty wallets (no
|
||||
entity to reuse) → falls back to `doc_create` and the same `RepoNotFound`.
|
||||
|
||||
## Decision
|
||||
|
||||
**Option B**: use the store NURI as both the `useShape` scope AND the write
|
||||
`@graph`, exactly like `expense-tracker-rdf`. This is why this lib's shim opens a
|
||||
store repo via `orm_start_graph` before writing, and why **`did:ng:i` must never
|
||||
be used as a scope** (it breaks writes with `RepoNotFound`). See the
|
||||
`orm_start_graph` scope rule in [`../simulation.md`](../simulation.md).
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Positive:** immediate writes after connect (no retry); persistence across
|
||||
reload; aligned with the official examples.
|
||||
- **Risk:** if NextGraph changes the store's open behaviour, this breaks.
|
||||
@@ -0,0 +1,64 @@
|
||||
# ADR — Shared-wallet login/logout flow
|
||||
|
||||
**Date:** 2026-06-15 · **Status:** Accepted (frozen). The rationale behind this
|
||||
lib's **faux login** (`accounts.ts`) and why it must never touch NextGraph.
|
||||
|
||||
## Starting constraint
|
||||
|
||||
NextGraph login **is not programmable**: it is a **web redirect** to the broker
|
||||
page (`nextgraph.net`). The shared wallet cannot be opened silently — at least one
|
||||
broker-redirect pass is required per device. The question is therefore not "how to
|
||||
avoid the redirect" but "how to order and present it" so the UX stays coherent.
|
||||
|
||||
## Decision — technical gate first, application "Connexion" second
|
||||
|
||||
Two distinct auth layers, presented in this order:
|
||||
|
||||
1. **Real layer (technical, NOT perceived as login).** The broker redirect appears
|
||||
**immediately, before any app render**. Because it precedes the app, the user
|
||||
reads it as a **technical access barrier to the test environment** (a beta
|
||||
wall), **not** an application login. Same shared credentials for everyone
|
||||
(given in the invitation, "access code" style). Once per device, then
|
||||
persistent. **Never labelled "login."**
|
||||
2. **Application layer (perceived as THE login).** A **"Connexion"** screen =
|
||||
**username only** (→ `localStorage`, the current principal). This is the login
|
||||
*in the user's perception*. **No password** → declarative connection (anyone
|
||||
takes any username — coherent with zero-security / friends). **"Déconnexion"**
|
||||
clears **only** the username and returns to "Connexion"; it **calls no NG
|
||||
function**.
|
||||
|
||||
The **real logout** (`ng.session_stop` / `user_disconnect` / `wallet_close`) stays
|
||||
**hidden** (settings/debug), because it forces a new redirect.
|
||||
|
||||
## Why (vs the rejected option)
|
||||
|
||||
**Rejected** — faux login first, then a warning page "enter this username/password",
|
||||
then a *Continue* button triggering the redirect. Rejected: strange workflow,
|
||||
dissonant double-login, a warning page that **looks like a scam**, and the
|
||||
redirect **resurfacing mid-use** on every session expiry.
|
||||
|
||||
**Chosen** because: the mental model stays coherent (the technical barrier not
|
||||
being perceived as login, the app-level Connexion/Déconnexion pair is complete and
|
||||
self-consistent); graceful degradation (a re-gate after a browser restart reads as
|
||||
"reconnecting to the environment", not a bug); and **similarity to the target
|
||||
infra** — the "broker redirect → app" shape is exactly the real multi-wallet flow.
|
||||
At migration you **remove the username "Connexion" screen** and the **technical
|
||||
barrier becomes the real per-user login** — the flow shape does not change.
|
||||
|
||||
## Verified technical facts (`nextgraph-rs`, 2026-06-15)
|
||||
|
||||
- **Session persistence: YES.** Wallet remembered iframe-side (`localStorage`
|
||||
long-term + `sessionStorage` for the active session); on reload `init()`
|
||||
recovers the session **without** re-triggering the redirect while the broker
|
||||
session exists (`sdk/js/web/src/index.ts`, `sdk/js/api-web/main.ts`). A full
|
||||
browser restart (losing `sessionStorage`) can re-trigger the gate.
|
||||
- **Real logout exposed: YES.** `ng.session_stop()`, `ng.user_disconnect()`,
|
||||
`ng.wallet_close()` (`sdk/js/lib-wasm/src/lib.rs`); they stop the session /
|
||||
clear the wallet and **force a new redirect** afterwards — hence: do NOT call
|
||||
them in the app-level "Déconnexion," and hide the real logout.
|
||||
|
||||
## How this lib realizes it
|
||||
|
||||
`accounts.ts` `AccountStore.login()`/`logout()` only read/write the username in an
|
||||
injected `AccountStorage`; they **never** call NG. See the faux login in
|
||||
[`../simulation.md`](../simulation.md).
|
||||
@@ -0,0 +1,61 @@
|
||||
# ADR — Use SPARQL DELETE (not ORM `ngSet.delete()`) to remove objects
|
||||
|
||||
**Date:** 2026-03-17 · **Status:** Accepted → Superseded (2026-06-15). Historical
|
||||
decision, ported for the current-SDK behaviour it records. Original context: the
|
||||
consuming app.
|
||||
|
||||
> **Superseded (2026-06-15).** The `ngSet.delete()` non-persistence bug that
|
||||
> motivated this decision was largely fixed upstream in `@ng-org/orm`; deletion
|
||||
> code went back to `ngSet.delete()`. Kept as arbitration memory — the CRDT
|
||||
> conflict rule ("don't combine the two") **still holds**.
|
||||
|
||||
## Context
|
||||
|
||||
Removing an object from the NextGraph store: `DeepSignalSet.delete()` updates the
|
||||
local reactive state (immediate UI) but **does not persist** to the broker — after
|
||||
refresh the object reappears.
|
||||
|
||||
## Options considered
|
||||
|
||||
### A — ORM `ngSet.delete(item)`
|
||||
Official API, instant local reactive update. **Against:** did not persist in
|
||||
practice (`delete()` returned `true`, local set updated, object back after
|
||||
refresh); `graph_orm_update` seemed to mishandle "remove" patches for top-level
|
||||
set objects (likely engine bug); failed silently.
|
||||
|
||||
### B — `ng.sparql_update()` with SPARQL DELETE
|
||||
`DELETE WHERE { GRAPH <graph> { <subject> ?p ?o } }` removes all the RDF triples.
|
||||
**For:** persists (survives refresh); the broker confirms via a `GraphOrmUpdate`
|
||||
`op: "remove"` that reactively removes the item from the ORM set; direct control.
|
||||
**Against:** not instant (~50ms SPARQL round-trip + broker callback); must NOT be
|
||||
combined with `ngSet.delete()`.
|
||||
|
||||
### C — both together
|
||||
**Does not work:** the ORM `.delete()` patch and the SPARQL DELETE conflict at the
|
||||
CRDT level → neither UI nor persistence.
|
||||
|
||||
## Decision
|
||||
|
||||
**Option B — SPARQL DELETE alone.** The broker returns a `GraphOrmUpdate`
|
||||
`op: "remove"` that reactively removes the item from the ORM set (UI updates, just
|
||||
not synchronously). **Do NOT** call `ngSet.delete()` alongside.
|
||||
|
||||
```ts
|
||||
await ng.sparql_update(
|
||||
session_id,
|
||||
`DELETE WHERE { GRAPH <${partGraph}> { <${partId}> ?p ?o } }`,
|
||||
partGraph,
|
||||
);
|
||||
```
|
||||
|
||||
This is the authoritative-delete pattern this lib's emulation relies on for inbox
|
||||
deposits and shim graphs (an interpolated NURI/subject must pass through
|
||||
`assertNuri`/`escapeLiteral` first — see SPARQL hardening in
|
||||
[`../simulation.md`](../simulation.md)).
|
||||
|
||||
## Consequences
|
||||
|
||||
- **Positive:** deletion persists; single source of truth (broker → ORM → UI).
|
||||
- **Negative:** slight UI delay (~50ms); diverges from the ORM README examples.
|
||||
- **Risk:** if `ng.sparql_update` changes, this breaks; revisit as `ngSet.delete()`
|
||||
matures upstream.
|
||||
@@ -0,0 +1,93 @@
|
||||
# Fallback — forking NextGraph to expose the inbox (path NOT taken)
|
||||
|
||||
**Status:** NOT taken — short-circuited by this lib's **emulated inbox**
|
||||
(`inbox.ts`, see [`simulation.md`](./simulation.md)). Kept as the fallback plan if
|
||||
a **native** broker inbox ever becomes necessary — chiefly for the **crypto
|
||||
anonymity** the emulation does not provide (native `from = None` sealed deposit).
|
||||
|
||||
Current NextGraph does not expose the inbox to the JS SDK: the verifier has no
|
||||
`InboxPost` arm and no wasm helper seals a deposit (see
|
||||
[`nextgraph-current-state.md`](./nextgraph-current-state.md) § Inbox). Two ways to
|
||||
get a real inbox: **emulate it** (what this lib does) or **fork the engine** (this
|
||||
document). The emulation won; this is the archived alternative.
|
||||
|
||||
## Strategic posture
|
||||
|
||||
The fork would be **explicitly temporary, not for upstream**. Hypothesis:
|
||||
NextGraph will eventually expose its **own** JS-SDK inbox solution, possibly
|
||||
different. When it lands, drop the fork and adapt. No PR is targeted. This posture
|
||||
is the reason the emulation was preferred: it avoids maintaining a fork + hosting
|
||||
a full stack for a feature the upstream will likely ship differently.
|
||||
|
||||
## Layer 1 — the Rust patch: 4 files (vanilla broker)
|
||||
|
||||
1. **`engine/net/src/types.rs`** — `InboxMsgContent::Link` is a **unit** variant
|
||||
(stub); give it a payload (or a `Notification` variant) carrying the target
|
||||
NURI + link to the deposited reference. Add an `InboxPost::new_link(...)`
|
||||
builder modeled on `new_contact_details`. `from = None` → anonymity.
|
||||
2. **`engine/verifier/src/request_processor.rs`** — add the missing command arm
|
||||
(there is no `InboxPost` arm). Ideally a high-level command (`NotifyInbox`) that
|
||||
builds the post on the Rust side (keeps crypto sealing in Rust). Model on
|
||||
`SocialQueryStart`.
|
||||
3. **`sdk/js/lib-wasm/src/lib.rs`** — expose `pub async fn inbox_post_link(
|
||||
session_id, to_inbox_nuri, to_profile_nuri, link, anonymous)`, modeled on
|
||||
`social_query_start`.
|
||||
4. **`engine/verifier/src/inbox_processor.rs`** (`process_inbox`) — a receive arm
|
||||
that **materializes** the message into a document in the owner's store (model on
|
||||
the `ContactDetails` handler). The app then reads via ORM/SPARQL — no new
|
||||
inbox-read API.
|
||||
|
||||
**Identity resolution** (known/anonymous): free via app-side SPARQL (JOIN the
|
||||
sender inbox NURI against `social:contact` docs). **Discovering the owner's
|
||||
inbox**: embed the owner's `public_store` inbox NURI in the entity document or
|
||||
public profile (the QR profile-share flow already carries it).
|
||||
|
||||
## Layer 2 — deployment (from the fork)
|
||||
|
||||
The patched verifier runs **in the iframe ng-app** (see integration model in
|
||||
[`nextgraph-current-state.md`](./nextgraph-current-state.md)) → **build and
|
||||
self-host `ngd` + the ng-app** from the fork, then rebuild Festipod's `@ng-org/web`
|
||||
with `NG_REDIR_SERVER`/`NG_DEV*` pointing at that ng-app. **No rewrite of the
|
||||
third-party integration** (stays iframe). The broker's inbox routing is already
|
||||
native, but since the patched ng-app is self-hosted, the **whole stack ships from
|
||||
the fork** (one source tree).
|
||||
|
||||
### Coolify hosting — 3 web pieces
|
||||
|
||||
1. **`ngd`** — stateful WebSocket daemon: container with a **persistent volume**
|
||||
for `--base-path` (RocksDB + keys + PeerId, never wiped), `--domain` mode behind
|
||||
Coolify's Traefik. Build: official Dockerfiles are broken → write a **custom
|
||||
multi-stage Rust Dockerfile** (RocksDB needs llvm/clang). First boot is
|
||||
**interactive** (admin-wallet invitation link) → script via `ngcli` or do it
|
||||
once by hand then persist in the volume.
|
||||
2. **ng-app** (iframe frontend, patched wasm) — **static build**
|
||||
(`pnpm webfilebuild`). Served statically.
|
||||
3. **Routing**: one domain serves the ng-app static AND proxies the WebSocket to
|
||||
`ngd`.
|
||||
|
||||
### Layer 1 (JS libs) — patched client npm packages
|
||||
|
||||
Maintain patched client packages, not just the wasm. The generic forwarding
|
||||
*technically* reaches a wasm method without touching the JS, but that's an untyped
|
||||
**hack** — quick test only. To modify for real:
|
||||
- **`@ng-org/web`** — modified anyway (broker URL) → add `inbox_post_link` to the
|
||||
typed API surface + `.d.ts`.
|
||||
- **Streamed methods** (if inbox *reads* ever stream) — need an entry on both
|
||||
sides. For write-only (request/response) — unneeded.
|
||||
- **`@ng-org/orm`** — only if inbox writes join the ORM flow. Otherwise unneeded.
|
||||
|
||||
## Layer 3 — consumer integration
|
||||
|
||||
Exposing the method is not enough; the consumer must model the entity + its inbox
|
||||
NURI, write the registration, deposit into the host inbox, and read/resolve
|
||||
notifications. Several of these are **already done** in the shared-wallet emulation
|
||||
(registration wired on the emulated `inbox.post`), which is precisely why this fork
|
||||
was not needed.
|
||||
|
||||
## Why this fallback still matters
|
||||
|
||||
The emulated inbox stores `from = null` as *absence of a triple*; it does not seal
|
||||
deposits, so it does not provide the target's **crypto** anonymity. If a consumer
|
||||
needs true anonymous-but-verifiable deposits to a non-connected host, only a native
|
||||
inbox (`from = None` sealed) delivers it — and this fork is the route. Until then,
|
||||
the emulation is sufficient.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Migration guide — when real NextGraph matures
|
||||
|
||||
The whole point of this library: the consumer already writes SDK-shaped code, so
|
||||
when NextGraph ships cross-wallet reads, capabilities and inboxes, **only this lib
|
||||
changes**. The consumer's application code does **not** change. This is the
|
||||
checklist.
|
||||
|
||||
## Guiding invariant
|
||||
|
||||
Every emulated piece has a 1:1 image in the real infra. Migration = swap the
|
||||
emulation for the real primitive, remove the scaffold. If a piece of the emulation
|
||||
has no clear target image, that is a drift signal (see
|
||||
[`simulation.md`](./simulation.md)).
|
||||
|
||||
## Checklist
|
||||
|
||||
### 1. Emulated ReadCaps → real capabilities
|
||||
Translate the per-document `CapRegistry` (`caps.ts`) into real NextGraph caps: the
|
||||
broker/verifier enforces them, and `useShape` already returns only authorized
|
||||
documents. The read filter (`read-filter.ts`) and the write guard (`ng-proxy.ts`
|
||||
`sparql_update` override) are then **dead code** — remove them. The access unit is
|
||||
already the document (`@graph`), matching the native per-repo cap model, so this is
|
||||
a data step, not a reshape.
|
||||
|
||||
### 2. Place documents in real native stores
|
||||
Today `docCreate(..., undefined)` writes every document into the shared wallet's
|
||||
**private** store, and the `public|protected|private` scope is a **logical label**
|
||||
in the shim (see the two-axes section in [`simulation.md`](./simulation.md)).
|
||||
|
||||
- **`doc_create` cannot target a non-private native store today** — verified:
|
||||
`StoreRepo` is **not JS-constructible** from the SDK, so there is no way to pass
|
||||
a public/protected store as the create destination (`docCreate`'s trailing
|
||||
`store` arg is left `undefined` → private store). The private store works only
|
||||
because it opens without `RepoNotFound`.
|
||||
- When the SDK lets you construct/target a native store, the migration adds a
|
||||
`getNativeStore(scope)`-style resolver returning the real store to pass as the
|
||||
`docCreate` destination, so the logical scope label becomes a real store
|
||||
placement. (No such helper exists yet — it is blocked on the SDK gap above.)
|
||||
- At that point `store-registry.ts` maps `(account, scope)` to the user's **real
|
||||
store NURI** instead of a document in the shared wallet; the per-scope index
|
||||
document (the store-container emulation) is replaced by the store itself. The
|
||||
consumer-facing surface (`createEntityDoc`, `listEntityDocs`, resolvers) is
|
||||
designed to survive that swap unchanged.
|
||||
|
||||
### 3. Drop the resolver / shim
|
||||
The `sharedWalletShim` (account → 3 scope-document NURIs, RDF in the private store)
|
||||
has **no target equivalent** — the target has no central directory. Remove it:
|
||||
`store-registry.ts`, `configureStoreRegistry`, the shim SPARQL. Cross-wallet reads
|
||||
replace the fan-out; per-user wallets replace the shared one.
|
||||
|
||||
### 4. Real inbox → drop the emulated curator
|
||||
Replace the emulated `inbox.ts` deposit (`docs.sparqlUpdate` into a shared-wallet
|
||||
document) with the native `inbox_post_link`, and move `read`/`materialize`/`watch`
|
||||
to a **separate curator package** (the deferred global-index curator — see the
|
||||
top-level README and [`decisions/discovery-model.md`](./decisions/discovery-model.md)).
|
||||
The in-client read side goes away. The single global index replaces the
|
||||
cross-account fan-out.
|
||||
|
||||
### 5. Faux login → real per-user login
|
||||
Remove `accounts.ts` (the username `localStorage` faux login) and the app-level
|
||||
"Connexion" screen. The technical broker gate **becomes** the real per-user login
|
||||
(see [`decisions/shared-wallet-login-flow.md`](./decisions/shared-wallet-login-flow.md)).
|
||||
The flow shape ("broker redirect → app") does not change.
|
||||
|
||||
### 6. Drop the isolation scaffold
|
||||
`isolation.ts` (application social-visibility filter) disappears against a
|
||||
different piece of infra than the caps: real per-account wallets + a real
|
||||
per-account social graph. Distinct axis from ReadCaps — remove independently.
|
||||
|
||||
### 7. Remove the build alias — the client becomes the real SDK
|
||||
The consumer imports `@ng-org/web` / `@ng-org/orm` resolved to this lib via a
|
||||
**build alias** during the polyfill period. Removing the alias makes those imports
|
||||
resolve to the real SDK — the `ng`/`useShape`/`inbox` surface is SDK-identical, so
|
||||
**no consumer code changes**. The one non-SDK call — `configure(...)` /
|
||||
`@ng-eventually/client/polyfill` — is deleted. The lib itself disappears.
|
||||
|
||||
## What does NOT change
|
||||
|
||||
**The consumer's application code.** Shapes, screens, the *acts* of granting
|
||||
access, entity→scope mapping, the connection graph — all injected, all untouched.
|
||||
Migration is entirely inside this library plus removing the alias + the bootstrap
|
||||
call. That asymmetry — a mature SDK face outward, all compensation inward — is the
|
||||
library's reason to exist.
|
||||
@@ -0,0 +1,279 @@
|
||||
# Current-state NextGraph — what the SDK/broker do and do NOT expose
|
||||
|
||||
**Owner:** this library. `@ng-eventually/client` exists because the *current*
|
||||
NextGraph JS SDK is immature. This file is the authoritative reference on what
|
||||
today's SDK/broker actually give us — the ground truth every polyfill in this
|
||||
lib compensates for. Read [`simulation.md`](./simulation.md) for how we emulate
|
||||
the mature behaviour on top of these limits, and
|
||||
[`migration-guide.md`](./migration-guide.md) for what changes when they lift.
|
||||
|
||||
Verified against `nextgraph-rs` (local clone at `../nextgraph-rs`, sibling of
|
||||
this repo) and the installed npm packages. Store/permission facts cross-checked
|
||||
with the official docs ([Documents & Stores](https://docs.nextgraph.org/en/documents/),
|
||||
[Getting started](https://docs.nextgraph.org/en/getting-started/)).
|
||||
|
||||
## Source pointers (`nextgraph-rs`)
|
||||
|
||||
Where the ground truth lives, so future re-verification is cheap:
|
||||
|
||||
- `sdk/js/lib-wasm/src/lib.rs` — the wasm API actually exposed to JS.
|
||||
- `engine/net/src/app_protocol.rs` — `AppRequestCommandV0` enum, `NuriV0` formats.
|
||||
- `engine/verifier/src/request_processor.rs` — the effective `app_request`
|
||||
dispatch (the truth on what is actually *processed*).
|
||||
- `engine/net/src/types.rs` — inbox types (`InboxPost`, `InboxMsg`, `InboxMsgContent`).
|
||||
- `engine/verifier/src/inbox_processor.rs` — inbox message handling.
|
||||
- `engine/verifier/src/verifier.rs:1423` — the `OpenRepo` TODO (cross-wallet read).
|
||||
- `engine/repo/src/types.rs` — `RootBranchV0.store: StoreOverlay` (repo → its store).
|
||||
|
||||
## The 5 store types
|
||||
|
||||
Every wallet has the **3 default stores** out of the box (session fields
|
||||
`private_store_id`, `protected_store_id`, `public_store_id`). Group and Dialog
|
||||
are created on demand.
|
||||
|
||||
| Store | Read | Write | Creation |
|
||||
|---|---|---|---|
|
||||
| **Private** | Owner only | Owner only | Default |
|
||||
| **Protected** | Owner + link+permission holders | Owner + permissioned collaborators | Default |
|
||||
| **Public** | Everyone, no capability | Owner only | Default |
|
||||
| **Group** | Group members | Group members (collaborative) | On demand |
|
||||
| **Dialog** | The two users only | The two users only | On demand |
|
||||
|
||||
Doc citations (verbatim): Private — *"only you have access to … not possible to
|
||||
share"*; Protected — *"share … but they will need a special link and permission"*;
|
||||
Public — *"equivalent to your website … without the need for special permissions"*;
|
||||
Group — *"each Group is a separate Store … documents inherit the permissions of
|
||||
the store"*; Dialog — *"hold all the data you exchange with another user (and only
|
||||
with that other user) … You cannot add more users"*.
|
||||
|
||||
## Document = repo (there is no `Document` type)
|
||||
|
||||
*"A Repo is the equivalent of an E2EE group for one and only one Document."*
|
||||
**1 document = 1 repo** (commits + permissions). Identifier: `did:ng:o:<RepoID>`.
|
||||
|
||||
There is **no `Document` type** in `nextgraph-rs` (verified 2026-06-29): a
|
||||
"document" is simply *any repo*. A **store is a special repo** (`is_store=true`,
|
||||
with `Store`/`Overlay`/`User` branches) — so *a store is a document, but a
|
||||
document is not necessarily a store*.
|
||||
|
||||
**Containment (store → repos) is by REFERENCE, not by a list.** A store does not
|
||||
hold a `Vec<RepoId>`: it references its repos through an **RDF graph** in its
|
||||
Overlay/User branch. Conversely each repo names its parent store via
|
||||
`RootBranchV0.store: StoreOverlay` — **a repo belongs to exactly one store**.
|
||||
|
||||
## Capability / ReadCap granularity — the load-bearing fact for this lib
|
||||
|
||||
`ReadCap = ObjectRef`. Granularity is at the **repo AND branch** level (each
|
||||
branch has its own `read_cap`), down to the **block** (`ObjectKey`/ChaCha20 key).
|
||||
Write is managed at the **document (repo)** level.
|
||||
|
||||
**No automatic read inheritance.** Holding a **store's** ReadCap does **not**
|
||||
grant the repos it contains — **you need each repo's own ReadCap**. The optional
|
||||
`inherit_perms_users_and_quorum_from_store: Option<ReadCap>` shares only
|
||||
users/quorum (write/permissions), **not** read-cap possession. (Repos of a
|
||||
`private_store` inherit implicitly.)
|
||||
|
||||
> **Consequence for this lib's emulation (see [`simulation.md`](./simulation.md)):**
|
||||
> the read access UNIT is the **repo = each item's `@graph`** — a per-DOCUMENT
|
||||
> filter, never per-store and never per-item. This is exactly what
|
||||
> `caps.ts` (`CapRegistry`) and `read-filter.ts` model: no store-level
|
||||
> inheritance, purely per-document caps. In a mono-store layout (all items in one
|
||||
> repo) the filter is therefore all-or-nothing on that document — which *is* the
|
||||
> native behaviour, and why fine-grained isolation requires one document per
|
||||
> entity.
|
||||
|
||||
### Store ↔ document confusion (recurring)
|
||||
|
||||
The isolation axis is the **document (repo/`@graph`)**, never the **store**: a
|
||||
store *contains* several documents and does not share their read caps. See the
|
||||
two-axes warning in [`simulation.md`](./simulation.md): "multi-store" in this
|
||||
lib's emulation means **multiple DOCUMENTS in one shared store**, not multiple
|
||||
stores.
|
||||
|
||||
## Capability sharing / NURI
|
||||
|
||||
Sharing transmits a **NURI** embedding the crypto capability (read and/or write).
|
||||
No central ACL: holding the NURI *is* the right. *"adding permissions can be done
|
||||
offline"*; *"removing permissions … requires a SyncSignature"* (synchronous).
|
||||
|
||||
## Inbox
|
||||
|
||||
**Every document has a native inbox.** A non-editor can **deposit a link (DID
|
||||
cap)** into it without being invited as an editor; the owner **moderates**. NURI:
|
||||
`did:ng:d:<inbox_id>`. Content: the `InboxMsgContent` enum (`ContactDetails`,
|
||||
`DialogRequest`, **`Link`**, `Patch`, `ServiceRequest`, `ExtRequest`,
|
||||
`RemoteQuery`, `SocialQuery`…). Messages are **sealed** (`crypto_box::seal`) to
|
||||
the inbox pubkey → only the owner decrypts. The `from` field is **optional** → an
|
||||
**anonymous** sender is possible. This is the "identified if known, anonymous
|
||||
otherwise" behaviour native to the protocol.
|
||||
|
||||
### The inbox is NOT usable from the JS SDK
|
||||
|
||||
- `app_request(request)` is exposed, and `AppRequestCommandV0::InboxPost` +
|
||||
`AppRequest::inbox_post()` exist. **BUT** the verifier's `request_processor`
|
||||
has **no `InboxPost` arm** (arms actually handled: `OrmStart(Discrete)`,
|
||||
`Fetch`, `FileGet`, `OrmUpdate`, `OrmDiscreteUpdate`, `SocialQueryStart`,
|
||||
`QrCodeProfile(Import)`, `Header`, `Create`, `FilePut`). Sending an `InboxPost`
|
||||
triggers nothing.
|
||||
- Building an `InboxPost` requires crypto sealing on the Rust side; **no wasm
|
||||
helper** exposes it.
|
||||
- Inbox deposit is only triggered **internally** by `QrCodeProfileImport`
|
||||
(`post_to_inbox(new_contact_details)`) and `social_query_start` (contact
|
||||
propagation via inbox).
|
||||
|
||||
**Consequence:** there is no clean way to "drop a Link" into an arbitrary
|
||||
document's inbox from the JS SDK today. This lib emulates the inbox instead of
|
||||
patching the broker — see [`simulation.md`](./simulation.md) (emulated inbox +
|
||||
curator) and [`fork-inbox-fallback.md`](./fork-inbox-fallback.md) (the Rust-patch
|
||||
path NOT taken). A related exposed primitive: `social_query_start` (a federated
|
||||
query via inbox up to `degree` hops) exists but is limited to **contacts** — it
|
||||
does not cover an anonymous notification to a non-connected host.
|
||||
|
||||
## JS SDK limits (`@ng-org/web`)
|
||||
|
||||
`@ng-org/web` (verified `0.1.2-alpha.13` = `upstream/main` at 2026-05-21, the
|
||||
installed version) **does NOT expose**: Group/Dialog store creation; capability
|
||||
sharing (a NURI with rights); permission manipulation; inbox deposit/read.
|
||||
|
||||
Available JS methods: `doc_create`, `doc_subscribe`, `sparql_query`,
|
||||
`sparql_update`, `orm_start_graph`, `orm_start_discrete`, `graph_orm_update`,
|
||||
`discrete_orm_update`, `file_get`, `app_request_stream`. The docs announce *"An
|
||||
API will be provided for permission manipulation"* (no date).
|
||||
|
||||
## Integration & deployment model
|
||||
|
||||
NextGraph is consumed via an **iframe proxy** (`@ng-org/web`): the third-party
|
||||
app contains no engine, it delegates to a hosted ng-app (default `nextgraph.net`)
|
||||
that runs the engine in an iframe.
|
||||
|
||||
### The JS packages
|
||||
|
||||
- **`@ng-org/web`** — **published**. Lightweight postMessage proxy (no wasm
|
||||
embedded). **The** third-party integration path; `@ng-org/orm` and every
|
||||
example depend on it. This lib wraps it.
|
||||
- **`@ng-org/api-web`** — **private** (unpublished). Full in-browser engine
|
||||
(loads `@ng-org/lib-wasm` in a Web Worker). Consumed only by `app/nextgraph`
|
||||
(the ng-app frontend) — **not** a third-party integration target.
|
||||
- **`@ng-org/lib-wasm`** — the compiled wasm engine (contains the verifier).
|
||||
Source `sdk/js/lib-wasm/`.
|
||||
- **`nextgraph`** (npm) — the NodeJS API (`pkg-node` build).
|
||||
- **`@ng-org/orm`** — reactive ORM (`useShape`…), built on `@ng-org/web`.
|
||||
|
||||
### Where the verifier runs
|
||||
|
||||
In the standard web model, the verifier runs **in the iframe**: `app/nextgraph`
|
||||
loads `api-web` → `lib-wasm` in a Web Worker, browser-side. The broker (`ngd`)
|
||||
only does **transport and storage**.
|
||||
|
||||
**Consequence:** changing verifier logic (`request_processor`,
|
||||
`inbox_processor`) means rebuilding the **ng-app**, not the broker.
|
||||
|
||||
### iframe model & build-time retargeting
|
||||
|
||||
`@ng-org/web` redirects to the hosted ng-app, which reloads the third-party app
|
||||
in an iframe after auth, then relays over `postMessage`. **Retargetable at build
|
||||
time** (`sdk/js/web/src/index.ts`, `import.meta.env`):
|
||||
|
||||
| Variable | Target |
|
||||
|---|---|
|
||||
| `NG_REDIR_SERVER` | default `nextgraph.net` |
|
||||
| `NG_DEV3` | `127.0.0.1:3033` |
|
||||
| `NG_DEV` | `localhost:14402`/`14404` |
|
||||
| `NG_DEV_LOCAL_BROKER` | `localhost:1421` |
|
||||
|
||||
**No runtime override** — `init()` takes no broker URL. To point at a
|
||||
self-hosted ng-app: **rebuild `@ng-org/web`** (pure TS, no wasm → trivial build).
|
||||
|
||||
### Proxy ↔ iframe ↔ worker plumbing (generic)
|
||||
|
||||
The call path is **entirely generic** (no allowlist): `@ng-org/web` is a JS
|
||||
`Proxy` relaying *any* method name over `postMessage`; `app/nextgraph` dispatches
|
||||
via `Reflect.apply(ng[method], …)`. So a new wasm function in simple
|
||||
request/response form is *reachable* without touching the JS — but that's an
|
||||
untyped **hack** (quick test, not a plan). The **streamed** case needs an entry
|
||||
on both sides (`E` in `@ng-org/web` + `streamed_api` in api-web; current streamed
|
||||
methods: `doc_subscribe`, `orm_start_graph`, `orm_start_discrete`, `file_get`,
|
||||
`app_request_stream`).
|
||||
|
||||
> This is exactly why `docs.ts` in this lib calls the **real injected `ng`**
|
||||
> directly and never layers our own `Proxy` on top of `@ng-org/web`'s
|
||||
> iframe-RPC proxy — see the `DataCloneError` double-proxy constraint in
|
||||
> [`simulation.md`](./simulation.md).
|
||||
|
||||
### The broker (ngd)
|
||||
|
||||
- Already supports the inbox natively (`inbox_post`, `inbox_register`,
|
||||
`inbox_pop_for_user` in `engine/net/src/server_broker.rs`) — a standard `ngd`
|
||||
would route the inbox, **no broker patch needed**. The gap is in the
|
||||
verifier/SDK layer, not the broker.
|
||||
- **WebSocket** daemon (`async-tungstenite`), **stateful**: RocksDB under
|
||||
`--base-path`, persisted PeerId (critical volume).
|
||||
- CLI: `--local PORT`, `--domain DOMAIN:PORT,LOCAL_PORT` (behind a TLS-terminated
|
||||
reverse proxy — Traefik/Coolify).
|
||||
- **Serves no static assets**: the ng-app frontend is a separate static deploy
|
||||
(`pnpm webfilebuild`). First boot is **interactive** (admin-wallet invitation
|
||||
link). Official Dockerfiles are **broken**.
|
||||
|
||||
## Apps & services: mono-user, no global data
|
||||
|
||||
NextGraph's app/service execution model — important because it **invalidates**
|
||||
the idea of "a service with its own wallet sharing global data".
|
||||
|
||||
- **Apps AND services are mono-user.** They see only **what the user makes
|
||||
available** to them. There is **no global data** natively, and no central
|
||||
service holding shared data.
|
||||
- **Local settings document.** Every app — even a singleton — and every service
|
||||
has a **settings document** the user configures it through.
|
||||
- **Multi-instance apps.** A **non-singleton** app can be **instantiated several
|
||||
times** (e.g. a text editor, once per open file).
|
||||
- **Singleton apps.** Also **mono-user**, but **bound to a particular user (the
|
||||
developer)**. A singleton app **can hold a global document**, administered by
|
||||
that user.
|
||||
|
||||
**Consequence for a "global document" (e.g. a discovery index):** the only path
|
||||
glimpsed is a **singleton app** whose global document is administered by the
|
||||
developer-user — **but this is not implemented and not guaranteed** (simpler
|
||||
paths may exist; to explore later). The **incorrect** model to avoid: "a
|
||||
dedicated service with its own wallet sharing a freely-readable index" — that
|
||||
does not exist in NextGraph (a service is mono-user, no global data). This is why
|
||||
a global-index curator package is **deferred** in this lib (see the top-level
|
||||
README).
|
||||
|
||||
## Third-party wallet auto-import constraint
|
||||
|
||||
Verified empirically (2026-06-17): with the **hosted** broker (`nextgraph.net`),
|
||||
a third-party web app **cannot** provision/import a wallet programmatically. A
|
||||
wallet must **pre-exist** in the browser before the auth redirect can succeed.
|
||||
|
||||
Mechanism (from `@ng-org/web`'s `ngweb.js` dist):
|
||||
|
||||
- **`init()` top-level REDIRECTS**: when `window.self === window.top` it does
|
||||
`window.location.href = https://nextgraph.net/redir/#/?o=<url>`. The app's code
|
||||
stops running.
|
||||
- **Every `ng.*` method is relayed** by `parent.postMessage` to `nextgraph.net`,
|
||||
and the handler **throws `"you must call init() first"` until a session is
|
||||
established** (internal `d !== false` guard). This includes
|
||||
`wallet_import_from_code`, `add_in_memory_wallet`, `session_in_memory_start`.
|
||||
- The third-party app runs **inside the iframe only AFTER** the broker has opened
|
||||
a wallet and established the session. There is **no window** where our code runs
|
||||
*before* the broker's wallet gate → **nothing to hook an auto-import onto**.
|
||||
|
||||
Of the wallet-import methods offered on `nextgraph.eu`, only the **wallet FILE**
|
||||
(`.ngw`) is a static, reusable export; TextCode/QR are temporary device↔device
|
||||
transfers (5 min, both devices online, single use) — unusable to embed. The only
|
||||
real way to eliminate the cross-origin round-trip is to self-host/fork the ng-app
|
||||
(see [`fork-inbox-fallback.md`](./fork-inbox-fallback.md)).
|
||||
|
||||
## Login is not programmable
|
||||
|
||||
NextGraph login is a **web redirect** to the broker page (`nextgraph.net`). There
|
||||
is no way to open a wallet silently — at least one broker-redirect pass per device
|
||||
is required. Session persistence: the wallet is remembered iframe-side
|
||||
(`localStorage` long-term + `sessionStorage` for the active session); on reload,
|
||||
`init()` recovers the session **without re-triggering the redirect** while the
|
||||
broker session exists (`sdk/js/web/src/index.ts`, `sdk/js/api-web/main.ts`). A
|
||||
full browser restart (losing `sessionStorage`) can re-trigger the gate. A real
|
||||
logout IS exposed (`ng.session_stop()`, `ng.user_disconnect()`,
|
||||
`ng.wallet_close()` in `sdk/js/lib-wasm/src/lib.rs`) but **forces a new
|
||||
redirect** afterwards. This lib's faux login sidesteps all of it — see the faux
|
||||
login in [`simulation.md`](./simulation.md).
|
||||
@@ -0,0 +1,255 @@
|
||||
# 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)`).
|
||||
|
||||
## `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).
|
||||
|
||||
### 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 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.
|
||||
Reference in New Issue
Block a user