docs+refactor(client): fidelity pass — id identity, drop connections, no faux-login, accurate NextGraph framing

Align the polyfill's surface and docs with the verified NextGraph reality and
remove application-level concepts:

- Identity is an ID, not a username: AccountRecord.id, shim predicate shim:id,
  normalizeId; accounts core becomes IdentityStore (set/clear/get) — the faux
  login/logout framing is gone (identity is set at wallet-import time).
- Relationship/connection is an application concept, not a platform primitive
  (NextGraph has no bilateral-connection primitive: grantee is unpersisted
  scaffolding, cap-send is unimplemented). Remove connections.ts; caps exposes
  only a directed grantRead(doc, granteeId) + a read-only protectedDocsOf(owner).
  Delete the now-dead isolation.ts social-visibility axis.
- Inbox docs: NextGraph has no separate curator — the recipient's own verifier
  unseals and applies each queued sealed message inline (process_inbox);
  inbox_post_link is a proposed/future API. Stop attributing the emulated
  curator to the platform.
- Read isolation reframed around the outcome: no cap -> empty union read;
  targeted read of an unheld repo -> RepoNotFound; cap introspection
  (canRead/governsRead) is emulation-only with no NextGraph API behind it.
- read-model.md corrected: the listing path is per-doc ANCHORED default-graph
  queries, never the anchorless GRAPH ?g union (that is O(wallet)); the probe
  section no longer claims the opposite.
- README recap table restructured (target | current NextGraph status | current
  emulation); INDEX_ACCOUNT documented as reservedAccount("index") in the
  sentinel namespace; de-domained generic-layer comments; softened tone.

