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