diff --git a/README.md b/README.md index 6f35c4f..08c3dc9 100644 --- a/README.md +++ b/README.md @@ -1,128 +1,122 @@ # ng-eventually -A **generic polyfill layer** over the [NextGraph](https://nextgraph.org) JS SDK. +A generic polyfill layer over the [NextGraph](https://nextgraph.org) JS SDK. NextGraph's JS SDK does not yet expose cross-wallet reads, capabilities, inboxes -or group stores. `ng-eventually` lets an app behave as if those existed today, by -emulating them on top of a **single shared wallet / broker**. It is **generic**: -it contains **no application domain** — the consumer injects its shapes and the -*acts* of granting access. +or group stores. `ng-eventually` lets a consumer application behave as if those +existed today, by emulating them on top of a single shared wallet / broker. It is +generic: it contains no application domain — the consumer application injects its +shapes and the acts of granting access. 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. +The asymmetry is the point. The consumer application writes SDK-shaped code as if +NextGraph were finished: per-entity documents in public/protected/private stores, +capabilities, inboxes. This library owns the current-state NextGraph knowledge and +the simulation that fabricates that mature face — a shared-wallet emulation — so +the application never sees it. As NextGraph matures, this library changes; the +consumer application'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). + authoritative reference on what the current SDK/broker do and do not expose + (the ground truth each 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/read-model.md`](./docs/read-model.md) — the READ MODEL the polyfill + behaviour on one shared wallet (shim, per-document ReadCaps, emulated inbox, + write guard, the two axes, the double-proxy constraint). +- [`docs/read-model.md`](./docs/read-model.md) — the read model the polyfill implements: events via the global index, everything else by following a shared - graph; listing via a bounded set of **per-doc anchored** `sparql_query`s (never - an anchorless union-scan of the physical wallet, never the ORM fan-out — both - hang/time out); reactivity via re-query on a change signal. -- [`docs/decisions/`](./docs/decisions/) — historical current-SDK ADRs - (private-store scope, SPARQL delete, shared-wallet login, discovery mechanism). + graph; listing via a bounded set of per-doc anchored `sparql_query`s; reactivity + via re-query on a change signal. +- [`docs/decisions/`](./docs/decisions/) — current-SDK ADRs (private-store scope, + SPARQL delete, shared-wallet identity, discovery mechanism). - [`docs/fork-inbox-fallback.md`](./docs/fork-inbox-fallback.md) — the Rust-patch / - self-host inbox path NOT taken (kept as fallback). + self-host inbox path not taken (kept as a fallback). - [`docs/migration-guide.md`](./docs/migration-guide.md) — the checklist for when real NextGraph matures. -## What is emulated (and how it goes away) +## What is emulated -**Nothing in this library is a real NextGraph feature.** Every behaviour below is -**emulated** — a stopgap fabricated on top of the *current, immature* NextGraph -(one shared wallet, everything physically readable). Each has a **real NextGraph -target**, and the switch to it is a **lib-only swap: the consumer's SDK-shaped code -does not change** (see [`docs/migration-guide.md`](./docs/migration-guide.md)). The -consumer always sees the mature SDK face; the emulation lives entirely here. +Nothing in this library is a real NextGraph feature. Each behaviour below is +emulated — a stopgap fabricated on top of the current, immature NextGraph (one +shared wallet, everything physically readable). The consumer application always +sees the mature SDK face; the emulation lives entirely here. -| Behavior | What the consumer sees (SDK-shaped API) | How it's emulated today (on one shared wallet) | Real NextGraph target | Migration (what changes; consumer unchanged) | +The table reads: what the consumer application does, the real NextGraph target it +is written against, the current NextGraph implementation status (why a workaround +is needed), and how this lib emulates it today. + +| Capability | What the consumer application does | Real NextGraph target | Current NextGraph status (why a workaround) | Current emulation | |---|---|---|---|---| -| **Multi-user / per-user wallet** | Each username is its own identity with its own documents | **One shared wallet** everyone opens; "users" are **virtual wallets** — shim accounts keyed by a **virtual-wallet id**, each mapped to its documents in `store-registry.ts` ([`simulation.md`](./docs/simulation.md#physical-wallet-vs-virtual-wallet--never-enumerate-the-physical-one)) | One real per-user wallet each; native cross-wallet reads | Each virtual wallet → a real wallet; drop the shim; the physical/virtual split dissolves | -| **3 native stores per user** | `public` / `protected` / `private` scopes | **3 emulated scope-index documents** per account — each "store" is an index doc listing its entity-doc NURIs; all physically live in the ONE shared private store (`docCreate(..., undefined)`), scope is a **logical label** ([`simulation.md`](./docs/simulation.md#a-virtual-wallets-structure--the-three-emulated-stores)) | The user's 3 real native stores hold the entity documents | The 3 index docs become the 3 real stores; the logical scope label becomes real placement | -| **Per-document read isolation** | `getCaps().open(doc, scope, owner)` — the *acts* of granting | Emulated **`CapRegistry`** (`caps.ts`, per-document ReadCap) + **read filter** (`read-filter.ts`, defence-in-depth view) + read-set construction in `read-model.ts`; owner/connections injected as principals ([`simulation.md`](./docs/simulation.md#emulated-readcap--per-document-capsts--read-filterts)) | Broker/verifier only delivers documents the wallet holds a ReadCap for; `useShape` already returns an authorized subset | Translate the registry to real caps; **delete** the read filter (dead code) — access unit is already the document (`@graph`) | -| **Bilateral connections** | `declareConnections(peers)` — declare your own side | Emulated **connection registry** (`connections.ts`): directed assertions, a link is live only when **both** sides assert (two-sided) → drives protected read grants ([`simulation.md`](./docs/simulation.md#making-the-readcap-active--current-user--connection-driven-grants)) | Mutual capability exchange between two wallets | Real mutual caps replace the materialized link; drop the registry | -| **Inbox (registration notifications)** | `inbox.post` / `read` / `watch` | Emulated **deposits via SPARQL** into an inbox document + **in-client materialization** (curator played inline); no native `inbox_post` exists in the JS SDK ([`simulation.md`](./docs/simulation.md#emulated-inbox--curator-inboxts), [`nextgraph-current-state.md`](./docs/nextgraph-current-state.md#inbox)) | Native per-document inbox: `inbox_post_link` seals a deposit; a separate curator materializes it | `post` → native `inbox_post_link`; read side → a **separate curator package** (deferred) | -| **Discovery of all public events** | `submitToIndex(ref)` / `readIndex()` | Emulated **global index** = a document **owned by a reserved special account** (`@index`), fed via **its inbox** + inline **curator** (dedup); a **stable NURI** every client resolves. **NOT a physical-wallet scan** ([`simulation.md`](./docs/simulation.md#emulated-discovery-index--special-account-discoveryts), [`read-model.md`](./docs/read-model.md)) | A real owned global document (undecided owner — singleton-app path only glimpsed), fed via its native inbox, materialized by a curator | Special account disappears; ownership moves to the decided owner; `submitToIndex` → native inbox post; curator queries the real index | -| **Reads / listing** | `listMyMeetingPoints()`, `listEvents()`, … by need | **Per-doc ANCHORED `sparql_query`** over the **virtual** wallet's by-need doc set — **never an anchorless scan of the physical wallet** (O(wallet), ~90s timeout) and never the ORM fan-out (~75s hang) ([`read-model.md`](./docs/read-model.md)) | Native per-wallet reads over real per-user stores | The anchored read is already native; only *bringing a repo into the session* becomes a real broker sync (the `OpenRepo` TODO) | -| **Reactivity** | Lists update on change | **Re-query** the bounded per-doc anchored set on a lightweight change signal (`doc_subscribe` / ORM on an already-opened single store) — there is **no reactive union query** ([`read-model.md`](./docs/read-model.md#reactivity--re-query-on-a-change-signal-no-reactive-union)) | Native reactive reads | Re-query pattern collapses onto native reactive primitives | -| **Writes** | Write an entity to its scope | **Per-entity documents** via **direct SPARQL** (`docs.sparqlUpdate` on the real injected `ng`); `doc_create` can only target the **private** store today (`StoreRepo` not JS-constructible) ([`simulation.md`](./docs/simulation.md#reponotfound-and-the-orm_start_graph-scope-rule)) | Writes land in the entity's real store via native primitives | `docCreate` targets the real per-scope store once the SDK lets you construct one | -| **Login** | `login(username)` / `logout()` | **Faux `localStorage` identity** (`accounts.ts`): declarative username, no password, no NextGraph call — the shared-wallet broker gate stays open underneath ([`simulation.md`](./docs/simulation.md#faux-login-accountsts)) | Opening your **own** wallet at the broker gate IS the login | Remove faux login; the broker redirect becomes the real per-user login (flow shape unchanged) | -| **Write-guard** | Writes refused without the write cap | **Best-effort**: the guard (`ng-proxy.ts`) fires only on the public proxy, but real write paths call the injected `ng` directly (the `DataCloneError` constraint) → **not guarded today** ([`simulation.md`](./docs/simulation.md#write-guard-coverage-honest-scope)) | Broker/verifier enforces the write cap natively | Native enforcement replaces the guard; delete it (dead code) | +| Multi-identity / per-identity wallet | Treats each identity id as its own wallet with its own documents | Each identity opens its own real wallet; native cross-wallet reads | Not-yet-implemented: the JS SDK exposes no cross-wallet read, so one session cannot read another identity's wallet | One shared wallet everyone opens; "identities" are virtual wallets — shim accounts keyed by an id, each mapped to its documents in `store-registry.ts` | +| Three native stores per identity | Places entities by scope `public` / `protected` / `private` | The identity's three real native stores hold the entity documents | Not-yet-implemented: `doc_create`/ORM can target only the private (and protected) native store today; a `public`/arbitrary `StoreRepo` is not JS-constructible | Three emulated scope-index documents per account — each "store" is an index doc listing its entity-doc NURIs; all physically live in the one shared private store, and scope is a logical label | +| Per-document read isolation | Declares a document's read policy via `getCaps().open(doc, scope, owner)`, then issues directed read grants (`grantRead(doc, granteeId)`) | The broker/verifier delivers only documents the wallet holds a ReadCap for; accessing a document without the cap yields an empty result in a union read (a targeted read of an unheld repo errors with `RepoNotFound`) | Bug/gap for emulation purposes: there is no cap-introspection API — a client cannot ask "may this identity read this doc?", so the polyfill cannot mirror the broker's decision from NextGraph itself | An emulated `CapRegistry` (`caps.ts`, per-document read/write caps) + a read filter (`read-filter.ts`, a defence-in-depth view) that keep only documents the current identity may read; `canRead`/`governsRead` are emulation-only, with no NextGraph API behind them | +| Directed read sharing | Owns the relationship concept ("who is connected to whom") itself, and for each relationship issues directed read grants on the owner's protected documents | A native per-document ReadCap issued to a specific identity — but note this target is itself not-yet-built in nextgraph-rs today, not merely unexposed in JS: `AccessGrantV0{grantee}` is unpersisted scaffolding and cap-send is `unimplemented!()`, so directing a grant to another identity has no working platform primitive yet | Not-yet-implemented: sending a cap to another identity is `unimplemented!()`, and no relationship/mutuality primitive exists — relationship is an application concept, not a platform one | The app selects the owner's protected documents via `getCaps().protectedDocsOf(owner)` and calls `grantRead(doc, granteeId)` per grantee; the lib records the per-document grant | +| Inbox (registration notifications) | `inbox.post` / `read` / `watch` | A message is sealed to the recipient's key and queued in their inbox; the recipient's own verifier unseals and applies each queued message inline while processing the inbox | Not-yet-implemented: the sender-side seal-into-inbox call (`inbox_post_link`) is proposed/future, not exposed in the JS SDK | Deposits written as RDF into an inbox document via SPARQL; `read`/`watch` read the deposits back — an in-lib stand-in for the recipient's own inbox processing | +| Discovery of all public events | `submitToIndex(ref)` / `readIndex()` | A real owned global document (owner undecided — a singleton-app path), fed via its native inbox, read as a materialized index | Not-yet-implemented / undecided: an identity's apps and services see only what it shares, so there is no global backend index yet | A global index document owned by a reserved special account (`@index`), fed via its inbox, read with dedup; a stable NURI every client resolves | +| Reads / listing | Lists the documents it needs, by scope, and reads them | Native per-wallet reads over the real per-identity stores | Bug/perf: an anchorless union query spans every named graph in the session store, which on a shared / accumulating wallet is O(wallet size) and stalls | A bounded, by-need set of per-doc anchored `sparql_query`s (each anchored to one repo's default graph), independent of wallet size | +| Reactivity | Lists update on change | Native reactive reads | Not-yet-implemented: there is no reactive union query across graphs | Re-query the bounded per-doc anchored set on a lightweight change signal (`doc_subscribe` / ORM on an already-opened single store) | +| Writes | Writes an entity to its scope | Writes land in the entity's real store via native primitives | Not-yet-implemented: `doc_create` can target only the private/protected store today (`StoreRepo` not JS-constructible) | Per-entity documents via direct SPARQL (`docs.sparqlUpdate` on the real injected `ng`) | +| Current identity | Sets the current identity id (established at wallet import) via the SDK's current-identity call | Opening one's own wallet at the broker gate establishes the session identity | Not-yet-implemented for the shared-wallet case: everyone shares one wallet, so the broker cannot distinguish identities | A relayed id (`accounts.ts` `IdentityStore` persists it); the read filter and inbox `from` read it | +| Write-guard | Writes refused without the write cap | The broker/verifier enforces the write cap natively | Partial: the guard fires only on the public proxy, but the real write paths call the injected `ng` directly (the `DataCloneError` constraint), so it is best-effort today | A `sparql_update` override (`ng-proxy.ts`) checking the emulated write cap | ## Packages -| Package | Role | At migration | -|---|---|---| -| **`@ng-eventually/client`** | **SDK-identical** wrapper the app imports instead of `@ng-org/web` / `@ng-org/orm`. Adds the polyfills the broker/verifier will do natively (shared-wallet login, capability enforcement, anticipated cap/inbox methods). | Disappears: the app points back at the real SDK (build alias removed). | +| Package | Role | +|---|---| +| `@ng-eventually/client` | The SDK-identical wrapper the app imports instead of `@ng-org/web` / `@ng-org/orm`. It adds the polyfills the broker/verifier will do natively (shared-wallet identity, capability enforcement, anticipated cap/inbox methods). As NextGraph matures, the app points back at the real SDK (build alias removed) and this package falls away. | -> **A global-index curator package is deferred.** NextGraph is **mono-user with -> no global data** (apps/services see only what the user shares; there is no -> multi-user backend). A global index would come from a **singleton app** -> (a global document administered by the developer) — **not implemented and -> uncertain**, and simpler paths may exist. So no second package for now; it will -> be (re)introduced once the global-index mechanism is decided. The curator must -> never be bundled in the client → it will be a separate package when it lands. +A global-index package is deferred. In NextGraph an identity's apps and services +see only what it shares, so there is no multi-identity backend. A global index +would come from a singleton app (a global document administered by the developer), +which is not implemented and undecided; simpler paths may exist. So there is no +second package for now — it will be introduced once the global-index mechanism is +decided, and it will be separate from the client. ## Design principle -The application code is written **as if the target NextGraph existed**. All -compensation lives here, *beside* the app. Migration = remove this layer; the -app code (SDK-shaped) is unchanged. +The application code is written as if the target NextGraph existed. All +compensation lives here, beside the app. As NextGraph matures, this layer falls +away; the app code (SDK-shaped) is unchanged. -- **SDK-identical surface**: the client wraps the real `ng` (a Proxy that - forwards everything and overrides only what must be emulated) and `useShape`. - The real SDK is **injected** via `configure()` (no hard import → build-alias - safe + testable). -- **Authorization = emulated capabilities**: documents carry grants; the client - 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` 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. +- SDK-identical surface: the client wraps the real `ng` (a Proxy that forwards + everything and overrides only what must be emulated) and `useShape`. The real + SDK is injected via `configure()` (no hard import → build-alias safe and + testable). +- Authorization is emulated capabilities: documents carry grants; the client + enforces them generically (read filter + write guard). The app declares a + document's read policy and issues directed grants — the same acts it will + perform in the target. No policy is injected. +- Inbox: the client `inbox` namespace deposits (`post`) and, in the shared-wallet + emulation, reads the deposits back (`read` / `materialize` / `watch`) in place + of the recipient's own inbox processing. +- Tests of the polyfill (against a real broker) live in this repo, so a consuming + app can test its features against a clean, mocked API. ## Status -**Implemented.** The polyfill mechanisms are wired against a real broker, not -stubbed: +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`). +- 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, directed grants) + + 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`). +- Identity — `accounts.ts` (`IdentityStore`, 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 +The remaining `TODO` markers are narrow: the shared-wallet credential passthrough +in the `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. +[`docs/migration-guide.md`](./docs/migration-guide.md) for what changes as +NextGraph matures. diff --git a/docs/decisions/discovery-model.md b/docs/decisions/discovery-model.md index 4f3ed52..e4d1a65 100644 --- a/docs/decisions/discovery-model.md +++ b/docs/decisions/discovery-model.md @@ -1,91 +1,94 @@ -# ADR — Discovery mechanism (inbox-fed index, curator, fan-out) +# ADR — Discovery mechanism (inbox-fed index, fan-out) **Date:** 2026-06-16 · **Status:** mechanism accepted; target owner undecided. -Ported here for the **discovery MECHANISM** it defines — the piece this lib +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. +product intent (what a consumer application *should* surface) is the consumer +application's concern, not this lib's; only the mechanism is recorded here. -## Access ≠ discovery +## Access is not 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. +- **Discovery**: how do I learn it exists, in order to read it? This is the ADR's topic. ## 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. +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), built up from its inbox (a + watcher ingests deposits and adds entries). +2. Primary discovery is that global index. +3. Relational is a secondary axis, overlaid: a peer's participations, + markers on the global list. It rests on existing per-item data (protected scope), + with 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** +2. **Synchronization**: subscribe to those documents so 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. +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 +- **No Group store.** The index is not open-write: it is an owned document + (public read) plus a native inbox (a primitive present on every document). Nobody + writes the index but its owner (by ingesting 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 +- **One mechanism, reused.** The inbox + ingest watcher serve both + submitting an entity to the index and a registration/deposit in one consumer + app — same `inbox.post` API, same handling. This is exactly `inbox.ts` in this + lib (`post` / `read` / `materialize` / `watch`). +- **Natural dedup / moderation point:** the inbox → index ingest 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** +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 +services), so a dedicated service with its own wallet sharing a freely-readable +index is not a NextGraph shape. 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 package is a deferred separate package in this lib (see the top-level README). ## Polyfill reality — the fan-out drift is now RESOLVED (special-account index) -The shared-wallet polyfill originally shipped a **cross-account fan-out over -every account's public documents** (`store-registry.ts` `listEntityDocs('public')` -/ `resolveReadGraphs`) — one account saw another's public entity **without a -connection**. This ADR classified that per-account fan-out as a **drift** to be -replaced by the single global index. +The shared-wallet polyfill originally shipped a cross-account fan-out over +every account's public documents (`store-registry.ts` `listEntityDocs('public')` +/ `resolveReadGraphs`) — one account saw another's public entity without any +relationship to its creator. This ADR classified that per-account fan-out as a drift +to be replaced by the single global index. -**That drift is now resolved in the polyfill.** The inbox-fed global index of -this ADR is implemented on top of a **RESERVED SPECIAL ACCOUNT** in the shim -(`discovery.ts`, `INDEX_ACCOUNT = "@index"`) that owns the index document while +That drift is now resolved in the polyfill. The inbox-fed global index of +this ADR is implemented on top of a reserved special account in the shim +(`discovery.ts`, `INDEX_ACCOUNT = reservedAccount("index")` — a sentinel-prefixed +key in the shim's reserved namespace that `normalizeId` can never produce, so it is +disjoint from any normalized user id, not the literal `"@index"`) that owns the +index document while the target owner stays undecided: `submitToIndex(ref)` deposits into the index -document's inbox; `readIndex()` materializes (dedup) the entries. The app-facing -discovery path is now **read the index**, exactly as this ADR prescribes — NOT -the fan-out. The cross-account fan-out survives only as an **internal lib -fallback** (it still powers per-scope listing like `resolveReadGraphs`), never +document's inbox; `readIndex()` ingests (dedups) the entries. The app-facing +discovery path is now "read the index", exactly as this ADR prescribes — not +the fan-out. The cross-account fan-out survives only as an internal lib +fallback (it still powers per-scope listing like `resolveReadGraphs`), never the discovery route. The special account is the provisional owner; at migration it disappears and ownership moves to the decided global-index owner (see -[`../migration-guide.md`](../migration-guide.md)) with the consumer surface -(`submitToIndex` / `readIndex`) unchanged. +[`../migration-guide.md`](../migration-guide.md)) with the consumer application's +surface (`submitToIndex` / `readIndex`) unchanged. ## 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. + corruption. Replaced by inbox deposit + owner-side ingest. - **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). diff --git a/docs/decisions/shared-wallet-login-flow.md b/docs/decisions/shared-wallet-login-flow.md index fa58827..baf1bde 100644 --- a/docs/decisions/shared-wallet-login-flow.md +++ b/docs/decisions/shared-wallet-login-flow.md @@ -1,64 +1,70 @@ -# ADR — Shared-wallet login/logout flow +# ADR — Shared-wallet identity flow (perceived login) -**Date:** 2026-06-15 · **Status:** Accepted (frozen). The rationale behind this -lib's **faux login** (`accounts.ts`) and why it must never touch NextGraph. +**Date:** 2026-06-15 · **Status:** Accepted (frozen). The rationale behind how +the consumer application presents identity selection as a perceived login, and why +the lib's identity store (`accounts.ts`) must never touch NextGraph. The lib itself +no longer frames this as a login: it receives an identity id, set at wallet-import +time; the perceived-login UX lives entirely in the consumer application. ## Starting constraint -NextGraph login **is not programmable**: it is a **web redirect** to the broker +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: +Two distinct 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 +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**. + persistent. It is not labelled "login." +2. **Application layer (perceived as the login).** A "Connexion" screen where the + user picks an identity id (relayed to the lib's identity store, persisted in + `localStorage`, the current principal). This is the login *in the user's + perception*, presented by the consumer application. No password — declarative + selection (anyone takes any id — coherent with zero-security / friends). In + practice the id is often a human-friendly handle. "Déconnexion" clears only + the stored id 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. +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. +**Rejected** — a perceived login first, then a warning page "enter this +id/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. +"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 id "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` +- **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 + 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()`, +- **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. + clear the wallet and force a new redirect afterwards — hence the app-level + "Déconnexion" does not call them, and the real logout stays hidden. ## 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 +`accounts.ts` is an `IdentityStore`: `set(id)` / `clear()` / `get()` only read/write +the identity id in an injected `AccountStorage`; they never call NG. The id is set at +wallet-import time and relayed via the lib's current-identity call; the perceived +login is the consumer application's. See the identity store in [`../simulation.md`](../simulation.md). diff --git a/docs/fork-inbox-fallback.md b/docs/fork-inbox-fallback.md index cdd770c..d57afc9 100644 --- a/docs/fork-inbox-fallback.md +++ b/docs/fork-inbox-fallback.md @@ -1,15 +1,15 @@ -# Fallback — forking NextGraph to expose the inbox (path NOT taken) +# Fallback — forking NextGraph to expose the inbox (path not taken) -**Status:** NOT taken — short-circuited by this lib's **emulated inbox** +**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). +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. +get a real inbox: emulate it (what this lib does) or fork the engine (this +document). The emulation was chosen; this is the archived alternative. ## Strategic posture @@ -33,13 +33,13 @@ a full stack for a feature the upstream will likely ship differently. 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. + where the owner's own verifier unseals the queued message and applies it inline + into 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 +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) @@ -76,18 +76,18 @@ Maintain patched client packages, not just the wasm. The generic forwarding sides. For write-only (request/response) — unneeded. - **`@ng-org/orm`** — only if inbox writes join the ORM flow. Otherwise unneeded. -## Layer 3 — consumer integration +## Layer 3 — consumer application 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 +Exposing the method is not enough; the consumer application 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. +deposits, so it does not provide the target's crypto anonymity. If a consumer +application 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. diff --git a/docs/migration-guide.md b/docs/migration-guide.md index 533a012..6be7d63 100644 --- a/docs/migration-guide.md +++ b/docs/migration-guide.md @@ -1,9 +1,8 @@ # 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. +The whole point of this library: the consumer application already writes SDK-shaped +code, so when NextGraph ships cross-wallet reads, capabilities and inboxes, only this +lib changes. The consumer application's code does not change. This is the checklist. ## Guiding invariant @@ -17,18 +16,19 @@ has no clear target image, that is a drift signal (see ### 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. +documents. The directed `grantRead(doc, granteeId)` maps to a native per-document +ReadCap issued to that identity. 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** +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 +- `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`. @@ -36,48 +36,51 @@ in the shim (see the two-axes section in [`simulation.md`](./simulation.md)). `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 +- 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. + surface facing the consumer application (`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: +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 +### 4. Real inbox → drop the in-lib read emulation 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. +document) with the native `inbox_post_link` (proposed/future). On the read side the +recipient's own verifier unseals each queued sealed message and applies it inline +when it processes its inbox — there is no separate curator to build; the in-lib read +emulation simply goes away (see the deferred global-index note in the top-level +README and [`decisions/discovery-model.md`](./decisions/discovery-model.md)). 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 +### 5. Retire the identity store → real per-user login +Remove `accounts.ts` (the `IdentityStore` that persists the identity id in +`localStorage`) 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. +`isolation.ts` (application-visibility scaffold) disappears against a +different piece of infra than the caps: real per-account wallets, and the +relationship concept the consumer application owns. 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 +The consumer application 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(...)` / +no consumer code changes. The one non-SDK call — `configure(...)` / `@ng-eventually/client/polyfill` — is deleted. The lib itself disappears. -## What does NOT change +## 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. +The consumer application's code. Shapes, screens, the *acts* of granting +access, entity→scope mapping, the relationship 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. diff --git a/docs/nextgraph-current-state.md b/docs/nextgraph-current-state.md index bc5b532..a709f2b 100644 --- a/docs/nextgraph-current-state.md +++ b/docs/nextgraph-current-state.md @@ -73,14 +73,18 @@ grant the repos it contains — **you need each repo's own ReadCap**. The option 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 +> 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. +> entity. Read isolation is cryptographic in the target: with no cap for a repo, a +> union / reactive read returns empty (the repo is never decrypted), while a +> targeted read of an unheld repo returns `RepoNotFound`. There is no +> cap-introspection API — the polyfill's `canRead` / `governsRead` are +> emulation-only, with no NextGraph API behind them. ### Store ↔ document confusion (recurring) @@ -98,36 +102,40 @@ 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: +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:`. 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 +`DialogRequest`, `Link`, `Patch`, `ServiceRequest`, `ExtRequest`, +`RemoteQuery`, `SocialQuery`…). Messages are sealed (`crypto_box::seal`) to +the inbox pubkey, so only the owner decrypts. The `from` field is optional, so 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 +The recipient's own verifier unseals each queued message and applies it inline when +it processes its inbox — there is no separate curator or materialization service. + +### 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)`, + `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` +- Building an `InboxPost` requires crypto sealing on the Rust side; no wasm + helper exposes it. A high-level `inbox_post_link` is a proposed/future API, not + yet present. +- 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. +patching the broker — see [`simulation.md`](./simulation.md) (emulated inbox) 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. ## The query capability — ONE local store, named graphs, union queries @@ -190,36 +198,36 @@ from JS today a repo becomes queryable ONLY by being `doc_create`d in this sessi (own docs) or synced by an internal path — never on demand by NURI+ReadCap. **Consequence for this lib's mono-wallet polyfill:** every account's documents are -`doc_create`d in the ONE shared wallet within the SAME session, so they are ALL -already in `self.repos`. `read-model.ts` reads the **bounded, by-need** set of docs -with ONE **anchored** `sparql_query` per doc (`SELECT ?s ?p ?o WHERE { ?s ?p ?o }`, +`doc_create`d in the one shared wallet within the same session, so they are all +already in `self.repos`. `read-model.ts` reads the bounded, by-need set of docs +with one anchored `sparql_query` per doc (`SELECT ?s ?p ?o WHERE { ?s ?p ?o }`, anchor = the doc NURI): the anchor resolves that same-session repo directly (no -separate open needed) and restricts the query to its graph → O(1) per doc, +separate open needed) and restricts the query to its graph, so it is O(1) per doc, independent of the store's size. An absent repo throws `RepoNotFound` on its own read and is skipped, never aborting the batch. -**Do NOT anchorless-union-scan on the read path.** An anchorless -`SELECT … WHERE { GRAPH ?g { ?s ?p ?o } }` spans EVERY named graph in the store — -O(wallet size). On a **shared / bloated** wallet that accumulates docs across runs -that was O(wallet) and timed out (~90s observed on `readUnion` / probe reads). The -per-doc anchored read makes a non-empty wallet irrelevant. At the real multi-store +The read path avoids an anchorless union-scan. An anchorless +`SELECT … WHERE { GRAPH ?g { ?s ?p ?o } }` spans every named graph in the store — +O(wallet size). On a shared wallet that accumulates docs across runs that cost grows +with the whole wallet, which is why the read path is per-doc anchored: the anchored +read makes a non-empty wallet irrelevant. At the real multi-store migration this is unchanged (the anchored read is native); only bringing a repo into the session changes: opening a real per-user store repo by cap becomes a native broker sync (the `OpenRepo` TODO at `verifier.rs:1423`). Opening still requires the -repo's **NURI + ReadCap** — there is **no store-level read inheritance** (see +repo's NURI + ReadCap — there is no store-level read inheritance (see § Capability / ReadCap granularity). -### The union is READ-ONLY — writes must target one document +### The union is read-only — writes must target one document `resolve_target_for_sparql(update=true)` returns `InvalidTarget` for `UserSite` / `None` (`request_processor.rs:275-282`). So `sparql_update` cannot write "to the -union": every write must name **one** document's `@graph` — exactly what the +union": every write must name one document's `@graph` — exactly what the polyfill's `docs.sparqlUpdate` already does. ### No reactive SPARQL — `sparql_query` is one-shot `sparql_query` is non-streamed: it computes a `QueryResults` and returns once -(`lib-wasm/src/lib.rs:352-405` / `553-610`). There is **no** "subscribe to a union +(`lib-wasm/src/lib.rs:352-405` / `553-610`). There is no "subscribe to a union query". The only reactive primitives are the streamed ones: `orm_start_graph`, `orm_start_discrete`, `doc_subscribe`, `app_request_stream`. @@ -228,18 +236,18 @@ query". The only reactive primitives are the streamed ones: `orm_start_graph`, The reactive ORM is structurally unfit for a fan-out of per-entity / not-yet-synced graphs, and this is *why* subscribing such a fan-out hangs: -- `OrmStartGraph` first loops over EVERY graph in the requested scope and calls +- `OrmStartGraph` first loops over every graph in the requested scope and calls `open_for_target(&nuri.target, /*publisher*/ true)` on each (`request_processor.rs:53-66`), and `orm/graph/initialize.rs` does the same fan-out again for the graphs the ORM discovers (~`125-128`). - `open_for_target` → `resolve_target` → `self.repos.get(repo_id).ok_or(RepoNotFound)` (`request_processor.rs:286-294` calling `resolve_target` at `:147`, the `RepoNotFound` at `:155/:163`). -- A **freshly-created per-entity doc**, or any **not-yet-synced other-account doc**, - is absent from `self.repos` → `RepoNotFound` propagates through the `?` and - **aborts the whole `orm_start_graph`**. The subscription never emits its initial → - the ORM `readyPromise` never resolves → the multi-second hang observed (≈75s) - when subscribing a fan-out of per-entity graphs. +- A freshly-created per-entity doc, or any not-yet-synced other-account doc, + is absent from `self.repos`, so `RepoNotFound` propagates through the `?` and + aborts the whole `orm_start_graph`. The subscription never emits its initial, so + the ORM `readyPromise` never resolves and the subscription hangs when a fan-out of + per-entity graphs is passed in. **Consequence:** passing per-entity / unsynced graphs to the reactive ORM is broken. Listing must go through a one-shot union `sparql_query` instead — see @@ -347,12 +355,12 @@ the idea of "a service with its own wallet sharing global data". 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 +glimpsed is a singleton app whose global document is administered by the +developer-user — though this is not implemented and not guaranteed (simpler +paths may exist; to explore later). The model that does exist is this +singleton-app one; a dedicated service with its own wallet sharing a +freely-readable index is not a NextGraph shape (a service is mono-user, no global +data). This is why a global-index package is deferred in this lib (see the top-level README). ## Third-party wallet auto-import constraint @@ -382,14 +390,15 @@ real way to eliminate the cross-origin round-trip is to self-host/fork the ng-ap ## Login is not programmable -NextGraph login is a **web redirect** to the broker page (`nextgraph.net`). There +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 +`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). +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 identity store sidesteps all of it — the identity +id is set at wallet-import time and relayed to the lib, without a separate login; +see the identity store in [`simulation.md`](./simulation.md). diff --git a/docs/read-model.md b/docs/read-model.md index 5f2326b..a574d31 100644 --- a/docs/read-model.md +++ b/docs/read-model.md @@ -1,40 +1,40 @@ -# The READ MODEL the polyfill implements +# The read model the polyfill implements How the polyfill turns "give me my lists" into concrete NextGraph reads on the -shared wallet. This is a **design decision**, grounded entirely in the query +shared wallet. This is a design decision, grounded entirely in the query capability documented in [`nextgraph-current-state.md`](./nextgraph-current-state.md) § *The query -capability*. The consumer (Festipod) never sees any of this: it asks -`@ng-eventually/client` for its lists **by need** and trusts the answer — the whole +capability*. The consumer application never sees any of this: it asks +`@ng-eventually/client` for its lists by need and trusts the answer — the whole read mechanism lives here, in the polyfill. -> **The rule in one line:** read each by-need doc with its OWN anchored -> `sparql_query`; NEVER run an anchorless union-scan over all graphs. An anchorless -> union spans **every** named graph in the session store — O(wallet size) — and on a -> shared/bloated wallet that accumulates across runs it produced ~90s timeouts. The -> per-doc anchored read is O(1) per doc, INDEPENDENT of wallet size, so a non-empty -> wallet no longer matters. +> The rule in one line: read each by-need doc with its own anchored +> `sparql_query`; never run an anchorless union-scan over all graphs. An anchorless +> union spans every named graph in the session store — O(wallet size) — which is why +> the read path is per-doc anchored on a shared wallet that accumulates across runs. +> The per-doc anchored read is O(1) per doc, independent of wallet size, so a +> non-empty wallet does not matter. The governing constraints (all verified in `nextgraph-rs`, cited there): -- One local oxigraph store per session; every opened repo is a **named graph**. -- `sparql_query` with **no anchor** → the **LOCAL UNION** of all opened graphs - (O(wallet), NOT used on the read path); with a string anchor → restricted to - **one** repo (that repo becomes the query's DEFAULT graph). Union is **read-only**. -- The anchor's one-repo restriction applies only to a **default-graph** body (no +- One local oxigraph store per session; every opened repo is a named graph. +- `sparql_query` with no anchor → the local union of all opened graphs + (O(wallet), not used on the read path); with a string anchor → restricted to + one repo (that repo becomes the query's default graph). Union is read-only. +- The anchor's one-repo restriction applies only to a default-graph body (no `GRAPH` wrapper); an explicit `GRAPH ?g { … }` body iterates the named graphs regardless of the anchor (see § probe step 4). The read path therefore uses an anchored `SELECT ?s ?p ?o WHERE { ?s ?p ?o }` (default-graph body) per doc. -- A repo is queryable **only after it is opened/synced** (needs its NURI + ReadCap; - no store-level read inheritance). **VERIFIED (T03.k):** the current JS SDK exposes - **no primitive that syncs an *unknown* repo** — `sparql_query`/`doc_subscribe`/ +- A repo is queryable only after it is opened/synced (needs its NURI + ReadCap; + no store-level read inheritance). Verified (T03.k): the current JS SDK exposes + no primitive that syncs an *unknown* repo — `sparql_query`/`doc_subscribe`/ `orm_start_graph` all resolve via `self.repos.get().ok_or(RepoNotFound)` and only touch a repo already present; the real loader `load_repo_from_read_cap` is - `pub(crate)`, unexposed. In THIS mono-wallet polyfill that is fine: every account's - docs are `doc_create`d in the SAME session, so they are all already in `self.repos` - and the anchorless union spans them with no per-doc open needed. The open step - becomes a real broker sync only at the multi-store migration. -- **No reactive union query**, and the reactive ORM **hangs** if handed a per-entity + `pub(crate)`, unexposed. In this mono-wallet polyfill that is fine: every account's + docs are `doc_create`d in the same session, so they are all already in `self.repos` + and the per-doc anchored read resolves each one directly with no per-doc open + needed. The open step becomes a real broker sync only at the multi-store migration. +- No reactive union query, and the reactive ORM hangs if handed a per-entity / unsynced graph fan-out (`RepoNotFound` aborts `orm_start_graph`). ## Two read regimes — enumerate vs follow @@ -42,71 +42,80 @@ The governing constraints (all verified in `nextgraph-rs`, cited there): There is **no cross-wallet read** in current NextGraph, so nothing is globally enumerable "for free". The polyfill splits every list into one of two regimes: -### Events (all public) = the GLOBAL INDEX — the ONE enumeration hack +### Events (all public) = the global index — the one enumeration hack Public events are the only thing enumerated across accounts, via the emulated discovery index (`discovery.readIndex`, see -[`simulation.md`](./simulation.md) § *Emulated discovery index*). This is the ONE +[`simulation.md`](./simulation.md) § *Emulated discovery index*). This is the one "hack", and it is justified precisely because P2P has no cross-wallet read: without a shared index a client could never learn that another account's public event-doc -**exists**. `readIndex` yields the event-doc **NURIs** to open/sync; those repos +exists. `readIndex` yields the event-doc NURIs to open/sync; those repos then enter the local union and become union-queryable. -### Everything else = FOLLOW a graph, never enumerate across accounts +### Everything else = follow a graph, never enumerate across accounts -My participations / my profile, a connection's shared protected data, my -notifications — **none** of these is enumerated across accounts. Each is reached by -**what is already reachable to me**: +My participations / my profile, protected data an owner has granted me, my +notifications — none of these is enumerated across accounts. Each is reached by +what is already reachable to me: -- **my own docs** (always in `self.repos`); -- docs reachable via a **connection's shared cap** (a bilateral connection surfaces - the peer's protected NURIs — see the bilateral connection registry in +- my own docs (always in `self.repos`); +- docs an owner has granted me via a directed per-document read grant + (`grantRead(doc, granteeId)` — see the per-document ReadCap in [`simulation.md`](./simulation.md)); -- my **inbox** (deposits addressed to me). +- my inbox (deposits addressed to me). -The rule of thumb: **Access ≠ discovery.** You only union-query over graphs you were -already entitled to open. +The rule of thumb: access is not discovery. You only union-query over graphs you +were already entitled to open. -## Listing = a bounded set of per-doc ANCHORED reads (never a union-scan, never the ORM fan-out) +Accessing a document without read rights yields an empty result: a reactive / union +read never decrypts a repo you hold no cap for, so it simply returns nothing (this +matches NextGraph's union read). A targeted read of a repo you do not hold diverges +in one way — it raises `RepoNotFound` rather than returning empty — and the read +path tolerates that per-doc (a doc that throws is skipped). The cap-introspection +used here (`canRead` / `governsRead`) is emulation-only; there is no NextGraph API +behind it, so it has no migration target. -To produce a list, take the **bounded, by-need** set of doc NURIs (the index-yielded -event NURIs, my own docs, a connection's shared NURIs) and read **each one with its -OWN anchored `sparql_query`** (`SELECT ?s ?p ?o WHERE { ?s ?p ?o }`, anchor = that +## Listing = a bounded set of per-doc anchored reads (never a union-scan, never the ORM fan-out) + +To produce a list, take the bounded, by-need set of doc NURIs (the index-yielded +event NURIs, my own docs, the NURIs an owner has granted me) and read each one with its +own anchored `sparql_query` (`SELECT ?s ?p ?o WHERE { ?s ?p ?o }`, anchor = that doc NURI, in parallel and tolerant per-doc). The anchor restricts the query to that -one repo's graph, so each read is O(1) in the doc's own size and INDEPENDENT of how +one repo's graph, so each read is O(1) in the doc's own size and independent of how many other graphs the (possibly bloated / shared) session store holds. -Do **NOT** run an **anchorless union-scan** (`SELECT … WHERE { GRAPH ?g { ?s ?p ?o } }`, -no anchor) over the local union: it iterates **every** named graph in the session -store — O(wallet size) — so on a shared wallet that accumulates across runs it times -out (~90s observed). The read-set is already bounded and known; read exactly those -docs, anchored, and never scan the wallet. +Do not run an anchorless union-scan (`SELECT … WHERE { GRAPH ?g { ?s ?p ?o } }`, +no anchor) over the local union: it iterates every named graph in the session +store — O(wallet size) — so on a shared wallet that accumulates across runs its cost +grows with the whole wallet. The read-set is already bounded and known; read exactly +those docs, anchored, and never scan the wallet. -Do **NOT** drive listing through the reactive ORM's per-document fan-out +Do not drive listing through the reactive ORM's per-document fan-out (`orm_start_graph` over many graphs): a freshly-created or not-yet-synced graph in -the fan-out makes `RepoNotFound` abort the whole subscription → the readyPromise -never resolves → the ~75s hang (root cause verified in +the fan-out makes `RepoNotFound` abort the whole subscription, so the readyPromise +never resolves and the subscription hangs (root cause verified in [`nextgraph-current-state.md`](./nextgraph-current-state.md) § *The ORM fan-out hang*). ## Reactivity = re-query on a change signal (no reactive union) -There is **no reactive union query**. So reactivity is assembled: +There is no reactive union query. So reactivity is assembled: - keep a lightweight reactive subscription — `doc_subscribe`, or the ORM on an - **already-opened single store** (never a per-entity fan-out) — on the synced docs; -- on its change signal, **re-run** the bounded set of per-doc anchored + already-opened single store (never a per-entity fan-out) — on the synced docs; +- on its change signal, re-run the bounded set of per-doc anchored `sparql_query`s (`readModel.readUnion`) — never an anchorless union-scan. Keep the reactive ORM strictly to already-opened single stores; it is a change *signal* source here, not the list source. -## The boundary with the consumer +## The boundary with the consumer application -Festipod asks the SDK for its lists by need (`listMyMeetingPoints()`, -`listEvents()`, …) and trusts the returned set. It never constructs a NURI, never -picks the union-vs-anchor mode, never touches the ORM. Open/sync + union-query + -re-query-on-signal all live in the polyfill. +The consumer application asks the SDK for its lists by need and trusts the returned +set. It never constructs a NURI, never picks the union-vs-anchor mode, never touches +the ORM. The domain-shaped list helpers (e.g. "my meeting points", "events") live in +the consumer application, not the lib; the lib exposes the generic by-need read. +Open/sync + union-query + re-query-on-signal all live in the polyfill. ## Minimal broker probe (confirms the union behaviour) @@ -127,17 +136,19 @@ The one experiment that pins down union vs anchor, to run against a real broker: // → rows from BOTH A's and B's graphs ``` -4. **Anchor = A** — expect only A: +4. **Anchor = A, default-graph body** (the form the read path actually uses) — + expect only A: ``` - sparql_query(sid, "SELECT ?g ?s ?p ?o WHERE { GRAPH ?g { ?s ?p ?o } }", - undefined, A /* string NURI → one repo */) + sparql_query(sid, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", + undefined, A /* string NURI → one repo becomes the default graph */) // → rows from A's graph only ``` -If (3) returns both and (4) returns only A, the union read model above holds as +If (3) returns both and (4) returns only A, the read model above holds as implemented in `resolve_target_for_sparql` / -`set_default_graph_as_union`. +`set_default_graph_as_union`: the anchor turns A's repo into the query's default +graph, and a default-graph body reads exactly that graph. ### Verified against the real broker (T03.k) @@ -146,33 +157,36 @@ Step (3) — **the load-bearing one** — is CONFIRMED: an anchorless (the local union of the opened graphs). That is the entire premise the listing path relies on. -Step (4) has a nuance worth recording: with an **explicit `GRAPH ?g { … }` body**, -passing `anchor = A` did **not** restrict the result to A (B still appeared). The -reason: the anchor sets the query's **default graph**, but a `GRAPH ?g` pattern -iterates over the **named graphs** regardless of the default graph — so an -explicit `GRAPH ?g` body spans every opened graph independently of the anchor. -The anchor's "one repo" restriction is observable only for a body that reads the -**default graph** (no `GRAPH` wrapper). The read model never needs the anchored -form for listing — it uses the anchorless `GRAPH ?g` union — so this does not -affect it. (The per-doc "open" step in `read-model.ts` uses an anchored `ASK` -only to CONFIRM presence — it cannot sync an unknown repo, see the VERIFIED note -above; a repo absent from `self.repos` throws `RepoNotFound` and is skipped.) +Step (4) has a nuance worth recording, and it is exactly why the read path uses a +**default-graph body**, not an explicit `GRAPH ?g` one: with an explicit +`GRAPH ?g { … }` body, passing `anchor = A` would **not** restrict the result to A +(B still appears). The reason: the anchor sets the query's **default graph**, but a +`GRAPH ?g` pattern iterates over the **named graphs** regardless of the default +graph — so an explicit `GRAPH ?g` body spans every opened graph independently of +the anchor. The anchor's "one repo" restriction is observable only for a body that +reads the **default graph** (no `GRAPH` wrapper). That is precisely why the per-doc +read in `read-model.ts` uses the anchored default-graph body +`SELECT ?s ?p ?o WHERE { ?s ?p ?o }`: the anchor makes that one repo the default +graph, so the read is bounded to it — O(1) per doc, independent of wallet size — +and never iterates the other named graphs. (A repo absent from `self.repos` throws +`RepoNotFound` and is skipped per-doc, see the VERIFIED note above — the read cannot +sync an unknown repo.) ## Implementation — `read-model.ts` `readModel.readUnion(docs)` implements this: for each requested doc NURI (the bounded by-need set), run — in parallel, tolerant per-doc (a doc that fails is -skipped, never aborting the batch like the ORM fan-out would) — ONE **anchored** +skipped, never aborting the batch like the ORM fan-out would) — one anchored `SELECT ?s ?p ?o WHERE { ?s ?p ?o }` with `anchor = docNuri`. The anchor restricts -the query to that doc's graph (default graph), so it returns ONLY that doc's -triples, O(1) per doc, independent of wallet size. There is **NO** anchorless -union-scan. Each entity's subject IRI IS its own document NURI, so the subject is +the query to that doc's graph (default graph), so it returns only that doc's +triples, O(1) per doc, independent of wallet size. There is no anchorless +union-scan. Each entity's subject IRI is its own document NURI, so the subject is the anchor doc NURI; the result is grouped per subject (keeping the `UnionSubject[]` shape: `subject`, `graph`, `props`). A ReadCap gate drops any doc the current user -may not read (defence-in-depth). The consumer maps the result to its types (e.g. -Festipod's `readEntities`). Reactivity = the consumer re-calls `readUnion` on its -change signal (no reactive union query exists). +may not read (defence-in-depth). The consumer application maps the result to its +types (e.g. its own `readEntities`). Reactivity = the consumer application re-calls +`readUnion` on its change signal (no reactive union query exists). > The name `readUnion` / `UnionSubject` is historical (it once ran a union query). -> The read is now **per-doc anchored**, bounded to the read-set — the "union" is only +> The read is now per-doc anchored, bounded to the read-set — the "union" is only > the logical concatenation of the per-doc results, never an anchorless graph scan. diff --git a/docs/simulation.md b/docs/simulation.md index 44d89a3..ef4e7cd 100644 --- a/docs/simulation.md +++ b/docs/simulation.md @@ -1,61 +1,61 @@ -# How this library emulates mature NextGraph on ONE shared wallet +# How this library emulates mature NextGraph on one shared wallet -> **EVERYTHING in this file is EMULATION.** Not one behaviour described here is a -> real NextGraph feature: each is a **stopgap** the lib fabricates on top of the -> *current, immature* NextGraph (the exact gaps it compensates for are in +> Everything in this file is emulation. None of the behaviours described here is a +> real NextGraph feature: each is a stopgap the lib fabricates on top of the +> current, immature NextGraph (the exact gaps it compensates for are in > [`nextgraph-current-state.md`](./nextgraph-current-state.md)). Every piece has a -> **real target** and goes away when NextGraph matures — the swap is **lib-only**, -> the consumer's code is unchanged. The per-behaviour recap table lives in the +> real target and goes away when NextGraph matures — the swap is lib-only, and the +> consumer application's code is unchanged. The per-behaviour recap table lives in the > top-level [`README.md`](../README.md) (*What is emulated (and how it goes away)*); > the removal checklist is [`migration-guide.md`](./migration-guide.md). Read this > file for *how* each emulation works; read those two for *what is fake* and *what > replaces it*. -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)). +The consumer application 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 +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 +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 +The lib's answer: everyone opens the same wallet. NextGraph sees a single +identity, so 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. +by emulation, the per-user stores + capabilities + inbox the consumer application +codes against. ## Physical wallet vs virtual wallet — never enumerate the physical one Because the emulation runs on ONE shared wallet, distinguish two levels: - **Physical wallet** — the real NextGraph wallet everyone opens. Its local store - holds **every account's documents PLUS the lib's own internals** (the shim index, - the inbox docs, the discovery index) as named graphs. It **accumulates without - bound** across sessions/runs. **Listing / scanning "all documents" of the physical - wallet is meaningless AND O(size)** — it mixes every user's data with lib internals, - and it is exactly what a `sparql_query` with **no anchor** (`GRAPH ?g { … }`) does - (it spans every synced graph). **Never do it.** The physical wallet is a substrate, + holds every account's documents plus the lib's own internals (the shim index, + the inbox docs, the discovery index) as named graphs. It accumulates without + bound across sessions/runs. Listing or scanning "all documents" of the physical + wallet is meaningless and O(size) — it mixes every user's data with lib internals, + and it is exactly what a `sparql_query` with no anchor (`GRAPH ?g { … }`) does + (it spans every synced graph). The physical wallet is a substrate, not something to enumerate. -- **Virtual wallet** — the lib's emulation of **one user's** wallet: the set of +- **Virtual wallet** — the lib's emulation of one user's wallet: the set of documents the shim attributes to that account (its per-scope index in `store-registry.ts`). This is what "the user owns". Over a *virtual* wallet, - "**list my documents**" is meaningful and **bounded** (only that account's docs). + "list my documents" is meaningful and bounded (only that account's docs). **Consequence for reads (see `read-model.md`):** to list a user's entities you -enumerate the **virtual** wallet — the account's scope index (bounded, O(my docs)), -NOT the physical union — then read those specific documents with a **per-doc anchored** +enumerate the *virtual* wallet — the account's scope index (bounded, O(my docs)), +not the physical union — then read those specific documents with a per-doc anchored `sparql_query`. A non-empty / bloated physical wallet then costs nothing, because the physical union is never scanned. Discovery (all public events) is the one bounded -enumeration hack and goes through the discovery **index**, not a physical scan. +enumeration hack and goes through the discovery index, not a physical scan. At migration each virtual wallet becomes a real per-user wallet; the physical/virtual distinction — and the "never enumerate the physical wallet" rule — dissolves into @@ -66,20 +66,20 @@ native per-wallet reads. 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`, +- **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**. +- **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 +`docCreate(sessionId, "Graph", "data:graph", "store", undefined)` targets 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 +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 application'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. @@ -98,32 +98,31 @@ public/protected/private stores — on top of one shared wallet. `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, + 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** — + identity resolution 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 +- **Per-entity documents + per-scope index.** `createEntityDoc(id, 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). + 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. This is a - **fallback / test-only** path, NOT the read path: enumerating every account and + fallback / test-only path, not the read path: enumerating every account and handing the NURIs to `useShape({ graphs })` opens/syncs other accounts' possibly- - unsynced docs and HANGS (the ORM fan-out, ~75s — see - [`read-model.md`](./read-model.md)). The real READ path is - `readModel.readUnion(docs)`, which reads the by-need doc set with **one PER-DOC - ANCHORED `sparql_query`** — **never an anchorless union-scan** of the physical - wallet (that is O(wallet size) and timed out ~90s; see - [`read-model.md`](./read-model.md)). The app resolves the by-need doc set from the - discovery index (public events) and `listMyEntityDocs(username, scope)` (my own - account, bounded — no cross-account fan-out). -- **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`). + unsynced docs and hangs (the ORM fan-out — see + [`read-model.md`](./read-model.md)). The real read path is + `readModel.readUnion(docs)`, which reads the by-need doc set with one per-doc + anchored `sparql_query`, never an anchorless union-scan of the physical + wallet (see [`read-model.md`](./read-model.md)). The consumer application resolves + the by-need doc set from the discovery index (public events) and + `listMyEntityDocs(id, scope)` (its own account, bounded — no cross-account fan-out). +- **Generic by construction.** The registry knows only the three native scopes, + zero application entity kind. The consumer application maps its entities to a scope + and injects the session + identity-id normalization via `configureStoreRegistry({ + getSession, normalizeId })` (`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 @@ -131,30 +130,31 @@ one private store via `docCreate(..., undefined)`). ### A virtual wallet's structure — the three emulated stores -A **virtual wallet** = one account in the shim, keyed by its **virtual-wallet id** -(the technical identifier the consumer sets when the physical wallet is opened; it -identifies *which* virtual wallet, and is NOT the consumer's application username). -Its structure mirrors the target "1 user = 1 wallet with 3 native stores": +A *virtual wallet* = one account in the shim, keyed by its virtual-wallet id +(the technical identifier the consumer application sets when the physical wallet is +opened; it identifies *which* virtual wallet, and is an id rather than a +human-friendly handle). Its structure mirrors the target "1 user = 1 wallet with 3 +native stores": ``` Virtual wallet (id) -├── public store = docPublic index → [ event doc NURI, PdR doc NURI, … ] -├── protected store = docProtected index → [ profile doc NURI, participation doc NURI, … ] -└── private store = docPrivate index → [ settings doc NURI, … ] +├── public store = docPublic index → [ entity doc NURI, entity doc NURI, … ] +├── protected store = docProtected index → [ record doc NURI, record doc NURI, … ] +└── private store = docPrivate index → [ record doc NURI, … ] ``` -So **yes, the 3 native stores (public/protected/private) are present** — but -**emulated**: each "store" is an **index document** -(`AccountRecord.{docPublic,docProtected,docPrivate}`) that LISTS the NURIs of the +So the 3 native stores (public/protected/private) are present, but emulated: each +"store" is an index document +(`AccountRecord.{docPublic,docProtected,docPrivate}`) that lists the NURIs of the per-entity documents in that scope. It is not a physical native store. -Everything is physical in ONE place: the 3 index documents, every per-entity -document, and the shim anchor itself all live in the shared **physical** wallet's +Everything is physical in one place: the 3 index documents, every per-entity +document, and the shim anchor itself all live in the shared physical wallet's private store (`docCreate(..., undefined)`). The 3-store structure is the per-account -**logical** layer the lib maintains on top. +logical layer the lib maintains on top. ``` -Physical wallet (shared, ONE) → private_store (physical) holds EVERYTHING: +Physical wallet (shared, one) → private_store (physical) holds everything: • the shim anchor: virtual-wallet-id → { docPublic, docProtected, docPrivate } • every account's 3 scope-index docs + all per-entity docs + inbox + discovery index ``` @@ -163,11 +163,11 @@ At migration each virtual wallet's 3 index documents become the user's 3 **real* native stores, the entity documents move into them physically, and the virtual/physical distinction dissolves (see [`migration-guide.md`](./migration-guide.md)). -### SDK-shaped scope resolvers — the consumer holds NO store-id +### SDK-shaped scope resolvers — the consumer application 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 +The consumer application 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 @@ -177,26 +177,26 @@ store-id: 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. + resolves to the user's real per-scope store — the change is in this function, + and the consumer application is unchanged. - **`resolveInboxAnchor()`** — the anchor where emulated inbox deposits land: a - **DEDICATED inbox document** (a reserved account's public scope document, from - `docCreate` — a real repo NURI, stable across clients), **not** the shared + dedicated inbox document (a reserved account's public scope document, from + `docCreate` — a real repo NURI, stable across clients), not the shared wallet's private-store root. Why dedicated: the shim (the account→document trust root) lives in the private-store graph and is scanned on every `loadShim`; - routing every inbox deposit into that SAME graph bloats it without bound + routing every inbox deposit into that same graph bloats it without bound (thousands of deposit triples across sessions), turning `loadShim` into a multi-second full-graph scan. A separate inbox document keeps the shim graph small and the deposits isolated. At migration it becomes the host's native inbox NURI. -Both resolve the native store ids from the **injected session** +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. +`privateStoreId` anchor). The consumer application 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 application 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 @@ -206,11 +206,11 @@ the ORM, the store's repo must be **explicitly opened** in the verifier's 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). + in the consumer application, 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`. +- 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, breaking 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 @@ -218,17 +218,17 @@ is preserved in [`decisions/private-store-nuri-scope.md`](./decisions/private-st ## 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 +A 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, +`@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`. +postMessage marshaling with `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: +4 multistore scenarios red, so it was reverted. The integration boundary is: - **Through the lib's public proxy** (validated): `useShape` (ORM + ReadCap filter), `init`/`initNg`, `login`. @@ -240,223 +240,236 @@ verified: routing the shim's `doc_create`/SPARQL through the public proxy turned ## Emulated ReadCap — per document (`caps.ts` + `read-filter.ts`) -In the target the broker only delivers documents the wallet holds a **ReadCap** +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: +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 + 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(doc, granteeId)` issues a directed read grant to one identity, + alongside `grantWrite` / `makePublic` / `open(doc, scope, owner)` / + `canRead` / `canWrite` / `governsRead` / `hasReadPolicy`, plus the read-only + accessor `protectedDocsOf(owner)` the consumer application uses to pick which + protected docs to grant. The consumer application performs the *acts* of granting + (create-public, grant a specific doc to a specific identity…) 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 + 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). +- **`useShape` (`use-shape.ts`)** applies the view only if + `caps.hasReadPolicy()` — otherwise it passes the real set through unchanged + (no regression when the consumer application 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 +### Making the ReadCap active — current identity + directed 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: +The filter only discriminates once the consumer application (a) tells the SDK who is +reading and (b) declares the access policy on the documents. Both are plain SDK +calls; the consumer application 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. + why isolation stays dormant until the consumer application makes 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(peers, as?)` (`polyfill.ts`)** — the SDK-shaped - **protected sharing act**, now **AUTHENTICATED / BILATERAL** (`connections.ts`). - Each call declares the CURRENT identity's OWN peers (`as` defaults to - `getCurrentUser()`); the lib records that as a **directed assertion authored by - the current identity** — a session can only ever assert its own side. A protected - read cap is issued between two principals only when **both have asserted the - other** (a materialized two-sided link, `ConnectionRegistry.neighbors` → - `CapRegistry.grantReadToConnections`). Public docs stay world-readable; private - docs stay owner-only. Re-callable; additive + idempotent. The consumer passes - only principals — no document NURI, no store id. + consumer application creates it: `public` → world-readable; `protected`/`private` + → owner reads, owner holds the write cap. `open` also remembers `(scope, owner)` + per document so `protectedDocsOf(owner)` can later enumerate the protected ones. +- **`grantRead(doc, granteeId)` (`caps.ts`, exposed via `getCaps()`)** — the one + relationship-shaped sharing act the lib exposes: a directed per-document read + grant issued to a specific identity. Public docs stay world-readable; private + docs stay owner-only; a protected doc becomes readable by `granteeId` once the + owner grants it. The consumer application passes a document NURI and a grantee id + — no store id. - **Why bilateral (adversarial finding).** If a single directed assertion granted - access, any reader could read any owner's protected documents by unilaterally - self-declaring a connection. The two-sided requirement is the emulation of the - target's mutual capability exchange: only a reciprocated link grants the cap. A - unilateral / self-declared connection grants **nothing** (proven in - `test/connections.test.ts` and `test/isolation-active.test.ts` case (b)). +The relationship concept — who is "connected" to whom, and therefore which of +their protected docs to grant — is owned by the consumer application, not the lib. +A connection or friendship is not a NextGraph primitive; the only platform-mappable +primitive is the directed per-document read grant above. So the consumer application +decides a relationship exists and, for each protected doc it wants to share, calls +`grantRead(doc, granteeId)` — typically iterating `protectedDocsOf(owner)` to pick +the owner's protected docs. The intended target of such a directed grant is a native +per-document ReadCap issued to that identity — but that target is itself +scaffolding-only in nextgraph-rs today, not merely unexposed in JS: `AccessGrantV0 +{grantee}` is unpersisted and cap-send is `unimplemented!()`, so directing a grant +to another identity is not-yet-built at the platform level. There is no bilateral +capability exchange to mirror, only (eventually) individual directed grants. -The result is the target's discrimination reproduced end-to-end: **private** → -owner; **protected** → owner + BILATERAL connections; **public** → all. Proven in -`test/isolation-active.test.ts`: (a) an unconnected principal is denied a protected -document, granted it after a two-sided `declareConnections`, and reads the public -document throughout; (b) a unilateral/self-declared connection is denied. +The result is the target's discrimination reproduced end-to-end: private → +owner; protected → owner + whoever the owner has directly granted; public → all. +Proven in `test/isolation-active.test.ts`: an unconnected principal is denied a +protected document, granted it after the owner issues a directed `grantRead`, and +reads the public document throughout. -This discrimination is only observable because each entity is **its own document** -(the consumer creates per-entity docs via `createEntityDoc` and `open`s each) — in -a mono-store layout the per-document ReadCap is all-or-nothing. +This discrimination is only observable because each entity is its own document +(the consumer application creates per-entity docs via `createEntityDoc` and `open`s +each) — in a mono-store layout the per-document ReadCap is all-or-nothing. ### 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 +per-document write cap on the public `ng` proxy only. In practice the +consumer application'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 application'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. +natively at migration); the read side is what makes isolation observably active. -### The per-document ReadCap is now THE isolation path (item-level filter retired) +### The per-document ReadCap is the isolation path (item-level filter retired) -Isolation is enforced by the **per-document ReadCap** (`caps.ts` + `read-filter.ts`) -alone: the access unit is the DOCUMENT (`@graph` = repo), grants are explicit -(`open` / `grantRead` / `makePublic`) and, for `protected`, driven by the -**bilateral connection registry** (`connections.ts`). Because the consumer now -writes **one document per entity** (`createEntityDoc` + `open` per entity), the -per-document cap discriminates at entity granularity — the target's behaviour. +Isolation is enforced by the per-document ReadCap (`caps.ts` + `read-filter.ts`) +alone: the access unit is the document (`@graph` = repo), and grants are explicit +(`open` / `grantRead` / `makePublic`) — for `protected`, the owner issues a directed +`grantRead(doc, granteeId)` per identity it wants to share with. Because the consumer +application now writes one document per entity (`createEntityDoc` + `open` per entity), +the per-document cap discriminates at entity granularity — the target's behaviour. -The old **item-level application-visibility filter** (`isolation.ts` -`applyIsolation`, a `Set`-of-records filter keyed on owner+scope) is **retired** -from the consumer path: the app carries **no** access logic — it declares its -identity and its bilateral connections and trusts the SDK. `isolation.ts` survives -only as the home of the generic `Connections` interface (consumed by -`connections.ts` / `caps.grantReadToConnections`) plus its own unit tests; its -matrix functions are dead scaffolding kept for reference and removed at migration. -There is no longer a second, coexisting app-layer filter to reconcile — the single -axis is the per-document cap, exactly as in the target. +The old item-level application-visibility filter (`isolation.ts` +`applyIsolation`, a `Set`-of-records filter keyed on owner+scope) is retired +from the consumer path: the application carries no access logic — it declares its +identity and issues directed grants, and trusts the SDK. Its matrix functions are +dead scaffolding kept for reference and removed at migration. There is no longer a +second, coexisting app-layer filter to reconcile — the single axis is the +per-document cap, exactly as in the target. -## Emulated inbox + curator (`inbox.ts`) +## Emulated inbox (`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: +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. +- **Target vs polyfill.** In the target, `post` seals a reference into the owner's + native inbox (`inbox_post_link(...)`, a proposed/future API) and the recipient's + own verifier unseals each queued message and applies it inline when it processes + its inbox — there is no separate curator or materialization process. Here, + everything is readable, so the lib emulates the read side 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 - BOUND to the current identity** (`getCurrentUser`) — it is authenticated, not + into the inbox document (in the shared wallet) via `docs.sparqlUpdate`. Each + deposit is a unique RDF subject, so concurrent deposits don't collide. `from` is + bound to the current identity (`getCurrentUser`) — it is authenticated, not caller-supplied: omit it to stamp the current user, pass `null` to deposit - ANONYMOUSLY, and a `from` naming ANOTHER principal is **rejected as a spoof**. - This reproduces the protocol's "identified if known, anonymous otherwise" AND + anonymously, and a `from` naming another principal is rejected as a spoof. + This reproduces the protocol's "identified if known, anonymous otherwise" and the target's guarantee that a client cannot forge another's sender identity (in the target the broker seals `from` from the wallet's own key; here the check closes the spoof the shared wallet would otherwise allow). The emulation stores `from = null` as *absence of a triple*, so it does not provide the target's - **crypto** anonymity (`from = None` sealed), which only a native inbox would. + crypto anonymity (`from = None` sealed), which only a native inbox would. Proven in `test/inbox.test.ts` case (c). -- **`read` / `materialize` (alias)** play the **emulated CURATOR**: they read the +- **`read` / `materialize` (alias)** emulate the recipient-side read: 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 +The module knows no domain — the consumer application 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. +`inbox_post_link` (proposed/future) and the read side is served by the recipient's +own verifier unsealing queued messages inline (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/read mechanism a consumer reuses for its own +purposes — e.g. a registration/deposit in one consumer app 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 +Discovery is a surface on top of the inbox, not a new primitive. Access is not the +same as discovery: a public entity is world-readable *with its NURI*; the discovery +index is how a client learns that NURI exists without holding a relationship 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 model is: one global index = an owned document (public read), fed via +its inbox. Nobody writes the index directly — a creator deposits a reference into +the index's inbox, and the index is built up from those deposits. That build-up +step 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 + reserved special account in the shim — `INDEX_ACCOUNT = reservedAccount("index")`. + This is NOT the key `"index"` / `"@index"`: `reservedAccount` mints a + sentinel-prefixed key in the shim's reserved namespace (e.g. `" reserved:index"`) + that `normalizeId` can never produce, so no user id — not even one typed as + "index" or "@index", which normalizes to the disjoint key "index" — can collide + with or hijack the index account (asserted in `discovery.test.ts`). 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 + 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, hence the 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 (bound to the current identity; anonymous when `null`). - `ref` is **opaque** here — the consumer serializes whatever locates the entity - (e.g. an entity document NURI + discovery metadata). **PUBLIC-ONLY guard:** when + `ref` is opaque here — the consumer application serializes whatever locates the + entity (e.g. an entity document NURI + discovery metadata). Public-only guard: when `opts.doc` names the document being surfaced, a document under a non-public - (protected/private) read policy is **REFUSED** (`caps.governsRead(doc) && + (protected/private) read policy is refused (`caps.governsRead(doc) && !caps.canRead(doc, null)`) — the global index is world-readable, so admitting a governed doc's NURI would leak it past its scope. Proven in `test/discovery.test.ts` case (d). -- **`readIndex()`** — the EMULATED CURATOR. Reads every submission, **dedups by - serialized `ref`** (the moderation point: a duplicate submission surfaces +- **`readIndex()`** — the emulated read side. 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` +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. +path: the consumer application 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 +`discovery.ts` knows no application domain — the consumer application 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`) +native `inbox_post_link` (proposed/future) on the index's inbox, and `readIndex` +queries the real 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 +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. It passes through (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 +mono-store default, no cap declared) flow through unchanged. This mirrors the target broker/verifier, which refuses a write without the document's write cap. -## Faux login (`accounts.ts`) +## Identity store (`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 +The real NextGraph login (redirect to the broker, opening the single shared +wallet) is perceived as a technical access barrier (see the login flow in [`decisions/shared-wallet-login-flow.md`](./decisions/shared-wallet-login-flow.md)). -THIS layer is the **perceived** login: +This layer is not a login: it is an `IdentityStore` that holds the current +identity id the consumer application relays to it: -- 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 +- The identity id is set at wallet-import time by the consumer application and + relayed to the lib via its current-identity call. It is persisted in + `localStorage` so the id survives reloads and lands on the same account + when the shared wallet re-opens. In practice the id is often a human-friendly + handle the consumer application chose, but the lib's surface speaks only of an id. +- `set(id)` / `clear()` / `get()` only read/write the id in storage. They never + call NextGraph (no `session_stop` / `wallet_close`) — the shared wallet stays + open underneath. The real logout lives elsewhere (hidden in the consumer + application'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` + React `Context`/`Provider` stays in the consumer application. `normalizeId` (case-insensitive, optional leading `@` stripped, trimmed) is the pure normalizer, reusable as the shim key normalizer. @@ -470,13 +483,13 @@ 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 (``, - e.g. a username minted into an account-subject IRI): percent-encodes every - IRI-hostile character so any username (spaces, unicode, punctuation) stays +- **`escapeIri`** — for untrusted values embedded into an IRI (``, + e.g. an identity id minted into an account-subject IRI): percent-encodes every + IRI-hostile character so any id (spaces, unicode, punctuation) stays usable while breakout is impossible. -- **`assertNuri`** — for trusted-SHAPED NURIs coming back from `ng` +- **`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. +These are re-exported from `@ng-eventually/client` so the consumer application +reuses the same escaping when it builds SPARQL. diff --git a/packages/client/README.md b/packages/client/README.md index 9e04303..784db94 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -1,32 +1,36 @@ # @ng-eventually/client -Two entry points — the data-plane is **SDK-identical**, the polyfill bootstrap is +Two entry points — the data-plane is SDK-identical, the polyfill bootstrap is separate: -| Import | Surface | At migration | -|---|---|---| -| `@ng-eventually/client` | **Same signature as the SDK** — `ng`, `useShape`, `inbox` (+ types). Drop-in for `@ng-org/web` / `@ng-org/orm`. | Resolves to the real SDK (build alias removed) — no code change. | -| `@ng-eventually/client/polyfill` | The **only non-SDK** surface — `configure`, `setCurrentUser`, capability helpers (`canRead`/`canWrite`/`defaultGrant`/`grantRead`). | Removed. | +| Import | Surface | +|---|---| +| `@ng-eventually/client` | The same signature as the SDK — `ng`, `useShape`, `inbox` (+ types). A drop-in for `@ng-org/web` / `@ng-org/orm`; as NextGraph matures it resolves to the real SDK (build alias removed) with no code change. | +| `@ng-eventually/client/polyfill` | The only non-SDK surface — `configure`, `setCurrentUser`, and capability helpers (`getCaps`, `grantRead`, `canRead`/`canWrite`). It falls away as NextGraph matures. | ```ts -// bootstrap (the only non-SDK call) — inject the REAL SDK +// bootstrap (the only non-SDK call) — inject the real SDK import { configure } from "@ng-eventually/client/polyfill"; configure({ ng: realNg, useShape: realUseShape, sharedWallet, currentUser }); -// from here on, pure SDK surface: +// from here on, a pure SDK surface: import { ng, useShape, inbox } from "@ng-eventually/client"; await ng.doc_create(/* … */); -const set = useShape(MyShape, scope); // filtered to what the user may read +const set = useShape(MyShape, scope); // filtered to what the identity may read await inbox.post(targetInbox, ref); // deposit (anticipated SDK API) ``` -What the polyfill adds on top of the real SDK (all removed/native at migration): -- **Shared-wallet login** (one wallet for everyone). -- **Capability enforcement** — read filter + write guard, on emulated grants - attached to documents. -- **Anticipated methods** (inbox `post`, capability ops) with their future-SDK - shapes, emulated for now. +What the polyfill adds on top of the real SDK (each emulated for now, native as +NextGraph matures): +- Shared-wallet identity (one wallet for everyone; the current identity id is + relayed to the SDK). +- Capability enforcement — a read filter + write guard over emulated grants + attached to documents; the app declares a document's read policy and issues + directed read grants. +- Anticipated methods (inbox `post`, capability ops) with their future-SDK shapes, + emulated for now. -Generic: **no application domain**. The consumer injects shapes and performs the -*acts* of granting access. The client **must not** contain the global-index -curator (a separate package, deferred — see the repo README). +Generic: no application domain. The consumer application injects its shapes and +performs the acts of granting access. The relationship concept ("who is connected +to whom") is the consumer application's own — the client exposes only directed +per-document read grants. diff --git a/packages/client/src/accounts.ts b/packages/client/src/accounts.ts index ea76944..ba7f243 100644 --- a/packages/client/src/accounts.ts +++ b/packages/client/src/accounts.ts @@ -1,25 +1,21 @@ /** - * accounts — the framework-agnostic core of the *simulated* app-level login. + * accounts — a framework-agnostic store for the current identity id. * - * STOPGAP — part of the shared-wallet shim. The real NextGraph login (redirect - * to the broker, opening the single SHARED wallet) is perceived as a technical - * access barrier, not as a login. 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. + * The identity a session acts as is established when its wallet is imported; the + * SDK is told who that is via the current-identity call. This small store just + * persists that id (in an injected storage) so it survives reloads and a second + * device, re-opening the same wallet, lands on the same identity. It carries no + * notion of a login step, a password, or a username — only an opaque identity id. * - * `login()` / `logout()` here 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. - * - * FRAMEWORK-AGNOSTIC on purpose: NO React, no DOM assumption beyond an optional - * storage. The React `Context`/`Provider` is a thin wrapper that stays in the - * consumer app (it wires `useState` around {@link login}/{@link logout}). The - * lib must not force a React dependency. Removed at migration. + * Framework-agnostic on purpose: no React, no DOM assumption beyond an optional + * storage. A consumer's React `Context`/`Provider` wraps `useState` around + * {@link IdentityStore.set}/{@link IdentityStore.clear}. The lib does not force a + * React dependency. Removed against real NextGraph, where the wallet session is + * the source of the identity id. */ -/** localStorage key holding the perceived-login username. */ -export const ACCOUNT_STORAGE_KEY = "ng-eventually.account.username"; +/** localStorage key holding the current identity id. */ +export const ACCOUNT_STORAGE_KEY = "ng-eventually.account.id"; /** * Minimal storage contract (a subset of the Web `Storage` interface). The @@ -34,20 +30,11 @@ export interface AccountStorage { } /** - * Normalize a username for matching: case-insensitive, optional leading `@` - * stripped, trimmed. Lets "marie", "@marie", "Marie" match interchangeably. - * Pure — safe to use as the shim key normalizer too. + * The persisted current identity id. A tiny store around an injected + * {@link AccountStorage}. It holds no framework state; the consumer's Provider + * mirrors `get()` into framework state and re-reads after `set`/`clear`. */ -export function normalizeUsername(username: string | null | undefined): string { - return (username ?? "").trim().replace(/^@+/, "").toLowerCase(); -} - -/** - * The persisted app-level account. A tiny store around an injected - * {@link AccountStorage}. Holds no React state; the consumer's Provider mirrors - * `get()` into framework state and re-reads after `login`/`logout`. - */ -export class AccountStore { +export class IdentityStore { private readonly storage: AccountStorage | null; private readonly key: string; @@ -56,7 +43,7 @@ export class AccountStore { this.key = key; } - /** The current app-level username (the perceived "login"). null = not connected. */ + /** The current identity id. null = no identity set yet. */ get(): string | null { if (!this.storage) return null; try { @@ -67,11 +54,11 @@ export class AccountStore { } /** - * Faux login — persist the (trimmed) username. Empty/blank is ignored and the - * previous value is kept (returns the resulting username, or null). No NG call. + * Persist the (trimmed) identity id. An empty/blank value is ignored and the + * previous id is kept (returns the resulting id, or null). No NextGraph call. */ - login(username: string): string | null { - const clean = username.trim(); + set(id: string): string | null { + const clean = id.trim(); if (!clean) return this.get(); if (this.storage) { try { @@ -83,8 +70,8 @@ export class AccountStore { return clean; } - /** Faux logout — clear the username only. No NG call. */ - logout(): void { + /** Clear the persisted identity id. No NextGraph call. */ + clear(): void { if (this.storage) { try { this.storage.removeItem(this.key); @@ -99,11 +86,11 @@ export class AccountStore { * Convenience factory using `globalThis.localStorage` when present, else a * null (non-persisting) store — so the same call is safe in browser and SSR. */ -export function browserAccountStore(key: string = ACCOUNT_STORAGE_KEY): AccountStore { +export function browserIdentityStore(key: string = ACCOUNT_STORAGE_KEY): IdentityStore { const ls = typeof globalThis !== "undefined" && (globalThis as { localStorage?: AccountStorage }).localStorage ? (globalThis as { localStorage: AccountStorage }).localStorage : null; - return new AccountStore(ls, key); + return new IdentityStore(ls, key); } diff --git a/packages/client/src/caps.ts b/packages/client/src/caps.ts index 2d9aa06..d1c21d5 100644 --- a/packages/client/src/caps.ts +++ b/packages/client/src/caps.ts @@ -1,25 +1,29 @@ /** - * Capability emulation — generic, no domain rules. Models NextGraph **ReadCaps** - * (and write caps) as faithfully as a data layer can. + * Capability emulation — generic, with no domain rules. It models NextGraph + * ReadCaps (and write caps) as a data layer can. * - * In NextGraph a ReadCap is possession of a *document's* (repo's) read key: the - * broker only ever delivers documents the wallet holds a cap for. The access - * UNIT is therefore the **document = repo**, identified here by its NURI — the - * `@graph` an item lives in — **never the item**. (Verified in nextgraph-rs: - * a store is just a container repo; holding a store's cap does NOT grant the - * repos it references — each document needs its own cap. So this registry is - * purely per-document, with NO store-level inheritance.) + * In NextGraph a ReadCap is possession of a document's (repo's) read key: the + * broker only delivers documents the wallet holds a cap for. The access unit is + * therefore the document = repo, identified here by its NURI — the `@graph` an + * item lives in, rather than the item. (A store is just a container repo, and + * holding a store's cap does not grant the repos it references — each document + * carries its own cap — so this registry is purely per-document, with no + * store-level inheritance.) * - * At migration this whole layer disappears: the broker/verifier enforces the - * real caps and `useShape` already returns only authorized documents. + * Sharing here is DIRECTED: a grant issues one grantee the read cap of one + * document (`grantRead(doc, granteeId)`). Whether two identities are "connected" + * — and therefore whether such a grant should be issued — is an application + * concept the consumer owns; this layer only records the resulting per-document + * grants. At migration this whole layer disappears: the broker/verifier enforces + * the real caps and `useShape` returns only authorized documents. */ import type { Nuri, PrincipalId, Scope } from "./types"; /** * Who holds the read/write cap of each document. The consumer populates it via - * cap operations (create-public, grant-to-a-connection…) exactly as it will in - * the target; this layer enforces possession generically — it knows no policy. + * cap operations (make-public, directed grant…) exactly as it will in the + * target; this layer enforces possession generically, with no policy of its own. */ export class CapRegistry { /** doc NURI → principals holding its READ cap. */ @@ -29,14 +33,14 @@ export class CapRegistry { /** doc NURIs readable by everyone (public_store repos — no cap needed). */ private publicDocs = new Set(); /** doc NURI → its declared (scope, owner), as recorded at {@link open}. Lets - * a later, connection-aware sharing act (see {@link grantReadToConnections}) - * re-derive which documents are `protected` and who owns them, without the - * consumer re-supplying that per-document — it already declared it at open. */ + * the consumer re-derive which documents are `protected` and who owns them + * (see {@link protectedDocsOf}) so it can issue directed grants, without + * re-supplying that per-document — it already declared it at open. */ private policy = new Map(); - /** Grant `principal` the READ cap of document `doc`. */ - grantRead(doc: Nuri, principal: PrincipalId): void { - add(this.readers, doc, principal); + /** Grant `grantee` the READ cap of document `doc` — a directed grant. */ + grantRead(doc: Nuri, grantee: PrincipalId): void { + add(this.readers, doc, grantee); } /** Grant `principal` the WRITE cap of document `doc`. */ @@ -62,23 +66,24 @@ export class CapRegistry { } /** - * Extend the read caps of every `protected` document so its owner's direct - * connections may read it — the sharing act "protected = owner + connections". - * `neighborsOf(owner)` yields the principals connected to `owner` (the consumer - * supplies its social graph; this layer invents no relationship). Public docs - * are already world-readable; private docs are untouched (owner only). Additive - * and idempotent: re-running after the connection graph changes only ever adds - * read caps for the current connections. + * The `protected` documents owned by `owner`, as recorded at {@link open}. The + * consumer uses this to issue directed read grants: it decides who may read an + * owner's protected documents (its own relationship concept) and calls + * {@link grantRead} on each of these documents for each such reader. Public + * documents are already world-readable and private documents stay owner-only, + * so only the protected ones are surfaced here. * - * This is the per-document ReadCap image of a native cap operation: in the - * target, sharing a protected repo with a connection issues that connection the - * repo's ReadCap. Here it grants the emulated read cap on the same unit. + * This mirrors a native cap operation: in the target, sharing a protected repo + * with another identity issues that identity the repo's ReadCap. Here the + * consumer selects the documents via this accessor and grants the emulated read + * cap on the same unit. */ - grantReadToConnections(neighborsOf: (owner: PrincipalId) => Iterable): void { - for (const [doc, { scope, owner }] of this.policy) { - if (scope !== "protected") continue; - for (const connection of neighborsOf(owner)) this.grantRead(doc, connection); + protectedDocsOf(owner: PrincipalId): Nuri[] { + const out: Nuri[] = []; + for (const [doc, { scope, owner: o }] of this.policy) { + if (scope === "protected" && o === owner) out.push(doc); } + return out; } /** Is `doc` under any READ-cap policy? (Undeclared docs are not enforced.) */ diff --git a/packages/client/src/connections.ts b/packages/client/src/connections.ts deleted file mode 100644 index c32b0ab..0000000 --- a/packages/client/src/connections.ts +++ /dev/null @@ -1,90 +0,0 @@ -/** - * connections — a BILATERAL (two-sided, authenticated) connection registry. - * - * STOPGAP / polyfill-era. In the target, "connected" means the two wallets have - * *each* issued the other a capability (a mutual, cryptographically-authenticated - * link). A single side cannot manufacture the relationship. Here — one shared - * wallet, everything physically readable — the registry reproduces that property - * as data: a connection between `a` and `b` is materialized ONLY when BOTH sides - * have asserted it. - * - * ── Why bilateral (the adversarial finding this defends) ────────────────────── - * "protected = owner + connections" must not be bypassable by a reader who simply - * self-declares a connection to the owner. If a single directed assertion granted - * access, any principal could read any owner's protected documents by unilaterally - * claiming a link. So the registry keeps DIRECTED assertions and exposes as - * `neighbors(p)` only the principals `q` for which BOTH `assert(p → q)` AND - * `assert(q → p)` are present — the materialized two-sided link. - * - * ── Generic by construction ─────────────────────────────────────────────────── - * Knows no application domain. The consumer maps its own relationship (accepted - * friendships, follows-back, …) onto directed assertions: each side asserts the - * other. Only when both assertions exist is the link live and does it drive a - * protected read grant (via {@link CapRegistry.grantReadToConnections}). Removed - * at migration, where a real mutual capability replaces the materialized link. - */ - -import type { Connections } from "./isolation"; -import type { PrincipalId } from "./types"; - -/** - * One directed connection assertion: `from` asserts a connection to `to`. A link - * is live (and grants protected read) only when the reverse assertion also - * exists. The consumer's own social graph is fed as these directed assertions. - */ -export interface ConnectionAssertion { - from: PrincipalId; - to: PrincipalId; -} - -/** - * Accumulates directed assertions and exposes the BILATERAL neighbourhood. Both - * `assert(a → b)` and `assert(b → a)` must be present for `a`/`b` to be neighbours. - */ -export class ConnectionRegistry { - /** principal → the set of principals it has asserted a connection TO. */ - private asserted = new Map>(); - - /** Record that `from` asserts a connection to `to` (one direction only). */ - assert(from: PrincipalId, to: PrincipalId): void { - if (!from || !to || from === to) return; - let s = this.asserted.get(from); - if (!s) this.asserted.set(from, (s = new Set())); - s.add(to); - } - - /** Record a batch of directed assertions. */ - assertAll(assertions: Iterable): void { - for (const { from, to } of assertions) this.assert(from, to); - } - - /** Has `from` asserted a connection to `to` (one direction)? */ - hasAsserted(from: PrincipalId, to: PrincipalId): boolean { - return this.asserted.get(from)?.has(to) ?? false; - } - - /** - * The BILATERAL neighbours of `principal`: every `q` such that `principal` and - * `q` have each asserted the other. A unilateral (one-sided) assertion yields - * NO neighbour — the defence against self-declared connections. - */ - neighbors(principal: PrincipalId): Set { - const out = new Set(); - const outgoing = this.asserted.get(principal); - if (!outgoing) return out; - for (const to of outgoing) if (this.hasAsserted(to, principal)) out.add(to); - return out; - } - - clear(): void { - this.asserted.clear(); - } -} - -/** - * Adapt a {@link ConnectionRegistry} to the {@link Connections} interface consumed - * by {@link CapRegistry.grantReadToConnections}. Only bilateral links surface. - */ -export function bilateralConnections(registry: ConnectionRegistry): Connections { - return { neighbors: (principal) => registry.neighbors(principal) }; -} diff --git a/packages/client/src/discovery.ts b/packages/client/src/discovery.ts index 53d27f2..5e1c84b 100644 --- a/packages/client/src/discovery.ts +++ b/packages/client/src/discovery.ts @@ -5,38 +5,36 @@ * an opaque reference and interprets the entries it reads back. * * ── The mechanism (see docs/decisions/discovery-model.md) ───────────────── - * 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. There is ONE global index — an OWNED document - * (public read), fed via ITS OWN inbox, and MATERIALIZED by a curator. Nobody - * writes the index directly: a creator DEPOSITS a reference into the index's - * inbox; the (emulated) curator ingests deposits into entries. Materialization - * is the natural dedup / moderation point. + * Access and discovery are separate concerns. A public entity is world-readable + * with its NURI; the discovery index is how a client learns that NURI exists + * without holding a grant to read its creator's other documents. There is one + * global index — an owned document (public read), fed via its own inbox. A + * creator deposits a reference into the index's inbox; reading the index folds + * those deposits into entries, deduplicating identical references along the way. * * ── The special account (polyfill owner) ────────────────────────────────── - * "Who owns the global index" is undecided in the target (NextGraph is - * mono-user with no global data — see docs/nextgraph-current-state.md § Apps & - * services; a singleton app is the only glimpsed path). So the polyfill parks - * ownership on a RESERVED SPECIAL ACCOUNT in the shim ({@link INDEX_ACCOUNT}). - * Its `public` scope document is the index document; deposits land in that - * document's inbox (a stable NURI: every client opening the same shared wallet - * resolves the same account → same document). This replaces the cross-account - * fan-out (`store-registry.ts` `listEntityDocs`) as the app-facing discovery - * path — the fan-out survives only as an internal fallback (see {@link readIndex}). + * Ownership of a truly global index is undecided in the real platform, where an + * identity's apps and services see only what that identity shares. The polyfill + * therefore parks ownership on a reserved special account in the shim + * ({@link INDEX_ACCOUNT}). Its `public` scope document is the index document; + * deposits land in that document's inbox (a stable NURI: every client opening the + * same shared wallet resolves the same account, so the same document). This is + * the app-facing discovery path, in place of a cross-account fan-out + * (`store-registry.ts` `listEntityDocs`), which survives only as an internal + * fallback (see {@link readIndex}). * - * ── TARGET vs POLYFILL ──────────────────────────────────────────────────── - * Target: `submitToIndex` seals a reference into the index's native inbox - * (`inbox_post_link`) and a SEPARATE curator package materializes deposits into - * the owned index document; `readIndex` is a query on the materialized index. - * Here, everything is emulated in-lib on the shared wallet (deposit via - * `inbox.post`, materialize via `inbox.read`). At migration the special account - * disappears; ownership moves to the decided global-index owner and this module - * points `readIndex` at the real materialized document. The consumer surface - * (`submitToIndex` / `readIndex`) is designed to survive that swap unchanged. + * ── Real target vs this emulation ───────────────────────────────────────── + * The intended real shape is: `submitToIndex` seals a reference into the index + * document's own inbox (a future `inbox_post_link`), and reading the index is a + * query on the materialized index document. Here, everything runs in-lib on the + * shared wallet (deposit via `inbox.post`, fold via `inbox.read`). Against real + * NextGraph the special account gives way to the decided global-index owner and + * `readIndex` points at that document; the consumer surface (`submitToIndex` / + * `readIndex`) is designed to survive that change unchanged. * - * All NextGraph I/O routes through `inbox.ts` (which routes through the T01.a - * `docs` primitives, the REAL injected `ng`), so this module imports NO - * `@ng-org` package. + * All NextGraph I/O routes through `inbox.ts` (which routes through the `docs` + * primitives, the real injected `ng`), so this module imports no `@ng-org` + * package. */ import * as inbox from "./inbox"; @@ -45,12 +43,12 @@ import { getCaps } from "./polyfill"; import type { Nuri, PrincipalId } from "./types"; /** - * The reserved SPECIAL ACCOUNT that owns the global discovery index in the - * polyfill. It hosts the index document but is never a real user. It lives in - * the registry's RESERVED namespace ({@link reservedAccount}), whose key - * `normalizeUser` can never produce — so a user named "index"/"@index" can NOT - * hijack it (they would normalize to "index", a disjoint key). Disappears at - * migration (see file header). + * The reserved special account that owns the global discovery index in the + * polyfill. It hosts the index document but is never a real identity. It lives in + * the registry's reserved namespace ({@link reservedAccount}), whose key + * `normalizeId` can never produce, so an id of "index"/"@index" cannot hijack it + * (it normalizes to "index", a disjoint key). Removed against real NextGraph + * (see file header). */ export const INDEX_ACCOUNT = reservedAccount("index"); @@ -68,16 +66,16 @@ export interface IndexEntry { export interface SubmitOptions { /** * Who is submitting. Omit for the current identity, or pass `null` for an - * ANONYMOUS submission. `from` is BOUND to the current identity by the inbox - * (naming another principal is rejected as a spoof — see {@link inbox.post}). + * anonymous submission. `from` is bound to the current identity by the inbox + * (naming another identity is rejected as a spoof — see {@link inbox.post}). */ from?: PrincipalId | null; /** * The NURI of the document being made discoverable. When given, the index - * enforces PUBLIC-ONLY: a document under a non-public (protected/private) read - * policy is REFUSED — the public index must never leak a governed document's - * NURI. Omit it only for a ref with no addressable document (rare); a governed - * doc always passes it so the guard can fire. + * admits only a public document: one under a non-public (protected/private) + * read policy is refused, so the world-readable index never exposes a governed + * document's NURI. Omit it only for a ref with no addressable document (rare); + * a governed document passes it so the guard can fire. */ doc?: Nuri; /** Optional deposit timestamp (ms epoch). Omitted → `Date.now()`. Passing it @@ -105,14 +103,15 @@ async function indexInboxNuri(): Promise { /** * Submit a reference to the global discovery index — the SDK act "make this * discoverable". Deposits `ref` into the index document's inbox via - * {@link inbox.post}; the curator ({@link readIndex}) materializes it into an - * entry. GENERIC: `ref` is opaque here (the consumer serializes whatever a - * client needs to later locate the entity — e.g. an entity document NURI plus - * discovery metadata). `from` follows the inbox convention (anonymous if `null`). + * {@link inbox.post}; reading the index ({@link readIndex}) folds it into an + * entry. `ref` is opaque here (the consumer serializes whatever a client needs to + * later locate the entity — e.g. an entity document NURI plus discovery metadata). + * `from` follows the inbox convention (anonymous when `null`). * - * PUBLIC-ONLY: when `opts.doc` names the document being surfaced, a document under - * a non-public read policy (protected/private) is REFUSED — the global index is - * world-readable, so admitting a governed doc's NURI would leak it past its scope. + * When `opts.doc` names the document being surfaced, a document under a + * non-public read policy (protected/private) is refused: the global index is + * world-readable, so admitting a governed document's NURI would expose it past + * its scope. */ export async function submitToIndex(ref: unknown, opts?: SubmitOptions): Promise { const doc = opts?.doc; @@ -135,11 +134,11 @@ export async function submitToIndex(ref: unknown, opts?: SubmitOptions): Promise } /** - * Read (materialize) the global discovery index — the EMULATED CURATOR. Reads - * every submission from the index inbox, DEDUPLICATES by serialized `ref` (the - * materialization dedup / moderation point of the discovery model: a duplicate - * submission surfaces once), and returns the entries sorted by `ts` ascending. - * At migration this becomes a query on the real materialized index document. + * Read the global discovery index. Reads every submission from the index inbox, + * deduplicates by serialized `ref` (a duplicate submission surfaces once — the + * discovery model's moderation point), and returns the entries sorted by `ts` + * ascending. Against real NextGraph this becomes a query on the materialized + * index document. */ export async function readIndex(): Promise { const target = await indexInboxNuri(); @@ -157,9 +156,9 @@ export async function readIndex(): Promise { } /** - * Watch the discovery index — the emulated curator's watcher. Polls - * {@link readIndex} and fires `onEntries` whenever the index changes. Returns an - * unsubscribe. Fires once immediately. (Deduplication is applied on each read.) + * Watch the discovery index. Polls {@link readIndex} and fires `onEntries` + * whenever the index changes. Returns an unsubscribe. Fires once immediately. + * (Deduplication is applied on each read.) */ export function watchIndex( onEntries: (entries: IndexEntry[]) => void, diff --git a/packages/client/src/docs.ts b/packages/client/src/docs.ts index 008df7e..026354b 100644 --- a/packages/client/src/docs.ts +++ b/packages/client/src/docs.ts @@ -1,12 +1,13 @@ /** * Low-level document + SPARQL primitives. * - * These call the **REAL injected `ng`** (`getConfig().ng`) DIRECTLY — never the - * public `ng` proxy (`makeNg`). This is a validated hard constraint, NOT a style + * These call the real injected `ng` (`getConfig().ng`) directly — never the + * public `ng` proxy (`makeNg`). This is a validated hard constraint, not a style * choice: the public `ng` is a JS `Proxy` over `@ng-org/web`'s iframe-RPC proxy, * and layering our Proxy on top breaks `doc_create`'s `postMessage` marshaling - * (`DataCloneError: function ... could not be cloned`). Reaching the real `ng` - * held in the config avoids the double-proxy. Do NOT import from `./ng-proxy`. + * with **`DataCloneError: function ... could not be cloned`** — the footgun this + * rule exists to prevent. Reaching the real `ng` held in the config avoids the + * double-proxy. Do not import from `./ng-proxy`. * * Signatures mirror the real `@ng-org/web` `ng` surface (verified against the * app's storeRegistry usage), so this is a drop-in for those raw calls. diff --git a/packages/client/src/inbox.ts b/packages/client/src/inbox.ts index bdf4828..d1a7b1f 100644 --- a/packages/client/src/inbox.ts +++ b/packages/client/src/inbox.ts @@ -1,25 +1,27 @@ /** - * Inbox — the ONE deposit + materialization mechanism, reused for BOTH meeting- - * point registration AND submission to the discovery index (see the discovery- - * model decision: same `inbox.post` API, same watcher). GENERIC by construction: - * this module knows no application domain (no meeting-point, no notification). - * The consumer supplies the inbox document NURI and interprets the `payload`. + * Inbox — a generic deposit + read/materialize mechanism the consumer reuses for + * its own purposes (same `inbox.post` API, same watcher — see the discovery-model + * decision). The mechanism itself knows no application domain: the consumer + * supplies the inbox document NURI and interprets the `payload`. (An example + * consumer mapping, purely illustrative: a consumer might use one inbox for a + * registration deposit and another for submitting a reference to an index.) * - * ── TARGET vs POLYFILL ──────────────────────────────────────────────────── - * Target: `post` seals a reference into the inbox owner's native inbox - * (`ng.inbox_post_link(...)`), and a SEPARATE curator process (the deferred - * `@ng-eventually/service` package) MATERIALIZES the deposits into the owned - * document. Here, in the shared-wallet polyfill (everything is readable), - * both sides are emulated in-lib: + * ── Real target vs this emulation ───────────────────────────────────────── + * In real NextGraph, a message is sealed to the recipient's key and queued into + * their inbox; the recipient's own verifier unseals each queued message and + * applies it inline as it processes the inbox — there is no separate curator + * process. A future `inbox_post_link` is the intended way to seal a link into an + * inbox from the sender side; it is not exposed yet. + * + * Here, on one shared wallet where everything is readable, both sides run in-lib: * - `post` appends a deposit `{ from, payload, ts }` as RDF into the inbox - * DOCUMENT (in the shared wallet) via the `docs.sparqlUpdate` primitive; - * - `read` / `watch` play the CURATOR: they read the deposits back via - * `docs.sparqlQuery` and expose them. This in-client emulation is enough - * for the polyfill — at migration the real materialization moves to the - * separate curator and this read side goes away. + * document (in the shared wallet) via the `docs.sparqlUpdate` primitive; + * - `read` / `watch` read the deposits back via `docs.sparqlQuery` and expose + * them. This in-lib read stands in for the recipient's own inbox processing + * until the sealed-inbox path (`inbox_post_link`) is available. * - * All NextGraph I/O routes through the T01.a `docs` primitives (the REAL - * injected `ng`, never `makeNg`), so this module imports NO `@ng-org` package. + * All NextGraph I/O routes through the `docs` primitives (the real injected `ng`, + * never `makeNg`), so this module imports no `@ng-org` package. */ import { sparqlUpdate, sparqlQuery } from "./docs"; @@ -92,13 +94,13 @@ function readBindings(result: unknown): Array> * (the real injected `ng`). Each deposit is a fresh RDF subject in the inbox * graph, so concurrent deposits don't collide. * - * `from` is BOUND TO THE CURRENT IDENTITY — it is authenticated, not - * caller-supplied. Omit it to stamp the current user; pass `null` to deposit - * ANONYMOUSLY (a legitimate choice — "identified if known, anonymous otherwise"). - * A `from` naming ANOTHER principal is a SPOOF and is REJECTED: in the target the + * `from` is bound to the current identity — it is authenticated, not + * caller-supplied. Omit it to stamp the current identity; pass `null` to deposit + * anonymously (a legitimate choice — identified if known, anonymous otherwise). + * A `from` naming another identity is rejected as a spoof: in the target the * broker seals the sender from the wallet's own key, so a client cannot forge - * another's identity. (At migration this check is redundant — the seal enforces - * it — but until then it closes the spoof the shared wallet would otherwise allow.) + * another's identity. This check is redundant once the seal enforces it, but + * until then it closes the spoof the shared wallet would otherwise allow. */ export async function post(targetInbox: Nuri, opts: PostOptions): Promise { const current = getCurrentUser(); @@ -140,13 +142,14 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise await sparqlUpdate(sid, update, targetInbox); } -// --- read / materialize (emulated curator) -------------------------------- +// --- read -------------------------------------------------------------- /** - * Read (materialize) every deposit currently in `targetInbox`, sorted by `ts` - * ascending. This is the EMULATED CURATOR: at migration a separate curator - * process materializes deposits and this in-client read goes away. The consumer - * interprets each deposit's `payload`. + * Read every deposit currently in `targetInbox`, sorted by `ts` ascending. In + * real NextGraph the recipient's own verifier applies queued messages inline as + * it processes the inbox; here this read stands in for that until the + * sealed-inbox path is available. The consumer interprets each deposit's + * `payload`. */ export async function read(targetInbox: Nuri): Promise { const sid = await sessionId(); @@ -179,15 +182,15 @@ export async function read(targetInbox: Nuri): Promise { return deposits; } -/** Alias for {@link read} — the name that reads as "run the curator now". */ +/** Alias for {@link read} — the name that reads as "process the inbox now". */ export const materialize = read; /** - * Subscription over an inbox — the emulated watcher. Polls {@link read} on an - * interval and invokes `onDeposits` with the full current deposit list whenever - * it changes (grows). Returns an unsubscribe function. The polyfill has no - * native reactive inbox subscription, so this emulates one; at migration it is - * replaced by the real curator's watch. `onDeposits` fires once immediately. + * Subscription over an inbox. Polls {@link read} on an interval and invokes + * `onDeposits` with the full current deposit list whenever it changes (grows). + * Returns an unsubscribe function. The polyfill has no reactive inbox + * subscription, so this polls; against real NextGraph it follows the recipient's + * own inbox processing. `onDeposits` fires once immediately. */ export function watch( targetInbox: Nuri, diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 1119240..9e0d225 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -22,8 +22,6 @@ export * as readModel from "./read-model"; export type { UnionSubject } from "./read-model"; export * as storeRegistry from "./store-registry"; export type { AccountRecord, RegistrySession } from "./store-registry"; -export * as isolation from "./isolation"; -export type { Connections, IsolationAccessors } from "./isolation"; export * as accounts from "./accounts"; export type { AccountStorage } from "./accounts"; diff --git a/packages/client/src/isolation.ts b/packages/client/src/isolation.ts deleted file mode 100644 index 40ad5d5..0000000 --- a/packages/client/src/isolation.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * isolation — APPLICATION-LAYER visibility filter, per **account + connections**. - * - * STOPGAP (shared-wallet shim): with one shared wallet everything is physically - * readable. To make staging *behave* like the target infra, the consumer HONORS - * a visibility matrix by filtering reads relative to the current principal and - * its social connections: - * - * - public → visible to everyone - * - protected → visible to the owner + its direct connections - * - private → visible to the owner only - * - * Pure functions — no NextGraph, no React, ZERO application domain. The consumer - * supplies (a) the connection graph (who is connected to whom — the lib does NOT - * invent it) and (b) accessors telling, for each item, its owner and scope. - * - * ── isolation vs ReadCap (read-filter.ts / caps.ts) — they COEXIST ────────── - * Two DIFFERENT axes, deliberately kept separate: - * - * ReadCap ({@link ./caps} + {@link ./read-filter}) - * - Unit : the DOCUMENT (an item's `@graph` = the repo it lives in). - * - Question: does the current principal HOLD this document's read cap? - * - Models : NextGraph's native capability delivery (broker-enforced). - * - Grants : explicit, per-document (grantRead / makePublic). - * - * isolation (this file) - * - Unit : the ITEM / record (per owner + scope). - * - Question: given WHO is connected to WHOM, may this principal see it? - * - Models : an application social-visibility policy, above the doc layer. - * - Grants : implicit, derived from the connection graph + the item's scope. - * - * They are NOT redundant and do NOT merge: ReadCap answers "can the wallet even - * fetch this document"; isolation answers "should the app show this record to - * this account given its relationships". The connection-derived `protected` - * visibility of isolation has no equivalent in the per-document cap model. Both - * are removable scaffolds; each disappears against a different piece of the real - * infra (caps → native ReadCaps; isolation → real per-account social graph + - * per-account wallets). See T01.c ## Result for the recorded decision. - */ - -import type { PrincipalId, Scope } from "./types"; - -/** - * The connection graph, consumer-injected. An undirected "is connected to" - * relation between principals (e.g. accepted friendships). The lib never builds - * this — the consumer maps its own domain (friendships, follows, …) onto it. - */ -export interface Connections { - /** All principals directly connected to `principal` (excluding itself). */ - neighbors(principal: PrincipalId): Iterable; -} - -/** - * Build a {@link Connections} from a flat list of undirected links. A link - * `{ a, b }` connects `a` and `b` both ways. Convenience for the common case; - * the consumer may implement {@link Connections} directly instead. - */ -export function connectionsFromLinks( - links: Iterable<{ a: PrincipalId; b: PrincipalId }>, -): Connections { - const adj = new Map>(); - const link = (x: PrincipalId, y: PrincipalId) => { - let s = adj.get(x); - if (!s) adj.set(x, (s = new Set())); - s.add(y); - }; - for (const { a, b } of links) { - link(a, b); - link(b, a); - } - return { - neighbors: (principal) => adj.get(principal) ?? new Set(), - }; -} - -/** - * The set the current principal may see PROTECTED data for: itself + its direct - * connections. Pure over the injected {@link Connections}. - */ -export function visibleSet(current: PrincipalId, connections: Connections): Set { - const set = new Set([current]); - for (const n of connections.neighbors(current)) set.add(n); - return set; -} - -/** - * May `current` see an item owned by `owner` at visibility `scope`, given the - * `visible` set (self + connections, precomputed via {@link visibleSet})? - * - * - public → always - * - protected → owner ∈ visible - * - private → owner === current - */ -export function isVisible( - scope: Scope, - owner: PrincipalId, - current: PrincipalId, - visible: Set, -): boolean { - switch (scope) { - case "public": - return true; - case "protected": - return visible.has(owner); - case "private": - return owner === current; - } -} - -/** How to read an item's owner + scope. Consumer-supplied — keeps this generic. */ -export interface IsolationAccessors { - /** The principal that owns / authored the item. */ - ownerOf: (item: T) => PrincipalId; - /** The item's visibility scope. */ - scopeOf: (item: T) => Scope; -} - -/** - * Pure: keep only the items `current` is allowed to see, per the visibility - * matrix and the injected connection graph. - * - * Empty `current` (no identity yet, e.g. during hydration) → pass everything - * through, so the app doesn't blank out mid-load (matches the app's prior - * `if (!currentUserId) return data` guard). - */ -export function applyIsolation( - items: Iterable, - current: PrincipalId, - connections: Connections, - accessors: IsolationAccessors, -): T[] { - const all = [...items]; - if (!current) return all; - const visible = visibleSet(current, connections); - return all.filter((item) => - isVisible(accessors.scopeOf(item), accessors.ownerOf(item), current, visible), - ); -} diff --git a/packages/client/src/polyfill.ts b/packages/client/src/polyfill.ts index c410495..6147854 100644 --- a/packages/client/src/polyfill.ts +++ b/packages/client/src/polyfill.ts @@ -11,19 +11,18 @@ import type { NgLike, UseShapeLike, PrincipalId } from "./types"; import type { RegistrySession } from "./store-registry"; import { CapRegistry } from "./caps"; -import { ConnectionRegistry, bilateralConnections } from "./connections"; /** * Consumer-injected dependencies of the storeRegistry (polyfill-era). The * registry itself is generic (it knows only native scopes); the consumer wires - * up how to reach the shared-wallet session and how to normalize a username + * up how to reach the shared-wallet session and how to normalize an identity id * used as the shim key. Removed at migration along with the whole shim. */ export interface StoreRegistryDeps { /** Resolve the current shared-wallet session (id + private-store anchor). */ getSession: () => Promise; - /** Normalize a username for shim keying. Default: trim (identity-ish). */ - normalizeUser?: (username: string) => string; + /** Normalize an identity id for shim keying. Default: trim (identity-ish). */ + normalizeId?: (id: string) => string; } export interface EventuallyConfig { @@ -47,9 +46,6 @@ let registryDeps: Required | null = null; /** The emulated ReadCap/WriteCap registry. Empty until the app declares caps; * while it has no read policy the read filter passes through (no regression). */ let caps = new CapRegistry(); -/** The emulated BILATERAL connection registry. Accumulates directed assertions - * (each authored by the asserting identity); only two-sided links materialize. */ -let connectionRegistry = new ConnectionRegistry(); export function configure(c: EventuallyConfig): void { cfg = c; @@ -70,7 +66,7 @@ export function resetConfig(): void { } /** - * Wire the storeRegistry's consumer-injected dependencies (session + username + * Wire the storeRegistry's consumer-injected dependencies (session + identity-id * normalization). Must be called before any storeRegistry.* use. Separate from * {@link configure} because it's storeRegistry-specific and, like the shim, * disappears at migration. @@ -78,7 +74,7 @@ export function resetConfig(): void { export function configureStoreRegistry(deps: StoreRegistryDeps): void { registryDeps = { getSession: deps.getSession, - normalizeUser: deps.normalizeUser ?? ((u: string) => u.trim()), + normalizeId: deps.normalizeId ?? ((id: string) => id.trim()), }; } @@ -95,7 +91,12 @@ export function resetStoreRegistry(): void { registryDeps = null; } -/** Set the current app-level user (the polyfill's notion of "who am I"). */ +/** + * Set the current identity id — who the SDK is reading/writing as. In the target + * this is the wallet user established at wallet-import time; here the consumer + * relays that id through this call so the read filter and the inbox `from` know + * who is acting. Passing `null` clears it (no identity yet, e.g. during startup). + */ export function setCurrentUser(id: PrincipalId | null): void { currentUser = id; } @@ -104,51 +105,16 @@ export function getCurrentUser(): PrincipalId | null { return currentUser; } -/** The emulated cap registry — the app grants/opens caps on it (as it will via - * real cap operations in the target). The read filter consults it. */ +/** The emulated cap registry — the app opens a document's read policy and issues + * directed read grants on it (as it will via real cap operations in the target). + * The read filter consults it. */ export function getCaps(): CapRegistry { return caps; } -/** - * Declare the CURRENT identity's own connections to the SDK — the domain sharing - * act "a protected document is readable by its owner AND that owner's connections". - * - * AUTHENTICATED / BILATERAL. Each entry in `peers` is a principal the CURRENT user - * (`getCurrentUser()`, or an explicit `as`) asserts a connection to. The lib - * records that assertion as authored BY the current identity — a session can only - * ever assert its OWN side. A protected read is granted between two principals only - * when BOTH have asserted the other (a materialized two-sided link). So a reader - * who unilaterally self-declares a connection to an owner gets NOTHING: the owner - * never asserted them back. Public stays world-readable; private stays owner-only. - * Re-callable whenever the connection graph changes (additive + idempotent). - * - * SDK-shaped: the consumer passes principals only — never a document NURI, a store - * id, or the cap registry internals. `as` names the asserting identity explicitly - * (defaults to the current user); the consumer normally omits it. - */ -export function declareConnections(peers: Iterable, as?: PrincipalId): void { - const self = as ?? currentUser; - if (self) for (const peer of peers) connectionRegistry.assert(self, peer); - // Re-derive protected grants from the CURRENT bilateral view (only two-sided - // links surface as neighbours). Idempotent: grants only ever accumulate. - caps.grantReadToConnections((owner) => connectionRegistry.neighbors(owner)); -} - -/** @internal — the bilateral connection registry (mainly for tests / adapters). */ -export function getConnectionRegistry(): ConnectionRegistry { - return connectionRegistry; -} - -/** The current bilateral connection view (only two-sided links surface). */ -export function getConnections() { - return bilateralConnections(connectionRegistry); -} - /** Reset all emulated caps (mainly for tests / fresh sessions). */ export function resetCaps(): void { caps = new CapRegistry(); - connectionRegistry = new ConnectionRegistry(); } // Cap surface — polyfill-era (caps are emulated now; native at migration). diff --git a/packages/client/src/read-model.ts b/packages/client/src/read-model.ts index 2353e52..723e0cb 100644 --- a/packages/client/src/read-model.ts +++ b/packages/client/src/read-model.ts @@ -1,39 +1,39 @@ /** - * read-model — the LISTING primitive of the polyfill: read a BOUNDED, by-need set - * of documents, each with its OWN anchored `sparql_query`, and return the triples + * read-model — the listing primitive of the polyfill: read a bounded, by-need set + * of documents, each with its own anchored `sparql_query`, and return the triples * grouped per subject. This is the mechanism documented in docs/read-model.md. * - * ── WHY per-doc ANCHORED, never an anchorless union-scan ─────────────────── - * An ANCHORED `sparql_query(sid, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", base, doc)` + * ── Why per-doc anchored, rather than an anchorless union-scan ───────────── + * An anchored `sparql_query(sid, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", base, doc)` * is restricted to the anchor repo's graph: `resolve_target_for_sparql(Repo)` → - * `Some(repo_graph_name)`, which becomes the query's DEFAULT graph. A body with NO - * `GRAPH` wrapper reads only that default graph → ONLY that doc's triples, O(1) per - * doc, INDEPENDENT of how many other graphs the local store holds. + * `Some(repo_graph_name)`, which becomes the query's default graph. A body with no + * `GRAPH` wrapper reads only that default graph → only that doc's triples, O(1) per + * doc, independent of how many other graphs the local store holds. * - * An ANCHORLESS query (`anchor` undefined → `UserSite` → `set_default_graph_as_union`) - * spans EVERY named graph currently in the session store. On a SHARED / bloated - * wallet that accumulates across runs, that is O(wallet size) → the observed ~90s - * timeouts. So the read path must NEVER union-scan all graphs: it reads exactly the - * bounded by-need set, one anchored query per doc. + * The footgun this avoids: an anchorless query (`anchor` undefined → `UserSite` → + * `set_default_graph_as_union`) spans EVERY named graph currently in the session + * store. On a shared / bloated wallet that accumulates across runs, that is + * O(wallet size) → the observed ~90s timeouts. So the read path never union-scans + * all graphs — it reads exactly the bounded by-need set, one anchored query per doc. * * NB (verified, docs/read-model.md § probe step 4): an explicit `GRAPH ?g { … }` - * body iterates the NAMED graphs regardless of the default graph, so an anchor does - * NOT bound such a body. The per-doc read therefore uses a DEFAULT-GRAPH body (no + * body iterates the named graphs regardless of the default graph, so an anchor does + * not bound such a body. The per-doc read therefore uses a default-graph body (no * `GRAPH` wrapper) so the anchor's one-repo restriction actually applies. * - * ── WHY not the reactive ORM fan-out ────────────────────────────────────── + * ── Why not the reactive ORM fan-out ────────────────────────────────────── * `useShape({ graphs: […manyDocs] })` drives `orm_start_graph` over a fan-out of * per-entity graphs; a freshly-created / not-yet-synced doc in that fan-out makes * `RepoNotFound` abort the whole subscription → the readyPromise never resolves → * the ~75s hang (docs/nextgraph-current-state.md § The ORM fan-out hang). Listing - * MUST instead be a set of one-shot anchored `sparql_query`s. There is no reactive - * union query, so reactivity is assembled by RE-QUERYING on a change signal. + * is instead a set of one-shot anchored `sparql_query`s. There is no reactive + * union query, so reactivity is assembled by re-querying on a change signal. * - * ── GENERIC by construction ─────────────────────────────────────────────── - * Zero application domain here: the consumer passes the doc NURIs to read (from + * ── Generic by construction ─────────────────────────────────────────────── + * No application domain here: the consumer passes the doc NURIs to read (from * the discovery index for public events, or its own scope docs for my-entities) * and interprets the returned per-subject property bags. All NextGraph I/O routes - * through the T01.a `docs` primitives (the REAL injected `ng`), so this module + * through the T01.a `docs` primitives (the real injected `ng`), so this module * imports no `@ng-org` package. * * At the real multi-store migration the per-doc anchored read is unchanged (native @@ -80,18 +80,18 @@ async function sessionId(): Promise { } /** - * Read ONE doc with an ANCHORED default-graph query, tolerant per-doc. + * Read one doc with an anchored default-graph query, tolerant per-doc. * - * The anchor (`doc` NURI) restricts the query to that repo's graph as the DEFAULT + * The anchor (`doc` NURI) restricts the query to that repo's graph as the default * graph (`resolve_target_for_sparql(Repo)` → `Some(repo_graph_name)`); a body with - * NO `GRAPH` wrapper reads exactly that default graph → ONLY this doc's triples. - * This is O(1) in the doc's own size and INDEPENDENT of the rest of the (possibly + * no `GRAPH` wrapper reads exactly that default graph → only this doc's triples. + * This is O(1) in the doc's own size and independent of the rest of the (possibly * bloated / shared) session store — it never iterates other graphs. * * All same-session repos (every doc `doc_create`d this session — in the mono-wallet - * polyfill, ALL of them) are already in `self.repos`, so the anchored query resolves + * polyfill, all of them) are already in `self.repos`, so the anchored query resolves * the repo directly with no separate open. A genuinely-absent repo throws - * `RepoNotFound` HERE, in isolation, and the doc is skipped — never aborting the + * `RepoNotFound` here, in isolation, and the doc is skipped — never aborting the * others (unlike the ORM fan-out). Returns the doc's rows, or `[]` on failure. * * At the real multi-store migration this becomes a real sync: opening a per-user @@ -103,7 +103,7 @@ async function readDoc( ): Promise>> { try { const nuri = assertNuri(doc); - // Anchored to `nuri` → default graph = this repo. NO `GRAPH ?g` wrapper, so + // Anchored to `nuri` → default graph = this repo. No `GRAPH ?g` wrapper, so // the anchor's one-repo restriction applies (an explicit `GRAPH ?g` body would // iterate all named graphs regardless of the anchor — see docs § probe step 4). const res = await sparqlQuery( @@ -126,10 +126,10 @@ async function readDoc( * Docs that fail are skipped (see {@link readDoc}); a failing doc never aborts the * batch. * - * NEVER an anchorless union-scan over all graphs (which is O(wallet size) and wrong - * on a shared / bloated wallet). Each doc is read with an anchored default-graph - * query, O(1) per doc, independent of wallet size — a non-empty wallet no longer - * matters. Reads run in parallel via `Promise.all`. + * Never an anchorless union-scan over all graphs (which is O(wallet size) and wrong + * on a shared / bloated wallet — the footgun this path exists to avoid). Each doc is + * read with an anchored default-graph query, O(1) per doc, independent of wallet + * size — a non-empty wallet no longer matters. Reads run in parallel via `Promise.all`. */ export async function readUnion(docs: Nuri[]): Promise { const sid = await sessionId(); @@ -142,17 +142,17 @@ export async function readUnion(docs: Nuri[]): Promise { ); // Cap gate (defence-in-depth). A doc whose read policy the current user may not - // satisfy is dropped. Isolation holds BY CONSTRUCTION (the app only resolves docs - // it is entitled to) AND BY FILTER here. Generic: the lib owns the cap registry; - // a doc under no policy (`!governsRead`) flows through unchanged. In this polyfill - // each subject IRI IS its own document NURI, so the cap key is the doc NURI. + // satisfy is dropped. Isolation holds both by construction (the app only resolves + // docs it is entitled to) and by filter here. Generic: the lib owns the cap + // registry; a doc under no policy (`!governsRead`) flows through unchanged. In this + // polyfill each subject IRI is its own document NURI, so the cap key is the doc NURI. const caps = getCaps(); const user = getCurrentUser(); const bySubject = new Map(); for (const { doc, rows } of perDoc) { if (caps.governsRead(doc) && !caps.canRead(doc, user)) continue; - // Anchored to `doc`, so every row belongs to `doc`; the subject IS the doc NURI + // Anchored to `doc`, so every row belongs to `doc`; the subject is the doc NURI // (writeEntity invariant). Pin subject/graph to the doc NURI (the anchor), which // is stable regardless of the repo_graph_name overlay suffix the store carries. for (const row of rows) { diff --git a/packages/client/src/sparql.ts b/packages/client/src/sparql.ts index 6ff20f4..405ae25 100644 --- a/packages/client/src/sparql.ts +++ b/packages/client/src/sparql.ts @@ -2,26 +2,26 @@ * SPARQL string-building safety helpers — shared by every module that builds * SPARQL by interpolation (inbox, store-registry). * - * WHY THIS EXISTS: SPARQL injection. When an untrusted value (a username, a - * payload) is spliced verbatim into a query, a `"` closes a literal and a `>` - * closes an IRI, letting the value inject arbitrary triples (or wreck the shim - * graph, which is the trust root mapping accounts → document NURIs). Every - * value that reaches a query MUST pass through one of these helpers first. + * These exist because of SPARQL injection. When an untrusted value (an identity + * id, a payload) is spliced verbatim into a query, a `"` closes a literal and a + * `>` closes an IRI, letting the value inject arbitrary triples (or corrupt the + * shim graph, the trust root mapping accounts → document NURIs). Every value that + * reaches a query passes through one of these helpers first. * - * TWO POSITIONS, TWO STRATEGIES: - * - LITERAL position (`"..."`): {@link escapeLiteral}. We *escape* rather than - * reject because literals legitimately carry arbitrary text (JSON payloads, - * display names). Escaping is lossless and reversible. + * Two positions, two strategies: + * - Literal position (`"..."`): {@link escapeLiteral}. Escape rather than reject, + * because literals legitimately carry arbitrary text (JSON payloads, display + * names). Escaping is lossless and reversible. * - IRI position (`<...>`): two cases. - * · Trusted-SHAPED NURIs coming back from `ng` (`did:ng:...`): validate - * with {@link assertNuri} — they should never contain IRI-breaking - * chars; if one does, something upstream is wrong, so we throw rather - * than silently build a broken/injected query. - * · UNTRUSTED values embedded into an IRI (a username used to mint an - * account-subject IRI): {@link escapeIri} percent-encodes every - * IRI-hostile character. We *encode* rather than reject so any username - * (spaces, unicode, punctuation) stays usable, while `<`, `>`, `"`, - * whitespace and control chars can never break out of the IRI. + * · Trusted-shaped NURIs coming back from `ng` (`did:ng:...`): validate with + * {@link assertNuri} — they should never contain IRI-breaking chars; if one + * does, something upstream is wrong, so it throws rather than silently + * building a broken/injected query. + * · Untrusted values embedded into an IRI (an identity id used to mint an + * account-subject IRI): {@link escapeIri} percent-encodes every IRI-hostile + * character. Encode rather than reject so any id (spaces, unicode, + * punctuation) stays usable, while `<`, `>`, `"`, whitespace and control + * chars can never break out of the IRI. */ /** @@ -56,8 +56,8 @@ function isIriForbidden(ch: string): boolean { /** * Percent-encode every IRI-hostile character in `value` so it is safe to embed * inside a SPARQL IRI ref (``). Use this for - * UNTRUSTED values (e.g. a username minted into an account-subject IRI): - * encoding keeps every username usable while making breakout impossible. + * untrusted values (e.g. an identity id minted into an account-subject IRI): + * encoding keeps every id usable while making breakout impossible. * * NOTE: this encodes only the delimiter/whitespace/control set, so ordinary * printable characters (including `:` `/` `.` `-` `_` and unicode letters) pass diff --git a/packages/client/src/store-registry.ts b/packages/client/src/store-registry.ts index ad1a77b..d83e797 100644 --- a/packages/client/src/store-registry.ts +++ b/packages/client/src/store-registry.ts @@ -1,11 +1,11 @@ /** * storeRegistry — resolves (account, scope) → document NURI. * - * **STOPGAP / polyfill-era.** Emulates the target infrastructure — where each - * user owns their own public/protected/private stores — on top of ONE shared + * Stopgap / polyfill-era. Emulates the target infrastructure — where each + * user owns their own public/protected/private stores — on top of one shared * wallet. It creates one document per (account × scope) inside that shared * wallet (via the `docs.docCreate` primitive), so the `scope` - * (`public|protected|private`) is a LOGICAL attribute tracked here, NOT a + * (`public|protected|private`) is a logical attribute tracked here, not a * physical NextGraph store. Isolation is enforced by the app layer + the * emulated cap registry, not by crypto. * @@ -14,13 +14,13 @@ * known from the session). That makes login cross-device: another device * opening the same wallet reads the same shim and finds the same accounts. * - * ── GENERIC by construction ────────────────────────────────────────────── - * This module knows ONLY the three native scopes. It does NOT know any - * application entity kind (event, meeting-point, profile, …). The consumer - * maps its entities to a scope and calls `createEntityDoc(scope)` / - * `listEntityDocs(scope)` with the resulting native scope. Zero domain here. + * ── Generic by construction ────────────────────────────────────────────── + * This module knows only the three native scopes; it knows no application + * entity kind. The consumer maps its entities to a scope and calls + * `createEntityDoc(scope)` / `listEntityDocs(scope)` with the resulting native + * scope. No application domain here. * - * ── WHAT DISAPPEARS AT MIGRATION ───────────────────────────────────────── + * ── What disappears at migration ───────────────────────────────────────── * At the real multi-store migration the shim vanishes entirely: `(account, * scope)` maps to the user's REAL store NURI instead of a document in the * shared wallet, `docCreate` targets the real per-user store, and the @@ -41,7 +41,7 @@ import type { Nuri, Scope } from "./types"; /** One account's three scope-document NURIs, as recorded in the shim. */ export interface AccountRecord { - username: string; + id: string; docPublic: Nuri; docProtected: Nuri; docPrivate: Nuri; @@ -50,7 +50,7 @@ export interface AccountRecord { const SHIM = "urn:ng-eventually:shim"; const P = { type: `${SHIM}:Account`, - username: `${SHIM}:username`, + id: `${SHIM}:id`, docPublic: `${SHIM}:docPublic`, docProtected: `${SHIM}:docProtected`, docPrivate: `${SHIM}:docPrivate`, @@ -61,22 +61,22 @@ const P = { // documents (one per entity) that live "in" that scope. const INDEX_SUBJECT = `${SHIM}:index`; -function accountSubject(username: string): string { - // The username is UNTRUSTED and lands in an IRI position. Percent-encode it +function accountSubject(id: string): string { + // The id is UNTRUSTED and lands in an IRI position. Percent-encode it // (escapeIri) so no `>` / `"` / whitespace / control char can break out of // the `<...>` and inject triples into the shim graph (the account→doc trust // root). accountKey() runs first so the subject stays stable per shim key. - return `${SHIM}:account:${escapeIri(accountKey(username))}`; + return `${SHIM}:account:${escapeIri(accountKey(id))}`; } // --- reserved accounts ----------------------------------------------------- // // Some accounts are internal to the lib (e.g. the discovery index owner) and -// must NOT collide with any user-chosen username. A reserved account is created +// must NOT collide with any user-chosen id. A reserved account is created // via {@link reservedAccount}, which marks the name with a sentinel PREFIX that -// `normalizeUser` (consumer-injected) can never produce: it strips a leading +// `normalizeId` (consumer-injected) can never produce: it strips a leading // `@`, trims, and lowercases, so a NUL prefix is unreachable. Reserved -// keys therefore live in a disjoint namespace from every normalized username — +// keys therefore live in a disjoint namespace from every normalized id — // a real user named "index"/"@index" can never resolve to the reserved // `reservedAccount("index")` account. const RESERVED_PREFIX = "\u0000reserved:"; @@ -84,24 +84,24 @@ const RESERVED_PREFIX = "\u0000reserved:"; /** * Wrap an internal account name so it occupies a key that no user input can * produce (see {@link RESERVED_PREFIX}). Pass the result to {@link ensureAccount} - * (and the other registry calls) instead of a bare username. + * (and the other registry calls) instead of a bare id. */ export function reservedAccount(name: string): string { return `${RESERVED_PREFIX}${name}`; } /** Whether a name is a reserved-account sentinel (from {@link reservedAccount}). */ -function isReserved(username: string): boolean { - return username.startsWith(RESERVED_PREFIX); +function isReserved(id: string): boolean { + return id.startsWith(RESERVED_PREFIX); } /** - * The shim/cache key for an account. Reserved accounts bypass `normalizeUser` + * The shim/cache key for an account. Reserved accounts bypass `normalizeId` * entirely and key on their sentinel-prefixed name, so they cannot collide with - * a normalized username; everyone else normalizes as usual. + * a normalized id; everyone else normalizes as usual. */ -function accountKey(username: string): string { - return isReserved(username) ? username : normalize(username); +function accountKey(id: string): string { + return isReserved(id) ? id : normalize(id); } // --- session / normalization access (injected by the consumer) ------------ @@ -118,8 +118,8 @@ export interface RegistrySession { publicStoreId?: string; } -function normalize(username: string): string { - return getStoreRegistryDeps().normalizeUser(username); +function normalize(id: string): string { + return getStoreRegistryDeps().normalizeId(id); } async function session(): Promise { @@ -174,10 +174,10 @@ export async function loadShim(): Promise> { const s = await session(); const anchor = await anchorNuri(); const query = ` - SELECT ?username ?docPublic ?docProtected ?docPrivate WHERE { + SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE { GRAPH <${assertNuri(anchor)}> { ?acc a <${P.type}> ; - <${P.username}> ?username ; + <${P.id}> ?id ; <${P.docPublic}> ?docPublic ; <${P.docProtected}> ?docProtected ; <${P.docPrivate}> ?docPrivate . @@ -187,11 +187,11 @@ export async function loadShim(): Promise> { try { const result = await sparqlQuery(s.sessionId, query, undefined, anchor); for (const row of readBindings(result)) { - const username = bindingValue(row, "username"); - if (!username) continue; - const key = accountKey(username); + const id = bindingValue(row, "id"); + if (!id) continue; + const key = accountKey(id); const record: AccountRecord = { - username, + id, docPublic: bindingValue(row, "docPublic"), docProtected: bindingValue(row, "docProtected"), docPrivate: bindingValue(row, "docPrivate"), @@ -210,15 +210,15 @@ export async function loadShim(): Promise> { /** * Resolve ONE account by its shim key with a BOUNDED query — O(1), independent * of the number of accounts in the shim. This is the HOT-PATH lookup: it hits - * the account record at its known subject (`accountSubject(username)`) directly, + * the account record at its known subject (`accountSubject(id)`) directly, * instead of scanning EVERY account like {@link loadShim}. Returns the account's * record or `null` if it does not exist yet. * * Cached per account (in `accountCache`); a hit skips the query entirely, so * repeated resolves of the same account are free. `resetRegistryCache` clears it. */ -export async function resolveAccount(username: string): Promise { - const key = accountKey(username); +export async function resolveAccount(id: string): Promise { + const key = accountKey(id); const cached = accountCache.get(key); if (cached) return cached; @@ -226,12 +226,12 @@ export async function resolveAccount(username: string): Promise { <${subj}> a <${P.type}> ; - <${P.username}> ?username ; + <${P.id}> ?id ; <${P.docPublic}> ?docPublic ; <${P.docProtected}> ?docProtected ; <${P.docPrivate}> ?docPrivate . @@ -243,7 +243,7 @@ export async function resolveAccount(username: string): Promise { * Ensure an account exists in the shim, creating its 3 scope documents on * first sight. Idempotent — returns the existing record if already present. */ -export async function ensureAccount(username: string): Promise { - const key = accountKey(username); +export async function ensureAccount(id: string): Promise { + const key = accountKey(id); // HOT PATH: targeted O(1) lookup — does THIS account already exist? — instead // of a full-shim scan (loadShim). Off the read/write hot path entirely. - const existing = await resolveAccount(username); + const existing = await resolveAccount(id); if (existing) return existing; const [docPublic, docProtected, docPrivate] = await Promise.all([ @@ -285,20 +285,20 @@ export async function ensureAccount(username: string): Promise { createDoc(), createDoc(), ]); - const record: AccountRecord = { username, docPublic, docProtected, docPrivate }; + const record: AccountRecord = { id, docPublic, docProtected, docPrivate }; const s = await session(); const anchor = await anchorNuri(); - const subj = accountSubject(username); + const subj = accountSubject(id); // `subj` is already IRI-safe (accountSubject → escapeIri). `anchor` is a - // trusted-shaped NURI → assertNuri. `username` is UNTRUSTED text in a LITERAL + // trusted-shaped NURI → assertNuri. `id` is UNTRUSTED text in a LITERAL // position → escapeLiteral. The doc NURIs come from `ng` but are stored as // literals here, so they are escaped as literals too (defence in depth). const update = ` INSERT DATA { GRAPH <${assertNuri(anchor)}> { <${subj}> a <${P.type}> ; - <${P.username}> "${escapeLiteral(username)}" ; + <${P.id}> "${escapeLiteral(id)}" ; <${P.docPublic}> "${escapeLiteral(docPublic)}" ; <${P.docProtected}> "${escapeLiteral(docProtected)}" ; <${P.docPrivate}> "${escapeLiteral(docPrivate)}" . @@ -328,12 +328,12 @@ function indexDocOf(record: AccountRecord, scope: Scope): Nuri { } /** - * NURI of the document where `username` writes GROUPED entities of `scope` - * (e.g. participations, profile — no per-entity document / no inbox needed). - * For per-entity scopes use {@link createEntityDoc} instead. + * NURI of the document where `id` writes GROUPED entities of `scope` (a single + * per-scope index document, for entities that need no per-entity document / no + * inbox). For per-entity scopes use {@link createEntityDoc} instead. */ -export async function resolveWriteGraph(username: string, scope: Scope): Promise { - const record = await ensureAccount(username); +export async function resolveWriteGraph(id: string, scope: Scope): Promise { + const record = await ensureAccount(id); return indexDocOf(record, scope); } @@ -414,8 +414,8 @@ export async function resolveInboxAnchor(): Promise { * document's NURI is appended to the account's scope index document (the * store-container). Returns the entity document NURI (use it as `@graph`). */ -export async function createEntityDoc(username: string, scope: Scope): Promise { - const record = await ensureAccount(username); +export async function createEntityDoc(id: string, scope: Scope): Promise { + const record = await ensureAccount(id); const indexDoc = indexDocOf(record, scope); const entityNuri = await createDoc(); const s = await session(); @@ -480,14 +480,14 @@ export async function listEntityDocs(scope: Scope): Promise { } /** - * The entity-document NURIs of `scope` belonging to ONE account (`username`) — - * the read-by-need path for "my own entities" (my profile, my participations). - * Bounded to a SINGLE account: it resolves only that account's scope index doc - * (via `ensureAccount`) and reads the contained NURIs — NO cross-account - * enumeration, so it never touches another account's unsynced docs. This is the - * helper FestipodDataContext uses instead of the all-accounts `listEntityDocs`. + * The entity-document NURIs of `scope` belonging to ONE account (`id`) — + * the read-by-need path for one account's own entities. Bounded to a SINGLE + * account: it resolves only that account's scope index doc (via `ensureAccount`) + * and reads the contained NURIs — NO cross-account fan-out, so it never touches + * another account's unsynced docs. This is the helper a consumer application uses + * for its own my-entities path, instead of the all-accounts `listEntityDocs`. */ -export async function listMyEntityDocs(username: string, scope: Scope): Promise { - const record = await ensureAccount(username); +export async function listMyEntityDocs(id: string, scope: Scope): Promise { + const record = await ensureAccount(id); return readScopeIndex(indexDocOf(record, scope)); } diff --git a/packages/client/src/types.ts b/packages/client/src/types.ts index f27af1a..6e95bd3 100644 --- a/packages/client/src/types.ts +++ b/packages/client/src/types.ts @@ -9,8 +9,8 @@ export type Nuri = string; * consumer's concern; this layer only knows the three scopes exist. */ export type Scope = "public" | "protected" | "private"; -/** App-level current identity. Target: the wallet user (`session.user`). - * Polyfill: a chosen id (e.g. a username), because everyone shares one wallet. */ +/** The current identity id. Target: the wallet user (`session.user`). Polyfill: + * a chosen id, because everyone shares one wallet. */ export type PrincipalId = string; /** diff --git a/packages/client/test/accounts.test.ts b/packages/client/test/accounts.test.ts index 04effc1..d2a4acd 100644 --- a/packages/client/test/accounts.test.ts +++ b/packages/client/test/accounts.test.ts @@ -1,8 +1,7 @@ import { test, expect } from "bun:test"; import { - AccountStore, - browserAccountStore, - normalizeUsername, + IdentityStore, + browserIdentityStore, ACCOUNT_STORAGE_KEY, type AccountStorage, } from "../src/accounts"; @@ -18,47 +17,39 @@ function fakeStorage(): AccountStorage & { map: Map } { }; } -test("normalizeUsername: strips leading @, trims, lowercases", () => { - expect(normalizeUsername("marie")).toBe("marie"); - expect(normalizeUsername("@Marie")).toBe("marie"); - expect(normalizeUsername(" @@MARIE ")).toBe("marie"); - expect(normalizeUsername(null)).toBe(""); - expect(normalizeUsername(undefined)).toBe(""); -}); - -test("AccountStore: login persists a trimmed username, get reads it back", () => { +test("IdentityStore: set persists a trimmed id, get reads it back", () => { const s = fakeStorage(); - const store = new AccountStore(s); + const store = new IdentityStore(s); expect(store.get()).toBeNull(); - expect(store.login(" Marie ")).toBe("Marie"); // trimmed, NOT normalized (display form) - expect(store.get()).toBe("Marie"); - expect(s.map.get(ACCOUNT_STORAGE_KEY)).toBe("Marie"); + expect(store.set(" marie ")).toBe("marie"); // trimmed + expect(store.get()).toBe("marie"); + expect(s.map.get(ACCOUNT_STORAGE_KEY)).toBe("marie"); }); -test("AccountStore: blank login is ignored, keeps the previous value", () => { - const store = new AccountStore(fakeStorage()); - store.login("bob"); - expect(store.login(" ")).toBe("bob"); +test("IdentityStore: a blank id is ignored, keeps the previous value", () => { + const store = new IdentityStore(fakeStorage()); + store.set("bob"); + expect(store.set(" ")).toBe("bob"); expect(store.get()).toBe("bob"); }); -test("AccountStore: logout clears the username (no throw)", () => { - const store = new AccountStore(fakeStorage()); - store.login("bob"); - store.logout(); +test("IdentityStore: clear removes the id (no throw)", () => { + const store = new IdentityStore(fakeStorage()); + store.set("bob"); + store.clear(); expect(store.get()).toBeNull(); }); -test("AccountStore: null storage degrades to non-persisting (SSR-safe)", () => { - const store = new AccountStore(null); +test("IdentityStore: null storage degrades to non-persisting (SSR-safe)", () => { + const store = new IdentityStore(null); expect(store.get()).toBeNull(); - expect(store.login("bob")).toBe("bob"); // returns the value, just doesn't persist + expect(store.set("bob")).toBe("bob"); // returns the value, just doesn't persist expect(store.get()).toBeNull(); - store.logout(); // no throw + store.clear(); // no throw }); -test("AccountStore: swallows storage errors on read and write", () => { +test("IdentityStore: swallows storage errors on read and write", () => { const throwing: AccountStorage = { getItem: () => { throw new Error("boom"); @@ -70,15 +61,15 @@ test("AccountStore: swallows storage errors on read and write", () => { throw new Error("boom"); }, }; - const store = new AccountStore(throwing); + const store = new IdentityStore(throwing); expect(store.get()).toBeNull(); // read error swallowed → null - expect(() => store.login("bob")).not.toThrow(); - expect(() => store.logout()).not.toThrow(); + expect(() => store.set("bob")).not.toThrow(); + expect(() => store.clear()).not.toThrow(); }); -test("browserAccountStore returns a working store (uses global localStorage if present)", () => { - const store = browserAccountStore("ng-eventually.test.account"); - expect(store).toBeInstanceOf(AccountStore); - // Behaves regardless of environment: login returns the value. - expect(store.login("zoe")).toBe("zoe"); +test("browserIdentityStore returns a working store (uses global localStorage if present)", () => { + const store = browserIdentityStore("ng-eventually.test.account"); + expect(store).toBeInstanceOf(IdentityStore); + // Behaves regardless of environment: set returns the value. + expect(store.set("zoe")).toBe("zoe"); }); diff --git a/packages/client/test/caps.test.ts b/packages/client/test/caps.test.ts index 9803900..8001ff0 100644 --- a/packages/client/test/caps.test.ts +++ b/packages/client/test/caps.test.ts @@ -13,7 +13,7 @@ test("protected documents: owner + explicitly granted principals only", () => { caps.open("did:ng:o:prot", "protected", "alice"); expect(caps.canRead("did:ng:o:prot", "alice")).toBe(true); expect(caps.canRead("did:ng:o:prot", "bob")).toBe(false); - caps.grantRead("did:ng:o:prot", "bob"); // bob becomes a connection of alice + caps.grantRead("did:ng:o:prot", "bob"); // a directed grant issues bob the read cap expect(caps.canRead("did:ng:o:prot", "bob")).toBe(true); }); @@ -25,6 +25,25 @@ test("private documents: owner only", () => { expect(caps.canRead("did:ng:o:priv", null)).toBe(false); }); +test("protectedDocsOf surfaces an owner's protected documents for directed grants", () => { + const caps = new CapRegistry(); + caps.open("did:ng:o:prot1", "protected", "alice"); + caps.open("did:ng:o:prot2", "protected", "alice"); + caps.open("did:ng:o:pub", "public", "alice"); // not protected → excluded + caps.open("did:ng:o:priv", "private", "alice"); // not protected → excluded + caps.open("did:ng:o:bob", "protected", "bob"); // other owner → excluded + expect(caps.protectedDocsOf("alice").sort()).toEqual([ + "did:ng:o:prot1", + "did:ng:o:prot2", + ]); + expect(caps.protectedDocsOf("bob")).toEqual(["did:ng:o:bob"]); + expect(caps.protectedDocsOf("carol")).toEqual([]); + // A directed grant on one of them makes the reader read that doc only. + caps.grantRead("did:ng:o:prot1", "carol"); + expect(caps.canRead("did:ng:o:prot1", "carol")).toBe(true); + expect(caps.canRead("did:ng:o:prot2", "carol")).toBe(false); +}); + test("write is restricted to write-cap holders; the creator always holds it", () => { const caps = new CapRegistry(); caps.open("did:ng:o:pub", "public", "alice"); diff --git a/packages/client/test/connections.test.ts b/packages/client/test/connections.test.ts deleted file mode 100644 index f3d5089..0000000 --- a/packages/client/test/connections.test.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * ConnectionRegistry — BILATERAL connection materialization (T03.h). - * - * A connection is live only when BOTH sides have asserted the other. A unilateral - * (self-declared) assertion yields no neighbour — the defence against a reader who - * fakes a connection to an owner to read that owner's protected documents. - */ -import { test, expect } from "bun:test"; -import { ConnectionRegistry, bilateralConnections } from "../src/connections"; - -test("a UNILATERAL assertion yields NO neighbour", () => { - const reg = new ConnectionRegistry(); - reg.assert("mallory", "alice"); // mallory self-declares; alice never asserts back - expect([...reg.neighbors("mallory")]).toEqual([]); - expect([...reg.neighbors("alice")]).toEqual([]); -}); - -test("a BILATERAL assertion (both sides) materializes the link", () => { - const reg = new ConnectionRegistry(); - reg.assert("alice", "bob"); - reg.assert("bob", "alice"); - expect([...reg.neighbors("alice")]).toEqual(["bob"]); - expect([...reg.neighbors("bob")]).toEqual(["alice"]); -}); - -test("mixed: only the reciprocated peers surface", () => { - const reg = new ConnectionRegistry(); - reg.assert("alice", "bob"); // reciprocated below - reg.assert("bob", "alice"); - reg.assert("alice", "carol"); // NOT reciprocated by carol - reg.assert("dave", "alice"); // dave asserts alice, alice never asserts dave - expect([...reg.neighbors("alice")].sort()).toEqual(["bob"]); -}); - -test("self-assertion and empty are ignored", () => { - const reg = new ConnectionRegistry(); - reg.assert("alice", "alice"); - reg.assert("", "bob"); - reg.assert("alice", ""); - expect([...reg.neighbors("alice")]).toEqual([]); -}); - -test("bilateralConnections adapts to the Connections interface", () => { - const reg = new ConnectionRegistry(); - reg.assertAll([ - { from: "alice", to: "bob" }, - { from: "bob", to: "alice" }, - ]); - const conns = bilateralConnections(reg); - expect([...conns.neighbors("alice")]).toEqual(["bob"]); - expect([...conns.neighbors("carol")]).toEqual([]); -}); - -test("clear() removes all assertions", () => { - const reg = new ConnectionRegistry(); - reg.assert("alice", "bob"); - reg.assert("bob", "alice"); - reg.clear(); - expect([...reg.neighbors("alice")]).toEqual([]); -}); diff --git a/packages/client/test/discovery.test.ts b/packages/client/test/discovery.test.ts index 51be219..9b93194 100644 --- a/packages/client/test/discovery.test.ts +++ b/packages/client/test/discovery.test.ts @@ -102,7 +102,7 @@ function makeFakeNg() { // Shim account SELECT. Two shapes: the full scan (`?acc a `) and // the TARGETED bounded resolve (` a `), which binds one // subject — honour that subject filter so the bounded query is O(1)/exact. - if (query.includes(`<${SHIM}:username>`)) { + if (query.includes(`<${SHIM}:id>`)) { const subjM = query.match(new RegExp(`GRAPH <[^>]+>\\s*\\{\\s*<([^>]+)>\\s+a\\s+<${SHIM}:Account>`)); const onlySubject = subjM ? subjM[1]! : null; const bySubject = new Map>(); @@ -110,16 +110,16 @@ function makeFakeNg() { if (q.g !== anchor) continue; if (onlySubject !== null && q.s !== onlySubject) continue; const rec = bySubject.get(q.s) ?? {}; - if (q.p === `${SHIM}:username`) rec.username = q.o; + if (q.p === `${SHIM}:id`) rec.id = q.o; if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o; if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o; if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o; bySubject.set(q.s, rec); } const bindings = [...bySubject.values()] - .filter((r) => r.username) + .filter((r) => r.id) .map((r) => ({ - username: { value: r.username! }, + id: { value: r.id! }, docPublic: { value: r.docPublic ?? "" }, docProtected: { value: r.docProtected ?? "" }, docPrivate: { value: r.docPrivate ?? "" }, @@ -167,7 +167,7 @@ function inject() { configure({ ng: ng as any, useShape: (() => {}) as any }); configureStoreRegistry({ getSession: async () => SESSION, - normalizeUser: (u) => u.trim().replace(/^@+/, "").toLowerCase(), + normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(), }); resetRegistryCache(); setCurrentUser(null); @@ -256,10 +256,10 @@ test("(d) submitToIndex refuses a PROTECTED/PRIVATE document (public-only)", asy resetCaps(); }); -test("INDEX_ACCOUNT lives in the reserved namespace (no typed username can equal it)", () => { - // The index account occupies a key no user input can produce: it is prefixed - // with a NUL control char, which a user cannot type into a username field and - // which no `normalizeUser` output (a typeable value) contains. So it is +test("INDEX_ACCOUNT lives in the reserved namespace (no typed id can equal it)", () => { + // The index account occupies a key no consumer input can produce: it is prefixed + // with a NUL control char, which a user cannot type into an id field and + // which no `normalizeId` output (a typeable value) contains. So it is // disjoint from the keys "index" / "@index" a hostile user would submit. expect(INDEX_ACCOUNT.startsWith("\u0000")).toBe(true); // unreachable-by-typing sentinel expect(INDEX_ACCOUNT).not.toBe("index"); diff --git a/packages/client/test/isolation-active.test.ts b/packages/client/test/isolation-active.test.ts index f60b735..b72bd1e 100644 --- a/packages/client/test/isolation-active.test.ts +++ b/packages/client/test/isolation-active.test.ts @@ -1,15 +1,18 @@ /** - * ReadCap ACTIVE (T03.h) — end-to-end proof that the emulated SDK enforces - * per-DOCUMENT isolation, driven by per-entity documents + BILATERAL connections. + * ReadCap ACTIVE — end-to-end proof that the emulated SDK enforces per-DOCUMENT + * isolation, driven by per-entity documents + DIRECTED read grants. * - * Mirrors exactly what the app does: create an entity document through the REAL - * registry (`createEntityDoc`), declare its cap policy via - * `getCaps().open(doc, scope, owner)`, set the current identity, and declare - * connections as the CURRENT identity's own peers (authenticated, bilateral). The - * read filter then discriminates: - * (a) unconnected principal denied a PROTECTED doc; granted after a BILATERAL - * connection; PUBLIC readable throughout — via the ACTIVE ReadCap. - * (b) a UNILATERAL / self-declared connection grants NOTHING. + * Mirrors what the app does: create an entity document through the REAL registry + * (`createEntityDoc`), declare its cap policy via `getCaps().open(doc, scope, + * owner)`, set the current identity, and — when the app decides two identities + * are related — issue a DIRECTED read grant on each of the owner's protected + * documents (`getCaps().grantRead(doc, granteeId)`). Whether identities are + * "connected" is the application's own concept: this test plays that role + * directly. The read filter then discriminates: + * (a) an ungranted principal is denied a PROTECTED doc; granted once the owner + * issues a directed grant; PUBLIC readable throughout — via the ACTIVE + * ReadCap. + * (b) no grant → no protected read (a reader cannot grant itself). */ import { test, expect, mock, afterAll } from "bun:test"; import { createEntityDoc, resetRegistryCache } from "../src/store-registry"; @@ -22,7 +25,6 @@ import { getCaps, resetCaps, setCurrentUser, - declareConnections, } from "../src/polyfill"; import { filterReadable } from "../src/read-filter"; @@ -43,13 +45,19 @@ function inject() { sparql_query: mock(async () => ({ results: { bindings: [] } })), }; configure({ ng: ng as any, useShape: (() => {}) as any }); - configureStoreRegistry({ getSession: async () => SESSION, normalizeUser: (u) => u.trim() }); + configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() }); resetRegistryCache(); resetCaps(); setCurrentUser(null); return ng; } +/** The app's relationship concept, played inline: grant `reader` the read cap of + * every protected document owned by `owner`. */ +function grantOwnerProtectedTo(owner: string, reader: string) { + for (const doc of getCaps().protectedDocsOf(owner)) getCaps().grantRead(doc, reader); +} + test("ReadCap active: a private entity doc created via the real registry is hidden from another principal", async () => { inject(); @@ -70,9 +78,9 @@ test("ReadCap active: a private entity doc created via the real registry is hidd expect(getCaps().hasReadPolicy()).toBe(true); }); -// (a) protected hidden while unconnected → revealed after a BILATERAL connection; -// public readable regardless — all through the ACTIVE ReadCap. -test("(a) PROTECTED doc: hidden unconnected, revealed after BILATERAL connection, PUBLIC always readable", async () => { +// (a) protected hidden while ungranted → revealed after a DIRECTED grant; public +// readable regardless — all through the ACTIVE ReadCap. +test("(a) PROTECTED doc: hidden ungranted, revealed after a DIRECTED grant, PUBLIC always readable", async () => { inject(); const aliceProtected = await createEntityDoc("alice", "protected"); @@ -86,22 +94,22 @@ test("(a) PROTECTED doc: hidden unconnected, revealed after BILATERAL connection ]; const view = (u: string) => filterReadable(items, getCaps(), u).map((i) => i["@id"]).sort(); - // BEFORE any connection: bob sees only the public item. + // BEFORE any grant: bob sees only the public item. expect(view("bob")).toEqual(["u1"]); expect(view("alice")).toEqual(["p1", "u1"]); - // BILATERAL: alice asserts bob AND bob asserts alice → the link materializes and - // the SDK issues the protected doc's read cap to bob. - declareConnections(["bob"], "alice"); - declareConnections(["alice"], "bob"); + // The app decides alice↔bob are related and grants bob the read cap of alice's + // protected documents. + grantOwnerProtectedTo("alice", "bob"); expect(view("bob")).toEqual(["p1", "u1"]); - // A third, unconnected principal still sees only the public one. + // A third, ungranted principal still sees only the public one. expect(view("carol")).toEqual(["u1"]); }); -// (b) A UNILATERAL / self-declared connection must NOT grant protected read. -test("(b) a UNILATERAL / self-declared connection grants NO protected read", async () => { +// (b) An identity gets no protected read until the OWNER issues the grant — a +// reader cannot grant itself. +test("(b) no directed grant → no protected read", async () => { inject(); const aliceProtected = await createEntityDoc("alice", "protected"); @@ -109,15 +117,11 @@ test("(b) a UNILATERAL / self-declared connection grants NO protected read", asy const items = [{ "@graph": aliceProtected, "@id": "p1" }]; const view = (u: string) => filterReadable(items, getCaps(), u).map((i) => i["@id"]); - // The ATTACKER (mallory) self-declares a connection to alice — a UNILATERAL - // assertion authored by mallory. Alice NEVER asserts mallory back. - declareConnections(["alice"], "mallory"); - expect(view("mallory")).toEqual([]); // still denied — no bilateral link + // mallory holds no grant on alice's protected doc → denied. + expect(view("mallory")).toEqual([]); - // Even if alice connects to bob (a different, legitimate bilateral link), - // mallory's one-sided assertion still grants nothing. - declareConnections(["bob"], "alice"); - declareConnections(["alice"], "bob"); + // Granting bob (a different, legitimate reader) leaves mallory denied. + grantOwnerProtectedTo("alice", "bob"); expect(view("mallory")).toEqual([]); expect(view("bob")).toEqual(["p1"]); }); diff --git a/packages/client/test/isolation.test.ts b/packages/client/test/isolation.test.ts deleted file mode 100644 index 059bffd..0000000 --- a/packages/client/test/isolation.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { test, expect } from "bun:test"; -import { - applyIsolation, - connectionsFromLinks, - visibleSet, - isVisible, - type IsolationAccessors, -} from "../src/isolation"; -import type { Scope } from "../src/types"; - -// Generic item: owner + scope. ZERO domain — the accessors read these fields. -interface Item { - id: string; - owner: string; - scope: Scope; -} -const acc: IsolationAccessors = { ownerOf: (i) => i.owner, scopeOf: (i) => i.scope }; - -// alice —— bob (carol is unconnected) -const links = [{ a: "alice", b: "bob" }]; -const conns = connectionsFromLinks(links); - -test("connectionsFromLinks / visibleSet: self + direct connections, both directions", () => { - expect([...conns.neighbors("alice")].sort()).toEqual(["bob"]); - expect([...conns.neighbors("bob")].sort()).toEqual(["alice"]); - expect([...conns.neighbors("carol")]).toEqual([]); - - expect([...visibleSet("alice", conns)].sort()).toEqual(["alice", "bob"]); - expect([...visibleSet("carol", conns)].sort()).toEqual(["carol"]); -}); - -test("isVisible matrix: public=all, protected=owner+connections, private=owner", () => { - const vis = visibleSet("alice", conns); // { alice, bob } - // public → everyone, regardless of owner - expect(isVisible("public", "carol", "alice", vis)).toBe(true); - // protected → owner must be in the visible set - expect(isVisible("protected", "bob", "alice", vis)).toBe(true); - expect(isVisible("protected", "carol", "alice", vis)).toBe(false); - // private → owner must be the current principal - expect(isVisible("private", "alice", "alice", vis)).toBe(true); - expect(isVisible("private", "bob", "alice", vis)).toBe(false); -}); - -test("applyIsolation narrows a mixed collection for the current principal", () => { - const items: Item[] = [ - { id: "e", owner: "carol", scope: "public" }, // public → kept for all - { id: "p1", owner: "alice", scope: "protected" }, // own protected → kept - { id: "p2", owner: "bob", scope: "protected" }, // connection's protected → kept - { id: "p3", owner: "carol", scope: "protected" }, // stranger's protected → dropped - { id: "s1", owner: "alice", scope: "private" }, // own private → kept - { id: "s2", owner: "bob", scope: "private" }, // connection's private → dropped - ]; - - const forAlice = applyIsolation(items, "alice", conns, acc).map((i) => i.id); - expect(forAlice).toEqual(["e", "p1", "p2", "s1"]); - - const forCarol = applyIsolation(items, "carol", conns, acc).map((i) => i.id); - // carol sees: public + her own protected + her own private - expect(forCarol).toEqual(["e", "p3"]); -}); - -test("applyIsolation with empty principal passes everything through (hydration guard)", () => { - const items: Item[] = [ - { id: "s1", owner: "alice", scope: "private" }, - { id: "p1", owner: "bob", scope: "protected" }, - ]; - expect(applyIsolation(items, "", conns, acc).map((i) => i.id)).toEqual(["s1", "p1"]); -}); diff --git a/packages/client/test/read-model.test.ts b/packages/client/test/read-model.test.ts index fa02822..45a4035 100644 --- a/packages/client/test/read-model.test.ts +++ b/packages/client/test/read-model.test.ts @@ -34,7 +34,7 @@ function inject(triplesByDoc: Record>) { configure({ ng: ng as any, useShape: (() => {}) as any }); configureStoreRegistry({ getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }), - normalizeUser: (u: string) => u, + normalizeId: (u: string) => u, }); return ng; } @@ -96,7 +96,7 @@ test("a doc that fails to read is skipped, not aborting the batch", async () => configure({ ng: ng as any, useShape: (() => {}) as any }); configureStoreRegistry({ getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }), - normalizeUser: (u: string) => u, + normalizeId: (u: string) => u, }); const subjects = await readUnion(["did:ng:o:ok", "did:ng:o:bad"]); diff --git a/packages/client/test/sparql.test.ts b/packages/client/test/sparql.test.ts index db934bd..fa72aab 100644 --- a/packages/client/test/sparql.test.ts +++ b/packages/client/test/sparql.test.ts @@ -43,7 +43,7 @@ test("escapeIri percent-encodes every IRI-breaking character", () => { test("escapeIri neutralises a full breakout attempt", () => { const attack = 'x> "pwn'; const encoded = escapeIri(attack); - // The encoded username cannot contain a raw `>`, `<`, `"` or space, so it + // The encoded id cannot contain a raw `>`, `<`, `"` or space, so it // cannot escape the surrounding IRI. expect(encoded).not.toMatch(/[<>" ]/); }); diff --git a/packages/client/test/store-registry.test.ts b/packages/client/test/store-registry.test.ts index ab1912e..455d75a 100644 --- a/packages/client/test/store-registry.test.ts +++ b/packages/client/test/store-registry.test.ts @@ -112,7 +112,7 @@ function makeFakeNg() { const sparql_query = mock(async (...a: unknown[]) => { const query = a[1] as string; const anchor = a[3] as string | undefined; - if (query.includes("")) { + if (query.includes("")) { // Account SELECT, scoped to the anchor graph. Two shapes: the full scan // (`?acc a `) and the TARGETED bounded resolve (` a // `) which binds one subject — honour that subject so the bounded @@ -124,16 +124,16 @@ function makeFakeNg() { if (q.g !== anchor) continue; if (onlySubject !== null && q.s !== onlySubject) continue; const rec = bySubject.get(q.s) ?? {}; - if (q.p === "urn:ng-eventually:shim:username") rec.username = q.o; + if (q.p === "urn:ng-eventually:shim:id") rec.id = q.o; if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o; if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = q.o; if (q.p === "urn:ng-eventually:shim:docPrivate") rec.docPrivate = q.o; bySubject.set(q.s, rec); } const bindings = [...bySubject.values()] - .filter((r) => r.username) + .filter((r) => r.id) .map((r) => ({ - username: { value: r.username! }, + id: { value: r.id! }, docPublic: { value: r.docPublic ?? "" }, docProtected: { value: r.docProtected ?? "" }, docPrivate: { value: r.docPrivate ?? "" }, @@ -157,7 +157,7 @@ function inject() { configure({ ng: ng as any, useShape: (() => {}) as any }); configureStoreRegistry({ getSession: async () => SESSION, - normalizeUser: (u) => u.trim().replace(/^@+/, "").toLowerCase(), + normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(), }); resetRegistryCache(); return ng; @@ -170,7 +170,7 @@ beforeEach(() => { test("ensureAccount creates 3 scope docs and persists them to the shim", async () => { const rec = await ensureAccount("Alice"); - expect(rec.username).toBe("Alice"); + expect(rec.id).toBe("Alice"); expect(fake.doc_create).toHaveBeenCalledTimes(3); expect(rec.docPublic).toMatch(/^did:ng:o:doc/); expect(rec.docProtected).not.toBe(rec.docPublic); @@ -191,7 +191,7 @@ test("loadShim round-trips a persisted account across a cache reset", async () = resetRegistryCache(); // force a re-read from the fake store const map = await loadShim(); const rec = map.get("bob"); - expect(rec?.username).toBe("Bob"); + expect(rec?.id).toBe("Bob"); expect(rec?.docPublic).toMatch(/^did:ng:o:doc/); }); @@ -260,19 +260,19 @@ test("listEntityDocs fans out across multiple accounts", async () => { test("allAccounts reflects every ensured account", async () => { await ensureAccount("Gina"); await ensureAccount("Hank"); - const names = (await allAccounts()).map((a) => a.username).sort(); + const names = (await allAccounts()).map((a) => a.id).sort(); expect(names).toEqual(["Gina", "Hank"]); }); // --- SPARQL injection hardening (F1) -------------------------------------- // -// A malicious username must NOT be able to break out of the literal / IRI it +// A malicious id must NOT be able to break out of the literal / IRI it // lands in and inject arbitrary triples into the shim (the account→doc trust // root). We inspect the exact SPARQL string the registry hands to sparql_update. -/** The raw INSERT DATA string produced by ensureAccount for `username`. */ -async function insertFor(username: string): Promise { - await ensureAccount(username); +/** The raw INSERT DATA string produced by ensureAccount for `id`. */ +async function insertFor(id: string): Promise { + await ensureAccount(id); const calls = fake.sparql_update.mock.calls; return calls[calls.length - 1]![1] as string; } @@ -284,31 +284,31 @@ function rawQuoteCount(s: string): number { return (withoutEscapes.match(/"/g) ?? []).length; } -test("injection: username with a quote cannot open extra literals", async () => { +test("injection: id with a quote cannot open extra literals", async () => { const evil = 'x" ; "pwn'; const update = await insertFor(evil); // A well-formed INSERT DATA with 4 predicate literals has exactly 8 raw // quotes (the delimiters). The injected `"` must have been escaped, so the // count stays 8 — no extra literal was opened. expect(rawQuoteCount(update)).toBe(8); - // The escaped username is present as a single literal value — the injected + // The escaped id is present as a single literal value — the injected // `` survives only as INERT text inside that literal (its // surrounding quotes are escaped `\"`), never as query syntax. expect(update).toContain('"x\\" ; \\"pwn"'); }); -test("injection: username with '>' cannot break out of the account-subject IRI", async () => { +test("injection: id with '>' cannot break out of the account-subject IRI", async () => { const evil = "x> ` — the encoded - // username must NOT contain a raw `>` that would close the IRI early. + // id must NOT contain a raw `>` that would close the IRI early. const subjMatch = update.match(/]*)>/)!; expect(subjMatch).not.toBeNull(); expect(subjMatch[1]).not.toMatch(/[<>" ]/); // fully percent-encoded expect(subjMatch[1]).toContain("%3E"); // the `>` became %3E }); -test("injection: newline / control chars in username are neutralised", async () => { +test("injection: newline / control chars in id are neutralised", async () => { const evil = "a\nb\tc"; const update = await insertFor(evil); // In the literal: escaped to \n / \t (no raw control char). @@ -342,20 +342,20 @@ function escapeLiteralRef(v: string): string { .replace(/\t/g, "\\t")}"`; } -test("injection: a malicious username still round-trips through the shim", async () => { +test("injection: a malicious id still round-trips through the shim", async () => { const evil = 'eve" ; "x'; const rec = await ensureAccount(evil); - expect(rec.username).toBe(evil); + expect(rec.id).toBe(evil); resetRegistryCache(); const map = await loadShim(); - // The stored username came back verbatim (escaping is lossless) under its + // The stored id came back verbatim (escaping is lossless) under its // normalized key, and exactly ONE account exists (no injected extra subject). const key = evil.trim().replace(/^@+/, "").toLowerCase(); - expect(map.get(key)?.username).toBe(evil); + expect(map.get(key)?.id).toBe(evil); expect(map.size).toBe(1); }); -test("normalizeUser defaults to trim when not provided", async () => { +test("normalizeId defaults to trim when not provided", async () => { const ng = makeFakeNg(); configure({ ng: ng as any, useShape: (() => {}) as any }); configureStoreRegistry({ getSession: async () => SESSION });