Consumer application (Festipod) rewired separately to own the relationship
concept and feed the lib an id. Lib gates: bun test 83 pass / 0 fail, tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sylvain Duchesne
2026-07-06 14:02:16 +02:00
parent d39b12885a
commit 63ecfeeff8
31 changed files with 1059 additions and 1396 deletions
+85 -91
View File
@@ -1,128 +1,122 @@
# ng-eventually # 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 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 or group stores. `ng-eventually` lets a consumer application behave as if those
emulating them on top of a **single shared wallet / broker**. It is **generic**: existed today, by emulating them on top of a single shared wallet / broker. It is
it contains **no application domain** — the consumer injects its shapes and the generic: it contains no application domain — the consumer application injects its
*acts* of granting access. shapes and the acts of granting access.
The name: *eventually* NextGraph will ship these features; until then this layer The name: *eventually* NextGraph will ship these features; until then this layer
fills the gap (and nods at eventual consistency / events). fills the gap (and nods at eventual consistency / events).
## The boundary — mature face out, compensation in ## The boundary — mature face out, compensation in
The asymmetry is the whole point. **Consumers write SDK-shaped code as if The asymmetry is the point. The consumer application writes SDK-shaped code as if
NextGraph were finished**: per-entity documents in public/protected/private NextGraph were finished: per-entity documents in public/protected/private stores,
stores, capabilities, inboxes. This library **owns all the current-state capabilities, inboxes. This library owns the current-state NextGraph knowledge and
NextGraph knowledge and the simulation** that fabricates that mature face — a the simulation that fabricates that mature face — a shared-wallet emulation — so
**shared-wallet** emulation — so the application never sees it. When NextGraph the application never sees it. As NextGraph matures, this library changes; the
matures, **only this library changes**; the consumer's code does not. consumer application's code does not.
Docs (this library's own engineering doctrine, under [`docs/`](./docs/)): Docs (this library's own engineering doctrine, under [`docs/`](./docs/)):
- [`docs/nextgraph-current-state.md`](./docs/nextgraph-current-state.md) — the - [`docs/nextgraph-current-state.md`](./docs/nextgraph-current-state.md) — the
authoritative reference on what the CURRENT SDK/broker do and do NOT expose authoritative reference on what the current SDK/broker do and do not expose
(the ground truth every polyfill compensates for). (the ground truth each polyfill compensates for).
- [`docs/simulation.md`](./docs/simulation.md) — how this lib emulates the mature - [`docs/simulation.md`](./docs/simulation.md) — how this lib emulates the mature
behaviour on ONE shared wallet (shim, per-document ReadCaps, emulated behaviour on one shared wallet (shim, per-document ReadCaps, emulated inbox,
inbox+curator, write guard, faux login, the two axes, the double-proxy write guard, the two axes, the double-proxy constraint).
constraint). - [`docs/read-model.md`](./docs/read-model.md) — the read model the polyfill
- [`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 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 graph; listing via a bounded set of per-doc anchored `sparql_query`s; reactivity
an anchorless union-scan of the physical wallet, never the ORM fan-out — both via re-query on a change signal.
hang/time out); reactivity via re-query on a change signal. - [`docs/decisions/`](./docs/decisions/) — current-SDK ADRs (private-store scope,
- [`docs/decisions/`](./docs/decisions/) — historical current-SDK ADRs SPARQL delete, shared-wallet identity, discovery mechanism).
(private-store scope, SPARQL delete, shared-wallet login, discovery mechanism).
- [`docs/fork-inbox-fallback.md`](./docs/fork-inbox-fallback.md) — the Rust-patch / - [`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 - [`docs/migration-guide.md`](./docs/migration-guide.md) — the checklist for when
real NextGraph matures. 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 Nothing in this library is a real NextGraph feature. Each behaviour below is
**emulated** — a stopgap fabricated on top of the *current, immature* NextGraph emulated — a stopgap fabricated on top of the current, immature NextGraph (one
(one shared wallet, everything physically readable). Each has a **real NextGraph shared wallet, everything physically readable). The consumer application always
target**, and the switch to it is a **lib-only swap: the consumer's SDK-shaped code sees the mature SDK face; the emulation lives entirely here.
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.
| 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 | | 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` |
| **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 | | 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** | `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`) | | 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 |
| **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 | | 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` | 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) | | 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()` | 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 | | 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** | `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) | | 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 | **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 | | 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** | 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 | | 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`) |
| **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) | | 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 | **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) | | 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 ## Packages
| Package | Role | At migration | | Package | Role |
|---|---|---| |---|---|
| **`@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). | | `@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 A global-index package is deferred. In NextGraph an identity's apps and services
> no global data** (apps/services see only what the user shares; there is no see only what it shares, so there is no multi-identity backend. A global index
> multi-user backend). A global index would come from a **singleton app** would come from a singleton app (a global document administered by the developer),
> (a global document administered by the developer) — **not implemented and which is not implemented and undecided; simpler paths may exist. So there is no
> uncertain**, and simpler paths may exist. So no second package for now; it will second package for now it will be introduced once the global-index mechanism is
> be (re)introduced once the global-index mechanism is decided. The curator must decided, and it will be separate from the client.
> never be bundled in the client → it will be a separate package when it lands.
## Design principle ## Design principle
The application code is written **as if the target NextGraph existed**. All The application code is written as if the target NextGraph existed. All
compensation lives here, *beside* the app. Migration = remove this layer; the compensation lives here, beside the app. As NextGraph matures, this layer falls
app code (SDK-shaped) is unchanged. away; the app code (SDK-shaped) is unchanged.
- **SDK-identical surface**: the client wraps the real `ng` (a Proxy that - SDK-identical surface: the client wraps the real `ng` (a Proxy that forwards
forwards everything and overrides only what must be emulated) and `useShape`. everything and overrides only what must be emulated) and `useShape`. The real
The real SDK is **injected** via `configure()` (no hard import → build-alias SDK is injected via `configure()` (no hard import → build-alias safe and
safe + testable). testable).
- **Authorization = emulated capabilities**: documents carry grants; the client - Authorization is emulated capabilities: documents carry grants; the client
enforces them generically (read filter + write guard). The app *attaches* enforces them generically (read filter + write guard). The app declares a
grants via cap operations — same as it will in the target. No policy is document's read policy and issues directed grants — the same acts it will
injected. perform in the target. No policy is injected.
- **Inbox**: the client `inbox` namespace deposits (`post`) and, in the - Inbox: the client `inbox` namespace deposits (`post`) and, in the shared-wallet
shared-wallet emulation, also plays the curator inline (`read` / `materialize` emulation, reads the deposits back (`read` / `materialize` / `watch`) in place
/ `watch`). At migration the read side moves to a separate curator package, of the recipient's own inbox processing.
deferred with the global-index mechanism — see the note above. - Tests of the polyfill (against a real broker) live in this repo, so a consuming
- **Tests** of the polyfill (against a real broker) live **in this repo**, so app can test its features against a clean, mocked API.
the consuming app can test its features against a mocked, clean API.
## Status ## Status
**Implemented.** The polyfill mechanisms are wired against a real broker, not Implemented. The polyfill mechanisms are wired against a real broker, not stubbed:
stubbed:
- **Shared-wallet shim**`store-registry.ts` (`(account, scope) → document - Shared-wallet shim — `store-registry.ts` (`(account, scope) → document NURI`,
NURI`, `createEntityDoc` / `listEntityDocs` + per-scope index, cross-device via `createEntityDoc` / `listEntityDocs` + per-scope index, cross-device via the RDF
the RDF shim anchored in the private store). shim anchored in the private store).
- **Document / SPARQL primitive** — `docs.ts`, calling the real injected `ng` - Document / SPARQL primitive — `docs.ts`, calling the real injected `ng` directly
directly (avoids the `@ng-org` double-proxy `DataCloneError`). (avoids the `@ng-org` double-proxy `DataCloneError`).
- **Emulated ReadCaps** — `caps.ts` (`CapRegistry`, per-document) + read filter - Emulated ReadCaps — `caps.ts` (`CapRegistry`, per-document, directed grants) +
`read-filter.ts` (reactive-set `Proxy` view), applied by `use-shape.ts` only read filter `read-filter.ts` (reactive-set `Proxy` view), applied by
when a policy is declared. `use-shape.ts` only when a policy is declared.
- **Write guard** — `ng-proxy.ts` (`sparql_update` override, emulated write cap). - Write guard — `ng-proxy.ts` (`sparql_update` override, emulated write cap).
- **Inbox** — `inbox.ts` (`post` / `read` / `materialize` / `watch`, emulated - Inbox — `inbox.ts` (`post` / `read` / `materialize` / `watch`).
curator inline). - Identity — `accounts.ts` (`IdentityStore`, injected storage).
- **Isolation** — `isolation.ts` (pure social-visibility matrix, distinct axis - SPARQL hardening — `sparql.ts` (`escapeLiteral` / `escapeIri` / `assertNuri`).
from ReadCaps).
- **Accounts** — `accounts.ts` (faux username login, injected storage).
- **SPARQL hardening** — `sparql.ts` (`escapeLiteral` / `escapeIri` / `assertNuri`).
Remaining `TODO` markers are narrow: the shared-wallet credential passthrough in The remaining `TODO` markers are narrow: the shared-wallet credential passthrough
the `login`/`session_start` proxy branch, and the anticipated cap/inbox SDK in the `session_start` proxy branch, and the anticipated cap/inbox SDK signatures
signatures to reconcile if the official API differs. See to reconcile if the official API differs. See
[`docs/simulation.md`](./docs/simulation.md) for what each piece does and [`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 [`docs/migration-guide.md`](./docs/migration-guide.md) for what changes as
migration. NextGraph matures.
+49 -46
View File
@@ -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. **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 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 product intent (what a consumer application *should* surface) is the consumer
this lib's; only the mechanism is recorded here. 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 - **Access**: may I read this document if I hold it? A public entity is
world-readable with its NURI. 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 ## The mechanism
1. **A single global index**, **fed via ITS inbox**. The creator does **not** edit 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 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 index is an owned document (public read), built up from its inbox (a
watcher ingests deposits adds entries). watcher ingests deposits and adds entries).
2. **Primary discovery = that global index.** 2. Primary discovery is that global index.
3. **Relational = secondary axis**, overlaid: a connection's participations, 3. Relational is a secondary axis, overlaid: a peer's participations,
markers on the global list. Rests on existing per-item data (protected scope) markers on the global list. It rests on existing per-item data (protected scope),
no new primitive. with no new primitive.
## The 3-stage frame ## The 3-stage frame
`discovery → synchronization → query` `discovery → synchronization → query`
1. **Discovery**: the index gives the NURIs of the entity documents. 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). (verifier: `self.repos` + oxigraph dataset).
3. **Query**: query what is **now local** (sort, limit, reactivity). **SPARQL/ORM 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`) run on the local set only (`resolve_target_for_sparql` searches `self.repos`)
you cannot query what is not loaded. you cannot query what is not loaded.
**Corollary:** a reactive query does not replace the index — it runs at stage 3 on **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. the local union that stages 1-2 built. You don't sync what you didn't discover.
## Why one reused mechanism ## Why one reused mechanism
- **No Group store.** The index is **not** open-write: it is an **owned document** - **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 (public read) plus a native inbox (a primitive present on every document). Nobody
writes the index but its owner (by materializing inbox deposits). So the model writes the index but its owner (by ingesting inbox deposits). So the model
stays "3 stores + Dialog + inboxes, no Group store." stays "3 stores + Dialog + inboxes, no Group store."
- **One mechanism, reused.** The **inbox + materialization watcher** serve BOTH - **One mechanism, reused.** The inbox + ingest watcher serve both
submitting an entity to the index AND registering to a meeting-point — same submitting an entity to the index and a registration/deposit in one consumer
`inbox.post` API, same handling. This is exactly `inbox.ts` in this lib (`post` app — same `inbox.post` API, same handling. This is exactly `inbox.ts` in this
/ `read` / `materialize` / `watch`). lib (`post` / `read` / `materialize` / `watch`).
- **Natural dedup / moderation point:** materialization (inbox → index) is where - **Natural dedup / moderation point:** the inbox → index ingest is where
duplicates are detected / moderated before insertion. duplicates are detected / moderated before insertion.
## Index owner — target model undecided ## Index owner — target model undecided
The "dedicated service with its own wallet sharing a freely-readable index" was NextGraph apps and services are mono-user with no global data
**incorrect**: NextGraph apps and services are **mono-user with no global data**
(see [`../nextgraph-current-state.md`](../nextgraph-current-state.md) § Apps & (see [`../nextgraph-current-state.md`](../nextgraph-current-state.md) § Apps &
services). The only path glimpsed for a global document is a **singleton app** services), so a dedicated service with its own wallet sharing a freely-readable
bound to the developer-user — **not implemented, uncertain**, to explore later. index is not a NextGraph shape. The only path glimpsed for a global document is a
This is why a global-index curator is a **deferred separate package** in this lib 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). (see the top-level README).
## Polyfill reality — the fan-out drift is now RESOLVED (special-account index) ## Polyfill reality — the fan-out drift is now RESOLVED (special-account index)
The shared-wallet polyfill originally shipped a **cross-account fan-out over The shared-wallet polyfill originally shipped a cross-account fan-out over
every account's public documents** (`store-registry.ts` `listEntityDocs('public')` every account's public documents (`store-registry.ts` `listEntityDocs('public')`
/ `resolveReadGraphs`) — one account saw another's public entity **without a / `resolveReadGraphs`) — one account saw another's public entity without any
connection**. This ADR classified that per-account fan-out as a **drift** to be relationship to its creator. This ADR classified that per-account fan-out as a drift
replaced by the single global index. to be replaced by the single global index.
**That drift is now resolved in the polyfill.** The inbox-fed global index of 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 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 (`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 the target owner stays undecided: `submitToIndex(ref)` deposits into the index
document's inbox; `readIndex()` materializes (dedup) the entries. The app-facing document's inbox; `readIndex()` ingests (dedups) the entries. The app-facing
discovery path is now **read the index**, exactly as this ADR prescribes — NOT 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 the fan-out. The cross-account fan-out survives only as an internal lib
fallback** (it still powers per-scope listing like `resolveReadGraphs`), never fallback (it still powers per-scope listing like `resolveReadGraphs`), never
the discovery route. The special account is the provisional owner; at migration the discovery route. The special account is the provisional owner; at migration
it disappears and ownership moves to the decided global-index owner (see it disappears and ownership moves to the decided global-index owner (see
[`../migration-guide.md`](../migration-guide.md)) with the consumer surface [`../migration-guide.md`](../migration-guide.md)) with the consumer application's
(`submitToIndex` / `readIndex`) unchanged. surface (`submitToIndex` / `readIndex`) unchanged.
## Alternatives rejected (mechanism) ## Alternatives rejected (mechanism)
- **Open-write index** (creator writes the index directly): required a - **Open-write index** (creator writes the index directly): required a
collaborative document (Group store, SDK-blocked) and exposed the index to 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 - **Purely relational discovery** (`social_query`): rejected as *primary* (a
global list is wanted); kept as a secondary axis. global list is wanted); kept as a secondary axis.
- **No index, direct reactive query**: impossible — SPARQL is local-only (stage 3). - **No index, direct reactive query**: impossible — SPARQL is local-only (stage 3).
+39 -33
View File
@@ -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 **Date:** 2026-06-15 · **Status:** Accepted (frozen). The rationale behind how
lib's **faux login** (`accounts.ts`) and why it must never touch NextGraph. 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 ## 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 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 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. avoid the redirect" but "how to order and present it" so the UX stays coherent.
## Decision — technical gate first, application "Connexion" second ## 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 1. **Real layer (technical, not perceived as login).** The broker redirect appears
**immediately, before any app render**. Because it precedes the app, the user 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 reads it as a technical access barrier to the test environment (a beta
wall), **not** an application login. Same shared credentials for everyone wall), not an application login. Same shared credentials for everyone
(given in the invitation, "access code" style). Once per device, then (given in the invitation, "access code" style). Once per device, then
persistent. **Never labelled "login."** persistent. It is not labelled "login."
2. **Application layer (perceived as THE login).** A **"Connexion"** screen = 2. **Application layer (perceived as the login).** A "Connexion" screen where the
**username only** (→ `localStorage`, the current principal). This is the login user picks an identity id (relayed to the lib's identity store, persisted in
*in the user's perception*. **No password** → declarative connection (anyone `localStorage`, the current principal). This is the login *in the user's
takes any username — coherent with zero-security / friends). **"Déconnexion"** perception*, presented by the consumer application. No password — declarative
clears **only** the username and returns to "Connexion"; it **calls no NG selection (anyone takes any id — coherent with zero-security / friends). In
function**. 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 The real logout (`ng.session_stop` / `user_disconnect` / `wallet_close`) stays
**hidden** (settings/debug), because it forces a new redirect. hidden (settings/debug), because it forces a new redirect.
## Why (vs the rejected option) ## Why (vs the rejected option)
**Rejected**faux login first, then a warning page "enter this username/password", **Rejected**a perceived login first, then a warning page "enter this
then a *Continue* button triggering the redirect. Rejected: strange workflow, id/password", then a *Continue* button triggering the redirect. Rejected: strange
dissonant double-login, a warning page that **looks like a scam**, and the workflow, dissonant double-login, a warning page that looks like a scam, and the
redirect **resurfacing mid-use** on every session expiry. redirect resurfacing mid-use on every session expiry.
**Chosen** because: the mental model stays coherent (the technical barrier not **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 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 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 "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. infra — the "broker redirect → app" shape is exactly the real multi-wallet flow.
At migration you **remove the username "Connexion" screen** and the **technical At migration you remove the id "Connexion" screen and the technical
barrier becomes the real per-user login** — the flow shape does not change. barrier becomes the real per-user login — the flow shape does not change.
## Verified technical facts (`nextgraph-rs`, 2026-06-15) ## 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()` 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 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. 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 / `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 clear the wallet and force a new redirect afterwards — hence the app-level
them in the app-level "Déconnexion," and hide the real logout. "Déconnexion" does not call them, and the real logout stays hidden.
## How this lib realizes it ## How this lib realizes it
`accounts.ts` `AccountStore.login()`/`logout()` only read/write the username in an `accounts.ts` is an `IdentityStore`: `set(id)` / `clear()` / `get()` only read/write
injected `AccountStorage`; they **never** call NG. See the faux login in 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). [`../simulation.md`](../simulation.md).
+20 -20
View File
@@ -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 (`inbox.ts`, see [`simulation.md`](./simulation.md)). Kept as the fallback plan if
a **native** broker inbox ever becomes necessary — chiefly for the **crypto a native broker inbox ever becomes necessary — chiefly for the crypto
anonymity** the emulation does not provide (native `from = None` sealed deposit). 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 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 `InboxPost` arm and no wasm helper seals a deposit (see
[`nextgraph-current-state.md`](./nextgraph-current-state.md) § Inbox). Two ways to [`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 get a real inbox: emulate it (what this lib does) or fork the engine (this
document). The emulation won; this is the archived alternative. document). The emulation was chosen; this is the archived alternative.
## Strategic posture ## 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 session_id, to_inbox_nuri, to_profile_nuri, link, anonymous)`, modeled on
`social_query_start`. `social_query_start`.
4. **`engine/verifier/src/inbox_processor.rs`** (`process_inbox`) — a receive arm 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 where the owner's own verifier unseals the queued message and applies it inline
the `ContactDetails` handler). The app then reads via ORM/SPARQL — no new into the owner's store (model on the `ContactDetails` handler). The app then
inbox-read API. reads via ORM/SPARQL — no new inbox-read API.
**Identity resolution** (known/anonymous): free via app-side SPARQL (JOIN the Identity resolution (known/anonymous): free via app-side SPARQL (JOIN the
sender inbox NURI against `social:contact` docs). **Discovering the owner's 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 inbox: embed the owner's `public_store` inbox NURI in the entity document or
public profile (the QR profile-share flow already carries it). public profile (the QR profile-share flow already carries it).
## Layer 2 — deployment (from the fork) ## 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. sides. For write-only (request/response) — unneeded.
- **`@ng-org/orm`** — only if inbox writes join the ORM flow. Otherwise 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 Exposing the method is not enough; the consumer application must model the entity +
NURI, write the registration, deposit into the host inbox, and read/resolve 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 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 (registration wired on the emulated `inbox.post`), which is precisely why this fork
was not needed. was not needed.
## Why this fallback still matters ## Why this fallback still matters
The emulated inbox stores `from = null` as *absence of a triple*; it does not seal 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 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 application needs true anonymous-but-verifiable deposits to a non-connected host,
inbox (`from = None` sealed) delivers it — and this fork is the route. Until then, only a native inbox (`from = None` sealed) delivers it — and this fork is the route.
the emulation is sufficient. Until then, the emulation is sufficient.
+37 -34
View File
@@ -1,9 +1,8 @@
# Migration guide — when real NextGraph matures # Migration guide — when real NextGraph matures
The whole point of this library: the consumer already writes SDK-shaped code, so The whole point of this library: the consumer application already writes SDK-shaped
when NextGraph ships cross-wallet reads, capabilities and inboxes, **only this lib code, so when NextGraph ships cross-wallet reads, capabilities and inboxes, only this
changes**. The consumer's application code does **not** change. This is the lib changes. The consumer application's code does not change. This is the checklist.
checklist.
## Guiding invariant ## Guiding invariant
@@ -17,18 +16,19 @@ has no clear target image, that is a drift signal (see
### 1. Emulated ReadCaps → real capabilities ### 1. Emulated ReadCaps → real capabilities
Translate the per-document `CapRegistry` (`caps.ts`) into real NextGraph caps: the Translate the per-document `CapRegistry` (`caps.ts`) into real NextGraph caps: the
broker/verifier enforces them, and `useShape` already returns only authorized broker/verifier enforces them, and `useShape` already returns only authorized
documents. The read filter (`read-filter.ts`) and the write guard (`ng-proxy.ts` documents. The directed `grantRead(doc, granteeId)` maps to a native per-document
`sparql_update` override) are then **dead code** — remove them. The access unit is ReadCap issued to that identity. The read filter (`read-filter.ts`) and the write
already the document (`@graph`), matching the native per-repo cap model, so this is guard (`ng-proxy.ts` `sparql_update` override) are then dead code — remove them. The
a data step, not a reshape. 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 ### 2. Place documents in real native stores
Today `docCreate(..., undefined)` writes every document into the shared wallet's 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)). in the shim (see the two-axes section in [`simulation.md`](./simulation.md)).
- **`doc_create` cannot target a non-private native store today** — verified: - `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 `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 a public/protected store as the create destination (`docCreate`'s trailing
`store` arg is left `undefined` → private store). The private store works only `store` arg is left `undefined` → private store). The private store works only
because it opens without `RepoNotFound`. 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 `getNativeStore(scope)`-style resolver returning the real store to pass as the
`docCreate` destination, so the logical scope label becomes a real store `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.) 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 - 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 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 document (the store-container emulation) is replaced by the store itself. The
consumer-facing surface (`createEntityDoc`, `listEntityDocs`, resolvers) is surface facing the consumer application (`createEntityDoc`, `listEntityDocs`,
designed to survive that swap unchanged. resolvers) is designed to survive that swap unchanged.
### 3. Drop the resolver / shim ### 3. Drop the resolver / shim
The `sharedWalletShim` (account → 3 scope-document NURIs, RDF in the private store) 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 `store-registry.ts`, `configureStoreRegistry`, the shim SPARQL. Cross-wallet reads
replace the fan-out; per-user wallets replace the shared one. 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 Replace the emulated `inbox.ts` deposit (`docs.sparqlUpdate` into a shared-wallet
document) with the native `inbox_post_link`, and move `read`/`materialize`/`watch` document) with the native `inbox_post_link` (proposed/future). On the read side the
to a **separate curator package** (the deferred global-index curator — see the recipient's own verifier unseals each queued sealed message and applies it inline
top-level README and [`decisions/discovery-model.md`](./decisions/discovery-model.md)). when it processes its inbox — there is no separate curator to build; the in-lib read
The in-client read side goes away. The single global index replaces the emulation simply goes away (see the deferred global-index note in the top-level
cross-account fan-out. 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 ### 5. Retire the identity store → real per-user login
Remove `accounts.ts` (the username `localStorage` faux login) and the app-level Remove `accounts.ts` (the `IdentityStore` that persists the identity id in
"Connexion" screen. The technical broker gate **becomes** the real per-user login `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)). (see [`decisions/shared-wallet-login-flow.md`](./decisions/shared-wallet-login-flow.md)).
The flow shape ("broker redirect → app") does not change. The flow shape ("broker redirect → app") does not change.
### 6. Drop the isolation scaffold ### 6. Drop the isolation scaffold
`isolation.ts` (application social-visibility filter) disappears against a `isolation.ts` (application-visibility scaffold) disappears against a
different piece of infra than the caps: real per-account wallets + a real different piece of infra than the caps: real per-account wallets, and the
per-account social graph. Distinct axis from ReadCaps — remove independently. relationship concept the consumer application owns. Distinct axis from ReadCaps —
remove independently.
### 7. Remove the build alias — the client becomes the real SDK ### 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 The consumer application imports `@ng-org/web` / `@ng-org/orm` resolved to this lib
**build alias** during the polyfill period. Removing the alias makes those imports 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 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. `@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 The consumer application's code. Shapes, screens, the *acts* of granting
access, entity→scope mapping, the connection graph — all injected, all untouched. access, entity→scope mapping, the relationship graph — all injected, all untouched.
Migration is entirely inside this library plus removing the alias + the bootstrap 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 call. That asymmetry — a mature SDK face outward, all compensation inward — is the
library's reason to exist. library's reason to exist.
+60 -51
View File
@@ -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 users/quorum (write/permissions), **not** read-cap possession. (Repos of a
`private_store` inherit implicitly.) `private_store` inherit implicitly.)
> **Consequence for this lib's emulation (see [`simulation.md`](./simulation.md)):** > 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 > 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 > filter, never per-store and never per-item. This is exactly what
> `caps.ts` (`CapRegistry`) and `read-filter.ts` model: no store-level > `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 > 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 > 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 > 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) ### Store ↔ document confusion (recurring)
@@ -98,36 +102,40 @@ offline"*; *"removing permissions … requires a SyncSignature"* (synchronous).
## Inbox ## Inbox
**Every document has a native inbox.** A non-editor can **deposit a link (DID 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: cap) into it without being invited as an editor; the owner moderates. NURI:
`did:ng:d:<inbox_id>`. Content: the `InboxMsgContent` enum (`ContactDetails`, `did:ng:d:<inbox_id>`. Content: the `InboxMsgContent` enum (`ContactDetails`,
`DialogRequest`, **`Link`**, `Patch`, `ServiceRequest`, `ExtRequest`, `DialogRequest`, `Link`, `Patch`, `ServiceRequest`, `ExtRequest`,
`RemoteQuery`, `SocialQuery`…). Messages are **sealed** (`crypto_box::seal`) to `RemoteQuery`, `SocialQuery`…). Messages are sealed (`crypto_box::seal`) to
the inbox pubkey only the owner decrypts. The `from` field is **optional** an 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 anonymous sender is possible. This is the "identified if known, anonymous
otherwise" behaviour native to the protocol. 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` + - `app_request(request)` is exposed, and `AppRequestCommandV0::InboxPost` +
`AppRequest::inbox_post()` exist. **BUT** the verifier's `request_processor` `AppRequest::inbox_post()` exist, but the verifier's `request_processor`
has **no `InboxPost` arm** (arms actually handled: `OrmStart(Discrete)`, has no `InboxPost` arm (arms actually handled: `OrmStart(Discrete)`,
`Fetch`, `FileGet`, `OrmUpdate`, `OrmDiscreteUpdate`, `SocialQueryStart`, `Fetch`, `FileGet`, `OrmUpdate`, `OrmDiscreteUpdate`, `SocialQueryStart`,
`QrCodeProfile(Import)`, `Header`, `Create`, `FilePut`). Sending an `InboxPost` `QrCodeProfile(Import)`, `Header`, `Create`, `FilePut`). Sending an `InboxPost`
triggers nothing. triggers nothing.
- Building an `InboxPost` requires crypto sealing on the Rust side; **no wasm - Building an `InboxPost` requires crypto sealing on the Rust side; no wasm
helper** exposes it. helper exposes it. A high-level `inbox_post_link` is a proposed/future API, not
- Inbox deposit is only triggered **internally** by `QrCodeProfileImport` yet present.
- Inbox deposit is only triggered internally by `QrCodeProfileImport`
(`post_to_inbox(new_contact_details)`) and `social_query_start` (contact (`post_to_inbox(new_contact_details)`) and `social_query_start` (contact
propagation via inbox). propagation via inbox).
**Consequence:** there is no clean way to "drop a Link" into an arbitrary **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 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 + patching the broker — see [`simulation.md`](./simulation.md) (emulated inbox) and
curator) and [`fork-inbox-fallback.md`](./fork-inbox-fallback.md) (the Rust-patch [`fork-inbox-fallback.md`](./fork-inbox-fallback.md) (the Rust-patch path not taken).
path NOT taken). A related exposed primitive: `social_query_start` (a federated A related exposed primitive: `social_query_start` (a federated query via inbox up to
query via inbox up to `degree` hops) exists but is limited to **contacts** — it `degree` hops) exists but is limited to contacts — it does not cover an anonymous
does not cover an anonymous notification to a non-connected host. notification to a non-connected host.
## The query capability — ONE local store, named graphs, union queries ## 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. (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 **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 `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 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 }`, 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 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 independent of the store's size. An absent repo throws `RepoNotFound` on its own
read and is skipped, never aborting the batch. read and is skipped, never aborting the batch.
**Do NOT anchorless-union-scan on the read path.** An anchorless The read path avoids an anchorless union-scan. An anchorless
`SELECT … WHERE { GRAPH ?g { ?s ?p ?o } }` spans EVERY named graph in the store — `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 O(wallet size). On a shared wallet that accumulates docs across runs that cost grows
that was O(wallet) and timed out (~90s observed on `readUnion` / probe reads). The with the whole wallet, which is why the read path is per-doc anchored: the anchored
per-doc anchored read makes a non-empty wallet irrelevant. At the real multi-store 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 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 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 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). § 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` / `resolve_target_for_sparql(update=true)` returns `InvalidTarget` for `UserSite` /
`None` (`request_processor.rs:275-282`). So `sparql_update` cannot write "to the `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. polyfill's `docs.sparqlUpdate` already does.
### No reactive SPARQL — `sparql_query` is one-shot ### No reactive SPARQL — `sparql_query` is one-shot
`sparql_query` is non-streamed: it computes a `QueryResults` and returns once `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`, query". The only reactive primitives are the streamed ones: `orm_start_graph`,
`orm_start_discrete`, `doc_subscribe`, `app_request_stream`. `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 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: 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 `open_for_target(&nuri.target, /*publisher*/ true)` on each
(`request_processor.rs:53-66`), and `orm/graph/initialize.rs` does the same (`request_processor.rs:53-66`), and `orm/graph/initialize.rs` does the same
fan-out again for the graphs the ORM discovers (~`125-128`). fan-out again for the graphs the ORM discovers (~`125-128`).
- `open_for_target``resolve_target``self.repos.get(repo_id).ok_or(RepoNotFound)` - `open_for_target``resolve_target``self.repos.get(repo_id).ok_or(RepoNotFound)`
(`request_processor.rs:286-294` calling `resolve_target` at `:147`, the (`request_processor.rs:286-294` calling `resolve_target` at `:147`, the
`RepoNotFound` at `:155/:163`). `RepoNotFound` at `:155/:163`).
- A **freshly-created per-entity doc**, or any **not-yet-synced other-account doc**, - A freshly-created per-entity doc, or any not-yet-synced other-account doc,
is absent from `self.repos` `RepoNotFound` propagates through the `?` and is absent from `self.repos`, so `RepoNotFound` propagates through the `?` and
**aborts the whole `orm_start_graph`**. The subscription never emits its initial aborts the whole `orm_start_graph`. The subscription never emits its initial, so
the ORM `readyPromise` never resolves the multi-second hang observed (≈75s) the ORM `readyPromise` never resolves and the subscription hangs when a fan-out of
when subscribing a fan-out of per-entity graphs. per-entity graphs is passed in.
**Consequence:** passing per-entity / unsynced graphs to the reactive ORM is broken. **Consequence:** passing per-entity / unsynced graphs to the reactive ORM is broken.
Listing must go through a one-shot union `sparql_query` instead — see 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. that user.
**Consequence for a "global document" (e.g. a discovery index):** the only path **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 glimpsed is a singleton app whose global document is administered by the
developer-user — **but this is not implemented and not guaranteed** (simpler developer-user — though this is not implemented and not guaranteed (simpler
paths may exist; to explore later). The **incorrect** model to avoid: "a paths may exist; to explore later). The model that does exist is this
dedicated service with its own wallet sharing a freely-readable index" — that singleton-app one; a dedicated service with its own wallet sharing a
does not exist in NextGraph (a service is mono-user, no global data). This is why freely-readable index is not a NextGraph shape (a service is mono-user, no global
a global-index curator package is **deferred** in this lib (see the top-level data). This is why a global-index package is deferred in this lib (see the top-level
README). README).
## Third-party wallet auto-import constraint ## 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 ## 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 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 is required. Session persistence: the wallet is remembered iframe-side
(`localStorage` long-term + `sessionStorage` for the active session); on reload, (`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 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 full browser restart (losing `sessionStorage`) can re-trigger the gate. A real
logout IS exposed (`ng.session_stop()`, `ng.user_disconnect()`, logout is exposed (`ng.session_stop()`, `ng.user_disconnect()`,
`ng.wallet_close()` in `sdk/js/lib-wasm/src/lib.rs`) but **forces a new `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 redirect afterwards. This lib's identity store sidesteps all of it — the identity
login in [`simulation.md`](./simulation.md). 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).
+95 -81
View File
@@ -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 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 capability documented in
[`nextgraph-current-state.md`](./nextgraph-current-state.md) § *The query [`nextgraph-current-state.md`](./nextgraph-current-state.md) § *The query
capability*. The consumer (Festipod) never sees any of this: it asks 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 `@ng-eventually/client` for its lists by need and trusts the answer — the whole
read mechanism lives here, in the polyfill. read mechanism lives here, in the polyfill.
> **The rule in one line:** read each by-need doc with its OWN anchored > 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 > `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 > union spans every named graph in the session store — O(wallet size) — which is why
> shared/bloated wallet that accumulates across runs it produced ~90s timeouts. The > the read path is per-doc anchored on a shared wallet that accumulates across runs.
> per-doc anchored read is O(1) per doc, INDEPENDENT of wallet size, so a non-empty > The per-doc anchored read is O(1) per doc, independent of wallet size, so a
> wallet no longer matters. > non-empty wallet does not matter.
The governing constraints (all verified in `nextgraph-rs`, cited there): The governing constraints (all verified in `nextgraph-rs`, cited there):
- One local oxigraph store per session; every opened repo is a **named graph**. - 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 - `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 (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**. 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 - 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 `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 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. 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; - 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 store-level read inheritance). Verified (T03.k): the current JS SDK exposes
**no primitive that syncs an *unknown* repo**`sparql_query`/`doc_subscribe`/ 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 `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 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 `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` 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 and the per-doc anchored read resolves each one directly with no per-doc open
becomes a real broker sync only at the multi-store migration. 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 - No reactive union query, and the reactive ORM hangs if handed a per-entity
/ unsynced graph fan-out (`RepoNotFound` aborts `orm_start_graph`). / unsynced graph fan-out (`RepoNotFound` aborts `orm_start_graph`).
## Two read regimes — enumerate vs follow ## 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 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: 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 Public events are the only thing enumerated across accounts, via the emulated
discovery index (`discovery.readIndex`, see 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 "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 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. 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 My participations / my profile, protected data an owner has granted me, my
notifications — **none** of these is enumerated across accounts. Each is reached by notifications — none of these is enumerated across accounts. Each is reached by
**what is already reachable to me**: what is already reachable to me:
- **my own docs** (always in `self.repos`); - my own docs (always in `self.repos`);
- docs reachable via a **connection's shared cap** (a bilateral connection surfaces - docs an owner has granted me via a directed per-document read grant
the peer's protected NURIs — see the bilateral connection registry in (`grantRead(doc, granteeId)` — see the per-document ReadCap in
[`simulation.md`](./simulation.md)); [`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 The rule of thumb: access is not discovery. You only union-query over graphs you
already entitled to open. 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 ## Listing = a bounded set of per-doc anchored reads (never a union-scan, never the ORM fan-out)
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 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 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. many other graphs the (possibly bloated / shared) session store holds.
Do **NOT** run an **anchorless union-scan** (`SELECT … WHERE { GRAPH ?g { ?s ?p ?o } }`, 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 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 store — O(wallet size) — so on a shared wallet that accumulates across runs its cost
out (~90s observed). The read-set is already bounded and known; read exactly those grows with the whole wallet. The read-set is already bounded and known; read exactly
docs, anchored, and never scan the wallet. 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 (`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 the fan-out makes `RepoNotFound` abort the whole subscription, so the readyPromise
never resolves the ~75s hang (root cause verified in never resolves and the subscription hangs (root cause verified in
[`nextgraph-current-state.md`](./nextgraph-current-state.md) § *The ORM fan-out [`nextgraph-current-state.md`](./nextgraph-current-state.md) § *The ORM fan-out
hang*). hang*).
## Reactivity = re-query on a change signal (no reactive union) ## 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 - 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; 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 - on its change signal, re-run the bounded set of per-doc anchored
`sparql_query`s (`readModel.readUnion`) — never an anchorless union-scan. `sparql_query`s (`readModel.readUnion`) — never an anchorless union-scan.
Keep the reactive ORM strictly to already-opened single stores; it is a change Keep the reactive ORM strictly to already-opened single stores; it is a change
*signal* source here, not the list source. *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()`, The consumer application asks the SDK for its lists by need and trusts the returned
`listEvents()`, …) and trusts the returned set. It never constructs a NURI, never set. It never constructs a NURI, never picks the union-vs-anchor mode, never touches
picks the union-vs-anchor mode, never touches the ORM. Open/sync + union-query + the ORM. The domain-shaped list helpers (e.g. "my meeting points", "events") live in
re-query-on-signal all live in the polyfill. 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) ## 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 // → 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 } }", sparql_query(sid, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
undefined, A /* string NURI → one repo */) undefined, A /* string NURI → one repo becomes the default graph */)
// → rows from A's graph only // → 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` / 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) ### 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 (the local union of the opened graphs). That is the entire premise the listing
path relies on. path relies on.
Step (4) has a nuance worth recording: with an **explicit `GRAPH ?g { … }` body**, Step (4) has a nuance worth recording, and it is exactly why the read path uses a
passing `anchor = A` did **not** restrict the result to A (B still appeared). The **default-graph body**, not an explicit `GRAPH ?g` one: with an explicit
reason: the anchor sets the query's **default graph**, but a `GRAPH ?g` pattern `GRAPH ?g { … }` body, passing `anchor = A` would **not** restrict the result to A
iterates over the **named graphs** regardless of the default graph — so an (B still appears). The reason: the anchor sets the query's **default graph**, but a
explicit `GRAPH ?g` body spans every opened graph independently of the anchor. `GRAPH ?g` pattern iterates over the **named graphs** regardless of the default
The anchor's "one repo" restriction is observable only for a body that reads the graph — so an explicit `GRAPH ?g` body spans every opened graph independently of
**default graph** (no `GRAPH` wrapper). The read model never needs the anchored the anchor. The anchor's "one repo" restriction is observable only for a body that
form for listing — it uses the anchorless `GRAPH ?g` union — so this does not reads the **default graph** (no `GRAPH` wrapper). That is precisely why the per-doc
affect it. (The per-doc "open" step in `read-model.ts` uses an anchored `ASK` read in `read-model.ts` uses the anchored default-graph body
only to CONFIRM presence — it cannot sync an unknown repo, see the VERIFIED note `SELECT ?s ?p ?o WHERE { ?s ?p ?o }`: the anchor makes that one repo the default
above; a repo absent from `self.repos` throws `RepoNotFound` and is skipped.) 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` ## Implementation — `read-model.ts`
`readModel.readUnion(docs)` implements this: for each requested doc NURI (the `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 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 `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 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 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 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[]` 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 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. may not read (defence-in-depth). The consumer application maps the result to its
Festipod's `readEntities`). Reactivity = the consumer re-calls `readUnion` on its types (e.g. its own `readEntities`). Reactivity = the consumer application re-calls
change signal (no reactive union query exists). `readUnion` on its change signal (no reactive union query exists).
> The name `readUnion` / `UnionSubject` is historical (it once ran a union query). > 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. > the logical concatenation of the per-doc results, never an anchorless graph scan.
+242 -229
View File
@@ -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 > 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 > 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 > current, immature NextGraph (the exact gaps it compensates for are in
> [`nextgraph-current-state.md`](./nextgraph-current-state.md)). Every piece has a > [`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**, > real target and goes away when NextGraph matures — the swap is lib-only, and the
> the consumer's code is unchanged. The per-behaviour recap table lives in 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)*); > 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 > 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 > file for *how* each emulation works; read those two for *what is fake* and *what
> replaces it*. > replaces it*.
The consumer writes against `@ng-eventually/client` **as if** NextGraph already The consumer application writes against `@ng-eventually/client` as if NextGraph
shipped per-entity documents in public/protected/private stores, capabilities and already shipped per-entity documents in public/protected/private stores, capabilities
inboxes. It hasn't (see [`nextgraph-current-state.md`](./nextgraph-current-state.md)). 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 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)). polyfill-era and disappears at migration ([`migration-guide.md`](./migration-guide.md)).
## The premise: one shared wallet, everything readable ## 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 `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 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 wallet" is blocked at the root — no data ever crosses the boundary between two
wallets. wallets.
The lib's answer: **everyone opens the same wallet**. NextGraph sees a single The lib's answer: everyone opens the same wallet. NextGraph sees a single
identity**everything is physically readable**. "Multi-user" becomes an identity, so everything is physically readable. "Multi-user" becomes an
application fiction the lib maintains. On top of that one wallet the lib rebuilds, 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 by emulation, the per-user stores + capabilities + inbox the consumer application
against. codes against.
## Physical wallet vs virtual wallet — never enumerate the physical one ## Physical wallet vs virtual wallet — never enumerate the physical one
Because the emulation runs on ONE shared wallet, distinguish two levels: Because the emulation runs on ONE shared wallet, distinguish two levels:
- **Physical wallet** — the real NextGraph wallet everyone opens. Its local store - **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, 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 the inbox docs, the discovery index) as named graphs. It accumulates without
bound** across sessions/runs. **Listing / scanning "all documents" of the physical 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, 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 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, (it spans every synced graph). The physical wallet is a substrate,
not something to enumerate. 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 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, `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 **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)), 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** 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 `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 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 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 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 The single most load-bearing distinction. Two **orthogonal** axes the
terminology historically fused: 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 / `protected_store_id`, `public_store_id`. Historic origin of "mono-store /
multi-store" (use 1 store vs the 3). multi-store" (use 1 store vs the 3).
- **Axis B — how many DOCUMENTS in a store?** A store contains documents; the - **Axis B — how many documents in a store?** A store contains documents; the
**document (= repo = `@graph`) is the sharing + rights boundary**. The ReadCap document (= repo = `@graph`) is the sharing + rights boundary. The ReadCap
hence **isolation** — is **PER-DOCUMENT**. hence isolation — is per-document.
**`docCreate(sessionId, "Graph", "data:graph", "store", undefined)` the shared `docCreate(sessionId, "Graph", "data:graph", "store", undefined)` targets the shared
wallet's PRIVATE store.** The trailing `store` arg left `undefined` targets the 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 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 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 `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 shim — not a NextGraph store. Therefore what a consumer application's "multi-store"
switches on is really **multi-DOCUMENT with logical scope labels**, never 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 multi-store. Do not read `Scope` (`types.ts`) as a physical store — it is the
logical label the registry attaches. 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 `docs.docCreate` primitive. The `scope` (`public|protected|private`) is a
logical attribute tracked here, not a physical store. logical attribute tracked here, not a physical store.
- **The `sharedWalletShim`** is the mapping `account → its 3 scope-document - **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 always known from the session: `RegistrySession.privateStoreId`). That makes
login **cross-device**: another device opening the same wallet reads the same identity resolution cross-device: another device opening the same wallet reads
shim and finds the same accounts. It is the account→document **trust root** — 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 which is why every untrusted value that reaches its SPARQL is escaped (see
SPARQL hardening below). SPARQL hardening below).
- **Per-entity documents + per-scope index.** `createEntityDoc(username, scope)` - **Per-entity documents + per-scope index.** `createEntityDoc(id, scope)`
makes a dedicated document for ONE entity (mirrors the target, where each entity 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 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 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). store-container (it lists the entity-document NURIs "in" that scope).
`listEntityDocs(scope)` unions the contained NURIs across all accounts. This is a `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- handing the NURIs to `useShape({ graphs })` opens/syncs other accounts' possibly-
unsynced docs and HANGS (the ORM fan-out, ~75s — see unsynced docs and hangs (the ORM fan-out — see
[`read-model.md`](./read-model.md)). The real READ path is [`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 `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 anchored `sparql_query`, never an anchorless union-scan of the physical
wallet (that is O(wallet size) and timed out ~90s; see wallet (see [`read-model.md`](./read-model.md)). The consumer application resolves
[`read-model.md`](./read-model.md)). The app resolves the by-need doc set from the the by-need doc set from the discovery index (public events) and
discovery index (public events) and `listMyEntityDocs(username, scope)` (my own `listMyEntityDocs(id, scope)` (its own account, bounded — no cross-account fan-out).
account, bounded — no cross-account fan-out). - **Generic by construction.** The registry knows only the three native scopes,
- **GENERIC by construction.** The registry knows only the three native scopes, zero application entity kind. The consumer application maps its entities to a scope
**zero** application entity kind. The consumer maps its entities to a scope and and injects the session + identity-id normalization via `configureStoreRegistry({
injects the session + username normalization via `configureStoreRegistry({ getSession, normalizeId })` (`polyfill.ts`).
getSession, normalizeUser })` (`polyfill.ts`).
The `store≠document` two axes materialize here directly: the registry moves along 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 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's structure — the three emulated stores
A **virtual wallet** = one account in the shim, keyed by its **virtual-wallet id** 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 (the technical identifier the consumer application sets when the physical wallet is
identifies *which* virtual wallet, and is NOT the consumer's application username). opened; it identifies *which* virtual wallet, and is an id rather than a
Its structure mirrors the target "1 user = 1 wallet with 3 native stores": human-friendly handle). Its structure mirrors the target "1 user = 1 wallet with 3
native stores":
``` ```
Virtual wallet (id) Virtual wallet (id)
├── public store = docPublic index → [ event doc NURI, PdR doc NURI, … ] ├── public store = docPublic index → [ entity doc NURI, entity doc NURI, … ]
├── protected store = docProtected index → [ profile doc NURI, participation doc NURI, … ] ├── protected store = docProtected index → [ record doc NURI, record doc NURI, … ]
└── private store = docPrivate index → [ settings doc NURI, … ] └── private store = docPrivate index → [ record doc NURI, … ]
``` ```
So **yes, the 3 native stores (public/protected/private) are present** — but So the 3 native stores (public/protected/private) are present, but emulated: each
**emulated**: each "store" is an **index document** "store" is an index document
(`AccountRecord.{docPublic,docProtected,docPrivate}`) that LISTS the NURIs of the (`AccountRecord.{docPublic,docProtected,docPrivate}`) that lists the NURIs of the
per-entity documents in that scope. It is not a physical native store. 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 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 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 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 } • the shim anchor: virtual-wallet-id → { docPublic, docProtected, docPrivate }
• every account's 3 scope-index docs + all per-entity docs + inbox + discovery index • 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 native stores, the entity documents move into them physically, and the
virtual/physical distinction dissolves (see [`migration-guide.md`](./migration-guide.md)). 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 The consumer application must never construct a `did:ng:${store_id}` NURI itself:
placement is the lib's job (the whole point of the SDK boundary). Two resolvers physical placement is the lib's job (the whole point of the SDK boundary). Two
turn a **logical scope** into an **opaque graph NURI** without exposing any resolvers turn a logical scope into an opaque graph NURI without exposing any
store-id: store-id:
- **`resolveScopeGraph(scope)`** — the graph where the current session writes - **`resolveScopeGraph(scope)`** — the graph where the current session writes
@@ -177,26 +177,26 @@ store-id:
native store; `public` + `protected` → the **protected** native store, because native store; `public` + `protected` → the **protected** native store, because
`doc_create`/ORM cannot target a non-private/protected native store today (SDK `doc_create`/ORM cannot target a non-private/protected native store today (SDK
blocker, [`migration-guide.md`](./migration-guide.md)). At migration each scope 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, resolves to the user's real per-scope store — the change is in this function,
the consumer is unchanged. and the consumer application is unchanged.
- **`resolveInboxAnchor()`** — the anchor where emulated inbox deposits land: a - **`resolveInboxAnchor()`** — the anchor where emulated inbox deposits land: a
**DEDICATED inbox document** (a reserved account's public scope document, from dedicated inbox document (a reserved account's public scope document, from
`docCreate` — a real repo NURI, stable across clients), **not** the shared `docCreate` — a real repo NURI, stable across clients), not the shared
wallet's private-store root. Why dedicated: the shim (the account→document trust 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`; 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 (thousands of deposit triples across sessions), turning `loadShim` into a
multi-second full-graph scan. A separate inbox document keeps the shim graph 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 small and the deposits isolated. At migration it becomes the host's native
inbox NURI. 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 (`RegistrySession.protectedStoreId` / `publicStoreId`, alongside the existing
`privateStoreId` anchor). The consumer hands the whole session to the lib at the `privateStoreId` anchor). The consumer application hands the whole session to the
ONE injection point (`configureStoreRegistry({ getSession })`) — that is wiring, lib at the one injection point (`configureStoreRegistry({ getSession })`) — that is
not placement logic; everything else in the consumer speaks only in scopes. If wiring, not placement logic; everything else in the consumer application speaks only
the session omits `protectedStoreId`, the non-private scopes fall back to the in scopes. If the session omits `protectedStoreId`, the non-private scopes fall back
private store rather than emit a broken NURI. to the private store rather than emit a broken NURI.
## `RepoNotFound` and the `orm_start_graph` scope rule ## `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`. without it, `orm_frontend_update` fails with `RepoNotFound`.
- **Scope** for `useShape`: the store NURI, e.g. `did:ng:${privateStoreId}` (or, - **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. - **`@graph`** (write target): the same store NURI.
- **Never use `did:ng:i` as a scope.** It subscribes to the user's whole site via - 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 a special code path (`NuriTargetV0::UserSite`) that does not open individual
repos** → breaks every write with `RepoNotFound`. repos, breaking every write with `RepoNotFound`.
Both the private and the protected native stores were verified to open the same 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 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 ## The `@ng-org` double-proxy `DataCloneError` constraint
**Validated hard constraint, not a style choice.** `docs.ts` calls the **real A validated hard constraint, not a style choice: `docs.ts` calls the real
injected `ng`** (`getConfig().ng`) DIRECTLY — never the public `ng` proxy injected `ng` (`getConfig().ng`) directly, never the public `ng` proxy
(`makeNg` in `ng-proxy.ts`). (`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). 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 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 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 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 - **Through the lib's public proxy** (validated): `useShape` (ORM + ReadCap
filter), `init`/`initNg`, `login`. 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`) ## 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 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 - **`CapRegistry` (`caps.ts`)** models ReadCaps as faithfully as a data layer
can. The access UNIT is the **document = repo NURI** (an item's `@graph`), 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 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 and holding its cap does not grant the repos it references (no store-level read
inheritance; verified). So the registry is **purely per-document**: inheritance; verified). So the registry is purely per-document:
`grantRead` / `grantWrite` / `makePublic` / `open(doc, scope, owner)` / `grantRead(doc, granteeId)` issues a directed read grant to one identity,
`canRead` / `canWrite` / `governsRead` / `hasReadPolicy`. The consumer performs alongside `grantWrite` / `makePublic` / `open(doc, scope, owner)` /
the *acts* of granting (create-public, grant-to-a-connection…) exactly as it `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. will in the target; the lib injects no policy.
- **`read-filter.ts`** — `makeReadFilteredView` wraps the reactive set in a - **`read-filter.ts`** — `makeReadFilteredView` wraps the reactive set in a
`Proxy`: iteration / `size` / `forEach` are filtered by `Proxy`: iteration / `size` / `forEach` are filtered by
`caps.canRead(item['@graph'], user)`; everything else (`add`, `delete`, `has`, `caps.canRead(item['@graph'], user)`; everything else (`add`, `delete`, `has`,
`getById`…) forwards to the target, preserving writes and reactivity. An item `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). restricts documents that *declare* a cap — no regression on ungoverned data).
`filterReadable` is the pure variant. `filterReadable` is the pure variant.
- **`useShape` (`use-shape.ts`)** applies the view **only if - **`useShape` (`use-shape.ts`)** applies the view only if
`caps.hasReadPolicy()`** — otherwise it passes the real set through unchanged `caps.hasReadPolicy()` — otherwise it passes the real set through unchanged
(no regression when the consumer declares no caps). (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 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 document — exactly the native behaviour, and why fine-grained isolation requires
one document per entity (axis B). 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 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 reading and (b) declares the access policy on the documents. Both are plain SDK
calls; the consumer never touches the registry internals: calls; the consumer application never touches the registry internals:
- **`setCurrentUser(id)` (`polyfill.ts`)** — the SDK's "current identity" call. - **`setCurrentUser(id)` (`polyfill.ts`)** — the SDK's "current identity" call.
`useShape`'s filtered view reads it lazily, so the delivered subset always `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 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 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 - **`getCaps().open(doc, scope, owner)`** — declares a document's policy when the
consumer creates it: `public` → world-readable; `protected`/`private` → owner consumer application creates it: `public` → world-readable; `protected`/`private`
reads, owner holds the write cap. `open` now also **remembers** `(scope, owner)` → owner reads, owner holds the write cap. `open` also remembers `(scope, owner)`
per document so a later connection-driven grant can find the protected ones. per document so `protectedDocsOf(owner)` can later enumerate the protected ones.
- **`declareConnections(peers, as?)` (`polyfill.ts`)** — the SDK-shaped - **`grantRead(doc, granteeId)` (`caps.ts`, exposed via `getCaps()`)** — the one
**protected sharing act**, now **AUTHENTICATED / BILATERAL** (`connections.ts`). relationship-shaped sharing act the lib exposes: a directed per-document read
Each call declares the CURRENT identity's OWN peers (`as` defaults to grant issued to a specific identity. Public docs stay world-readable; private
`getCurrentUser()`); the lib records that as a **directed assertion authored by docs stay owner-only; a protected doc becomes readable by `granteeId` once the
the current identity** — a session can only ever assert its own side. A protected owner grants it. The consumer application passes a document NURI and a grantee id
read cap is issued between two principals only when **both have asserted the — no store id.
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.
**Why bilateral (adversarial finding).** If a single directed assertion granted The relationship concept — who is "connected" to whom, and therefore which of
access, any reader could read any owner's protected documents by unilaterally their protected docs to grant — is owned by the consumer application, not the lib.
self-declaring a connection. The two-sided requirement is the emulation of the A connection or friendship is not a NextGraph primitive; the only platform-mappable
target's mutual capability exchange: only a reciprocated link grants the cap. A primitive is the directed per-document read grant above. So the consumer application
unilateral / self-declared connection grants **nothing** (proven in decides a relationship exists and, for each protected doc it wants to share, calls
`test/connections.test.ts` and `test/isolation-active.test.ts` case (b)). `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** The result is the target's discrimination reproduced end-to-end: private →
owner; **protected** → owner + BILATERAL connections; **public** → all. Proven in owner; protected → owner + whoever the owner has directly granted; public → all.
`test/isolation-active.test.ts`: (a) an unconnected principal is denied a protected Proven in `test/isolation-active.test.ts`: an unconnected principal is denied a
document, granted it after a two-sided `declareConnections`, and reads the public protected document, granted it after the owner issues a directed `grantRead`, and
document throughout; (b) a unilateral/self-declared connection is denied. reads the public document throughout.
This discrimination is only observable because each entity is **its own document** 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 (the consumer application creates per-entity docs via `createEntityDoc` and `open`s
a mono-store layout the per-document ReadCap is all-or-nothing. each) — in a mono-store layout the per-document ReadCap is all-or-nothing.
### Write-guard coverage (honest scope) ### Write-guard coverage (honest scope)
The emulated write guard (`ng-proxy.ts`, `sparql_update` override) enforces the 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 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 consumer application's write paths (`docs.sparqlUpdate`, ORM `ngSet`) call the real
`ng` directly** — never the public proxy — for the validated `DataCloneError` 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 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 through the public proxy, but the consumer application's real write paths bypass it
**not** guarded today. This is a deliberate, recorded limitation of the emulation 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 (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`) 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 alone: the access unit is the document (`@graph` = repo), and grants are explicit
(`open` / `grantRead` / `makePublic`) and, for `protected`, driven by the (`open` / `grantRead` / `makePublic`) for `protected`, the owner issues a directed
**bilateral connection registry** (`connections.ts`). Because the consumer now `grantRead(doc, granteeId)` per identity it wants to share with. Because the consumer
writes **one document per entity** (`createEntityDoc` + `open` per entity), the application now writes one document per entity (`createEntityDoc` + `open` per entity),
per-document cap discriminates at entity granularity — the target's behaviour. the per-document cap discriminates at entity granularity — the target's behaviour.
The old **item-level application-visibility filter** (`isolation.ts` The old item-level application-visibility filter (`isolation.ts`
`applyIsolation`, a `Set`-of-records filter keyed on owner+scope) is **retired** `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 from the consumer path: the application carries no access logic — it declares its
identity and its bilateral connections and trusts the SDK. `isolation.ts` survives identity and issues directed grants, and trusts the SDK. Its matrix functions are
only as the home of the generic `Connections` interface (consumed by dead scaffolding kept for reference and removed at migration. There is no longer a
`connections.ts` / `caps.grantReadToConnections`) plus its own unit tests; its second, coexisting app-layer filter to reconcile — the single axis is the
matrix functions are dead scaffolding kept for reference and removed at migration. per-document cap, exactly as in the target.
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 Current NextGraph does not expose the inbox to the JS SDK (verifier has no
`InboxPost` arm; no wasm sealing helper — see `InboxPost` arm; no wasm sealing helper — see
[`nextgraph-current-state.md`](./nextgraph-current-state.md) § Inbox). Rather than [`nextgraph-current-state.md`](./nextgraph-current-state.md) § Inbox). Rather than
fork the broker ([`fork-inbox-fallback.md`](./fork-inbox-fallback.md)), the lib 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 - **Target vs polyfill.** In the target, `post` seals a reference into the owner's
inbox (`ng.inbox_post_link(...)`) and a **separate curator** materializes native inbox (`inbox_post_link(...)`, a proposed/future API) and the recipient's
deposits into the owned document. Here, everything is readable, so both sides are own verifier unseals each queued message and applies it inline when it processes
emulated in-lib. 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 - **`post(targetInbox, opts)`** appends a deposit `{ from, payload, ts }` as RDF
into the inbox DOCUMENT (in the shared wallet) via `docs.sparqlUpdate`. Each 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 deposit is a unique RDF subject, so concurrent deposits don't collide. `from` is
BOUND to the current identity** (`getCurrentUser`) — it is authenticated, not bound to the current identity (`getCurrentUser`) — it is authenticated, not
caller-supplied: omit it to stamp the current user, pass `null` to deposit 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**. anonymously, and a `from` naming another principal is rejected as a spoof.
This reproduces the protocol's "identified if known, anonymous otherwise" AND 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'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 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 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 `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). 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`. deposits back via `docs.sparqlQuery`, JSON-parse each payload, sort by `ts`.
- **`watch(targetInbox, onDeposits, { intervalMs })`** is the emulated watcher: it - **`watch(targetInbox, onDeposits, { intervalMs })`** is the emulated watcher: it
polls `read` and fires when the deposit count changes (the polyfill has no polls `read` and fires when the deposit count changes (the polyfill has no
reactive inbox subscription). Fires once immediately; returns an unsubscribe. 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 NURI and interprets `payload`. At migration `post` becomes the native
`inbox_post_link` and the read side moves to a **separate curator package** (see `inbox_post_link` (proposed/future) and the read side is served by the recipient's
the deferred global-index note in the top-level README and own verifier unsealing queued messages inline (see the deferred global-index note in
[`decisions/discovery-model.md`](./decisions/discovery-model.md)). The inbox + the top-level README and [`decisions/discovery-model.md`](./decisions/discovery-model.md)).
watcher is the ONE deposit/materialization mechanism reused for BOTH meeting-point The inbox + watcher is the one deposit/read mechanism a consumer reuses for its own
registration AND submission to a discovery index — same `post` API, same watcher. 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`) ## Emulated discovery index + special account (`discovery.ts`)
Discovery is a **surface on top of the inbox**, not a new primitive. **Access Discovery is a surface on top of the inbox, not a new primitive. Access is not the
discovery**: a public entity is world-readable *with its NURI*; the discovery 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 connection 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)). 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 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 its inbox. Nobody writes the index directly — a creator deposits a reference into
creator DEPOSITS a reference into the index's inbox; the curator ingests deposits the index's inbox, and the index is built up from those deposits. That build-up
into entries. Materialization is the natural **dedup / moderation point**. step is the natural dedup / moderation point.
- **The special account (polyfill owner).** "Who owns the global index" is - **The special account (polyfill owner).** "Who owns the global index" is
undecided in the target (NextGraph is mono-user with no global data — a 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 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 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 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 `public` scope document is the index document, and its inbox receives the
deposits — a **stable NURI**: every client opening the same shared wallet deposits — a stable NURI: every client opening the same shared wallet
resolves the same account same document, so all clients read/write ONE resolves the same account, hence the same document, so all clients read/write one
shared index. shared index.
- **`submitToIndex(ref, opts?)`** — the SDK act "make this discoverable". - **`submitToIndex(ref, opts?)`** — the SDK act "make this discoverable".
Deposits `ref` into the index document's inbox via `inbox.post`. `from` follows Deposits `ref` into the index document's inbox via `inbox.post`. `from` follows
the inbox convention (bound to the current identity; anonymous when `null`). the inbox convention (bound to the current identity; anonymous when `null`).
`ref` is **opaque** here — the consumer serializes whatever locates the entity `ref` is opaque here — the consumer application serializes whatever locates the
(e.g. an entity document NURI + discovery metadata). **PUBLIC-ONLY guard:** when 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 `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 !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 governed doc's NURI would leak it past its scope. Proven in
`test/discovery.test.ts` case (d). `test/discovery.test.ts` case (d).
- **`readIndex()`** — the EMULATED CURATOR. Reads every submission, **dedups by - **`readIndex()`** — the emulated read side. Reads every submission, dedups by
serialized `ref`** (the moderation point: a duplicate submission surfaces serialized `ref` (the moderation point: a duplicate submission surfaces
once), returns entries sorted by `ts`. `watchIndex(onEntries, opts?)` is the once), returns entries sorted by `ts`. `watchIndex(onEntries, opts?)` is the
emulated watcher (polls `readIndex`). 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 `listEntityDocs('public')` / `resolveReadGraphs`) as the app-facing discovery
path: the app submits public entities to the index and reads the index, instead path: the consumer application submits public entities to the index and reads the
of fanning out over every account's public documents. The fan-out survives only index, instead of fanning out over every account's public documents. The fan-out
as an **internal lib fallback** — kept for the per-scope listing it also powers survives only as an internal lib fallback — kept for the per-scope listing it also
(e.g. `resolveReadGraphs`), never the app's discovery route. 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: `ref` shape and its meaning. At migration the special account disappears:
ownership moves to the decided global-index owner, `submitToIndex` becomes the 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 native `inbox_post_link` (proposed/future) on the index's inbox, and `readIndex`
materialized index document. The consumer surface (`submitToIndex` / `readIndex`) queries the real index document. The consumer surface (`submitToIndex` / `readIndex`)
is designed to survive that swap unchanged. is designed to survive that swap unchanged.
## Emulated write guard (`ng-proxy.ts`) ## Emulated write guard (`ng-proxy.ts`)
The public `ng` proxy overrides `sparql_update` to enforce an emulated **write 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 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 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 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. 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 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 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)). 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 - The identity id is set at wallet-import time by the consumer application and
`localStorage` so the "session" survives reloads and lands on the same account relayed to the lib via its current-identity call. It is persisted in
when the shared wallet re-opens. `localStorage` so the id survives reloads and lands on the same account
- `login()` / `logout()` are **FAUX**: they only read/write the username in when the shared wallet re-opens. In practice the id is often a human-friendly
storage. They must **NEVER** call NextGraph (no `session_stop` / `wallet_close`) handle the consumer application chose, but the lib's surface speaks only of an id.
— the shared wallet stays open underneath. The real logout lives elsewhere - `set(id)` / `clear()` / `get()` only read/write the id in storage. They never
(hidden in the consumer's settings/debug), because it forces a new redirect. call NextGraph (no `session_stop` / `wallet_close`) — the shared wallet stays
- **Framework-agnostic**: no React, no DOM beyond an optional injected 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 `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 (case-insensitive, optional leading `@` stripped, trimmed) is the pure
normalizer, reusable as the shim key normalizer. normalizer, reusable as the shim key normalizer.
@@ -470,13 +483,13 @@ document trust root):
- **`escapeLiteral`** — for LITERAL position (`"..."`): escapes backslash, - **`escapeLiteral`** — for LITERAL position (`"..."`): escapes backslash,
double-quote, C0 whitespace. Lossless (literals legitimately carry arbitrary double-quote, C0 whitespace. Lossless (literals legitimately carry arbitrary
text — JSON payloads, display names). text — JSON payloads, display names).
- **`escapeIri`** — for UNTRUSTED values embedded into an IRI (`<PREFIX:${…}>`, - **`escapeIri`** — for untrusted values embedded into an IRI (`<PREFIX:${…}>`,
e.g. a username minted into an account-subject IRI): percent-encodes every e.g. an identity id minted into an account-subject IRI): percent-encodes every
IRI-hostile character so any username (spaces, unicode, punctuation) stays IRI-hostile character so any id (spaces, unicode, punctuation) stays
usable while breakout is impossible. 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 (`did:ng:...`): validates and throws on IRI-breaking chars rather than emitting
a malformed/injected query. a malformed/injected query.
These are re-exported from `@ng-eventually/client` so the consumer reuses the same These are re-exported from `@ng-eventually/client` so the consumer application
escaping when it builds SPARQL. reuses the same escaping when it builds SPARQL.
+21 -17
View File
@@ -1,32 +1,36 @@
# @ng-eventually/client # @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: separate:
| Import | Surface | At migration | | Import | Surface |
|---|---|---| |---|---|
| `@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` | 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`, capability helpers (`canRead`/`canWrite`/`defaultGrant`/`grantRead`). | Removed. | | `@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 ```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"; import { configure } from "@ng-eventually/client/polyfill";
configure({ ng: realNg, useShape: realUseShape, sharedWallet, currentUser }); 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"; import { ng, useShape, inbox } from "@ng-eventually/client";
await ng.doc_create(/* … */); 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) await inbox.post(targetInbox, ref); // deposit (anticipated SDK API)
``` ```
What the polyfill adds on top of the real SDK (all removed/native at migration): What the polyfill adds on top of the real SDK (each emulated for now, native as
- **Shared-wallet login** (one wallet for everyone). NextGraph matures):
- **Capability enforcement** — read filter + write guard, on emulated grants - Shared-wallet identity (one wallet for everyone; the current identity id is
attached to documents. relayed to the SDK).
- **Anticipated methods** (inbox `post`, capability ops) with their future-SDK - Capability enforcement — a read filter + write guard over emulated grants
shapes, emulated for now. 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 Generic: no application domain. The consumer application injects its shapes and
*acts* of granting access. The client **must not** contain the global-index performs the acts of granting access. The relationship concept ("who is connected
curator (a separate package, deferred — see the repo README). to whom") is the consumer application's own — the client exposes only directed
per-document read grants.
+26 -39
View File
@@ -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 * The identity a session acts as is established when its wallet is imported; the
* to the broker, opening the single SHARED wallet) is perceived as a technical * SDK is told who that is via the current-identity call. This small store just
* access barrier, not as a login. THIS layer is the perceived login: the user * persists that id (in an injected storage) so it survives reloads and a second
* picks a username (no password — declarative), persisted in `localStorage` so * device, re-opening the same wallet, lands on the same identity. It carries no
* the "session" survives reloads and lands on the same account when the shared * notion of a login step, a password, or a username — only an opaque identity id.
* wallet re-opens.
* *
* `login()` / `logout()` here are FAUX: they only read/write the username in * Framework-agnostic on purpose: no React, no DOM assumption beyond an optional
* storage. They must NEVER call NextGraph (no session_stop / wallet_close) — the * storage. A consumer's React `Context`/`Provider` wraps `useState` around
* shared wallet stays open underneath. The real logout lives elsewhere. * {@link IdentityStore.set}/{@link IdentityStore.clear}. The lib does not force a
* * React dependency. Removed against real NextGraph, where the wallet session is
* FRAMEWORK-AGNOSTIC on purpose: NO React, no DOM assumption beyond an optional * the source of the identity id.
* 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.
*/ */
/** localStorage key holding the perceived-login username. */ /** localStorage key holding the current identity id. */
export const ACCOUNT_STORAGE_KEY = "ng-eventually.account.username"; export const ACCOUNT_STORAGE_KEY = "ng-eventually.account.id";
/** /**
* Minimal storage contract (a subset of the Web `Storage` interface). The * 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 `@` * The persisted current identity id. A tiny store around an injected
* stripped, trimmed. Lets "marie", "@marie", "Marie" match interchangeably. * {@link AccountStorage}. It holds no framework state; the consumer's Provider
* Pure — safe to use as the shim key normalizer too. * mirrors `get()` into framework state and re-reads after `set`/`clear`.
*/ */
export function normalizeUsername(username: string | null | undefined): string { export class IdentityStore {
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 {
private readonly storage: AccountStorage | null; private readonly storage: AccountStorage | null;
private readonly key: string; private readonly key: string;
@@ -56,7 +43,7 @@ export class AccountStore {
this.key = key; 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 { get(): string | null {
if (!this.storage) return null; if (!this.storage) return null;
try { try {
@@ -67,11 +54,11 @@ export class AccountStore {
} }
/** /**
* Faux login — persist the (trimmed) username. Empty/blank is ignored and the * Persist the (trimmed) identity id. An empty/blank value is ignored and the
* previous value is kept (returns the resulting username, or null). No NG call. * previous id is kept (returns the resulting id, or null). No NextGraph call.
*/ */
login(username: string): string | null { set(id: string): string | null {
const clean = username.trim(); const clean = id.trim();
if (!clean) return this.get(); if (!clean) return this.get();
if (this.storage) { if (this.storage) {
try { try {
@@ -83,8 +70,8 @@ export class AccountStore {
return clean; return clean;
} }
/** Faux logout — clear the username only. No NG call. */ /** Clear the persisted identity id. No NextGraph call. */
logout(): void { clear(): void {
if (this.storage) { if (this.storage) {
try { try {
this.storage.removeItem(this.key); this.storage.removeItem(this.key);
@@ -99,11 +86,11 @@ export class AccountStore {
* Convenience factory using `globalThis.localStorage` when present, else a * Convenience factory using `globalThis.localStorage` when present, else a
* null (non-persisting) store — so the same call is safe in browser and SSR. * 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 = const ls =
typeof globalThis !== "undefined" && typeof globalThis !== "undefined" &&
(globalThis as { localStorage?: AccountStorage }).localStorage (globalThis as { localStorage?: AccountStorage }).localStorage
? (globalThis as { localStorage: AccountStorage }).localStorage ? (globalThis as { localStorage: AccountStorage }).localStorage
: null; : null;
return new AccountStore(ls, key); return new IdentityStore(ls, key);
} }
+38 -33
View File
@@ -1,25 +1,29 @@
/** /**
* Capability emulation — generic, no domain rules. Models NextGraph **ReadCaps** * Capability emulation — generic, with no domain rules. It models NextGraph
* (and write caps) as faithfully as a data layer can. * ReadCaps (and write caps) as a data layer can.
* *
* In NextGraph a ReadCap is possession of a *document's* (repo's) read key: the * 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 * broker only delivers documents the wallet holds a cap for. The access unit is
* UNIT is therefore the **document = repo**, identified here by its NURI — the * therefore the document = repo, identified here by its NURI — the `@graph` an
* `@graph` an item lives in — **never the item**. (Verified in nextgraph-rs: * item lives in, rather than the item. (A store is just a container repo, and
* a store is just a container repo; holding a store's cap does NOT grant the * holding a store's cap does not grant the repos it references — each document
* repos it references — each document needs its own cap. So this registry is * carries its own cap — so this registry is purely per-document, with no
* purely per-document, with NO store-level inheritance.) * store-level inheritance.)
* *
* At migration this whole layer disappears: the broker/verifier enforces the * Sharing here is DIRECTED: a grant issues one grantee the read cap of one
* real caps and `useShape` already returns only authorized documents. * 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"; import type { Nuri, PrincipalId, Scope } from "./types";
/** /**
* Who holds the read/write cap of each document. The consumer populates it via * 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 * cap operations (make-public, directed grant…) exactly as it will in the
* the target; this layer enforces possession generically — it knows no policy. * target; this layer enforces possession generically, with no policy of its own.
*/ */
export class CapRegistry { export class CapRegistry {
/** doc NURI → principals holding its READ cap. */ /** 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). */ /** doc NURIs readable by everyone (public_store repos — no cap needed). */
private publicDocs = new Set<Nuri>(); private publicDocs = new Set<Nuri>();
/** doc NURI → its declared (scope, owner), as recorded at {@link open}. Lets /** doc NURI → its declared (scope, owner), as recorded at {@link open}. Lets
* a later, connection-aware sharing act (see {@link grantReadToConnections}) * the consumer re-derive which documents are `protected` and who owns them
* re-derive which documents are `protected` and who owns them, without the * (see {@link protectedDocsOf}) so it can issue directed grants, without
* consumer re-supplying that per-document — it already declared it at open. */ * re-supplying that per-document — it already declared it at open. */
private policy = new Map<Nuri, { scope: Scope; owner: PrincipalId }>(); private policy = new Map<Nuri, { scope: Scope; owner: PrincipalId }>();
/** Grant `principal` the READ cap of document `doc`. */ /** Grant `grantee` the READ cap of document `doc` — a directed grant. */
grantRead(doc: Nuri, principal: PrincipalId): void { grantRead(doc: Nuri, grantee: PrincipalId): void {
add(this.readers, doc, principal); add(this.readers, doc, grantee);
} }
/** Grant `principal` the WRITE cap of document `doc`. */ /** 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 * The `protected` documents owned by `owner`, as recorded at {@link open}. The
* connections may read it — the sharing act "protected = owner + connections". * consumer uses this to issue directed read grants: it decides who may read an
* `neighborsOf(owner)` yields the principals connected to `owner` (the consumer * owner's protected documents (its own relationship concept) and calls
* supplies its social graph; this layer invents no relationship). Public docs * {@link grantRead} on each of these documents for each such reader. Public
* are already world-readable; private docs are untouched (owner only). Additive * documents are already world-readable and private documents stay owner-only,
* and idempotent: re-running after the connection graph changes only ever adds * so only the protected ones are surfaced here.
* read caps for the current connections.
* *
* This is the per-document ReadCap image of a native cap operation: in the * This mirrors a native cap operation: in the target, sharing a protected repo
* target, sharing a protected repo with a connection issues that connection the * with another identity issues that identity the repo's ReadCap. Here the
* repo's ReadCap. Here it grants the emulated read cap on the same unit. * consumer selects the documents via this accessor and grants the emulated read
* cap on the same unit.
*/ */
grantReadToConnections(neighborsOf: (owner: PrincipalId) => Iterable<PrincipalId>): void { protectedDocsOf(owner: PrincipalId): Nuri[] {
for (const [doc, { scope, owner }] of this.policy) { const out: Nuri[] = [];
if (scope !== "protected") continue; for (const [doc, { scope, owner: o }] of this.policy) {
for (const connection of neighborsOf(owner)) this.grantRead(doc, connection); if (scope === "protected" && o === owner) out.push(doc);
} }
return out;
} }
/** Is `doc` under any READ-cap policy? (Undeclared docs are not enforced.) */ /** Is `doc` under any READ-cap policy? (Undeclared docs are not enforced.) */
-90
View File
@@ -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<PrincipalId, Set<PrincipalId>>();
/** 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<ConnectionAssertion>): 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<PrincipalId> {
const out = new Set<PrincipalId>();
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) };
}
+54 -55
View File
@@ -5,38 +5,36 @@
* an opaque reference and interprets the entries it reads back. * an opaque reference and interprets the entries it reads back.
* *
* ── The mechanism (see docs/decisions/discovery-model.md) ───────────────── * ── The mechanism (see docs/decisions/discovery-model.md) ─────────────────
* Access discovery. A public entity is world-readable *with its NURI*; the * Access and discovery are separate concerns. A public entity is world-readable
* discovery index is how a client learns that NURI EXISTS without holding a * with its NURI; the discovery index is how a client learns that NURI exists
* connection to its creator. There is ONE global index — an OWNED document * without holding a grant to read its creator's other documents. There is one
* (public read), fed via ITS OWN inbox, and MATERIALIZED by a curator. Nobody * global index — an owned document (public read), fed via its own inbox. A
* writes the index directly: a creator DEPOSITS a reference into the index's * creator deposits a reference into the index's inbox; reading the index folds
* inbox; the (emulated) curator ingests deposits into entries. Materialization * those deposits into entries, deduplicating identical references along the way.
* is the natural dedup / moderation point.
* *
* ── The special account (polyfill owner) ────────────────────────────────── * ── The special account (polyfill owner) ──────────────────────────────────
* "Who owns the global index" is undecided in the target (NextGraph is * Ownership of a truly global index is undecided in the real platform, where an
* mono-user with no global data — see docs/nextgraph-current-state.md § Apps & * identity's apps and services see only what that identity shares. The polyfill
* services; a singleton app is the only glimpsed path). So the polyfill parks * therefore parks ownership on a reserved special account in the shim
* ownership on a RESERVED SPECIAL ACCOUNT in the shim ({@link INDEX_ACCOUNT}). * ({@link INDEX_ACCOUNT}). Its `public` scope document is the index document;
* Its `public` scope document is the index document; deposits land in that * deposits land in that document's inbox (a stable NURI: every client opening the
* document's inbox (a stable NURI: every client opening the same shared wallet * same shared wallet resolves the same account, so the same document). This is
* resolves the same account → same document). This replaces the cross-account * the app-facing discovery path, in place of a cross-account fan-out
* fan-out (`store-registry.ts` `listEntityDocs`) as the app-facing discovery * (`store-registry.ts` `listEntityDocs`), which survives only as an internal
* path — the fan-out survives only as an internal fallback (see {@link readIndex}). * fallback (see {@link readIndex}).
* *
* ── TARGET vs POLYFILL ──────────────────────────────────────────────────── * ── Real target vs this emulation ─────────────────────────────────────────
* Target: `submitToIndex` seals a reference into the index's native inbox * The intended real shape is: `submitToIndex` seals a reference into the index
* (`inbox_post_link`) and a SEPARATE curator package materializes deposits into * document's own inbox (a future `inbox_post_link`), and reading the index is a
* the owned index document; `readIndex` is a query on the materialized index. * query on the materialized index document. Here, everything runs in-lib on the
* Here, everything is emulated in-lib on the shared wallet (deposit via * shared wallet (deposit via `inbox.post`, fold via `inbox.read`). Against real
* `inbox.post`, materialize via `inbox.read`). At migration the special account * NextGraph the special account gives way to the decided global-index owner and
* disappears; ownership moves to the decided global-index owner and this module * `readIndex` points at that document; the consumer surface (`submitToIndex` /
* points `readIndex` at the real materialized document. The consumer surface * `readIndex`) is designed to survive that change unchanged.
* (`submitToIndex` / `readIndex`) is designed to survive that swap unchanged.
* *
* All NextGraph I/O routes through `inbox.ts` (which routes through the T01.a * All NextGraph I/O routes through `inbox.ts` (which routes through the `docs`
* `docs` primitives, the REAL injected `ng`), so this module imports NO * primitives, the real injected `ng`), so this module imports no `@ng-org`
* `@ng-org` package. * package.
*/ */
import * as inbox from "./inbox"; import * as inbox from "./inbox";
@@ -45,12 +43,12 @@ import { getCaps } from "./polyfill";
import type { Nuri, PrincipalId } from "./types"; import type { Nuri, PrincipalId } from "./types";
/** /**
* The reserved SPECIAL ACCOUNT that owns the global discovery index in the * 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 * polyfill. It hosts the index document but is never a real identity. It lives in
* the registry's RESERVED namespace ({@link reservedAccount}), whose key * the registry's reserved namespace ({@link reservedAccount}), whose key
* `normalizeUser` can never produce so a user named "index"/"@index" can NOT * `normalizeId` can never produce, so an id of "index"/"@index" cannot hijack it
* hijack it (they would normalize to "index", a disjoint key). Disappears at * (it normalizes to "index", a disjoint key). Removed against real NextGraph
* migration (see file header). * (see file header).
*/ */
export const INDEX_ACCOUNT = reservedAccount("index"); export const INDEX_ACCOUNT = reservedAccount("index");
@@ -68,16 +66,16 @@ export interface IndexEntry {
export interface SubmitOptions { export interface SubmitOptions {
/** /**
* Who is submitting. Omit for the current identity, or pass `null` for an * 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 * anonymous submission. `from` is bound to the current identity by the inbox
* (naming another principal is rejected as a spoof — see {@link inbox.post}). * (naming another identity is rejected as a spoof — see {@link inbox.post}).
*/ */
from?: PrincipalId | null; from?: PrincipalId | null;
/** /**
* The NURI of the document being made discoverable. When given, the index * The NURI of the document being made discoverable. When given, the index
* enforces PUBLIC-ONLY: a document under a non-public (protected/private) read * admits only a public document: one under a non-public (protected/private)
* policy is REFUSED — the public index must never leak a governed document's * read policy is refused, so the world-readable index never exposes a governed
* NURI. Omit it only for a ref with no addressable document (rare); a governed * document's NURI. Omit it only for a ref with no addressable document (rare);
* doc always passes it so the guard can fire. * a governed document passes it so the guard can fire.
*/ */
doc?: Nuri; doc?: Nuri;
/** Optional deposit timestamp (ms epoch). Omitted → `Date.now()`. Passing it /** Optional deposit timestamp (ms epoch). Omitted → `Date.now()`. Passing it
@@ -105,14 +103,15 @@ async function indexInboxNuri(): Promise<Nuri> {
/** /**
* Submit a reference to the global discovery index — the SDK act "make this * Submit a reference to the global discovery index — the SDK act "make this
* discoverable". Deposits `ref` into the index document's inbox via * discoverable". Deposits `ref` into the index document's inbox via
* {@link inbox.post}; the curator ({@link readIndex}) materializes it into an * {@link inbox.post}; reading the index ({@link readIndex}) folds it into an
* entry. GENERIC: `ref` is opaque here (the consumer serializes whatever a * entry. `ref` is opaque here (the consumer serializes whatever a client needs to
* client needs to later locate the entity — e.g. an entity document NURI plus * later locate the entity — e.g. an entity document NURI plus discovery metadata).
* discovery metadata). `from` follows the inbox convention (anonymous if `null`). * `from` follows the inbox convention (anonymous when `null`).
* *
* PUBLIC-ONLY: when `opts.doc` names the document being surfaced, a document under * When `opts.doc` names the document being surfaced, a document under a
* a non-public read policy (protected/private) is REFUSED — the global index is * 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. * world-readable, so admitting a governed document's NURI would expose it past
* its scope.
*/ */
export async function submitToIndex(ref: unknown, opts?: SubmitOptions): Promise<void> { export async function submitToIndex(ref: unknown, opts?: SubmitOptions): Promise<void> {
const doc = opts?.doc; 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 * Read the global discovery index. Reads every submission from the index inbox,
* every submission from the index inbox, DEDUPLICATES by serialized `ref` (the * deduplicates by serialized `ref` (a duplicate submission surfaces once — the
* materialization dedup / moderation point of the discovery model: a duplicate * discovery model's moderation point), and returns the entries sorted by `ts`
* submission surfaces once), and returns the entries sorted by `ts` ascending. * ascending. Against real NextGraph this becomes a query on the materialized
* At migration this becomes a query on the real materialized index document. * index document.
*/ */
export async function readIndex(): Promise<IndexEntry[]> { export async function readIndex(): Promise<IndexEntry[]> {
const target = await indexInboxNuri(); const target = await indexInboxNuri();
@@ -157,9 +156,9 @@ export async function readIndex(): Promise<IndexEntry[]> {
} }
/** /**
* Watch the discovery index — the emulated curator's watcher. Polls * Watch the discovery index. Polls {@link readIndex} and fires `onEntries`
* {@link readIndex} and fires `onEntries` whenever the index changes. Returns an * whenever the index changes. Returns an unsubscribe. Fires once immediately.
* unsubscribe. Fires once immediately. (Deduplication is applied on each read.) * (Deduplication is applied on each read.)
*/ */
export function watchIndex( export function watchIndex(
onEntries: (entries: IndexEntry[]) => void, onEntries: (entries: IndexEntry[]) => void,
+5 -4
View File
@@ -1,12 +1,13 @@
/** /**
* Low-level document + SPARQL primitives. * Low-level document + SPARQL primitives.
* *
* These call the **REAL injected `ng`** (`getConfig().ng`) DIRECTLY — never the * These call the real injected `ng` (`getConfig().ng`) directly — never the
* public `ng` proxy (`makeNg`). This is a validated hard constraint, NOT a style * 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, * 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 * and layering our Proxy on top breaks `doc_create`'s `postMessage` marshaling
* (`DataCloneError: function ... could not be cloned`). Reaching the real `ng` * with **`DataCloneError: function ... could not be cloned`** — the footgun this
* held in the config avoids the double-proxy. Do NOT import from `./ng-proxy`. * 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 * 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. * app's storeRegistry usage), so this is a drop-in for those raw calls.
+38 -35
View File
@@ -1,25 +1,27 @@
/** /**
* Inbox — the ONE deposit + materialization mechanism, reused for BOTH meeting- * Inbox — a generic deposit + read/materialize mechanism the consumer reuses for
* point registration AND submission to the discovery index (see the discovery- * its own purposes (same `inbox.post` API, same watcher — see the discovery-model
* model decision: same `inbox.post` API, same watcher). GENERIC by construction: * decision). The mechanism itself knows no application domain: the consumer
* this module knows no application domain (no meeting-point, no notification). * supplies the inbox document NURI and interprets the `payload`. (An example
* The consumer supplies the inbox document NURI and interprets the `payload`. * 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 ──────────────────────────────────────────────────── * ── Real target vs this emulation ─────────────────────────────────────────
* Target: `post` seals a reference into the inbox owner's native inbox * In real NextGraph, a message is sealed to the recipient's key and queued into
* (`ng.inbox_post_link(...)`), and a SEPARATE curator process (the deferred * their inbox; the recipient's own verifier unseals each queued message and
* `@ng-eventually/service` package) MATERIALIZES the deposits into the owned * applies it inline as it processes the inbox — there is no separate curator
* document. Here, in the shared-wallet polyfill (everything is readable), * process. A future `inbox_post_link` is the intended way to seal a link into an
* both sides are emulated in-lib: * 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 * - `post` appends a deposit `{ from, payload, ts }` as RDF into the inbox
* DOCUMENT (in the shared wallet) via the `docs.sparqlUpdate` primitive; * document (in the shared wallet) via the `docs.sparqlUpdate` primitive;
* - `read` / `watch` play the CURATOR: they read the deposits back via * - `read` / `watch` read the deposits back via `docs.sparqlQuery` and expose
* `docs.sparqlQuery` and expose them. This in-client emulation is enough * them. This in-lib read stands in for the recipient's own inbox processing
* for the polyfill — at migration the real materialization moves to the * until the sealed-inbox path (`inbox_post_link`) is available.
* separate curator and this read side goes away.
* *
* All NextGraph I/O routes through the T01.a `docs` primitives (the REAL * All NextGraph I/O routes through the `docs` primitives (the real injected `ng`,
* injected `ng`, never `makeNg`), so this module imports NO `@ng-org` package. * never `makeNg`), so this module imports no `@ng-org` package.
*/ */
import { sparqlUpdate, sparqlQuery } from "./docs"; import { sparqlUpdate, sparqlQuery } from "./docs";
@@ -92,13 +94,13 @@ function readBindings(result: unknown): Array<Record<string, { value: string }>>
* (the real injected `ng`). Each deposit is a fresh RDF subject in the inbox * (the real injected `ng`). Each deposit is a fresh RDF subject in the inbox
* graph, so concurrent deposits don't collide. * graph, so concurrent deposits don't collide.
* *
* `from` is BOUND TO THE CURRENT IDENTITY — it is authenticated, not * `from` is bound to the current identity — it is authenticated, not
* caller-supplied. Omit it to stamp the current user; pass `null` to deposit * caller-supplied. Omit it to stamp the current identity; pass `null` to deposit
* ANONYMOUSLY (a legitimate choice — "identified if known, anonymous otherwise"). * anonymously (a legitimate choice — identified if known, anonymous otherwise).
* A `from` naming ANOTHER principal is a SPOOF and is REJECTED: in the target the * 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 * 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 * another's identity. This check is redundant once the seal enforces it, but
* it — but until then it closes the spoof the shared wallet would otherwise allow.) * until then it closes the spoof the shared wallet would otherwise allow.
*/ */
export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void> { export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void> {
const current = getCurrentUser(); const current = getCurrentUser();
@@ -140,13 +142,14 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void>
await sparqlUpdate(sid, update, targetInbox); await sparqlUpdate(sid, update, targetInbox);
} }
// --- read / materialize (emulated curator) -------------------------------- // --- read --------------------------------------------------------------
/** /**
* Read (materialize) every deposit currently in `targetInbox`, sorted by `ts` * Read every deposit currently in `targetInbox`, sorted by `ts` ascending. In
* ascending. This is the EMULATED CURATOR: at migration a separate curator * real NextGraph the recipient's own verifier applies queued messages inline as
* process materializes deposits and this in-client read goes away. The consumer * it processes the inbox; here this read stands in for that until the
* interprets each deposit's `payload`. * sealed-inbox path is available. The consumer interprets each deposit's
* `payload`.
*/ */
export async function read(targetInbox: Nuri): Promise<Deposit[]> { export async function read(targetInbox: Nuri): Promise<Deposit[]> {
const sid = await sessionId(); const sid = await sessionId();
@@ -179,15 +182,15 @@ export async function read(targetInbox: Nuri): Promise<Deposit[]> {
return deposits; 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; export const materialize = read;
/** /**
* Subscription over an inbox — the emulated watcher. Polls {@link read} on an * Subscription over an inbox. Polls {@link read} on an interval and invokes
* interval and invokes `onDeposits` with the full current deposit list whenever * `onDeposits` with the full current deposit list whenever it changes (grows).
* it changes (grows). Returns an unsubscribe function. The polyfill has no * Returns an unsubscribe function. The polyfill has no reactive inbox
* native reactive inbox subscription, so this emulates one; at migration it is * subscription, so this polls; against real NextGraph it follows the recipient's
* replaced by the real curator's watch. `onDeposits` fires once immediately. * own inbox processing. `onDeposits` fires once immediately.
*/ */
export function watch( export function watch(
targetInbox: Nuri, targetInbox: Nuri,
-2
View File
@@ -22,8 +22,6 @@ export * as readModel from "./read-model";
export type { UnionSubject } from "./read-model"; export type { UnionSubject } from "./read-model";
export * as storeRegistry from "./store-registry"; export * as storeRegistry from "./store-registry";
export type { AccountRecord, RegistrySession } 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 * as accounts from "./accounts";
export type { AccountStorage } from "./accounts"; export type { AccountStorage } from "./accounts";
-138
View File
@@ -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<PrincipalId>;
}
/**
* 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<PrincipalId, Set<PrincipalId>>();
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<PrincipalId>(),
};
}
/**
* 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<PrincipalId> {
const set = new Set<PrincipalId>([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<PrincipalId>,
): 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<T> {
/** 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<T>(
items: Iterable<T>,
current: PrincipalId,
connections: Connections,
accessors: IsolationAccessors<T>,
): 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),
);
}
+14 -48
View File
@@ -11,19 +11,18 @@
import type { NgLike, UseShapeLike, PrincipalId } from "./types"; import type { NgLike, UseShapeLike, PrincipalId } from "./types";
import type { RegistrySession } from "./store-registry"; import type { RegistrySession } from "./store-registry";
import { CapRegistry } from "./caps"; import { CapRegistry } from "./caps";
import { ConnectionRegistry, bilateralConnections } from "./connections";
/** /**
* Consumer-injected dependencies of the storeRegistry (polyfill-era). The * Consumer-injected dependencies of the storeRegistry (polyfill-era). The
* registry itself is generic (it knows only native scopes); the consumer wires * 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. * used as the shim key. Removed at migration along with the whole shim.
*/ */
export interface StoreRegistryDeps { export interface StoreRegistryDeps {
/** Resolve the current shared-wallet session (id + private-store anchor). */ /** Resolve the current shared-wallet session (id + private-store anchor). */
getSession: () => Promise<RegistrySession>; getSession: () => Promise<RegistrySession>;
/** Normalize a username for shim keying. Default: trim (identity-ish). */ /** Normalize an identity id for shim keying. Default: trim (identity-ish). */
normalizeUser?: (username: string) => string; normalizeId?: (id: string) => string;
} }
export interface EventuallyConfig { export interface EventuallyConfig {
@@ -47,9 +46,6 @@ let registryDeps: Required<StoreRegistryDeps> | null = null;
/** The emulated ReadCap/WriteCap registry. Empty until the app declares caps; /** The emulated ReadCap/WriteCap registry. Empty until the app declares caps;
* while it has no read policy the read filter passes through (no regression). */ * while it has no read policy the read filter passes through (no regression). */
let caps = new CapRegistry(); 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 { export function configure(c: EventuallyConfig): void {
cfg = c; 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 * normalization). Must be called before any storeRegistry.* use. Separate from
* {@link configure} because it's storeRegistry-specific and, like the shim, * {@link configure} because it's storeRegistry-specific and, like the shim,
* disappears at migration. * disappears at migration.
@@ -78,7 +74,7 @@ export function resetConfig(): void {
export function configureStoreRegistry(deps: StoreRegistryDeps): void { export function configureStoreRegistry(deps: StoreRegistryDeps): void {
registryDeps = { registryDeps = {
getSession: deps.getSession, 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; 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 { export function setCurrentUser(id: PrincipalId | null): void {
currentUser = id; currentUser = id;
} }
@@ -104,51 +105,16 @@ export function getCurrentUser(): PrincipalId | null {
return currentUser; return currentUser;
} }
/** The emulated cap registry — the app grants/opens caps on it (as it will via /** The emulated cap registry — the app opens a document's read policy and issues
* real cap operations in the target). The read filter consults it. */ * directed read grants on it (as it will via real cap operations in the target).
* The read filter consults it. */
export function getCaps(): CapRegistry { export function getCaps(): CapRegistry {
return caps; 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<PrincipalId>, 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). */ /** Reset all emulated caps (mainly for tests / fresh sessions). */
export function resetCaps(): void { export function resetCaps(): void {
caps = new CapRegistry(); caps = new CapRegistry();
connectionRegistry = new ConnectionRegistry();
} }
// Cap surface — polyfill-era (caps are emulated now; native at migration). // Cap surface — polyfill-era (caps are emulated now; native at migration).
+36 -36
View File
@@ -1,39 +1,39 @@
/** /**
* read-model — the LISTING primitive of the polyfill: read a BOUNDED, by-need set * 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 * 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. * grouped per subject. This is the mechanism documented in docs/read-model.md.
* *
* ── WHY per-doc ANCHORED, never an anchorless union-scan ─────────────────── * ── 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)` * 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)` → * 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 * `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 * `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. * doc, independent of how many other graphs the local store holds.
* *
* An ANCHORLESS query (`anchor` undefined → `UserSite` → `set_default_graph_as_union`) * The footgun this avoids: an anchorless query (`anchor` undefined → `UserSite` →
* spans EVERY named graph currently in the session store. On a SHARED / bloated * `set_default_graph_as_union`) spans EVERY named graph currently in the session
* wallet that accumulates across runs, that is O(wallet size) → the observed ~90s * store. On a shared / bloated wallet that accumulates across runs, that is
* timeouts. So the read path must NEVER union-scan all graphs: it reads exactly the * O(wallet size) → the observed ~90s timeouts. So the read path never union-scans
* bounded by-need set, one anchored query per doc. * 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 { … }` * 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 * 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 * 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. * `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 * `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 * per-entity graphs; a freshly-created / not-yet-synced doc in that fan-out makes
* `RepoNotFound` abort the whole subscription → the readyPromise never resolves → * `RepoNotFound` abort the whole subscription → the readyPromise never resolves →
* the ~75s hang (docs/nextgraph-current-state.md § The ORM fan-out hang). Listing * 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 * 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. * union query, so reactivity is assembled by re-querying on a change signal.
* *
* ── GENERIC by construction ─────────────────────────────────────────────── * ── Generic by construction ───────────────────────────────────────────────
* Zero application domain here: the consumer passes the doc NURIs to read (from * 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) * 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 * 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. * imports no `@ng-org` package.
* *
* At the real multi-store migration the per-doc anchored read is unchanged (native * At the real multi-store migration the per-doc anchored read is unchanged (native
@@ -80,18 +80,18 @@ async function sessionId(): Promise<string> {
} }
/** /**
* 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 * 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. * 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 * 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. * bloated / shared) session store — it never iterates other graphs.
* *
* All same-session repos (every doc `doc_create`d this session — in the mono-wallet * 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 * 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. * 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 * At the real multi-store migration this becomes a real sync: opening a per-user
@@ -103,7 +103,7 @@ async function readDoc(
): Promise<Array<Record<string, { value: string } | undefined>>> { ): Promise<Array<Record<string, { value: string } | undefined>>> {
try { try {
const nuri = assertNuri(doc); 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 // 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). // iterate all named graphs regardless of the anchor — see docs § probe step 4).
const res = await sparqlQuery( 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 * Docs that fail are skipped (see {@link readDoc}); a failing doc never aborts the
* batch. * batch.
* *
* NEVER an anchorless union-scan over all graphs (which is O(wallet size) and wrong * 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 * on a shared / bloated wallet — the footgun this path exists to avoid). Each doc is
* query, O(1) per doc, independent of wallet size — a non-empty wallet no longer * read with an anchored default-graph query, O(1) per doc, independent of wallet
* matters. Reads run in parallel via `Promise.all`. * size — a non-empty wallet no longer matters. Reads run in parallel via `Promise.all`.
*/ */
export async function readUnion(docs: Nuri[]): Promise<UnionSubject[]> { export async function readUnion(docs: Nuri[]): Promise<UnionSubject[]> {
const sid = await sessionId(); const sid = await sessionId();
@@ -142,17 +142,17 @@ export async function readUnion(docs: Nuri[]): Promise<UnionSubject[]> {
); );
// Cap gate (defence-in-depth). A doc whose read policy the current user may not // 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 // satisfy is dropped. Isolation holds both by construction (the app only resolves
// it is entitled to) AND BY FILTER here. Generic: the lib owns the cap registry; // docs it is entitled to) and by filter here. Generic: the lib owns the cap
// a doc under no policy (`!governsRead`) flows through unchanged. In this polyfill // registry; a doc under no policy (`!governsRead`) flows through unchanged. In this
// each subject IRI IS its own document NURI, so the cap key is the doc NURI. // polyfill each subject IRI is its own document NURI, so the cap key is the doc NURI.
const caps = getCaps(); const caps = getCaps();
const user = getCurrentUser(); const user = getCurrentUser();
const bySubject = new Map<string, UnionSubject>(); const bySubject = new Map<string, UnionSubject>();
for (const { doc, rows } of perDoc) { for (const { doc, rows } of perDoc) {
if (caps.governsRead(doc) && !caps.canRead(doc, user)) continue; 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 // (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. // is stable regardless of the repo_graph_name overlay suffix the store carries.
for (const row of rows) { for (const row of rows) {
+20 -20
View File
@@ -2,26 +2,26 @@
* SPARQL string-building safety helpers — shared by every module that builds * SPARQL string-building safety helpers — shared by every module that builds
* SPARQL by interpolation (inbox, store-registry). * SPARQL by interpolation (inbox, store-registry).
* *
* WHY THIS EXISTS: SPARQL injection. When an untrusted value (a username, a * These exist because of SPARQL injection. When an untrusted value (an identity
* payload) is spliced verbatim into a query, a `"` closes a literal and a `>` * 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 wreck the shim * `>` closes an IRI, letting the value inject arbitrary triples (or corrupt the
* graph, which is the trust root mapping accounts → document NURIs). Every * shim graph, the trust root mapping accounts → document NURIs). Every value that
* value that reaches a query MUST pass through one of these helpers first. * reaches a query passes through one of these helpers first.
* *
* TWO POSITIONS, TWO STRATEGIES: * Two positions, two strategies:
* - LITERAL position (`"..."`): {@link escapeLiteral}. We *escape* rather than * - Literal position (`"..."`): {@link escapeLiteral}. Escape rather than reject,
* reject because literals legitimately carry arbitrary text (JSON payloads, * because literals legitimately carry arbitrary text (JSON payloads, display
* display names). Escaping is lossless and reversible. * names). Escaping is lossless and reversible.
* - IRI position (`<...>`): two cases. * - IRI position (`<...>`): two cases.
* · Trusted-SHAPED NURIs coming back from `ng` (`did:ng:...`): validate * · Trusted-shaped NURIs coming back from `ng` (`did:ng:...`): validate with
* with {@link assertNuri} — they should never contain IRI-breaking * {@link assertNuri} — they should never contain IRI-breaking chars; if one
* chars; if one does, something upstream is wrong, so we throw rather * does, something upstream is wrong, so it throws rather than silently
* than silently build a broken/injected query. * building a broken/injected query.
* · UNTRUSTED values embedded into an IRI (a username used to mint an * · Untrusted values embedded into an IRI (an identity id used to mint an
* account-subject IRI): {@link escapeIri} percent-encodes every * account-subject IRI): {@link escapeIri} percent-encodes every IRI-hostile
* IRI-hostile character. We *encode* rather than reject so any username * character. Encode rather than reject so any id (spaces, unicode,
* (spaces, unicode, punctuation) stays usable, while `<`, `>`, `"`, * punctuation) stays usable, while `<`, `>`, `"`, whitespace and control
* whitespace and control chars can never break out of the IRI. * 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 * Percent-encode every IRI-hostile character in `value` so it is safe to embed
* inside a SPARQL IRI ref (`<PREFIX:${escapeIri(value)}>`). Use this for * inside a SPARQL IRI ref (`<PREFIX:${escapeIri(value)}>`). Use this for
* UNTRUSTED values (e.g. a username minted into an account-subject IRI): * untrusted values (e.g. an identity id minted into an account-subject IRI):
* encoding keeps every username usable while making breakout impossible. * encoding keeps every id usable while making breakout impossible.
* *
* NOTE: this encodes only the delimiter/whitespace/control set, so ordinary * NOTE: this encodes only the delimiter/whitespace/control set, so ordinary
* printable characters (including `:` `/` `.` `-` `_` and unicode letters) pass * printable characters (including `:` `/` `.` `-` `_` and unicode letters) pass
+61 -61
View File
@@ -1,11 +1,11 @@
/** /**
* storeRegistry — resolves (account, scope) → document NURI. * storeRegistry — resolves (account, scope) → document NURI.
* *
* **STOPGAP / polyfill-era.** Emulates the target infrastructure — where each * Stopgap / polyfill-era. Emulates the target infrastructure — where each
* user owns their own public/protected/private stores — on top of ONE shared * 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. It creates one document per (account × scope) inside that shared
* wallet (via the `docs.docCreate` primitive), so the `scope` * 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 * physical NextGraph store. Isolation is enforced by the app layer + the
* emulated cap registry, not by crypto. * emulated cap registry, not by crypto.
* *
@@ -14,13 +14,13 @@
* known from the session). That makes login cross-device: another device * known from the session). That makes login cross-device: another device
* opening the same wallet reads the same shim and finds the same accounts. * opening the same wallet reads the same shim and finds the same accounts.
* *
* ── GENERIC by construction ────────────────────────────────────────────── * ── Generic by construction ──────────────────────────────────────────────
* This module knows ONLY the three native scopes. It does NOT know any * This module knows only the three native scopes; it knows no application
* application entity kind (event, meeting-point, profile, …). The consumer * entity kind. The consumer maps its entities to a scope and calls
* maps its entities to a scope and calls `createEntityDoc(scope)` / * `createEntityDoc(scope)` / `listEntityDocs(scope)` with the resulting native
* `listEntityDocs(scope)` with the resulting native scope. Zero domain here. * scope. No application domain here.
* *
* ── WHAT DISAPPEARS AT MIGRATION ───────────────────────────────────────── * ── What disappears at migration ─────────────────────────────────────────
* At the real multi-store migration the shim vanishes entirely: `(account, * 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 * 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 * 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. */ /** One account's three scope-document NURIs, as recorded in the shim. */
export interface AccountRecord { export interface AccountRecord {
username: string; id: string;
docPublic: Nuri; docPublic: Nuri;
docProtected: Nuri; docProtected: Nuri;
docPrivate: Nuri; docPrivate: Nuri;
@@ -50,7 +50,7 @@ export interface AccountRecord {
const SHIM = "urn:ng-eventually:shim"; const SHIM = "urn:ng-eventually:shim";
const P = { const P = {
type: `${SHIM}:Account`, type: `${SHIM}:Account`,
username: `${SHIM}:username`, id: `${SHIM}:id`,
docPublic: `${SHIM}:docPublic`, docPublic: `${SHIM}:docPublic`,
docProtected: `${SHIM}:docProtected`, docProtected: `${SHIM}:docProtected`,
docPrivate: `${SHIM}:docPrivate`, docPrivate: `${SHIM}:docPrivate`,
@@ -61,22 +61,22 @@ const P = {
// documents (one per entity) that live "in" that scope. // documents (one per entity) that live "in" that scope.
const INDEX_SUBJECT = `${SHIM}:index`; const INDEX_SUBJECT = `${SHIM}:index`;
function accountSubject(username: string): string { function accountSubject(id: string): string {
// The username is UNTRUSTED and lands in an IRI position. Percent-encode it // The id is UNTRUSTED and lands in an IRI position. Percent-encode it
// (escapeIri) so no `>` / `"` / whitespace / control char can break out of // (escapeIri) so no `>` / `"` / whitespace / control char can break out of
// the `<...>` and inject triples into the shim graph (the account→doc trust // the `<...>` and inject triples into the shim graph (the account→doc trust
// root). accountKey() runs first so the subject stays stable per shim key. // 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 ----------------------------------------------------- // --- reserved accounts -----------------------------------------------------
// //
// Some accounts are internal to the lib (e.g. the discovery index owner) and // 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 // 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 // `@`, 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 // a real user named "index"/"@index" can never resolve to the reserved
// `reservedAccount("index")` account. // `reservedAccount("index")` account.
const RESERVED_PREFIX = "\u0000reserved:"; 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 * 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} * 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 { export function reservedAccount(name: string): string {
return `${RESERVED_PREFIX}${name}`; return `${RESERVED_PREFIX}${name}`;
} }
/** Whether a name is a reserved-account sentinel (from {@link reservedAccount}). */ /** Whether a name is a reserved-account sentinel (from {@link reservedAccount}). */
function isReserved(username: string): boolean { function isReserved(id: string): boolean {
return username.startsWith(RESERVED_PREFIX); 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 * 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 { function accountKey(id: string): string {
return isReserved(username) ? username : normalize(username); return isReserved(id) ? id : normalize(id);
} }
// --- session / normalization access (injected by the consumer) ------------ // --- session / normalization access (injected by the consumer) ------------
@@ -118,8 +118,8 @@ export interface RegistrySession {
publicStoreId?: string; publicStoreId?: string;
} }
function normalize(username: string): string { function normalize(id: string): string {
return getStoreRegistryDeps().normalizeUser(username); return getStoreRegistryDeps().normalizeId(id);
} }
async function session(): Promise<RegistrySession> { async function session(): Promise<RegistrySession> {
@@ -174,10 +174,10 @@ export async function loadShim(): Promise<Map<string, AccountRecord>> {
const s = await session(); const s = await session();
const anchor = await anchorNuri(); const anchor = await anchorNuri();
const query = ` const query = `
SELECT ?username ?docPublic ?docProtected ?docPrivate WHERE { SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
GRAPH <${assertNuri(anchor)}> { GRAPH <${assertNuri(anchor)}> {
?acc a <${P.type}> ; ?acc a <${P.type}> ;
<${P.username}> ?username ; <${P.id}> ?id ;
<${P.docPublic}> ?docPublic ; <${P.docPublic}> ?docPublic ;
<${P.docProtected}> ?docProtected ; <${P.docProtected}> ?docProtected ;
<${P.docPrivate}> ?docPrivate . <${P.docPrivate}> ?docPrivate .
@@ -187,11 +187,11 @@ export async function loadShim(): Promise<Map<string, AccountRecord>> {
try { try {
const result = await sparqlQuery(s.sessionId, query, undefined, anchor); const result = await sparqlQuery(s.sessionId, query, undefined, anchor);
for (const row of readBindings(result)) { for (const row of readBindings(result)) {
const username = bindingValue(row, "username"); const id = bindingValue(row, "id");
if (!username) continue; if (!id) continue;
const key = accountKey(username); const key = accountKey(id);
const record: AccountRecord = { const record: AccountRecord = {
username, id,
docPublic: bindingValue(row, "docPublic"), docPublic: bindingValue(row, "docPublic"),
docProtected: bindingValue(row, "docProtected"), docProtected: bindingValue(row, "docProtected"),
docPrivate: bindingValue(row, "docPrivate"), docPrivate: bindingValue(row, "docPrivate"),
@@ -210,15 +210,15 @@ export async function loadShim(): Promise<Map<string, AccountRecord>> {
/** /**
* Resolve ONE account by its shim key with a BOUNDED query — O(1), independent * 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 * 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 * instead of scanning EVERY account like {@link loadShim}. Returns the account's
* record or `null` if it does not exist yet. * record or `null` if it does not exist yet.
* *
* Cached per account (in `accountCache`); a hit skips the query entirely, so * Cached per account (in `accountCache`); a hit skips the query entirely, so
* repeated resolves of the same account are free. `resetRegistryCache` clears it. * repeated resolves of the same account are free. `resetRegistryCache` clears it.
*/ */
export async function resolveAccount(username: string): Promise<AccountRecord | null> { export async function resolveAccount(id: string): Promise<AccountRecord | null> {
const key = accountKey(username); const key = accountKey(id);
const cached = accountCache.get(key); const cached = accountCache.get(key);
if (cached) return cached; if (cached) return cached;
@@ -226,12 +226,12 @@ export async function resolveAccount(username: string): Promise<AccountRecord |
const anchor = await anchorNuri(); const anchor = await anchorNuri();
// `subj` is already IRI-safe (accountSubject → escapeIri); `anchor` is a // `subj` is already IRI-safe (accountSubject → escapeIri); `anchor` is a
// trusted-shaped NURI → assertNuri. The query is bounded to this one subject. // trusted-shaped NURI → assertNuri. The query is bounded to this one subject.
const subj = accountSubject(username); const subj = accountSubject(id);
const query = ` const query = `
SELECT ?username ?docPublic ?docProtected ?docPrivate WHERE { SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
GRAPH <${assertNuri(anchor)}> { GRAPH <${assertNuri(anchor)}> {
<${subj}> a <${P.type}> ; <${subj}> a <${P.type}> ;
<${P.username}> ?username ; <${P.id}> ?id ;
<${P.docPublic}> ?docPublic ; <${P.docPublic}> ?docPublic ;
<${P.docProtected}> ?docProtected ; <${P.docProtected}> ?docProtected ;
<${P.docPrivate}> ?docPrivate . <${P.docPrivate}> ?docPrivate .
@@ -243,7 +243,7 @@ export async function resolveAccount(username: string): Promise<AccountRecord |
if (rows.length === 0) return null; if (rows.length === 0) return null;
const row = rows[0]!; const row = rows[0]!;
const record: AccountRecord = { const record: AccountRecord = {
username: bindingValue(row, "username") || username, id: bindingValue(row, "id") || id,
docPublic: bindingValue(row, "docPublic"), docPublic: bindingValue(row, "docPublic"),
docProtected: bindingValue(row, "docProtected"), docProtected: bindingValue(row, "docProtected"),
docPrivate: bindingValue(row, "docPrivate"), docPrivate: bindingValue(row, "docPrivate"),
@@ -273,11 +273,11 @@ async function createDoc(): Promise<Nuri> {
* Ensure an account exists in the shim, creating its 3 scope documents on * Ensure an account exists in the shim, creating its 3 scope documents on
* first sight. Idempotent — returns the existing record if already present. * first sight. Idempotent — returns the existing record if already present.
*/ */
export async function ensureAccount(username: string): Promise<AccountRecord> { export async function ensureAccount(id: string): Promise<AccountRecord> {
const key = accountKey(username); const key = accountKey(id);
// HOT PATH: targeted O(1) lookup — does THIS account already exist? — instead // 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. // 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; if (existing) return existing;
const [docPublic, docProtected, docPrivate] = await Promise.all([ const [docPublic, docProtected, docPrivate] = await Promise.all([
@@ -285,20 +285,20 @@ export async function ensureAccount(username: string): Promise<AccountRecord> {
createDoc(), createDoc(),
createDoc(), createDoc(),
]); ]);
const record: AccountRecord = { username, docPublic, docProtected, docPrivate }; const record: AccountRecord = { id, docPublic, docProtected, docPrivate };
const s = await session(); const s = await session();
const anchor = await anchorNuri(); const anchor = await anchorNuri();
const subj = accountSubject(username); const subj = accountSubject(id);
// `subj` is already IRI-safe (accountSubject → escapeIri). `anchor` is a // `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 // position → escapeLiteral. The doc NURIs come from `ng` but are stored as
// literals here, so they are escaped as literals too (defence in depth). // literals here, so they are escaped as literals too (defence in depth).
const update = ` const update = `
INSERT DATA { INSERT DATA {
GRAPH <${assertNuri(anchor)}> { GRAPH <${assertNuri(anchor)}> {
<${subj}> a <${P.type}> ; <${subj}> a <${P.type}> ;
<${P.username}> "${escapeLiteral(username)}" ; <${P.id}> "${escapeLiteral(id)}" ;
<${P.docPublic}> "${escapeLiteral(docPublic)}" ; <${P.docPublic}> "${escapeLiteral(docPublic)}" ;
<${P.docProtected}> "${escapeLiteral(docProtected)}" ; <${P.docProtected}> "${escapeLiteral(docProtected)}" ;
<${P.docPrivate}> "${escapeLiteral(docPrivate)}" . <${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` * NURI of the document where `id` writes GROUPED entities of `scope` (a single
* (e.g. participations, profile — no per-entity document / no inbox needed). * per-scope index document, for entities that need no per-entity document / no
* For per-entity scopes use {@link createEntityDoc} instead. * inbox). For per-entity scopes use {@link createEntityDoc} instead.
*/ */
export async function resolveWriteGraph(username: string, scope: Scope): Promise<Nuri> { export async function resolveWriteGraph(id: string, scope: Scope): Promise<Nuri> {
const record = await ensureAccount(username); const record = await ensureAccount(id);
return indexDocOf(record, scope); return indexDocOf(record, scope);
} }
@@ -414,8 +414,8 @@ export async function resolveInboxAnchor(): Promise<Nuri> {
* document's NURI is appended to the account's scope index document (the * document's NURI is appended to the account's scope index document (the
* store-container). Returns the entity document NURI (use it as `@graph`). * store-container). Returns the entity document NURI (use it as `@graph`).
*/ */
export async function createEntityDoc(username: string, scope: Scope): Promise<Nuri> { export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
const record = await ensureAccount(username); const record = await ensureAccount(id);
const indexDoc = indexDocOf(record, scope); const indexDoc = indexDocOf(record, scope);
const entityNuri = await createDoc(); const entityNuri = await createDoc();
const s = await session(); const s = await session();
@@ -480,14 +480,14 @@ export async function listEntityDocs(scope: Scope): Promise<Nuri[]> {
} }
/** /**
* The entity-document NURIs of `scope` belonging to ONE account (`username`) — * The entity-document NURIs of `scope` belonging to ONE account (`id`) —
* the read-by-need path for "my own entities" (my profile, my participations). * the read-by-need path for one account's own entities. Bounded to a SINGLE
* Bounded to a SINGLE account: it resolves only that account's scope index doc * account: it resolves only that account's scope index doc (via `ensureAccount`)
* (via `ensureAccount`) and reads the contained NURIs — NO cross-account * and reads the contained NURIs — NO cross-account fan-out, so it never touches
* enumeration, so it never touches another account's unsynced docs. This is the * another account's unsynced docs. This is the helper a consumer application uses
* helper FestipodDataContext uses instead of the all-accounts `listEntityDocs`. * for its own my-entities path, instead of the all-accounts `listEntityDocs`.
*/ */
export async function listMyEntityDocs(username: string, scope: Scope): Promise<Nuri[]> { export async function listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]> {
const record = await ensureAccount(username); const record = await ensureAccount(id);
return readScopeIndex(indexDocOf(record, scope)); return readScopeIndex(indexDocOf(record, scope));
} }
+2 -2
View File
@@ -9,8 +9,8 @@ export type Nuri = string;
* consumer's concern; this layer only knows the three scopes exist. */ * consumer's concern; this layer only knows the three scopes exist. */
export type Scope = "public" | "protected" | "private"; export type Scope = "public" | "protected" | "private";
/** App-level current identity. Target: the wallet user (`session.user`). /** The current identity id. Target: the wallet user (`session.user`). Polyfill:
* Polyfill: a chosen id (e.g. a username), because everyone shares one wallet. */ * a chosen id, because everyone shares one wallet. */
export type PrincipalId = string; export type PrincipalId = string;
/** /**
+28 -37
View File
@@ -1,8 +1,7 @@
import { test, expect } from "bun:test"; import { test, expect } from "bun:test";
import { import {
AccountStore, IdentityStore,
browserAccountStore, browserIdentityStore,
normalizeUsername,
ACCOUNT_STORAGE_KEY, ACCOUNT_STORAGE_KEY,
type AccountStorage, type AccountStorage,
} from "../src/accounts"; } from "../src/accounts";
@@ -18,47 +17,39 @@ function fakeStorage(): AccountStorage & { map: Map<string, string> } {
}; };
} }
test("normalizeUsername: strips leading @, trims, lowercases", () => { test("IdentityStore: set persists a trimmed id, get reads it back", () => {
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", () => {
const s = fakeStorage(); const s = fakeStorage();
const store = new AccountStore(s); const store = new IdentityStore(s);
expect(store.get()).toBeNull(); expect(store.get()).toBeNull();
expect(store.login(" Marie ")).toBe("Marie"); // trimmed, NOT normalized (display form) expect(store.set(" marie ")).toBe("marie"); // trimmed
expect(store.get()).toBe("Marie"); expect(store.get()).toBe("marie");
expect(s.map.get(ACCOUNT_STORAGE_KEY)).toBe("Marie"); expect(s.map.get(ACCOUNT_STORAGE_KEY)).toBe("marie");
}); });
test("AccountStore: blank login is ignored, keeps the previous value", () => { test("IdentityStore: a blank id is ignored, keeps the previous value", () => {
const store = new AccountStore(fakeStorage()); const store = new IdentityStore(fakeStorage());
store.login("bob"); store.set("bob");
expect(store.login(" ")).toBe("bob"); expect(store.set(" ")).toBe("bob");
expect(store.get()).toBe("bob"); expect(store.get()).toBe("bob");
}); });
test("AccountStore: logout clears the username (no throw)", () => { test("IdentityStore: clear removes the id (no throw)", () => {
const store = new AccountStore(fakeStorage()); const store = new IdentityStore(fakeStorage());
store.login("bob"); store.set("bob");
store.logout(); store.clear();
expect(store.get()).toBeNull(); expect(store.get()).toBeNull();
}); });
test("AccountStore: null storage degrades to non-persisting (SSR-safe)", () => { test("IdentityStore: null storage degrades to non-persisting (SSR-safe)", () => {
const store = new AccountStore(null); const store = new IdentityStore(null);
expect(store.get()).toBeNull(); 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(); 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 = { const throwing: AccountStorage = {
getItem: () => { getItem: () => {
throw new Error("boom"); throw new Error("boom");
@@ -70,15 +61,15 @@ test("AccountStore: swallows storage errors on read and write", () => {
throw new Error("boom"); throw new Error("boom");
}, },
}; };
const store = new AccountStore(throwing); const store = new IdentityStore(throwing);
expect(store.get()).toBeNull(); // read error swallowed → null expect(store.get()).toBeNull(); // read error swallowed → null
expect(() => store.login("bob")).not.toThrow(); expect(() => store.set("bob")).not.toThrow();
expect(() => store.logout()).not.toThrow(); expect(() => store.clear()).not.toThrow();
}); });
test("browserAccountStore returns a working store (uses global localStorage if present)", () => { test("browserIdentityStore returns a working store (uses global localStorage if present)", () => {
const store = browserAccountStore("ng-eventually.test.account"); const store = browserIdentityStore("ng-eventually.test.account");
expect(store).toBeInstanceOf(AccountStore); expect(store).toBeInstanceOf(IdentityStore);
// Behaves regardless of environment: login returns the value. // Behaves regardless of environment: set returns the value.
expect(store.login("zoe")).toBe("zoe"); expect(store.set("zoe")).toBe("zoe");
}); });
+20 -1
View File
@@ -13,7 +13,7 @@ test("protected documents: owner + explicitly granted principals only", () => {
caps.open("did:ng:o:prot", "protected", "alice"); 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", "alice")).toBe(true);
expect(caps.canRead("did:ng:o:prot", "bob")).toBe(false); 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); 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); 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", () => { test("write is restricted to write-cap holders; the creator always holds it", () => {
const caps = new CapRegistry(); const caps = new CapRegistry();
caps.open("did:ng:o:pub", "public", "alice"); caps.open("did:ng:o:pub", "public", "alice");
-60
View File
@@ -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([]);
});
+9 -9
View File
@@ -102,7 +102,7 @@ function makeFakeNg() {
// Shim account SELECT. Two shapes: the full scan (`?acc a <Account>`) and // Shim account SELECT. Two shapes: the full scan (`?acc a <Account>`) and
// the TARGETED bounded resolve (`<subj> a <Account>`), which binds one // the TARGETED bounded resolve (`<subj> a <Account>`), which binds one
// subject — honour that subject filter so the bounded query is O(1)/exact. // 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 subjM = query.match(new RegExp(`GRAPH <[^>]+>\\s*\\{\\s*<([^>]+)>\\s+a\\s+<${SHIM}:Account>`));
const onlySubject = subjM ? subjM[1]! : null; const onlySubject = subjM ? subjM[1]! : null;
const bySubject = new Map<string, Record<string, string>>(); const bySubject = new Map<string, Record<string, string>>();
@@ -110,16 +110,16 @@ function makeFakeNg() {
if (q.g !== anchor) continue; if (q.g !== anchor) continue;
if (onlySubject !== null && q.s !== onlySubject) continue; if (onlySubject !== null && q.s !== onlySubject) continue;
const rec = bySubject.get(q.s) ?? {}; 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}:docPublic`) rec.docPublic = q.o;
if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o; if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o;
if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o; if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o;
bySubject.set(q.s, rec); bySubject.set(q.s, rec);
} }
const bindings = [...bySubject.values()] const bindings = [...bySubject.values()]
.filter((r) => r.username) .filter((r) => r.id)
.map((r) => ({ .map((r) => ({
username: { value: r.username! }, id: { value: r.id! },
docPublic: { value: r.docPublic ?? "" }, docPublic: { value: r.docPublic ?? "" },
docProtected: { value: r.docProtected ?? "" }, docProtected: { value: r.docProtected ?? "" },
docPrivate: { value: r.docPrivate ?? "" }, docPrivate: { value: r.docPrivate ?? "" },
@@ -167,7 +167,7 @@ function inject() {
configure({ ng: ng as any, useShape: (() => {}) as any }); configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({ configureStoreRegistry({
getSession: async () => SESSION, getSession: async () => SESSION,
normalizeUser: (u) => u.trim().replace(/^@+/, "").toLowerCase(), normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
}); });
resetRegistryCache(); resetRegistryCache();
setCurrentUser(null); setCurrentUser(null);
@@ -256,10 +256,10 @@ test("(d) submitToIndex refuses a PROTECTED/PRIVATE document (public-only)", asy
resetCaps(); resetCaps();
}); });
test("INDEX_ACCOUNT lives in the reserved namespace (no typed username can equal it)", () => { test("INDEX_ACCOUNT lives in the reserved namespace (no typed id can equal it)", () => {
// The index account occupies a key no user input can produce: it is prefixed // 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 a username field and // with a NUL control char, which a user cannot type into an id field and
// which no `normalizeUser` output (a typeable value) contains. So it is // which no `normalizeId` output (a typeable value) contains. So it is
// disjoint from the keys "index" / "@index" a hostile user would submit. // 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.startsWith("\u0000")).toBe(true); // unreachable-by-typing sentinel
expect(INDEX_ACCOUNT).not.toBe("index"); expect(INDEX_ACCOUNT).not.toBe("index");
+35 -31
View File
@@ -1,15 +1,18 @@
/** /**
* ReadCap ACTIVE (T03.h) — end-to-end proof that the emulated SDK enforces * ReadCap ACTIVE — end-to-end proof that the emulated SDK enforces per-DOCUMENT
* per-DOCUMENT isolation, driven by per-entity documents + BILATERAL connections. * isolation, driven by per-entity documents + DIRECTED read grants.
* *
* Mirrors exactly what the app does: create an entity document through the REAL * Mirrors what the app does: create an entity document through the REAL registry
* registry (`createEntityDoc`), declare its cap policy via * (`createEntityDoc`), declare its cap policy via `getCaps().open(doc, scope,
* `getCaps().open(doc, scope, owner)`, set the current identity, and declare * owner)`, set the current identity, and — when the app decides two identities
* connections as the CURRENT identity's own peers (authenticated, bilateral). The * are related — issue a DIRECTED read grant on each of the owner's protected
* read filter then discriminates: * documents (`getCaps().grantRead(doc, granteeId)`). Whether identities are
* (a) unconnected principal denied a PROTECTED doc; granted after a BILATERAL * "connected" is the application's own concept: this test plays that role
* connection; PUBLIC readable throughout — via the ACTIVE ReadCap. * directly. The read filter then discriminates:
* (b) a UNILATERAL / self-declared connection grants NOTHING. * (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 { test, expect, mock, afterAll } from "bun:test";
import { createEntityDoc, resetRegistryCache } from "../src/store-registry"; import { createEntityDoc, resetRegistryCache } from "../src/store-registry";
@@ -22,7 +25,6 @@ import {
getCaps, getCaps,
resetCaps, resetCaps,
setCurrentUser, setCurrentUser,
declareConnections,
} from "../src/polyfill"; } from "../src/polyfill";
import { filterReadable } from "../src/read-filter"; import { filterReadable } from "../src/read-filter";
@@ -43,13 +45,19 @@ function inject() {
sparql_query: mock(async () => ({ results: { bindings: [] } })), sparql_query: mock(async () => ({ results: { bindings: [] } })),
}; };
configure({ ng: ng as any, useShape: (() => {}) as any }); 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(); resetRegistryCache();
resetCaps(); resetCaps();
setCurrentUser(null); setCurrentUser(null);
return ng; 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 () => { test("ReadCap active: a private entity doc created via the real registry is hidden from another principal", async () => {
inject(); inject();
@@ -70,9 +78,9 @@ test("ReadCap active: a private entity doc created via the real registry is hidd
expect(getCaps().hasReadPolicy()).toBe(true); expect(getCaps().hasReadPolicy()).toBe(true);
}); });
// (a) protected hidden while unconnected → revealed after a BILATERAL connection; // (a) protected hidden while ungranted → revealed after a DIRECTED grant; public
// public readable regardless — all through the ACTIVE ReadCap. // readable regardless — all through the ACTIVE ReadCap.
test("(a) PROTECTED doc: hidden unconnected, revealed after BILATERAL connection, PUBLIC always readable", async () => { test("(a) PROTECTED doc: hidden ungranted, revealed after a DIRECTED grant, PUBLIC always readable", async () => {
inject(); inject();
const aliceProtected = await createEntityDoc("alice", "protected"); 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(); 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("bob")).toEqual(["u1"]);
expect(view("alice")).toEqual(["p1", "u1"]); expect(view("alice")).toEqual(["p1", "u1"]);
// BILATERAL: alice asserts bob AND bob asserts alice → the link materializes and // The app decides alice↔bob are related and grants bob the read cap of alice's
// the SDK issues the protected doc's read cap to bob. // protected documents.
declareConnections(["bob"], "alice"); grantOwnerProtectedTo("alice", "bob");
declareConnections(["alice"], "bob");
expect(view("bob")).toEqual(["p1", "u1"]); 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"]); expect(view("carol")).toEqual(["u1"]);
}); });
// (b) A UNILATERAL / self-declared connection must NOT grant protected read. // (b) An identity gets no protected read until the OWNER issues the grant — a
test("(b) a UNILATERAL / self-declared connection grants NO protected read", async () => { // reader cannot grant itself.
test("(b) no directed grant → no protected read", async () => {
inject(); inject();
const aliceProtected = await createEntityDoc("alice", "protected"); 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 items = [{ "@graph": aliceProtected, "@id": "p1" }];
const view = (u: string) => filterReadable(items, getCaps(), u).map((i) => i["@id"]); const view = (u: string) => filterReadable(items, getCaps(), u).map((i) => i["@id"]);
// The ATTACKER (mallory) self-declares a connection to alice — a UNILATERAL // mallory holds no grant on alice's protected doc → denied.
// assertion authored by mallory. Alice NEVER asserts mallory back. expect(view("mallory")).toEqual([]);
declareConnections(["alice"], "mallory");
expect(view("mallory")).toEqual([]); // still denied — no bilateral link
// Even if alice connects to bob (a different, legitimate bilateral link), // Granting bob (a different, legitimate reader) leaves mallory denied.
// mallory's one-sided assertion still grants nothing. grantOwnerProtectedTo("alice", "bob");
declareConnections(["bob"], "alice");
declareConnections(["alice"], "bob");
expect(view("mallory")).toEqual([]); expect(view("mallory")).toEqual([]);
expect(view("bob")).toEqual(["p1"]); expect(view("bob")).toEqual(["p1"]);
}); });
-68
View File
@@ -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<Item> = { 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"]);
});
+2 -2
View File
@@ -34,7 +34,7 @@ function inject(triplesByDoc: Record<string, Array<[string, string]>>) {
configure({ ng: ng as any, useShape: (() => {}) as any }); configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({ configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }), getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
normalizeUser: (u: string) => u, normalizeId: (u: string) => u,
}); });
return ng; 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 }); configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({ configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }), 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"]); const subjects = await readUnion(["did:ng:o:ok", "did:ng:o:bad"]);
+1 -1
View File
@@ -43,7 +43,7 @@ test("escapeIri percent-encodes every IRI-breaking character", () => {
test("escapeIri neutralises a full breakout attempt", () => { test("escapeIri neutralises a full breakout attempt", () => {
const attack = 'x> <urn:evil> "pwn'; const attack = 'x> <urn:evil> "pwn';
const encoded = escapeIri(attack); 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 <PREFIX:...> IRI. // cannot escape the surrounding <PREFIX:...> IRI.
expect(encoded).not.toMatch(/[<>" ]/); expect(encoded).not.toMatch(/[<>" ]/);
}); });
+22 -22
View File
@@ -112,7 +112,7 @@ function makeFakeNg() {
const sparql_query = mock(async (...a: unknown[]) => { const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string; const query = a[1] as string;
const anchor = a[3] as string | undefined; const anchor = a[3] as string | undefined;
if (query.includes("<urn:ng-eventually:shim:username>")) { if (query.includes("<urn:ng-eventually:shim:id>")) {
// Account SELECT, scoped to the anchor graph. Two shapes: the full scan // Account SELECT, scoped to the anchor graph. Two shapes: the full scan
// (`?acc a <Account>`) and the TARGETED bounded resolve (`<subj> a // (`?acc a <Account>`) and the TARGETED bounded resolve (`<subj> a
// <Account>`) which binds one subject — honour that subject so the bounded // <Account>`) which binds one subject — honour that subject so the bounded
@@ -124,16 +124,16 @@ function makeFakeNg() {
if (q.g !== anchor) continue; if (q.g !== anchor) continue;
if (onlySubject !== null && q.s !== onlySubject) continue; if (onlySubject !== null && q.s !== onlySubject) continue;
const rec = bySubject.get(q.s) ?? {}; 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:docPublic") rec.docPublic = q.o;
if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = 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; if (q.p === "urn:ng-eventually:shim:docPrivate") rec.docPrivate = q.o;
bySubject.set(q.s, rec); bySubject.set(q.s, rec);
} }
const bindings = [...bySubject.values()] const bindings = [...bySubject.values()]
.filter((r) => r.username) .filter((r) => r.id)
.map((r) => ({ .map((r) => ({
username: { value: r.username! }, id: { value: r.id! },
docPublic: { value: r.docPublic ?? "" }, docPublic: { value: r.docPublic ?? "" },
docProtected: { value: r.docProtected ?? "" }, docProtected: { value: r.docProtected ?? "" },
docPrivate: { value: r.docPrivate ?? "" }, docPrivate: { value: r.docPrivate ?? "" },
@@ -157,7 +157,7 @@ function inject() {
configure({ ng: ng as any, useShape: (() => {}) as any }); configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({ configureStoreRegistry({
getSession: async () => SESSION, getSession: async () => SESSION,
normalizeUser: (u) => u.trim().replace(/^@+/, "").toLowerCase(), normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
}); });
resetRegistryCache(); resetRegistryCache();
return ng; return ng;
@@ -170,7 +170,7 @@ beforeEach(() => {
test("ensureAccount creates 3 scope docs and persists them to the shim", async () => { test("ensureAccount creates 3 scope docs and persists them to the shim", async () => {
const rec = await ensureAccount("Alice"); const rec = await ensureAccount("Alice");
expect(rec.username).toBe("Alice"); expect(rec.id).toBe("Alice");
expect(fake.doc_create).toHaveBeenCalledTimes(3); expect(fake.doc_create).toHaveBeenCalledTimes(3);
expect(rec.docPublic).toMatch(/^did:ng:o:doc/); expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
expect(rec.docProtected).not.toBe(rec.docPublic); 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 resetRegistryCache(); // force a re-read from the fake store
const map = await loadShim(); const map = await loadShim();
const rec = map.get("bob"); const rec = map.get("bob");
expect(rec?.username).toBe("Bob"); expect(rec?.id).toBe("Bob");
expect(rec?.docPublic).toMatch(/^did:ng:o:doc/); 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 () => { test("allAccounts reflects every ensured account", async () => {
await ensureAccount("Gina"); await ensureAccount("Gina");
await ensureAccount("Hank"); 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"]); expect(names).toEqual(["Gina", "Hank"]);
}); });
// --- SPARQL injection hardening (F1) -------------------------------------- // --- 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 // 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. // root). We inspect the exact SPARQL string the registry hands to sparql_update.
/** The raw INSERT DATA string produced by ensureAccount for `username`. */ /** The raw INSERT DATA string produced by ensureAccount for `id`. */
async function insertFor(username: string): Promise<string> { async function insertFor(id: string): Promise<string> {
await ensureAccount(username); await ensureAccount(id);
const calls = fake.sparql_update.mock.calls; const calls = fake.sparql_update.mock.calls;
return calls[calls.length - 1]![1] as string; return calls[calls.length - 1]![1] as string;
} }
@@ -284,31 +284,31 @@ function rawQuoteCount(s: string): number {
return (withoutEscapes.match(/"/g) ?? []).length; 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" ; <urn:evil> "pwn'; const evil = 'x" ; <urn:evil> "pwn';
const update = await insertFor(evil); const update = await insertFor(evil);
// A well-formed INSERT DATA with 4 predicate literals has exactly 8 raw // A well-formed INSERT DATA with 4 predicate literals has exactly 8 raw
// quotes (the delimiters). The injected `"` must have been escaped, so the // quotes (the delimiters). The injected `"` must have been escaped, so the
// count stays 8 — no extra literal was opened. // count stays 8 — no extra literal was opened.
expect(rawQuoteCount(update)).toBe(8); 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
// `<urn:evil>` survives only as INERT text inside that literal (its // `<urn:evil>` survives only as INERT text inside that literal (its
// surrounding quotes are escaped `\"`), never as query syntax. // surrounding quotes are escaped `\"`), never as query syntax.
expect(update).toContain('"x\\" ; <urn:evil> \\"pwn"'); expect(update).toContain('"x\\" ; <urn:evil> \\"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> <urn:evil"; const evil = "x> <urn:evil";
const update = await insertFor(evil); const update = await insertFor(evil);
// The account subject is `<urn:ng-eventually:shim:account:...>` — the encoded // The account subject is `<urn:ng-eventually:shim:account:...>` — 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(/<urn:ng-eventually:shim:account:([^>]*)>/)!; const subjMatch = update.match(/<urn:ng-eventually:shim:account:([^>]*)>/)!;
expect(subjMatch).not.toBeNull(); expect(subjMatch).not.toBeNull();
expect(subjMatch[1]).not.toMatch(/[<>" ]/); // fully percent-encoded expect(subjMatch[1]).not.toMatch(/[<>" ]/); // fully percent-encoded
expect(subjMatch[1]).toContain("%3E"); // the `>` became %3E 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 evil = "a\nb\tc";
const update = await insertFor(evil); const update = await insertFor(evil);
// In the literal: escaped to \n / \t (no raw control char). // In the literal: escaped to \n / \t (no raw control char).
@@ -342,20 +342,20 @@ function escapeLiteralRef(v: string): string {
.replace(/\t/g, "\\t")}"`; .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" ; <urn:evil> "x'; const evil = 'eve" ; <urn:evil> "x';
const rec = await ensureAccount(evil); const rec = await ensureAccount(evil);
expect(rec.username).toBe(evil); expect(rec.id).toBe(evil);
resetRegistryCache(); resetRegistryCache();
const map = await loadShim(); 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). // normalized key, and exactly ONE account exists (no injected extra subject).
const key = evil.trim().replace(/^@+/, "").toLowerCase(); 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); 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(); const ng = makeFakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any }); configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({ getSession: async () => SESSION }); configureStoreRegistry({ getSession: async () => SESSION });