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