refactor: renommer client → sdk, et fusionner les deux portes en une

Deux mouvements de surface, aucun changement de comportement.

**`packages/client` → `packages/sdk`, `@ng-eventually/client` → `@ng-eventually/sdk`.**
« client » ne disait rien : ce paquet EST le SDK que l'application appelle, et c'est
tout ce qu'elle appelle. L'ancien nom reste comme mot-clé de recherche dans
`docs/source-layout-by-fate.md` et le tableau des paquets du README.

**Une seule entrée.** L'entrée `./polyfill` disparaît ; ses symboles applicatifs —
`configure`, `configureStoreRegistry`, `setCurrentUser`, `connectedUser` et leurs types
— vivent dans un bloc `POLYFILL-ERA` de `src/index.ts`.

Ce que la seconde porte portait mérite d'être nommé avant d'être retiré : *ce qu'on
importe de ce chemin est exactement ce qu'on supprimera à la migration*. Une seule
porte perd ce signal — rien à la ligne d'import ne distingue `configure`, qui part, de
`docs`, que le vrai SDK remplace sur place. Trois choses le portent désormais : le bloc
lui-même, l'inventaire d'exports de `docs/api-contract.md` (épinglé par
`test/vocabulary.test.ts`, donc il ne peut pas rancir en silence), et le contrôle de
vocabulaire sur les noms publiés.

**Six symboles quittent la surface au passage**, et la fusion est ce qui a rendu le
choix visible plutôt qu'hérité :

- `getConfig` / `getStoreRegistryDeps` — câblage interne, atteint par
  `shared-wallet/bootstrap` ;
- `resetConfig` / `resetStoreRegistry` / `resetCaps` — remises à zéro de test, atteintes
  par leur chemin interne, ce qui est leur raison d'être ;
- le `share` direct — `inbox.share` a toujours été la même fonction, et la publier deux
  fois brouillait la frontière qu'elle servait à marquer.

Corrections d'affirmations fausses trouvées en chemin : le contrat annonçait `isNuri` /
`hasReadCap` sur la porte SDK alors qu'ils ne sont plus exportés depuis le passage au
permissif en entrée (`NuriLike` validé à la porte) ; le README du paquet documentait
`capFor`, `shareCap`, `getCaps` et `publishRepoLink`, dont aucun n'existe ; et le README
de l'app d'exemple affirmait que la suite e2e la pilote, ce qui reste à faire.

179 tests unitaires, typecheck bibliothèque / exemple / harnais, e2e 42/42 contre le
broker en ligne — mesuré une fois après le renommage, une fois après la fusion.
This commit is contained in:
Sylvain Duchesne
2026-08-07 11:05:19 +02:00
parent 0832338201
commit 0eb25286c8
85 changed files with 423 additions and 413 deletions
+123
View File
@@ -0,0 +1,123 @@
# @ng-eventually/sdk
One entry point. Most of what it publishes has the same signature as the future SDK —
`ng`, `useShape`, `watchShape`, `docs`, `inbox`, `storeRegistry`, `readUnion` (+ types) —
and is 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.
**Four calls do not, and they are the whole of what you will delete:** `configure`,
`configureStoreRegistry`, `setCurrentUser`, `connectedUser`. They exist because one
shared wallet hosts every user; upstream, an application imports the SDK and each user
opens their own wallet. `src/index.ts` groups them under a heading that says so.
*(There were two entry points until 2026-08-07, `.` and `./polyfill`, and the second one
WAS that list. One door is easier to import from and says less — hence the grouping, and
hence `docs/api-contract.md`, whose export inventory a test keeps honest.)*
Per-symbol, with the target signature and an epistemic label on every claim:
[`docs/api-contract.md`](../../docs/api-contract.md).
> **Reading is key possession, and the isolation here is still fake.** The cap surface
> has the shape of the real model — you hold a document's `ReadCap` or you do not read
> it, and there is no authorization list anywhere — but nothing is encrypted yet and
> the stand-in key is a constant. Nothing this library does may be described as
> "anonymous" or "private" until per-document encryption lands (P1b).
```ts
import {
// SDK-shaped — the real SDK replaces these in place.
ensureIdentity, storeRegistry, inbox, readUnion, docs,
// Polyfill-era — these go away, and they are the whole of what goes away.
configure, configureStoreRegistry, setCurrentUser,
} from "@ng-eventually/sdk";
configure({ ng: realNg, useShape: realUseShape, sharedWallet });
configureStoreRegistry({ getSession });
await ensureIdentity(); // who am I (shared wallet)
const doc = await storeRegistry.createEntityDoc(me, "protected");
await docs.sparqlUpdate(sid, `INSERT DATA { … }`, doc);
const subjects = await readUnion(await storeRegistry.listMyEntityDocs(me, "protected"));
```
## Principle — the polyfill compensates, it never extends
**Its only reason to exist is to bridge a NextGraph implementation gap.** Every
non-SDK surface must map to something NextGraph will provide natively, and must fall
away at that point — no bespoke features, no observability, no convenience API that
isn't strictly *"NextGraph will do this later"*. The test for any proposed addition:
*does it compensate a real, exhibited gap?* If not, it belongs in the consumer
application. And a compensation whose gap is not actually exhibited on the target
broker is dead weight, not defensive code.
Both halves are binding — **the surface AND the implementation** stay as close as
possible to what NextGraph plans. The question to ask at every choice: *would this
make a caller learn something it has to UNLEARN at migration?* If yes, it is a
deviation, whatever it buys.
What the polyfill adds, each emulated now and native later:
- **Shared-wallet identity** — one wallet hosts every user, so the library fabricates
*virtual users* and confines every access to the connected one
(`emulated-verifier/reach.ts`). Upstream, each user opens their own wallet.
- **Capability emulation** — per-identity cap possession plus a read filter over it:
you read the documents whose cap you hold. There is no authorization list, because
the real model has none.
- **Inbox** — `post`, `postToDocument`, `share`, and the recipient's processing.
The model is verified (an inbox is a keypair on one repo); no JS surface exists yet.
Generic by construction: no application domain here. See
[`examples/notebook`](../../examples/notebook) for an application written against it,
which the e2e suite drives.
## How a document is reached — the three acts, and no others
```ts
import { storeRegistry, inbox, readUnion } from "@ng-eventually/sdk";
// 1. CREATE — you hold its cap, with nothing to declare.
const doc = await storeRegistry.createEntityDoc(me, "protected");
// 2. GIVE TO READ — name the document and the person. The key is looked up and
// sealed into a deposit; the recipient applies it by connecting, with nothing
// to call. Irreversible: there is no revoking a key already handed out.
await inbox.share(doc, "bob");
// 3. CIRCULATE THE REFERENCE — no call at all. Every reference this surface returns
// is BARE: it names the document and grants nothing. If the document sits in a
// PUBLIC store, the store serves its read cap to whoever asks, so the bare
// reference is enough to read it — and if it does not, the reference still names
// it and opens nothing.
const publicDoc = await storeRegistry.createEntityDoc(me, "public");
// …put `publicDoc` in a QR code, a message, another document. Nothing else to do.
await readUnion([publicDoc]); // a stranger holding only this reads it
```
**The invariant behind all three: you never derive a cap from a bare reference.** You
look it up in what you hold, you were given it, or a public store served it. A
`did:ng:o:…` without `:r:` names a document and opens nothing — which is what makes
confidentiality composable: a widely circulated document may point at a restricted
one, and following the reference gets you a name, not a key. See
[`docs/readcap-and-nuri-model.md`](../../docs/readcap-and-nuri-model.md) § 0.
## The types carry that invariant
`Nuri` and `ReadCap` are **template literal types**, not `string` aliases:
```ts
type Nuri = `did:ng:${string}`
type ReadCap = `did:ng:${string}:r:${string}`
```
They are still strings — assignable to `string`, JSON-serializable, no wrapper — but
the distinction is checked. A `ReadCap` goes wherever a `Nuri` is expected (a cap *is*
a NURI with the key inside); the reverse does not compile.
**Permissive in, precise out.** Public entries take `NuriLike` (`Nuri | string`) and
validate at the door, so a value coming from storage, a URL or a form needs no
narrowing and no cast on your side; what they *return* is a precise `Nuri`. The
runtime checks stay regardless — a JavaScript caller never meets the compiler.
```ts
const saved = localStorage.getItem("doc"); // string | null
if (saved) await readUnion([saved]); // ✓ validated at the door
```
+344
View File
@@ -0,0 +1,344 @@
# SDK reference — reading data with `@ng-eventually/sdk`
**Audience:** anyone using `@ng-eventually/sdk` (the app that consumes it, and
the lib itself when honoring the contract). This is the reference on the SDK's
**read/reactivity surface** — how you read data and how a read stays live.
`@ng-eventually/sdk` is written and consumed as if NextGraph were a **finished,
mature SDK**: documents per entity placed by scope, capabilities, inboxes, and a
**reactive ORM**. This file documents that finished-SDK contract. Where today's
emulation does not yet deliver it, that is called out in one clearly-separated
section at the end ([§ Current emulation status](#current-emulation-status)) and in
[`nextgraph-current-state.md`](../../../docs/nextgraph-current-state.md) — that is
an emulation gap to close, **not** the SDK's design. Read the reference itself as the
target contract.
The ground truth for the finished contract is the real NextGraph platform
(`nextgraph-rs`, sibling clone at `../nextgraph-rs`); the reactive primitives are
cited by `file:symbol` throughout so a future agent can re-verify cheaply.
---
## TL;DR — the canonical read is reactive
> **Read data with the reactive ORM hook `useShape` — subscribe to a shape over a
> scope, get the current value, and re-render on every change (yours or a remote
> peer's, synced through the broker). Subscription/push, never polling. One-shot
> reads are the exception, not the rule.**
```ts
import { useShape } from "@ng-eventually/sdk";
import { EventShapeType } from "…/shapes/orm/…";
function EventList() {
// A live, reactive set. Re-renders whenever any Event doc in scope changes —
// locally or from a remote peer synced by the broker. No polling, no refetch.
const events = useShape(EventShapeType, { graphs: [scopeNuri] });
return <>{[...events].map((e) => <Row key={e["@id"]} event={e} />)}</>;
}
```
---
## The reactivity model — subscription/push, never polling
NextGraph's philosophy is **subscription-based push**. You do not poll for changes;
you **subscribe once** and the platform **pushes** an update to every subscriber the
moment a document changes. A change is a new **commit** on a document's branch, and
it reaches subscribers whether it was applied **locally** (your own write) or
**delivered from a remote peer** and synced through the broker.
The load-bearing fact — verified in `nextgraph-rs` — is that both origins converge
on a single push point in the verifier:
- **Local commit** (your own SPARQL update / ORM write): the write path builds
`BranchUpdateInfo`s and calls `Verifier::update_graph`
(`engine/verifier/src/commits/transaction.rs:646`).
- **Remote commit** (another session/peer, synced via the broker): the broker hands
the event to `Verifier::deliver` (`engine/verifier/src/verifier.rs:1718`) →
`verify_commit``verify_async_transaction`
(`engine/verifier/src/commits/transaction.rs:295`), which calls the **same**
`update_graph` (`transaction.rs:327`).
- **The single choke point:** `update_graph` pushes an `AppResponseV0::Patch` to the
branch's subscribers via `Verifier::push_app_response`
(`engine/verifier/src/verifier.rs:252`) — it looks up the branch in
`branch_subscriptions` and `sender.send(response).await`**and** fans out to the
reactive ORM via `orm_backend_update`
(`engine/verifier/src/orm/graph/handle_backend_update.rs:48`), which sends an
`AppResponseV0::GraphOrmUpdate` to each ORM subscription whose scope was touched.
So: **one document, one commit, every subscriber pushed** — the same code path for a
local edit and for a remote peer's edit arriving over the network. That is what makes
a `useShape` read reactive *across peers* with no polling.
---
## The recommended read path — the reactive ORM hook `useShape`
`useShape` is **the** way to read. It subscribes to a **shape** (a typed view — the
SHEX/ORM shape) over a **scope** (one or more document NURIs / a subject set), returns
the current materialized set immediately, and **re-renders the component on every
change** to any document in scope.
### Signature
```ts
useShape<T extends BaseType>(
shape: ShapeType<T>,
scope: Scope | string | undefined,
): DeepSignalSet<T>
```
- `shape` — the ORM shape type (generated from a SHEX shape). Names the entity type
and the properties to materialize.
- `scope` — where to read: a `{ graphs, subjects }` scope object or a NURI string.
**Not to be confused with this library's `Scope`** (`types.ts`), which is the
literal union `public | protected | private` naming a store. Same word, two
meanings: the ORM's is a read target, ours is a placement.
`undefined` yields an empty read.
- **Returns** a `DeepSignalSet<T>` — a **live reactive set**. Iterate it like a set;
the component re-renders whenever the set changes.
Verified surface in `nextgraph-rs`:
`sdk/js/orm/src/frontendAdapters/react/useShape.ts` (`useShape`, line 86) →
`OrmSubscription` (`sdk/js/orm/src/connector/GraphOrmSubscription.ts`), which calls
`ng.orm_start_graph(...)` with a callback, applies the initial materialized objects
and every subsequent patch to a `DeepSignalSet`
(`applyPatchesToDeepSignal`), and drives React re-render via
`useDeepSignal` (`@ng-org/alien-deepsignals/react`). Vue and Svelte adapters exist
alongside the React one (`sdk/js/orm/src/frontendAdapters/{vue,svelte}/`).
`@ng-eventually/sdk` re-exports `useShape` from
[`../src/surface/use-shape.ts`](../src/surface/use-shape.ts); import it from the SDK
(`@ng-eventually/sdk`), never from `@ng-org/orm` directly.
### What you get, in order
1. **An initial value.** On subscribe, the ORM materializes the current objects in
scope and delivers them first (`AppResponseV0::GraphOrmInitial`,
`engine/verifier/src/orm/graph/initialize.rs:113`). The hook returns them as the
initial `DeepSignalSet`.
2. **A stream of updates.** On every subsequent commit affecting the scope — local or
remote — the ORM pushes a patch (`AppResponseV0::GraphOrmUpdate`), the connector
applies it to the `DeepSignalSet`, and the component re-renders. No refetch, no
interval.
### Under the hood — the streamed primitives
`useShape` is built on NextGraph's streamed request primitives. A consumer never
calls these directly, but they define the contract:
- `orm_start_graph(shape, scope, …, callback)` — the reactive **graph ORM**
subscription (`sdk/js/lib-wasm/src/lib.rs:1951`). Returns `GraphOrmInitial` then a
stream of `GraphOrmUpdate`. This is what `useShape` uses.
- `orm_start_discrete(nuri, …, callback)` — the reactive **discrete** (Yjs/Automerge
document) ORM (`lib-wasm/src/lib.rs:1929`); `DiscreteOrmInitial` then
`DiscreteOrmUpdate`.
- `doc_subscribe(nuri, …, callback)` — a lower-level **document** subscription
(`lib-wasm/src/lib.rs:1908`; verifier `Verifier::create_branch_subscription`,
`verifier.rs:352`). Delivers an initial `TabInfo` + `State` (heads, full graph,
discrete state, files — `verifier.rs:470`/`:476`) then a stream of `Patch` on each
commit. This is the raw reactive read; `useShape` is the typed, ergonomic layer on
top.
- All of these are **streamed** (marked by `AppRequestCommandV0::is_stream()`,
`engine/net/src/app_protocol.rs:762`) and delivered through the one generic streamed
binding `app_request_stream_` (`lib-wasm/src/lib.rs:1385`), which invokes a JS
callback per `AppResponse` and returns a cancel function. The reactive surface is
**callback-based** at the wasm boundary; `useShape` hides that behind a reactive
signal.
> **Rule of thumb:** to read, `useShape`. It subscribes, gives you the value now, and
> keeps it live. Reach for a one-shot read only when you explicitly do *not* want to
> stay subscribed.
---
## The one-shot read — the exception
Sometimes you want the current value **once**, with no live subscription (a batch, a
guard, a migration). NextGraph's one-shot read is a plain SPARQL query — non-streamed,
computes a result and returns once (`sparql_query`,
`sdk/js/lib-wasm/src/lib.rs:352`/`553`; no "subscribe to a query" exists —
`sparql_query` is not reactive).
In `@ng-eventually/sdk` the one-shot read is exposed as:
- **`docs.sparqlQuery(sid, query, base?, anchor?)`** — a raw anchored SPARQL query
([`../src/surface/docs.ts`](../src/surface/docs.ts)). `anchor` = the document NURI to read; the
anchor restricts the query to that one repo's graph.
- **`readUnion(docs)`** — read a **bounded, by-need set** of document NURIs,
each with its own anchored query, grouped per subject
([`../src/surface/read-model.ts`](../src/surface/read-model.ts)). This is the polyfill's listing
primitive (see [§ Current emulation status](#current-emulation-status) and
[`read-model.md`](../../../docs/read-model.md)).
One-shot reads do not re-render on change. To stay live over a one-shot read you must
re-run it on a change **signal** (e.g. re-call `readUnion` when a `doc_subscribe`
fires) — a manual assembly that exists only because of the emulation gap below; the
finished contract is `useShape`.
---
## The write surface (at a glance)
You do not need the write internals to read, but reads and writes share the same
document model, so briefly:
- **Create a document:** `docs.docCreate(sid, crdt, cls, dest, store?)`
([`../src/surface/docs.ts`](../src/surface/docs.ts)) — mirrors `ng.doc_create`. **One document = one
repo** (`did:ng:o:<RepoID>`); there is no separate `Document` type.
- **Write into it:** `docs.sparqlUpdate(sid, query, anchor)` — a SPARQL
`INSERT/DELETE` scoped to the anchor document's graph. Or, at the ORM layer, the ORM
update primitives (`graph_orm_update`). A write is a **commit** on the document's
branch — which is exactly what every `useShape` subscriber over that document is
pushed.
- **Writes target one document**, never "the union": a SPARQL update must name one
document's graph (`resolve_target_for_sparql(update=true)` returns `InvalidTarget`
for the union, `engine/verifier/src/request_processor.rs:275`).
---
## Identity & scope (what a consumer needs)
Data is isolated **per document (repo)**, and each document lives in a **scope**:
| Scope | Read | Write |
|---|---|---|
| **Private** | Owner only | Owner only |
| **Protected** | Owner + whoever the owner delivered the cap to | Owner + permissioned collaborators |
| **Public** | Whoever has the URL (the repo link) | **Owner only** |
Consequences a consumer must internalize:
- **Reading is key possession, never an authorization list.** You hold a document's
`ReadCap` (`…:r:{cap}`) or you do not read it — there is no "may X read Y?" to ask,
here or upstream. A cap-less `did:ng:o:…` **names** a document without granting
anything, which is what lets public content point at private content without
disclosing it. Caps reach you two ways: creating a document files its own, and
someone delivering one to your inbox (`shareCap`). Nothing derives a cap from a
bare reference.
- **Isolation is per-document, not per-store.** Holding a store's cap does **not**
grant read on the documents it contains — each document has its own ReadCap. Fine-
grained isolation therefore means **one document per entity**
(`engine/repo/src/types.rs`, ReadCap granularity; see
[`nextgraph-current-state.md`](../../../docs/nextgraph-current-state.md) §
*Capability / ReadCap granularity*).
- **Read isolation is cryptographic.** A reactive/union read over a repo you hold no
cap for simply returns nothing (the repo is never decrypted); a *targeted* read of
an unheld repo raises `RepoNotFound`.
- **Public means everyone reads, only the owner writes.** There is **no** primitive by
which a non-owner appends to a document (public or otherwise): a write commit
requires repo **membership** plus a matching write permission, gated by
`Repo::verify_permission` (`engine/repo/src/repo.rs:584` — a non-member author is
`PermissionDenied`) and cryptographically bound to the repo's write-cap secret. The
permission enum (`engine/repo/src/types.rs:1729`, `PermissionV0`) has `WriteAsync`/
`WriteSync` but **no** add-only/append permission and **no** public-writable grant.
To surface data to others without a shared write, use the **inbox** (any identity —
even anonymous — can deposit; only the owner reads back) or make the document
**public-readable** and let each identity own its own document. *Per-document inboxes
are this library's, not the engine's: upstream only the public and protected store
repos carry one (`engine/verifier/src/site.rs:128,149`).*
Depositing into a document you do not own is **one** call, and it names the document:
```ts
await inbox.postToDocument(doc, { payload: { signingUp: true } });
```
You need the **document** (its cap), nothing else — the address rides on it. It
**throws** if the document has no inbox: its owner opens one with
`storeRegistry.openDocumentInbox(doc)` for documents meant to receive, so a fresh
document has none. When "no inbox" is an expected case, check first with
`storeRegistry.documentInboxAddress(doc)` (→ `Nuri | undefined`).
A deposit carries no target document, deliberately — one inbox belongs to one
document, so the address already identifies it, exactly as upstream (`inboxes:
PubKey → RepoId`). Do not encode the document in your payload; you would have to
unlearn it. Reading that inbox is a different right, and it stays the owner's
(`inbox.read` refuses otherwise).
The consumer asks the SDK for what it needs and trusts the result; it does not
construct NURIs, pick union-vs-anchor, or reason about caps. The domain-shaped list
helpers live in the consumer app; the SDK exposes the generic reactive/by-need read.
---
## Current emulation status
> **This section is about where today's polyfill does NOT yet deliver the reactive
> contract above.** It is an **emulation gap to close**, not the SDK's design. The
> reference above is the target; the finished SDK reads reactively via `useShape`
> everywhere. Full detail:
> [`nextgraph-current-state.md`](../../../docs/nextgraph-current-state.md),
> [`read-model.md`](../../../docs/read-model.md),
> [`simulation.md`](../../../docs/simulation.md).
Today, on a single shared wallet emulating the mature platform, four gaps diverge
from the reactive contract:
1. **Entity-list reads are one-shot, not reactive.** The reactive ORM cannot be used
as the listing primitive because the ORM fan-out over a set of per-entity /
not-yet-synced document graphs **hangs**: a freshly-created or unsynced graph makes
`RepoNotFound` abort the whole `orm_start_graph`, so the subscription never emits
its initial and never resolves (root cause verified —
`engine/verifier/src/request_processor.rs` `resolve_target` →
`self.repos.get(...).ok_or(RepoNotFound)`; see
[`nextgraph-current-state.md`](../../../docs/nextgraph-current-state.md) § *The ORM
fan-out hang*). So the lib reads entity lists with **`readUnion`** — a
bounded set of one-shot anchored `sparql_query`s
([`read-model.md`](../../../docs/read-model.md)) — and reassembles reactivity by
**re-querying on a change signal** (a lightweight `doc_subscribe` / single-store ORM
used only as a *signal* source, then re-run `readUnion`). `useShape` remains valid
for a **single already-opened document**; it is the per-entity **fan-out** that is
unfit today.
2. **The inbox uses a polling watcher.** The inbox is emulated
(`AppRequestCommandV0::InboxPost` has no verifier arm today; no wasm helper seals a
deposit), so `inbox.watch` ([`../src/surface/inbox.ts`](../src/surface/inbox.ts)) **polls** via
`setInterval` (default 1s) instead of subscribing. The finished contract is push
(the broker already routes the inbox natively); these become subscriptions when the
sealed-inbox path is exposed to JS (no such method exists today).
3. **No cross-wallet / on-demand repo open.** There is no JS primitive to sync an
*unknown* repo by NURI+ReadCap today (`load_repo_from_read_cap` is `pub(crate)`,
unexposed; the `OpenRepo` broker path is a TODO at
`engine/verifier/src/verifier.rs:1423`). The mono-wallet polyfill sidesteps this:
every account's docs are `doc_create`d in the same session, so they are already
queryable. At the multi-store migration, opening a repo by cap becomes a native
broker sync and the anchored read is unchanged.
4. **The subscription may not echo the writer's OWN local commit — HYPOTHESIS
(high-confidence), confirmation in progress (2026-07-18); NOT confirmed, NOT
fixed.** Unlike gaps 13 (designed emulation stopgaps), this is a suspected
defect in the polyfill's own reactive assembly. When a client does a local
`sparqlUpdate` on a doc it is itself subscribed to (`subscribeDoc` /
`ng.doc_subscribe`), the subscription callback appears NOT to fire for its OWN
local commit in the same session — so the reactive re-read chain
([`../src/surface/watch-shape.ts`](../src/surface/watch-shape.ts) `watchShape` → `reread` →
[`../src/surface/read-model.ts`](../src/surface/read-model.ts) `readUnion`) never runs, and
consumers keep the STALE value until the next connection delivers a fresh
initial `State`. **Remote** commits DO push correctly (verified: cross-browser
reactive update works). A code review verified the consumer wiring is correct,
the doc IS in the subscribed set, and a triggered re-read WOULD return the new
value — leaving the self-commit echo as the only suspect link. That link is
**INFERRED**, not observed: the real `ng.doc_subscribe` runtime is not readable
from source, and [`../src/surface/subscribe.ts`](../src/surface/subscribe.ts)'s own doc-comment
CLAIMS local writes push a `Patch` — contradicted by the observation. (This
also sits in tension with § *The reactivity model* above, which documents the
target contract — one commit, every subscriber pushed, local or remote.) The
requirement at stake is multi-user: a value change (e.g. a participant count)
must propagate reactively to ALL viewers — other viewers (remote push, which
works) AND the writer's own view (this suspect link). **Treatment (PLANNED,
not done):** confirm first via the temporary instrumentation just added
([`../src/surface/subscribe.ts`](../src/surface/subscribe.ts) ≈`:119` logs
`doc_subscribe FIRE <nuri> (State|Patch)`;
[`../src/surface/watch-shape.ts`](../src/surface/watch-shape.ts) ≈`:341` logs
`reread TRIGGER by <nuri>` — line numbers volatile, grep the log strings);
then, IF confirmed, fix **polyfill-side** — a
local commit should notify the doc's active `subscribeDoc` callbacks.
Consumers must not compensate. Short entry:
[`nextgraph-current-state.md`](../../../docs/nextgraph-current-state.md) §
*Known open issues*.
When these gaps close, the read path collapses to the reference above: `useShape`
everywhere, push everywhere, no polling and no re-query-on-signal assembly.
+332
View File
@@ -0,0 +1,332 @@
/**
* Real-broker plumbing for the SDK e2e harness — a DEDICATED test wallet for
* `@ng-eventually/sdk`, fully separate from any consumer app's profile.
*
* Adapted from the Festipod app's `src/shared/support/hooks.ts` (the reference
* real-broker Playwright flow): headless wallet CREATION on nextgraph.eu, broker
* redirect via nextgraph.net, iframe handling. Here it authenticates a wallet
* created FOR THIS LIB (distinct name + distinct profile dir), and loads the
* minimal SDK page (sdk-entry.ts) inside the broker iframe.
*/
import { chromium, type BrowserContext, type Page, type Frame } from "playwright";
import { execSync } from "node:child_process";
import * as http from "node:http";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ── Dedicated, gitignored profile + wallet (NOT the app's .playwright-profile) ──
export const PROFILE_DIR = path.resolve(__dirname, ".playwright-profile-lib");
const WALLET_READY_MARKER = path.join(PROFILE_DIR, ".wallet-ready");
export const WALLET_NAME = "ng-eventually-e2e";
export const WALLET_PASSWORD = "ng-eventually-e2e";
const ENTRY = path.resolve(__dirname, "sdk-entry.ts");
const BUNDLE_OUT = path.resolve(__dirname, ".dist", "sdk-entry.js");
const LAUNCH_ARGS = [
"--disable-features=PrivateNetworkAccessRespectPreflightResults,BlockInsecurePrivateNetworkRequests,PrivateNetworkAccessForWorkers,PrivateNetworkAccessForNavigations",
"--allow-insecure-localhost",
"--disable-web-security",
];
function resolveChromePath(): string | undefined {
const p = chromium
.executablePath()
.replace("chrome-headless-shell", "chrome")
.replace("chromium_headless_shell", "chromium");
return p.includes("headless") ? undefined : p;
}
export function buildBundle(): void {
fs.mkdirSync(path.dirname(BUNDLE_OUT), { recursive: true });
execSync(`bun build ${ENTRY} --outfile ${BUNDLE_OUT} --bundle --format=esm`, {
stdio: "pipe",
cwd: path.resolve(__dirname, ".."),
});
}
export function serveHarness(): Promise<{ url: string; close: () => void }> {
const bundle = fs.readFileSync(BUNDLE_OUT, "utf-8");
const html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>ng-eventually sdk e2e</title></head><body><div id="root"></div><script type="module" src="/sdk-entry.js"></script></body></html>`;
const server = http.createServer((req, res) => {
if (req.url === "/sdk-entry.js") {
res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" });
res.end(bundle);
} else {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(html);
}
});
return new Promise((resolve) => {
server.listen(0, "127.0.0.1", () => {
const port = (server.address() as { port: number }).port;
resolve({ url: `http://127.0.0.1:${port}`, close: () => server.close() });
});
});
}
/**
* Create the dedicated lib wallet once (headless UI flow on nextgraph.eu),
* persisted in PROFILE_DIR for the duration of the batch.
*
* ── One PHYSICAL user per batch, not one forever ──────────────────────────
* This used to reuse a single wallet across every run, guarded by a ready marker. That
* made the suite slow itself down, monotonically: each batch mints ~11 FRESH virtual
* identities (`@alice-…`, `@owner-…`, `@recon-…`), each with three scope documents and
* an inbox, and they all land in the SAME physical user. Nothing ever removed them. A
* cold resynchronisation is O(the physical user's size) — which this library's own docs
* state — so the wallet created on 2026-07-10 had grown enough to take 286s on a single
* sync step, against 250s a week earlier, and the drift was invisible because no one
* measured it.
*
* The fresh identities are not the mistake — they are what makes a batch reproducible
* (a stable inbox accumulates its past runs' deposits otherwise). The mistake was
* keeping the physical user that holds them. So: a new one per batch, which also makes
* the cold-sync duration comparable from one run to the next instead of being a number
* that only ever grows.
*
* What this does NOT change: the profile stays persistent WITHIN a batch, because
* CONTRACT 1 and 2 test exactly that (a faithful reconnect over the same profile, and
* the absence of an account fork across it).
*/
export async function ensureWallet(): Promise<void> {
if (fs.existsSync(WALLET_READY_MARKER)) {
const age = Date.now() - fs.statSync(WALLET_READY_MARKER).mtimeMs;
console.log(
`[e2e] discarding the previous batch's wallet (${Math.round(age / 60000)} min old) — ` +
"a physical user is per-batch, see ensureWallet",
);
fs.rmSync(PROFILE_DIR, { recursive: true, force: true });
}
console.log("[e2e] creating this batch's wallet on nextgraph.eu...");
fs.mkdirSync(PROFILE_DIR, { recursive: true });
const ctx = await chromium.launchPersistentContext(PROFILE_DIR, {
headless: true,
executablePath: resolveChromePath(),
args: LAUNCH_ARGS,
});
const page = ctx.pages()[0] || (await ctx.newPage());
page.on("pageerror", () => {});
try {
await page.goto("https://nextgraph.eu/", { waitUntil: "domcontentloaded", timeout: 30000 });
const createButton = page.getByText("Create Wallet", { exact: true });
await createButton.waitFor({ state: "visible", timeout: 15000 });
await createButton.click();
await page.waitForURL("**/account*", { timeout: 15000 }).catch(() => {});
const acceptButton = page.getByText("I accept", { exact: true });
await acceptButton.waitFor({ state: "visible", timeout: 15000 });
await acceptButton.click();
const usernameInput = page.locator("#username-input");
await usernameInput.waitFor({ state: "visible", timeout: 30000 });
await usernameInput.fill(WALLET_NAME);
const passwordInput = page.locator("#password-input");
await passwordInput.waitFor({ state: "visible", timeout: 5000 });
await passwordInput.fill(WALLET_PASSWORD);
const submitButton = page.getByText("create my wallet", { exact: false });
await submitButton.waitFor({ state: "visible", timeout: 5000 });
await submitButton.click();
await page.waitForURL("**/#/wallet/login", { timeout: 30000 });
await page.waitForTimeout(2000);
// First login → bootstrap the verifier repos from the broker.
const walletLink = page.getByText("Click here to login with your wallet");
if (await walletLink.isVisible({ timeout: 5000 }).catch(() => false)) {
await walletLink.click();
await page.waitForTimeout(1000);
}
const loginPassword = page.locator('input[type="password"]');
await loginPassword.waitFor({ state: "visible", timeout: 10000 });
await loginPassword.fill(WALLET_PASSWORD);
await loginPassword.press("Enter");
await page.waitForTimeout(10000);
console.log("[e2e] dedicated lib wallet created + bootstrapped");
} finally {
await ctx.close();
}
fs.writeFileSync(WALLET_READY_MARKER, new Date().toISOString());
}
export async function launchWalletContext(): Promise<BrowserContext> {
return chromium.launchPersistentContext(PROFILE_DIR, {
headless: true,
executablePath: resolveChromePath(),
args: LAUNCH_ARGS,
});
}
/**
* Create a BRAND-NEW wallet in a BRAND-NEW profile dir and RETURN the launched
* context, without a `.wallet-ready` marker and WITHOUT tearing the context down.
* Unlike {@link ensureWallet} (which reuses one persistent dedicated wallet across
* runs — so it is always "hot"), this mints a genuinely FRESH wallet each call so
* the cold-start (private-store repo not yet in `self.repos`) can be exercised.
*
* Same headless nextgraph.eu creation + first-login-bootstrap flow as ensureWallet,
* but the context stays OPEN and is returned (with its dir) so the caller can then
* open the SDK page in the SAME profile — i.e. the very first app session over a
* wallet that has never run the app. Caller cleans up ctx + dir.
*/
export async function createFreshWalletContext(): Promise<{
ctx: BrowserContext;
dir: string;
name: string;
}> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-eventually-fresh-"));
const name = "ng-fresh-" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
const ctx = await chromium.launchPersistentContext(dir, {
headless: true,
executablePath: resolveChromePath(),
args: LAUNCH_ARGS,
});
const page = ctx.pages()[0] || (await ctx.newPage());
page.on("pageerror", () => {});
await page.goto("https://nextgraph.eu/", { waitUntil: "domcontentloaded", timeout: 30000 });
const createButton = page.getByText("Create Wallet", { exact: true });
await createButton.waitFor({ state: "visible", timeout: 15000 });
await createButton.click();
await page.waitForURL("**/account*", { timeout: 15000 }).catch(() => {});
const acceptButton = page.getByText("I accept", { exact: true });
await acceptButton.waitFor({ state: "visible", timeout: 15000 });
await acceptButton.click();
const usernameInput = page.locator("#username-input");
await usernameInput.waitFor({ state: "visible", timeout: 30000 });
await usernameInput.fill(name);
const passwordInput = page.locator("#password-input");
await passwordInput.waitFor({ state: "visible", timeout: 5000 });
await passwordInput.fill(WALLET_PASSWORD);
const submitButton = page.getByText("create my wallet", { exact: false });
await submitButton.waitFor({ state: "visible", timeout: 5000 });
await submitButton.click();
await page.waitForURL("**/#/wallet/login", { timeout: 30000 });
await page.waitForTimeout(2000);
// First login → bootstrap the verifier repos from the broker (this is what a
// brand-new wallet does on its very first unlock).
const walletLink = page.getByText("Click here to login with your wallet");
if (await walletLink.isVisible({ timeout: 5000 }).catch(() => false)) {
await walletLink.click();
await page.waitForTimeout(1000);
}
const loginPassword = page.locator('input[type="password"]');
await loginPassword.waitFor({ state: "visible", timeout: 10000 });
await loginPassword.fill(WALLET_PASSWORD);
await loginPassword.press("Enter");
await page.waitForTimeout(10000);
await page.close().catch(() => {});
return { ctx, dir, name };
}
/**
* Launch a persistent context on a FRESH, EMPTY profile dir (its own userDataDir).
* Empty local storage ⇒ empty verifier repo cache ⇒ the reconnection cold-start:
* the same wallet's repos are on the broker but NOT in this profile's IndexedDB, so
* a session over it starts with an empty `self.repos`. Caller must import the wallet
* (see {@link importWalletViaFile}) before opening the SDK page. Returns the context
* and the dir so the caller can clean it up.
*/
export async function launchCleanProfileContext(): Promise<{ ctx: BrowserContext; dir: string }> {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ng-eventually-clean-"));
const ctx = await chromium.launchPersistentContext(dir, {
headless: true,
executablePath: resolveChromePath(),
args: LAUNCH_ARGS,
});
return { ctx, dir };
}
/**
* Import a `.ngw` wallet FILE into the current (clean) profile via the standalone
* nextgraph.eu "Import a Wallet File" flow, then unlock it with the password. After
* this the profile holds the wallet — but NOT the repos' local cache — so the next
* SDK session over it hits the broker-only cold-start. Adapted from the Festipod
* app's `importWalletViaFile` (the proven real-broker wallet-file import).
*/
export async function importWalletViaFile(page: Page, ngwPath: string): Promise<void> {
await page.goto("https://nextgraph.eu/#/wallet/login", { waitUntil: "domcontentloaded" });
// Let the SPA render + attach the file input (uploading too early → EncryptionError).
await page.waitForTimeout(3000);
await page.locator('input[type=file]').waitFor({ state: "attached", timeout: 15000 });
await page.setInputFiles('input[type=file]', ngwPath);
const passwordInput = page.locator('input[type=password]').first();
await passwordInput.waitFor({ state: "visible", timeout: 15000 });
await passwordInput.fill(WALLET_PASSWORD);
await passwordInput.press("Enter");
const confirm = page.getByRole("button", { name: /Confirm/i });
if (await confirm.isVisible({ timeout: 2000 }).catch(() => false)) await confirm.click().catch(() => {});
await page.waitForTimeout(8000); // unlock + verifier bootstrap from the broker
}
/**
* Navigate through the broker (nextgraph.net redirect) to load `appUrl` in the
* broker iframe; unlock the dedicated wallet if a login is shown; return the app
* iframe Frame. Adapted from Festipod setupBrokerPage + completeBrokerLogin.
*/
export async function setupBrokerPage(page: Page, appUrl: string): Promise<Frame> {
const brokerRedirect = `https://nextgraph.net/redir/#/?o=${encodeURIComponent(appUrl)}`;
await page.goto(brokerRedirect, { waitUntil: "domcontentloaded" });
const loginButton = page.getByText("Login", { exact: true });
if (await loginButton.isVisible({ timeout: 2000 }).catch(() => false)) {
await loginButton.click();
await page.waitForURL("**/wallet/login", { timeout: 5000 }).catch(() => {});
}
const hasAppFrame = () => page.frames().some((f) => f.url().includes("127.0.0.1"));
const walletLink = page.getByText("Click here to login with your wallet", { exact: false });
const loginDeadline = Date.now() + 25000;
while (Date.now() < loginDeadline && !hasAppFrame() && !(await walletLink.isVisible().catch(() => false))) {
await page.waitForTimeout(500);
}
if (!hasAppFrame() && (await walletLink.isVisible().catch(() => false))) {
await walletLink.click();
await page.waitForTimeout(1000);
const passwordInput = page.locator('input[type="password"]');
if (await passwordInput.isVisible({ timeout: 8000 }).catch(() => false)) {
await passwordInput.fill(WALLET_PASSWORD);
await passwordInput.press("Enter");
await page.waitForTimeout(3000);
}
}
let appFrame: Frame | null = null;
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
for (const f of page.frames()) {
if (f.url().startsWith(appUrl) || f.url().includes("127.0.0.1")) {
appFrame = f;
break;
}
}
if (appFrame) break;
for (const iframe of await page.locator("iframe").all()) {
const src = await iframe.getAttribute("src");
if (src && src.includes("127.0.0.1")) {
const el = await iframe.elementHandle();
appFrame = (await el?.contentFrame()) ?? null;
if (appFrame) break;
}
}
if (appFrame) break;
await page.waitForTimeout(500);
}
if (!appFrame) {
const frames = page.frames().map((f) => f.url());
throw new Error(`SDK iframe not found after 30s. Frames: ${JSON.stringify(frames)}`);
}
return appFrame;
}
@@ -0,0 +1,280 @@
/**
* DECISIVE real-broker determination: does `doc_subscribe` actually PUSH when a
* subscribed document is written?
*
* This is the reactive-layer coverage whose ABSENCE let a reactivity bug ship: the
* app's whole read-model reactivity rests on `subscribeDoc(nuri, cb)` (the polyfill
* wrapper over `ng.doc_subscribe`, `src/subscribe.ts`) firing `cb` again on every
* commit to the doc. Two pushes are load-bearing in production and were reported as
* NOT firing:
* (SELF) a session's own `sparqlUpdate` to a doc it subscribes to.
* (CROSS) another session writes to a doc the first session subscribes to.
*
* This runner exercises BOTH against the REAL broker, through the SAME public
* surface the app uses — `subscribeDoc` (via the harness's `stateProbe*` bridge,
* which passes the raw `AppResponse` straight through the polyfill wrapper),
* `docs.docCreate`, and `docs.sparqlUpdate` (`writeTo`). It records EVERY push as a
* typed event (`{ typeKey: "State" | "Patch" | "TabInfo" | …, elapsedMs }`) so the
* verdict is the ground truth "did the subscription callback fire again", not a
* re-read of the document. Each wait is a single event-driven promise+timeout on the
* push (NO re-read loop) — a timeout is a DEFINITE "did-not-fire", not a flaky miss.
*
* Standalone (NOT `bun test`). Run:
* bun run e2e/reactivity-doc-subscribe.ts
* (or `bun run test:e2e:reactivity` from packages/sdk)
*
* It reuses the exact real-broker plumbing of run.ts / broker.ts: the dedicated lib
* wallet, the broker iframe, `window.__sdk`. The CROSS case opens a SECOND page on
* the SAME persistent wallet context — a second concurrent verifier session on one
* shared wallet (as faithfulReconnect does) — and writes from it.
*/
import type { Frame, Page, BrowserContext } from "playwright";
import {
buildBundle,
serveHarness,
ensureWallet,
launchWalletContext,
setupBrokerPage,
} from "./broker";
type Check = { name: string; ok: boolean; detail?: string };
const results: Check[] = [];
function record(name: string, ok: boolean, detail?: string): void {
results.push({ name, ok, detail });
console.log(` [${ok ? "PASS" : "FAIL"}] ${name}${detail ? " — " + detail : ""}`);
}
type Event = { typeKey: string; elapsedMs: number };
// Call a bridge method inside a given iframe.
function sdk<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> {
return frame.evaluate(
([m, a]) => (window as any).__sdk[m as string](...(a as unknown[])),
[method, args] as const,
) as Promise<T>;
}
/**
* The decisive wait: resolve TRUE as soon as the probe's recorded push count grows
* past `base` (the subscription callback fired again), or FALSE on timeout. This is
* a promise+timeout on the PUSH itself — it polls only the in-memory event counter
* the `subscribeDoc` callback writes, NEVER re-reads the document. A FALSE here is a
* definite non-delivery within the window, not a missed re-read.
*/
async function waitForPush(frame: Frame, base: number, timeoutMs: number): Promise<boolean> {
try {
await frame.waitForFunction(
(b) => (window as any).__sdk.stateProbeEvents().length > (b as number),
base,
{ timeout: timeoutMs },
);
return true;
} catch {
return false; // timed out → the callback did NOT fire again within the window
}
}
const seq = (events: Event[]): string =>
events.length ? events.map((e) => `${e.typeKey}@${e.elapsedMs}ms`).join(" → ") : "(none)";
async function openSession(
ctx: BrowserContext,
url: string,
tag: string,
): Promise<{ page: Page; frame: Frame; sessionId: string }> {
const page = await ctx.newPage();
page.on("pageerror", (e) => console.error(`[iframe error:${tag}]`, e.message));
page.on("console", (m) => {
const t = m.text();
// Surface the polyfill's own "doc_subscribe FIRE" diagnostic (subscribe.ts) if
// access logging happens to be on — an independent confirmation of a push.
if (m.type() === "error") console.error(`[iframe console:${tag}]`, t);
else if (t.includes("doc_subscribe FIRE")) console.log(`[${tag}] ${t}`);
});
const frame = await setupBrokerPage(page, url);
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", {
timeout: 60000,
});
const info = await sdk<{ session_id: string } | null>(frame, "sessionInfo");
const sessionId = info?.session_id ?? "(none)";
console.log(`[session:${tag}] connected — session_id=${sessionId}`);
return { page, frame, sessionId };
}
const SELF_TIMEOUT_MS = 10000;
const CROSS_TIMEOUT_MS = 15000;
const STATE_TIMEOUT_MS = 20000;
async function main(): Promise<void> {
console.log("[reactivity] building SDK page bundle...");
buildBundle();
console.log("[reactivity] ensuring dedicated lib wallet...");
await ensureWallet();
const { url, close: closeServer } = await serveHarness();
console.log(`[reactivity] harness served at ${url}`);
let ctx: BrowserContext | null = null;
try {
ctx = await launchWalletContext();
// ── Session A (the subscriber for both cases) ────────────────────────────
const A = await openSession(ctx, url, "A");
// ════════════════════════════════════════════════════════════════════════
// CASE 1 — SELF: A subscribes to D, then A itself writes to D.
// ════════════════════════════════════════════════════════════════════════
console.log("\n── CASE 1: SELF (single session — own write to own subscribed doc) ──");
{
const doc = await sdk<string>(A.frame, "docCreate");
console.log(` [SELF] created doc D = ${doc}`);
await sdk(A.frame, "stateProbeSubscribe", doc);
// Wait for the initial State (the sync barrier). TabInfo may precede it.
const gotState = await (async () => {
try {
await A.frame.waitForFunction(
() => (window as any).__sdk.stateProbeStateCount() >= 1,
{ timeout: STATE_TIMEOUT_MS },
);
return true;
} catch {
return false;
}
})();
const afterSubscribe = await sdk<Event[]>(A.frame, "stateProbeEvents");
console.log(` [SELF] pushes after subscribe: ${seq(afterSubscribe)}`);
record(
"SELF: initial State push arrives on subscribe (baseline sanity)",
gotState && afterSubscribe.some((e) => e.typeKey === "State"),
`sequence=${seq(afterSubscribe)}`,
);
// Now the decisive write: A's OWN sparqlUpdate to D.
const preWrite = afterSubscribe.length;
console.log(` [SELF] A writes to D (own sparqlUpdate); waiting ≤${SELF_TIMEOUT_MS}ms for a push…`);
await sdk(A.frame, "writeTo", doc, "self-1");
const fired = await waitForPush(A.frame, preWrite, SELF_TIMEOUT_MS);
const afterWrite = await sdk<Event[]>(A.frame, "stateProbeEvents");
const newEvents = afterWrite.slice(preWrite);
console.log(` [SELF] pushes AFTER own write: ${seq(newEvents)}`);
console.log(` [SELF] VERDICT: callback ${fired ? "FIRED" : "did NOT fire"} within ${SELF_TIMEOUT_MS}ms`);
record(
`SELF: subscription callback fires on the session's OWN write (≤${SELF_TIMEOUT_MS}ms)`,
fired,
`newPushes=${seq(newEvents)}`,
);
await sdk(A.frame, "stateProbeStop");
}
// ════════════════════════════════════════════════════════════════════════
// CASE 2 — CROSS-SESSION: A subscribes to D2; a SECOND session B (same shared
// wallet, own concurrent verifier session) writes to D2.
// ════════════════════════════════════════════════════════════════════════
console.log("\n── CASE 2: CROSS-SESSION (session B writes to a doc session A subscribes to) ──");
let B: { page: Page; frame: Frame; sessionId: string } | null = null;
try {
B = await openSession(ctx, url, "B");
} catch (e: any) {
console.log(` [CROSS] COULD-NOT-TEST: second concurrent session on the shared wallet failed to open: ${String(e?.message ?? e)}`);
record(
"CROSS: second concurrent session opened on the shared wallet",
false,
`open failed: ${String(e?.message ?? e)} — see Festipod multibrowser harness as the alternative venue`,
);
}
if (B) {
// NB: `session_id` is a PER-PAGE local verifier counter (each fresh iframe
// numbers its first session "1"), so it is NOT a global identifier and cannot
// be used to prove distinctness. The REAL proof that A and B are two separate
// verifier sessions is behavioural: B's write reaches A only after a broker
// round-trip (a delayed Patch), not as an instant same-session echo.
console.log(
` [CROSS] both pages connected — A.session=${A.sessionId} B.session=${B.sessionId} (per-page local counter; distinctness shown by the cross-broker propagation below)`,
);
record(
"CROSS: a second concurrent page/session is open on the same shared wallet",
true,
`A=${A.sessionId} B=${B.sessionId} (session_id is a per-page counter, not a global id)`,
);
// A creates D2 and subscribes.
const doc2 = await sdk<string>(A.frame, "docCreate");
console.log(` [CROSS] A created doc D2 = ${doc2}`);
await sdk(A.frame, "stateProbeSubscribe", doc2);
const gotState2 = await (async () => {
try {
await A.frame.waitForFunction(
() => (window as any).__sdk.stateProbeStateCount() >= 1,
{ timeout: STATE_TIMEOUT_MS },
);
return true;
} catch {
return false;
}
})();
const afterSub2 = await sdk<Event[]>(A.frame, "stateProbeEvents");
console.log(` [CROSS] A pushes after subscribe: ${seq(afterSub2)}`);
record(
"CROSS: A receives its initial State on D2 (baseline sanity)",
gotState2 && afterSub2.some((e) => e.typeKey === "State"),
`sequence=${seq(afterSub2)}`,
);
// B writes to D2. Capture a write failure (e.g. RepoNotFound) explicitly —
// it would mean B cannot reach A's doc, which is itself a determination.
const preCross = afterSub2.length;
let writeThrew: string | null = null;
// Cross-session writes to a doc created by ANOTHER session can be slow: B must
// sync/open D2's repo before it can commit. Time it separately so the push
// latency is reported relative to when B's write actually LANDED, not to
// subscribe time.
console.log(` [CROSS] B writes to D2 from its own session…`);
const tWriteStart = Date.now();
try {
await sdk(B.frame, "writeTo", doc2, "cross-1");
} catch (e: any) {
writeThrew = String(e?.message ?? e);
console.log(` [CROSS] B's write THREW: ${writeThrew}`);
}
const writeMs = Date.now() - tWriteStart;
record("CROSS: session B's write to D2 did not throw", writeThrew === null, writeThrew ? writeThrew : `landed in ${writeMs}ms`);
console.log(` [CROSS] B's write returned in ${writeMs}ms; now waiting ≤${CROSS_TIMEOUT_MS}ms for A's push…`);
const tWaitStart = Date.now();
const crossFired = writeThrew ? false : await waitForPush(A.frame, preCross, CROSS_TIMEOUT_MS);
const pushAfterWriteMs = Date.now() - tWaitStart;
const afterCross = await sdk<Event[]>(A.frame, "stateProbeEvents");
const crossNew = afterCross.slice(preCross);
console.log(` [CROSS] A pushes AFTER B's write: ${seq(crossNew)}`);
console.log(
` [CROSS] VERDICT: A's callback ${crossFired ? `FIRED (${pushAfterWriteMs}ms after B's write landed)` : "did NOT fire"} within ${CROSS_TIMEOUT_MS}ms${writeThrew ? " (B's write threw first)" : ""}`,
);
record(
`CROSS: A's subscription callback fires on B's write (≤${CROSS_TIMEOUT_MS}ms after B's write landed)`,
crossFired,
`newPushes=${seq(crossNew)} (B write took ${writeMs}ms; push ${crossFired ? pushAfterWriteMs + "ms after" : "not seen"})${writeThrew ? ` — B write threw: ${writeThrew}` : ""}`,
);
await sdk(A.frame, "stateProbeStop");
}
} finally {
try { if (ctx) await ctx.close(); } catch { /* ignore */ }
closeServer();
}
// ── Determination summary (not a pass/fail gate — this is a probe) ──────────
console.log("\n══ doc_subscribe delivery determination ══");
for (const r of results) console.log(` [${r.ok ? "PASS" : "FAIL"}] ${r.name}${r.detail ? " — " + r.detail : ""}`);
const self = results.find((r) => r.name.startsWith("SELF: subscription callback fires"));
const cross = results.find((r) => r.name.startsWith("CROSS: A's subscription callback fires"));
console.log("\n SELF →", self ? (self.ok ? "FIRES" : "DOES-NOT-FIRE") : "could-not-test");
console.log(" CROSS →", cross ? (cross.ok ? "FIRES" : "DOES-NOT-FIRE") : "could-not-test");
}
main().catch((e) => {
console.error("[reactivity] fatal:", e);
process.exit(1);
});
+121
View File
@@ -0,0 +1,121 @@
/**
* COLD-START repro — a genuinely FRESH wallet (never ran the app) driving the
* shim's first account resolution/provision.
*
* Unlike `run.ts`, which reuses ONE dedicated wallet (always "hot" — its
* private-store repo is already in the verifier's `self.repos`), this mints a
* BRAND-NEW wallet + fresh profile per run and opens the SDK page over it as the
* very first session. It then, in order:
* 1) probes the RAW anchored shim SELECT on `did:ng:${private_store_id}` — the
* cold-start bug surfaces here as `RepoNotFound` (the private-store repo not
* yet open) rather than a silent 0 rows;
* 2) runs `ensureAccount` (resetRegistryCache first) — the app's first-login
* bootstrap — and asserts it provisions 3 scope docs WITHOUT throwing;
* 3) re-resolves the SAME id from a fresh anchored read and asserts it returns
* the SAME docs (real persistence in the shim).
*
* Expected BEFORE the fix: step 1 throws RepoNotFound; step 2/3 fail to persist.
* Expected AFTER the fix: step 1 may still throw (raw, no open), but step 2/3
* succeed because ensureAccount opens the anchor repo before read/write.
*
* Run: `bun run e2e/repro-fresh-wallet.ts`.
*/
import * as fs from "node:fs";
import type { Frame, Page, BrowserContext } from "playwright";
import { buildBundle, serveHarness, createFreshWalletContext, setupBrokerPage } from "./broker";
type Check = { name: string; ok: boolean; detail?: string };
const results: Check[] = [];
function check(name: string, ok: boolean, detail?: string): void {
results.push({ name, ok, detail });
console.log(` [${ok ? "PASS" : "FAIL"}] ${name}${detail ? " — " + detail : ""}`);
}
function sdk<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> {
return frame.evaluate(
([m, a]) => (window as any).__sdk[m as string](...(a as unknown[])),
[method, args] as const,
) as Promise<T>;
}
async function main(): Promise<void> {
console.log("[repro] building SDK page bundle...");
buildBundle();
const { url, close: closeServer } = await serveHarness();
console.log(`[repro] harness served at ${url}`);
console.log("[repro] creating a BRAND-NEW wallet (fresh profile)...");
let ctx: BrowserContext | null = null;
let dir: string | null = null;
let page: Page | null = null;
try {
const fresh = await createFreshWalletContext();
ctx = fresh.ctx;
dir = fresh.dir;
console.log(`[repro] fresh wallet: ${fresh.name}`);
page = await ctx.newPage();
page.on("pageerror", (e) => console.error("[iframe error]", e.message));
page.on("console", (m) => {
if (m.type() === "error") console.error("[iframe console]", m.text());
});
console.log("[repro] opening SDK page over the FRESH wallet (first-ever app session)...");
const frame = await setupBrokerPage(page, url);
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", {
timeout: 60000,
});
console.log("[repro] connected. Driving the cold-start shim resolution...");
// 1) RAW anchored shim probe — the diagnostic. Reports RepoNotFound if the
// private-store repo is not yet open in this fresh session.
const probe = await sdk<any>(frame, "shimAnchorProbe");
console.log(
` [DIAG] raw anchored shim read: threw=${probe.threw} error=${probe.error} rows=${probe.rows}`,
);
// 2) ensureAccount — the real bootstrap. This MUST provision cleanly on a fresh
// wallet (all 3 docs truthy, no throw). This is the load-bearing assertion.
const ensured = await sdk<any>(frame, "coldEnsureAccount", "@cold-user-1");
check(
"fresh wallet: ensureAccount provisions the account without throwing",
!ensured.threw && !!ensured.docPublic && !!ensured.docProtected && !!ensured.docPrivate,
ensured.threw
? `THREW: ${ensured.error}`
: `pub=${String(ensured.docPublic).slice(0, 22)}… prot=${String(ensured.docProtected).slice(0, 22)}`,
);
// 3) Re-resolve from a FRESH anchored read — proves the shim actually persisted.
const verified = await sdk<any>(frame, "verifyShimPersisted", "@cold-user-1");
check(
"fresh wallet: the provisioned account persists (re-resolves the SAME docs, no RepoNotFound)",
!verified.threw &&
verified.docPublic === ensured.docPublic &&
verified.docProtected === ensured.docProtected &&
verified.docPrivate === ensured.docPrivate,
verified.threw
? `THREW: ${verified.error}`
: `same=${verified.docPublic === ensured.docPublic && verified.docProtected === ensured.docProtected}`,
);
} finally {
try { if (page) await page.close(); } catch { /* ignore */ }
try { if (ctx) await ctx.close(); } catch { /* ignore */ }
try { if (dir) fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
closeServer();
}
const passed = results.filter((r) => r.ok).length;
const failed = results.length - passed;
console.log(`\n══ cold-start repro: ${passed} passed, ${failed} failed ══`);
if (failed > 0) {
for (const r of results.filter((x) => !x.ok)) console.log(` - ${r.name}: ${r.detail ?? ""}`);
process.exit(1);
}
}
main().catch((e) => {
console.error("[repro] fatal:", e);
process.exit(1);
});
+797
View File
@@ -0,0 +1,797 @@
/**
* Real-broker e2e runner for `@ng-eventually/sdk` — the polyfill's OWN suite,
* in the SDK domain (no application concepts), with a DEDICATED wallet.
*
* Standalone (NOT `bun test`), so it never mixes into the fake-ng unit suite.
* Run: `bun run e2e/run.ts` (or `bun run test:e2e` from packages/sdk).
*
* It: builds the SDK page bundle, creates/reuses the dedicated lib wallet, opens
* the broker iframe on the real broker with that wallet, waits for `window.__sdk`
* to connect, then drives every polyfill behavior through the bridge and asserts
* the real-broker outcomes. Each check is event-driven where reactivity matters
* (waitForFunction on a counter), never a blind sleep.
*/
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import type { Frame, Page, BrowserContext } from "playwright";
import {
buildBundle,
serveHarness,
ensureWallet,
launchWalletContext,
launchCleanProfileContext,
importWalletViaFile,
setupBrokerPage,
} from "./broker";
type Check = { name: string; ok: boolean; detail?: string };
const results: Check[] = [];
function record(name: string, ok: boolean, detail?: string): void {
results.push({ name, ok, detail });
const tag = ok ? "PASS" : "FAIL";
console.log(` [${tag}] ${name}${detail ? " — " + detail : ""}`);
}
function check(name: string, cond: boolean, detail?: string): void {
record(name, !!cond, detail);
}
async function step(name: string, fn: () => Promise<void>): Promise<void> {
try {
await fn();
} catch (e: any) {
record(name, false, "threw: " + String(e?.message ?? e));
}
}
// A short helper: call a bridge method inside the iframe.
function sdk<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> {
return frame.evaluate(
([m, a]) => (window as any).__sdk[m as string](...(a as unknown[])),
[method, args] as const,
) as Promise<T>;
}
function sdkGet<T>(frame: Frame, method: string, ...args: unknown[]): Promise<T> {
// Same as sdk() but for synchronous getters (no await inside the bridge).
return frame.evaluate(
([m, a]) => (window as any).__sdk[m as string](...(a as unknown[])),
[method, args] as const,
) as Promise<T>;
}
/**
* FAITHFUL reconnection — the real app's reconnect path, NOT export/reimport.
*
* Opens a BRAND-NEW page on the SAME persistent wallet context (`ctx`, the profile
* that already holds the wallet + its local IndexedDB repo cache) and drives a NEW
* broker login through it. That new login mints a FRESH verifier session (empty
* `self.repos` at connect) while the page is a fresh SDK-module instance (empty
* open-repo registry + empty store-registry cache). This is EXACTLY what the app
* does on re-enter/reload (src/modules/event/steps/data/reconnexion.steps.ts:
* `this.page.context().newPage()` + `pool.setupBrokerPage`), and is the ONLY faithful
* cold-open: it does NOT wipe the profile, so it does NOT force the broker to resync
* every repo from scratch (which export/reimport-into-empty-profile DOES — masking
* the very cold-read/anti-fork gap under test). The repos are on the broker AND in the
* profile's cache, but this session's verifier hasn't opened them yet — so the SDK's
* open-before-read (open-repo.ts) and anti-fork retry (store-registry.ts) are what must
* bridge the gap. Returns the fresh page + its connected iframe Frame.
*/
async function faithfulReconnect(
ctx: BrowserContext,
url: string,
): Promise<{ page: Page; frame: Frame }> {
const p = await ctx.newPage();
p.on("pageerror", (e) => console.error("[iframe error:reconnect]", e.message));
p.on("console", (m) => {
if (m.type() === "error") console.error("[iframe console:reconnect]", m.text());
});
const frame = await setupBrokerPage(p, url);
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", { timeout: 60000 });
return { page: p, frame };
}
/**
* The batch's own budget, and the measurement that explains an overrun.
*
* Not a `timeout` wrapped around the command from outside: when that fired it killed the
* browser, and the suite reported `Target page, context or browser has been closed` —
* which reads as an application bug and was twice diagnosed as one. A budget belongs to
* the thing that knows what it is spending it on, and it must say so when it runs out.
*/
const BATCH_BUDGET_MS = 45 * 60 * 1000;
const batchStart = Date.now();
/**
* The slowest cold resynchronisation of the batch — the number that drifted from 250s to
* 286s over a month without anyone looking, because it only ever appeared inside one
* step's detail line. It is the health indicator of the physical user, so it is reported
* with the summary.
*/
let coldSyncMs = 0;
/** Fail with the cause named, rather than letting a killed browser look like a defect. */
function assertWithinBudget(): void {
const spent = Date.now() - batchStart;
if (spent > BATCH_BUDGET_MS) {
throw new Error(
`[e2e] batch budget exceeded (${Math.round(spent / 60000)} min > ` +
`${BATCH_BUDGET_MS / 60000} min). This is almost always the physical user having ` +
"grown: a cold resync is O(its size). Check the cold-sync figure printed above — " +
"it should be stable from batch to batch now that each gets a fresh wallet.",
);
}
}
async function main(): Promise<void> {
console.log("[e2e] building SDK page bundle...");
buildBundle();
console.log("[e2e] ensuring dedicated lib wallet...");
await ensureWallet();
const { url, close: closeServer } = await serveHarness();
console.log(`[e2e] harness served at ${url}`);
let ctx: BrowserContext | null = null;
let page: Page | null = null;
try {
ctx = await launchWalletContext();
page = await ctx.newPage();
page.on("pageerror", (e) => console.error("[iframe error]", e.message));
page.on("console", (m) => {
if (m.type() === "error") console.error("[iframe console]", m.text());
});
console.log("[e2e] loading SDK page in broker iframe...");
const frame = await setupBrokerPage(page, url);
// Wait for the bridge to exist + the broker session to connect.
await frame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
await frame.waitForFunction(() => (window as any).__sdk.status() === "connected", {
timeout: 60000,
});
const info = await sdkGet<any>(frame, "sessionInfo");
check("broker session connected", info?.session_id !== undefined && info?.session_id !== null, `session=${JSON.stringify(info)}`);
// ── access gate ─────────────────────────────────────────────────────────
console.log("\n── access gate ──");
await step("the gate asks on a first access, and settles the identity normalized", async () => {
const r = await sdk<any>(frame, "accessGateFirstVisit", "@Erin");
check(
"barrier shown, Entrer disabled while empty, identity normalized, barrier removed",
r.shown === true && r.disabledWhenEmpty === true && r.identity === "erin" && r.stillMounted === false,
`shown=${r.shown} disabledWhenEmpty=${r.disabledWhenEmpty} identity=${r.identity} stillMounted=${r.stillMounted}`,
);
});
await step("the gate stays away when the identity is already known", async () => {
const r = await sdk<any>(frame, "accessGateReturningVisit", "erin");
check("no barrier for a returning user", r.shown === false && r.identity === "erin", `shown=${r.shown}`);
});
// ── docs primitives ─────────────────────────────────────────────────────
console.log("\n── docs primitives ──");
await step("docCreate returns a usable NURI", async () => {
const nuri = await sdk<string>(frame, "docCreate");
check("docCreate returns a usable NURI", typeof nuri === "string" && nuri.length > 0, nuri);
});
await step("SPARQL graph-behavior characterization (a/b/c)", async () => {
const rt = await sdk<any>(frame, "docRoundTrip");
// (a) THE load-bearing assertion fake-ng cannot verify: the anchored default-
// graph write (no GRAPH clause) round-trips through the real broker's
// repo_graph_name overlay. This is the canonical shape the lib writes, and
// what read-model / inbox / store-registry all rely on.
check(
"(a) anchored default-graph write (no GRAPH) ROUND-TRIPS",
rt.anchoredPresent === true,
`predicates=${JSON.stringify(rt.predicates)}`,
);
// (b) FINDING (reported, not gating): on THIS broker version an explicit
// `INSERT DATA { GRAPH <plainNuri> {…} }` ANCHORED to the same doc ALSO
// round-trips — readable both via the anchored default-graph read
// (explicitGraphPresent) AND via an explicit `GRAPH <plainNuri>` read
// (explicitViaNamedGraph). i.e. when anchored, the plain NURI resolves to the
// SAME repo graph — there is NO "phantom graph" here. The lib still writes the
// no-GRAPH default-graph shape as the always-safe canonical convention; this
// records what the broker actually does so the "phantom graph" comments can be
// re-checked against this broker version by re-running this harness.
record(
"(b) [finding] explicit GRAPH <plainNuri> ANCHORED resolves to the same repo (no phantom graph)",
true,
`defaultGraphRead=${rt.explicitGraphPresent} namedGraphRead=${rt.explicitViaNamedGraph} (informational)`,
);
// (c) FINDING: the ANCHORLESS `GRAPH ?g { … }` union scan spans EVERY named
// graph in the session store (it saw BOTH doc A's and doc B's graphs). This is
// the O(wallet-size) cost the read path avoids by reading each doc with its own
// anchored default-graph query. Reported, not gating (it is a perf property of
// the union, not a correctness assertion of the lib's write/read shape).
const us = rt.unionSpan ?? {};
record(
"(c) [finding] anchorless GRAPH ?g scan spans ALL named graphs (O(wallet) union)",
true,
us.graphCount === -1
? `anchorless scan errored: ${us.error} (union claim NOT re-verified here)`
: `sawDocA=${us.sawDocA} sawDocB=${us.sawDocB} graphCount=${us.graphCount} (both ⇒ union spans all graphs)`,
);
});
// ── read-model ──────────────────────────────────────────────────────────
console.log("\n── read-model ──");
await step("readUnion over N docs → per-doc subjects", async () => {
const r = await sdk<any>(frame, "readUnionOverDocs", 3, false);
check("readUnion returns one subject per doc", r.subjectCount === 3, `subjects=${r.subjectCount}/3`);
});
await step("readUnion per-doc tolerance (bad NURI skipped)", async () => {
const r = await sdk<any>(frame, "readUnionOverDocs", 2, true);
check("bad NURI does not abort the batch", r.subjectCount === 2, `subjects=${r.subjectCount}/2 (+1 bad)`);
});
await step("readUnion cap gate", async () => {
const r = await sdk<any>(frame, "readUnionCapGate");
check("cap gate drops doc for stranger, keeps for owner", r.strangerCount === 0 && r.ownerCount === 1, `stranger=${r.strangerCount} owner=${r.ownerCount}`);
});
// ── reactivity (doc_subscribe) ──────────────────────────────────────────
console.log("\n── reactivity (doc_subscribe) ──");
await step("subscribeDoc initial + on-write", async () => {
const { doc } = await sdk<any>(frame, "subscribeDocStart", "h1");
// initial state push (event-driven wait)
await frame.waitForFunction(() => (window as any).__sdk.subscribeCount("h1") >= 1, { timeout: 20000 });
const initial = await sdkGet<number>(frame, "subscribeCount", "h1");
check("subscribeDoc fires on initial state", initial >= 1, `count=${initial}`);
// subsequent real write → another push
await sdk(frame, "writeTo", doc, "w1");
await frame.waitForFunction(
(base) => (window as any).__sdk.subscribeCount("h1") > (base as number),
initial,
{ timeout: 20000 },
);
const afterWrite = await sdkGet<number>(frame, "subscribeCount", "h1");
check("subscribeDoc fires on a subsequent write", afterWrite > initial, `count=${afterWrite} (>${initial})`);
// unsubscribe stops callbacks
await sdk(frame, "subscribeStop", "h1");
const frozen = await sdkGet<number>(frame, "subscribeCount", "h1");
await sdk(frame, "writeTo", doc, "w2");
await page!.waitForTimeout(3000);
const afterUnsub = await sdkGet<number>(frame, "subscribeCount", "h1");
check("unsubscribe stops callbacks", afterUnsub === frozen, `count stayed ${afterUnsub}`);
});
await step("subscribeDocs per-doc isolation", async () => {
const { good } = await sdk<any>(frame, "subscribeDocsStart");
await frame.waitForFunction(() => (window as any).__sdk.multiSubCounts().good >= 1, { timeout: 20000 });
const base = await sdkGet<any>(frame, "multiSubCounts");
await sdk(frame, "writeTo", good, "mw1");
await frame.waitForFunction(
(b) => (window as any).__sdk.multiSubCounts().good > (b as number),
base.good,
{ timeout: 20000 },
);
const after = await sdkGet<any>(frame, "multiSubCounts");
check("good doc fires despite a dead doc in the set", after.good > base.good, `good=${after.good} bad=${after.bad}`);
await sdk(frame, "multiSubStop");
});
// ── inbox ───────────────────────────────────────────────────────────────
console.log("\n── inbox ──");
await step("inbox post → read round-trip", async () => {
// Fresh user per run: an inbox is stable for its owner, so a reused id would
// read back the previous runs' deposits too (the wallet persists).
const r = await sdk<any>(frame, "inboxPostRead", "@inbox-user-" + Date.now(), { k: "a" }, { k: "b" });
const payloads = (r.deposits || []).map((d: any) => JSON.stringify(d.payload));
check(
"post then read returns both deposits (sorted)",
r.deposits.length === 2 && payloads.includes('{"k":"a"}') && payloads.includes('{"k":"b"}'),
`deposits=${r.deposits.length}`,
);
});
await step("inbox watch fires on deposit", async () => {
await sdk(frame, "inboxWatchStart", "@watcher-" + Date.now());
await frame.waitForFunction(() => (window as any).__sdk.inboxWatchState().fires >= 1, { timeout: 20000 });
const base = await sdkGet<any>(frame, "inboxWatchState");
await sdk(frame, "inboxWatchDeposit", { landed: true });
await frame.waitForFunction(
(b) => (window as any).__sdk.inboxWatchState().fires > (b as number),
base.fires,
{ timeout: 20000 },
);
const after = await sdkGet<any>(frame, "inboxWatchState");
check("watch fires when a deposit lands", after.fires > base.fires && after.lastLen >= 1, `fires=${after.fires} lastLen=${after.lastLen}`);
await sdk(frame, "inboxWatchStop");
});
await step("a document's inbox: owner opens, a third party resolves and deposits", async () => {
const t = Date.now();
const r = await sdk<any>(frame, "documentInboxDeposit", "@owner-" + t, "@depositor-" + t);
check(
"the depositor holds only the BARE reference, resolves the same inbox from it, deposits, and the address stays out of the data",
r.sameInbox === true &&
r.openRefused === true &&
JSON.stringify(r.deposits) === JSON.stringify([{ viaPostToDocument: true }, { joining: true }]) &&
!r.props.some((p: string) => p.startsWith("urn:ng-eventually:")),
`sameInbox=${r.sameInbox} openRefused=${r.openRefused} deposits=${JSON.stringify(r.deposits)} props=${JSON.stringify(r.props)}`,
);
});
await step("inbox spoof guard", async () => {
const r = await sdk<any>(frame, "inboxSpoofGuard");
check("post as another principal is rejected; self + anon allowed", r.spoofRejected && r.selfOk && r.anonOk, `spoof=${r.spoofRejected} self=${r.selfOk} anon=${r.anonOk}`);
});
// ── store-registry ──────────────────────────────────────────────────────
console.log("\n── store-registry ──");
await step("ensureAccount idempotent", async () => {
const r = await sdk<any>(frame, "ensureAccountIdempotent", "@alice-" + Date.now());
check("ensureAccount returns the same 3 docs on repeat", r.same === true, `same=${r.same}`);
});
await step("createEntityDoc + listMyEntityDocs bounded to one account", async () => {
const t = Date.now();
const r = await sdk<any>(frame, "entityDocsBounded", "@ea-" + t, "@eb-" + t);
check("listMyEntityDocs lists A's docs and does NOT leak B's", r.hasA1 && r.hasA2 && !r.leaksB, `A1=${r.hasA1} A2=${r.hasA2} leaksB=${r.leaksB} listA=${r.listA.length}`);
});
await step("scope resolvers", async () => {
const r = await sdk<any>(frame, "scopeResolvers");
check("scope resolvers return NURIs (private distinct from protected/public)", !!r.priv && !!r.prot && !!r.pub && r.priv !== r.prot, `priv=${r.priv?.slice(0,16)}… prot=${r.prot?.slice(0,16)}`);
});
// ── watchShape (reactive useQuery-shaped read) ──────────────────────────
// The real cycle: first subscription reads isPending (barrier not yet crossed),
// then after the broker sync it reaches isSuccess with the seeded datum present.
// A separate empty scope reaches isSuccess with data:[] (synced-but-empty — the
// distinction useShape's upgrade will make native, surfaced here from getSyncState).
console.log("\n── watchShape (reactive useQuery-shaped read) ──");
await step("watchShape: first subscribe isPending → isSuccess with data present", async () => {
const h = "cyc" + Date.now();
const CLS = "urn:e2e:ws:Event";
const seed = await sdk<any>(frame, "watchShapeSeedAndSubscribe", h, CLS);
check("first snapshot after subscribe is isPending (barrier not crossed)", seed.initial.isPending === true && seed.initial.isSuccess === false, `initial=${JSON.stringify(seed.initial)}`);
// Event-driven: wait for the barrier to cross + the datum to land.
await frame.waitForFunction(
(hh) => {
const s = (window as any).__sdk.watchShapeSnapshot(hh as string);
return s && s.isSuccess && s.dataLen >= 1;
},
h,
{ timeout: 30000 },
);
const snap = await sdkGet<any>(frame, "watchShapeSnapshot", h);
check("reaches isSuccess with the seeded datum (titles include 'seeded')", snap.isSuccess && !snap.isPending && !snap.isError && snap.dataLen >= 1 && snap.titles.includes("seeded"), `snap=${JSON.stringify(snap)}`);
await sdk(frame, "watchShapeStop", h);
});
await step("watchShape: empty scope reaches isSuccess with data:[]", async () => {
const h = "empty" + Date.now();
const CLS = "urn:e2e:ws:Event";
await sdk<any>(frame, "watchShapeEmptyStart", h, CLS);
await frame.waitForFunction(
(hh) => {
const s = (window as any).__sdk.watchShapeSnapshot(hh as string);
return s && s.isSuccess;
},
h,
{ timeout: 30000 },
);
const snap = await sdkGet<any>(frame, "watchShapeSnapshot", h);
check("empty scope: isSuccess, data:[] (synced-but-empty, not stuck pending)", snap.isSuccess && !snap.isPending && !snap.isError && snap.dataLen === 0, `snap=${JSON.stringify(snap)}`);
await sdk(frame, "watchShapeStop", h);
});
// ── caps / read-filter (in-memory cap model) ────────────────────────────
console.log("\n── caps / read-filter (in-memory cap model) ──");
await step("read-filter: you read what your keyring holds, nothing else", async () => {
const r = await sdk<any>(frame, "capsReadFilter");
// The owner reads the documents whose caps their keyring holds — and NOT the
// one it does not, even though its NURI is right there in the set.
const ownerReadsHeld =
r.ownerView.includes("protected-item") && r.ownerView.includes("public-item");
const ownerMissesUnheld = !r.ownerView.includes("unheld-item");
// A stranger holds nothing at all — a bare reference names without reading.
const strangerReadsNothing = r.strangerView.length === 0;
// …until the repo link of the PUBLISHED document reaches them.
const linkOpensPublic =
r.strangerWithLinkView.length === 1 && r.strangerWithLinkView.includes("public-item");
check(
"the read-filtered view decides on possession alone: owner sees what he holds, a stranger nothing, and a filed cap opens it",
ownerReadsHeld && ownerMissesUnheld && strangerReadsNothing && linkOpensPublic,
`owner=${JSON.stringify(r.ownerView)} stranger=${JSON.stringify(r.strangerView)} withCap=${JSON.stringify(r.strangerWithLinkView)}`,
);
});
await step("shareCap: a cap delivered to an inbox reveals the doc", async () => {
const r = await sdk<any>(frame, "capsShareCap", "@friend-" + Date.now());
check(
"share → inbox processed → the shared doc becomes readable, and the delivery is not surfaced",
r.before === 0 && r.after === 1 && r.surfacedDeposits === 0,
`before=${r.before} after=${r.after} surfaced=${r.surfacedDeposits}`,
);
});
// ── accounts (IdentityStore) ────────────────────────────────────────────
console.log("\n── accounts (IdentityStore) ──");
await step("IdentityStore set/get/clear", async () => {
const setr = await sdk<string>(frame, "identitySet", "@ident-user");
const got = await sdkGet<string>(frame, "identityGet");
const cleared = await sdk<string | null>(frame, "identityClear");
check("IdentityStore set→get→clear", setr === "@ident-user" && got === "@ident-user" && cleared === null, `set=${setr} get=${got} cleared=${cleared}`);
});
// ── reconnection cold-start (real-broker regression) ─────────────────────
// Phase 1 (THIS session): seed — create a per-entity doc under (id, protected)
// and write a marker triple (`protected` carries participations). Also EXPORT the
// wallet `.ngw` so phase 2 can re-import the SAME wallet into a CLEAN browser
// profile.
//
// Phase 2 (the faithful reconnect): import the wallet into a BRAND-NEW empty
// profile dir (no local IndexedDB copy of the repos) and open a fresh SDK session
// over it — the repos exist on the BROKER but NOT in this profile's local cache,
// so this is a true reconnect (not a same-profile relaunch, which masks the gap by
// eagerly rehydrating repos from local storage). Then re-read purely from the
// wallet and assert the marker comes back.
//
// FINDING (recorded, see the digest): on THIS SDK/broker version the marker also
// comes back WITHOUT the open-before-read heal — the broker-login bootstrap opens
// the user's repos before the read (the `rawAnchoredNoOpen` detail below shows the
// bare anchored query already resolves rows). So this test is a real-broker
// REGRESSION guard for reconnection reads, NOT a fail-without-the-fix proof; the
// cold-start the fix targets was diagnosed in the app and does not reproduce
// through this harness's login path.
console.log("\n── reconnection cold-start (real-broker regression) ──");
await step("fresh session (clean profile, same wallet) re-reads a persisted entity doc", async () => {
const reconId = "@recon-" + Date.now();
const scope = "protected";
const seed = await sdk<any>(frame, "reconnectSeed", reconId, scope);
check(
"seed: entity doc created + listed in the seeding session",
seed.listedInSeed.includes(seed.entityNuri),
`entity=${String(seed.entityNuri).slice(0, 24)}… listedInSeed=${seed.listedInSeed.length} origSession=${info?.session_id}`,
);
// Export the wallet file and materialize it for the clean-profile import.
const exp = await sdk<any>(frame, "exportWalletFile");
const ngwPath = path.join(os.tmpdir(), `ng-eventually-recon-${Date.now()}.ngw`);
fs.writeFileSync(ngwPath, Buffer.from(exp.b64, "base64"));
let cleanCtx: BrowserContext | null = null;
let cleanDir: string | null = null;
let cleanPage: Page | null = null;
try {
const launched = await launchCleanProfileContext();
cleanCtx = launched.ctx;
cleanDir = launched.dir;
cleanPage = await cleanCtx.newPage();
cleanPage.on("pageerror", (e) => console.error("[iframe error:clean]", e.message));
cleanPage.on("console", (m) => { if (m.type() === "error") console.error("[iframe console:clean]", m.text()); });
// Import the SAME wallet into the empty profile (broker-only repos), then open
// the SDK page in a fresh broker session over it.
await importWalletViaFile(cleanPage, ngwPath);
const cleanFrame = await setupBrokerPage(cleanPage, url);
await cleanFrame.waitForFunction(() => (window as any).__sdk !== undefined, { timeout: 30000 });
await cleanFrame.waitForFunction(() => (window as any).__sdk.status() === "connected", { timeout: 60000 });
const cleanInfo = await sdkGet<any>(cleanFrame, "sessionInfo");
check(
"clean-profile session connected (fresh verifier, broker-only repos)",
cleanInfo?.session_id !== undefined && cleanInfo?.session_id !== null,
`session=${cleanInfo?.session_id}`,
);
const r = await sdk<any>(cleanFrame, "reconnectRead", reconId, scope, seed.entityNuri, seed.marker);
check(
"fresh clean-profile session re-reads the persisted marker (reconnection regression)",
r.markerPresent === true,
`rawAnchoredNoOpen=${r.rawRowCount} listed=${r.listedCount} foundEntity=${r.foundEntity} subjects=${r.subjectCount} markerPresent=${r.markerPresent}`,
);
} finally {
try { if (cleanPage) await cleanPage.close(); } catch { /* ignore */ }
try { if (cleanCtx) await cleanCtx.close(); } catch { /* ignore */ }
try { if (cleanDir) fs.rmSync(cleanDir, { recursive: true, force: true }); } catch { /* ignore */ }
try { fs.rmSync(ngwPath, { force: true }); } catch { /* ignore */ }
}
});
// ── CONTRACT 1: faithful reconnect COLD-READ (public + protected) ─────────
// The real app's reconnect: a FRESH page on the SAME persistent profile + a NEW
// broker login (fresh verifier session), NOT export/reimport into an empty
// profile (which forces a full resync and MASKS the cold-open). Session 1 seeds a
// per-entity doc + marker in BOTH public and protected scopes; a faithful fresh
// session must relist + re-read its OWN persisted markers, POLLING for the sync —
// the wait IS the normal path. NB the observed cost is dominated by broker READ
// latency, not pure sync-lag: ONE reconnectRead cycle (open-repo heal awaits the
// initial-state push per repo + anti-fork retry budget + anchored readUnion, all
// round-tripping the real broker) measures ~90-105s. So the poll deadline is a
// generous MULTI-cycle bound (120s past post-connect) rather than a tight 30s — a
// single slow cycle must not be mistaken for a sync failure. We report the observed
// time (the "signal"). If a marker never lands within the bound the check FAILS (a
// real regression), never a silent 0-row.
console.log("\n── CONTRACT 1: faithful reconnect cold-read (public + protected) ──");
await step("faithful reconnect (same profile, new login) re-reads persisted public + protected docs", async () => {
const reconId = "@recon-faithful-" + Date.now();
// Seed BOTH scopes in the ORIGINAL session (where the repos are open).
const seedPub = await sdk<any>(frame, "reconnectSeed", reconId, "public");
const seedProt = await sdk<any>(frame, "reconnectSeed", reconId, "protected");
check(
"seed: public + protected entity docs listed in the seeding session",
seedPub.listedInSeed.includes(seedPub.entityNuri) && seedProt.listedInSeed.includes(seedProt.entityNuri),
`pub=${String(seedPub.entityNuri).slice(0, 20)}… prot=${String(seedProt.entityNuri).slice(0, 20)}`,
);
let rp: Page | null = null;
try {
// Faithful reconnect: fresh page on the SAME persistent context + new login.
const tLoginStart = Date.now();
const rc = await faithfulReconnect(ctx!, url);
const loginMs = Date.now() - tLoginStart;
rp = rc.page;
const rInfo = await sdkGet<any>(rc.frame, "sessionInfo");
// Fidelity is STRUCTURAL: a fresh page → fresh iframe → fresh SDK-module
// instance (empty open-repo + store-registry caches) + a new broker connect
// over the SAME persistent profile. We assert the reconnect connected; the
// broker numbers session_id per-connection (may reuse 1), so we REPORT the
// ids rather than gate on them differing.
check(
"reconnect session connected (fresh iframe/verifier over the SAME persistent profile)",
rInfo?.session_id !== undefined && rInfo?.session_id !== null,
`reconnectSession=${rInfo?.session_id} origSession=${info?.session_id}`,
);
// Poll each scope up to 30s (from post-connect) for the marker to sync back
// into THIS fresh session. syncMs is the pure sync lag (login excluded); we
// report it as the observed sync signal, plus the first-attempt rawNoOpen (the
// bare anchored read WITHOUT the open-repo heal) which shows whether the heal
// is load-bearing on this SDK/broker version.
for (const [scope, seed] of [["public", seedPub], ["protected", seedProt]] as const) {
let found = false;
let syncMs = -1;
let firstRawNoOpen = -99;
let lastDetail = "";
const tSyncStart = Date.now();
const deadline = tSyncStart + 120000;
let firstAttempt = true;
while (Date.now() < deadline) {
const r = await sdk<any>(rc.frame, "reconnectRead", reconId, scope, seed.entityNuri, seed.marker);
if (firstAttempt) { firstRawNoOpen = r.rawRowCount; firstAttempt = false; }
lastDetail = `firstRawNoOpen=${firstRawNoOpen} rawNoOpen=${r.rawRowCount} listed=${r.listedCount} foundEntity=${r.foundEntity} subjects=${r.subjectCount}`;
if (r.markerPresent === true) {
found = true;
syncMs = Date.now() - tSyncStart;
break;
}
await rc.page.waitForTimeout(1000);
}
check(
`[SYNC] reconnect re-reads its OWN persisted ${scope} marker (cold-read)`,
found,
found
? ((coldSyncMs = Math.max(coldSyncMs, syncMs)),
`synced in ${syncMs}ms (reconnect-login ${loginMs}ms) — ${lastDetail}`)
: `NEVER synced within 120s (reconnect-login ${loginMs}ms) — ${lastDetail}`,
);
}
} finally {
try { if (rp) await rp.close(); } catch { /* ignore */ }
}
});
// ── CONTRACT 2: NON-FORK of account across a faithful reconnect ───────────
// Resolving the SAME identifier after a faithful reconnect must return the SAME
// account docs (docPublic/docProtected/docPrivate) — never a SECOND provisioning
// (an account fork), which would strand the first session's data. Session 1
// provisions the account (records its NURIs); a faithful fresh session re-resolves
// the SAME id and must return IDENTICAL NURIs. The anti-fork retry bridges the
// shim-not-yet-synced window; we POLL (generous 120s multi-cycle bound — one
// accountDocs resolve round-trips the broker's anti-fork retry budget, ~90-100s)
// and report when the SAME NURIs land (the sync signal). If they never match (or a
// new set appears) → FAIL.
console.log("\n── CONTRACT 2: non-fork of account across a faithful reconnect ──");
await step("resolving the same identifier after a faithful reconnect returns the SAME account docs (no fork)", async () => {
const forkId = "@nonfork-" + Date.now();
const orig = await sdk<any>(frame, "accountDocs", forkId);
check(
"session 1 provisioned the account (3 scope docs)",
!!orig.docPublic && !!orig.docProtected && !!orig.docPrivate,
`pub=${String(orig.docPublic).slice(0, 20)}`,
);
let rp: Page | null = null;
try {
const tLoginStart = Date.now();
const rc = await faithfulReconnect(ctx!, url);
const loginMs = Date.now() - tLoginStart;
rp = rc.page;
let same = false;
let syncMs = -1;
let last: any = null;
const tSyncStart = Date.now();
const deadline = tSyncStart + 120000;
while (Date.now() < deadline) {
last = await sdk<any>(rc.frame, "accountDocs", forkId);
if (
last.docPublic === orig.docPublic &&
last.docProtected === orig.docProtected &&
last.docPrivate === orig.docPrivate
) {
same = true;
syncMs = Date.now() - tSyncStart;
break;
}
await rc.page.waitForTimeout(1000);
}
check(
"[SYNC] fresh session re-resolves the SAME account NURIs (no second provisioning)",
same,
same
? `same account in ${syncMs}ms (reconnect-login ${loginMs}ms)`
: `FORKED — orig pub=${String(orig.docPublic).slice(0, 20)}… got pub=${String(last?.docPublic).slice(0, 20)}… (differs)`,
);
} finally {
try { if (rp) await rp.close(); } catch { /* ignore */ }
}
});
// ── CONTRACT 3: first-State barrier (doc_subscribe sync-point) ──────────────
//
// Empirical pin of the implicit contract that open-repo.ts relies on:
// "the 1st event emitted by doc_subscribe is a State that marks the end of
// the initial broker sync — after it, presence is guaranteed and absence
// is definitive."
//
// Three sub-contracts:
// (3a) PRESENCE GUARANTEED — write a triple in session, subscribe, capture
// the FIRST event; it must be a `State`, and an anchored SPARQL query
// immediately after must find the triple (no second wait needed).
// (3b) ABSENCE DEFINITIVE — subscribe to an empty-but-valid doc, capture
// the FIRST event; it must be a `State` that reflects 0 triples AND
// must NOT be followed by a late Patch within a grace window.
// (3c) STATE vs TIMEOUT — the event log carries the real event-type key from
// the raw AppResponse (`{ V0: { State | Patch | TabInfo | … } }`), so
// we can distinguish a genuine first-State from a silent timeout.
//
// Every wait here is event-driven (waitForFunction on the event count) with a
// 30s timeout that produces a FAIL, not a silent green.
console.log("\n── CONTRACT 3: first-State barrier (doc_subscribe sync-point) ──");
// 3a — PRESENCE GUARANTEED AT FIRST STATE
await step("(3a) presence guaranteed at first State", async () => {
const triple = { s: "urn:e2e:state:s", p: "urn:e2e:state:p", o: "state-contract-present" };
// Write the triple first (same session, write is already committed broker-side).
const doc = await sdk<string>(frame, "stateProbeWrite", triple);
// Subscribe in a fresh call and start timing.
const tSubscribe = Date.now();
await sdk(frame, "stateProbeSubscribe", doc);
// Wait event-driven for the FIRST event of any type — reveals the push ordering.
await frame.waitForFunction(
() => (window as any).__sdk.stateProbeEvents().length >= 1,
{ timeout: 30000 },
);
const firstAnyEventMs = Date.now() - tSubscribe;
const eventsAfterAny = await sdkGet<Array<{ typeKey: string; elapsedMs: number }>>(frame, "stateProbeEvents");
const firstAnyEvent = eventsAfterAny[0];
// 3c: first event MUST have a recognised type key — distinguishes a real push
// from a synthetic timeout/parse failure. The broker emits TabInfo first, then
// State (VERIFIED empirically: TabInfo at ~2-5ms, State at ~5-15ms).
check(
"(3c) first event has a recognised type key (not a synthetic timeout)",
firstAnyEvent !== undefined && firstAnyEvent.typeKey !== "unknown" && firstAnyEvent.typeKey !== "parse-error",
`first-event typeKey=${firstAnyEvent?.typeKey ?? "none"} elapsedMs=${firstAnyEvent?.elapsedMs ?? "?"}ms (wall-clock: ${firstAnyEventMs}ms)`,
);
// Now wait specifically for the FIRST State event (may be the 2nd+ overall push —
// the broker pushes TabInfo before State).
await frame.waitForFunction(
() => (window as any).__sdk.stateProbeStateCount() >= 1,
{ timeout: 30000 },
);
const firstStateWallMs = Date.now() - tSubscribe;
const eventsAfterState = await sdkGet<Array<{ typeKey: string; elapsedMs: number }>>(frame, "stateProbeEvents");
const firstStateEvent = eventsAfterState.find((e) => e.typeKey === "State");
// 3a-i: a State event MUST arrive (not just TabInfo). This is the sync-point
// barrier — the broker delivers State after syncing up to the broker's heads.
check(
"(3a-i) a State event arrives (sync-point barrier confirmed)",
firstStateEvent !== undefined,
`stateElapsedMs=${firstStateEvent?.elapsedMs ?? "never"} events=${JSON.stringify(eventsAfterState.map((e) => e.typeKey))}`,
);
// 3a-ii: AFTER the State, an anchored SPARQL query must find the triple
// WITHOUT any additional wait. The State is the sync barrier.
const q = await sdk<{ rows: number; found: boolean }>(frame, "stateProbeQuery", triple.s, triple.p);
check(
"(3a-ii) triple is present in SPARQL query immediately after first State (no extra wait)",
q.found === true,
`rows=${q.rows} found=${q.found} stateMs=${firstStateEvent?.elapsedMs ?? "?"}ms`,
);
console.log(
` [INFO] push ordering: ${eventsAfterState.map((e) => `${e.typeKey}@${e.elapsedMs}ms`).join(" → ")}`,
);
console.log(
` [INFO] first-State latency: ${firstStateEvent?.elapsedMs ?? "?"}ms (wall-clock: ${firstStateWallMs}ms since subscribe call)`,
);
await sdk(frame, "stateProbeStop");
});
// 3b — ABSENCE DEFINITIVE AT FIRST STATE
await step("(3b) absence definitive at first State (empty doc stays empty)", async () => {
// Create an empty doc and subscribe atomically.
await sdk<string>(frame, "stateProbeEmptyDoc");
// Wait for the first State event (TabInfo arrives first, State second).
const tSubscribe = Date.now();
await frame.waitForFunction(
() => (window as any).__sdk.stateProbeStateCount() >= 1,
{ timeout: 30000 },
);
const firstStateWallMs = Date.now() - tSubscribe;
const eventsAfterState = await sdkGet<Array<{ typeKey: string; elapsedMs: number }>>(frame, "stateProbeEvents");
const firstStateEvent = eventsAfterState.find((e) => e.typeKey === "State");
check(
"(3b-i) a State event arrives for an empty doc (sync barrier fires even for empty)",
firstStateEvent !== undefined,
`stateMs=${firstStateEvent?.elapsedMs ?? "never"} events=${JSON.stringify(eventsAfterState.map((e) => e.typeKey))}`,
);
// Verify the doc is empty via SPARQL immediately after the State.
const qEmpty = await sdk<{ rows: number; found: boolean }>(
frame,
"stateProbeQuery",
"urn:e2e:state:s",
"urn:e2e:state:p",
);
check(
"(3b-ii) SPARQL query immediately after first State confirms the doc is empty",
qEmpty.found === false && qEmpty.rows === 0,
`rows=${qEmpty.rows} found=${qEmpty.found}`,
);
// Grace window: wait 5s and verify no data-bearing Patch arrives after the State.
// A second State is normal (broker may re-push); only a Patch with actual data
// would violate "absence is definitive". We check the SPARQL result, not event types,
// because a Patch on an empty doc that stays empty is also fine.
await page!.waitForTimeout(5000);
const qAfterGrace = await sdk<{ rows: number; found: boolean }>(
frame,
"stateProbeQuery",
"urn:e2e:state:s",
"urn:e2e:state:p",
);
const eventsAfterGrace = await sdkGet<Array<{ typeKey: string; elapsedMs: number }>>(frame, "stateProbeEvents");
check(
"(3b-iii) SPARQL still empty after 5s grace window (absence at first State is definitive)",
!qAfterGrace.found && qAfterGrace.rows === 0,
`foundAfterGrace=${qAfterGrace.found} events=${JSON.stringify(eventsAfterGrace.map((e) => e.typeKey))}`,
);
console.log(
` [INFO] push ordering: ${eventsAfterGrace.map((e) => `${e.typeKey}@${e.elapsedMs}ms`).join(" → ")}`,
);
console.log(
` [INFO] first-State latency (empty doc): ${firstStateEvent?.elapsedMs ?? "?"}ms (wall-clock: ${firstStateWallMs}ms since subscribe call)`,
);
await sdk(frame, "stateProbeStop");
});
} finally {
try { if (page) await page.close(); } catch { /* ignore */ }
try { if (ctx) await ctx.close(); } catch { /* ignore */ }
closeServer();
}
// ── Summary ───────────────────────────────────────────────────────────────
const passed = results.filter((r) => r.ok).length;
const failed = results.length - passed;
const batchMin = ((Date.now() - batchStart) / 60000).toFixed(1);
console.log(
`\n══ SDK e2e summary: ${passed} passed, ${failed} failed, ${results.length} total ` +
`— batch ${batchMin} min, slowest cold sync ${Math.round(coldSyncMs / 1000)}s ══`,
);
// A fresh wallet per batch is what should keep the cold sync flat; if it climbs from
// one batch to the next, the per-batch wallet is not being discarded.
assertWithinBudget();
if (failed > 0) {
console.log("Failures:");
for (const r of results.filter((x) => !x.ok)) console.log(` - ${r.name}: ${r.detail ?? ""}`);
process.exit(1);
}
}
main().catch((e) => {
console.error("[e2e] fatal:", e);
process.exit(1);
});
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"types": ["bun"],
"noEmit": true
},
"include": ["."]
}
+41
View File
@@ -0,0 +1,41 @@
{
"name": "@ng-eventually/sdk",
"version": "0.0.0",
"type": "module",
"description": "SDK-identical wrapper over @ng-org/web + @ng-org/orm with emulated capabilities and inbox. Drop-in; remove at migration.",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"peerDependencies": {
"@ng-org/web": "*",
"@ng-org/orm": "*",
"@ng-org/shex-orm": "*",
"@ng-org/alien-deepsignals": "*"
},
"peerDependenciesMeta": {
"@ng-org/web": {
"optional": true
},
"@ng-org/orm": {
"optional": true
},
"@ng-org/shex-orm": {
"optional": true
},
"@ng-org/alien-deepsignals": {
"optional": true
}
},
"devDependencies": {
"@ng-org/web": "0.1.2-alpha.13",
"@ng-org/shex-orm": "0.1.2-alpha.8",
"@ng-org/alien-deepsignals": "0.1.2-alpha.11"
},
"scripts": {
"test": "bun test",
"test:e2e": "bun run e2e/run.ts",
"test:e2e:reactivity": "bun run e2e/reactivity-doc-subscribe.ts"
}
}
@@ -0,0 +1,509 @@
/**
* The durable cap/inbox registers — this library's stand-in for the compartments the
* verifier maintains on a repo's own branches.
*
* Upstream these are not RDF at all: they are streams of service commits on branches
* whose CRDT is `BranchCrdt::None` (`engine/repo/src/types.rs:1420`). Each register here
* names its native counterpart:
*
* - **Store branch** — `AddRepo { read_cap }` (`engine/repo/src/types.rs:1890-1899`):
* the cap of a document you CREATED, filed beside the store that holds it. Replaying
* it is what reloads a store's documents with their keys (`AddRepo::verify` ->
* `Verifier::load_repo_from_read_cap`, `engine/verifier/src/verifier.rs:2237`).
* - **User branch, links** — `AddLink { read_cap }` (`types.rs:1939-1948`), *"so that a
* user can share with all its device a new Link they received"*, external repos only.
* - **User branch, inbox caps** — `AddInboxCap { repo_id, overlay, priv_key }`
* (`types.rs:1969-1981`): which inboxes you may READ. Keyed by `repo_id`, hence valid
* for ANY repo — `update_inbox_cap_v0` applies it with no `is_store` check
* (`engine/verifier/src/verifier.rs:1920`).
* - **Header branch** — a document's deposit ADDRESS, readable by any holder of it.
* The one register with NO native counterpart: upstream an address is TRANSMITTED
* (a message, a profile QR code), never published, and `inboxes: PubKey -> RepoId` is
* a per-session local table (`verifier.rs:105`). Publishing is our divergence, taken
* because an emulation has no message channel — see
* `docs/briefs/2026-08-03-document-inbox-addressing.md`.
*
* Why separate from the shim next door: these emulate the VERIFIER's bookkeeping and
* survive conceptually — at migration the native side keeps them, only our RDF
* representation goes. `shared-wallet/account-registry.ts` has no counterpart at all and
* evaporates. One file until 2026-08-03, two fates.
*
* The imports back into `shared-wallet/` are deliberate, visible cross-fate edges: a
* register needs the shim to know WHOSE it is, and where its store document lives. Every
* use sits inside a function body, so the module cycle is inert at evaluation time.
*/
import { sparqlUpdate, sparqlQuery } from "../surface/docs";
import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
import { escapeLiteral } from "../surface/sparql";
import { hasReadCap, isNuri } from "../model/nuri";
import { mustNotAttempt } from "./reach";
import { fetchReadCap } from "./public-store";
import { ensureRepoOpen } from "./open-repo";
import { accessLogPrefix } from "../shared-wallet/access-log";
import {
P,
USER_BRANCH_SUBJECT,
STORE_BRANCH_SUBJECT,
HEADER_BRANCH_SUBJECT,
accountKey,
session,
readBindings,
bindingValue,
resolveAccount,
storeOf,
readUserStore,
userInbox,
createDoc,
ensureAccount,
type VirtualUserRecord,
} from "../shared-wallet/account-registry";
import type { InboxScope, Nuri, ReadCap, Scope } from "../model/types";
/**
* Does `nuri` belong to the CURRENT wallet as one of its inboxes? The predicate the
* inbox read guard consults (`inbox.ts`). Anonymous holds no inbox, so it is false
* for everyone until an identity is set.
*/
export async function isOwnInbox(nuri: Nuri): Promise<boolean> {
const holder = getCurrentUser();
if (holder === null) return false;
// Either of the user's two inboxes counts as its own.
for (const scope of ["public", "protected"] as const) {
if ((await userInbox(holder, scope)) === nuri) return true;
}
// …and the inbox of any document this user opened one on (the emulated
// `AddInboxCap` records on its User branch).
return (await readInboxCapPairs()).some((p) => p.inbox === nuri);
}
// --- the cap side of a user's store ----------------------------------
/**
* File the caps of documents the CURRENT holder owns into what they hold — the
* emulated `AddRepo { read_cap }`.
*
* Upstream, creating a document commits an `AddRepo { read_cap }` into a typed
* branch of the store, and that branch — listing the store's documents, each with
* its read key — carries the owner's caps. Here the per-(account × scope) index
* document plays the store-container role, so it carries the caps too: a
* document appended to it on creation, or read back from it on a later session,
* puts its cap in the owner's hands with nothing for the consumer to do. That is
* what makes the invariant hold both ways — you never derive a cap from a bare
* reference, and yet a document's own creator is never locked out of it.
*
* Scoped to the current holder ON PURPOSE: another account's documents are listed
* by the cross-account fan-out (`listEntityDocs`), and those caps are emphatically
* not ours to hold. `id` is compared through the shim key, so it matches however
* the consumer spells the identity.
*/
export function holdOwnCap(id: string, scope: Scope, doc: Nuri, cap: ReadCap): void {
const holder = getCurrentUser();
if (holder === null || accountKey(holder) !== accountKey(id)) return;
const caps = getCaps();
// `learn(cap)`, not `open(doc, scope)` — the cap must be the SAME value that was
// written to the Store branch, not a second one minted from the NURI. They agree
// today only because the stand-in value is a constant; with a real key (P1b) a
// second mint would produce a DIFFERENT key and the document would be unreadable
// by the very session that created it. Mint once, store it, hold that one.
caps.learn(cap);
// Which store the document sits in is a registry fact, applied separately — and a
// MARK only, for the same reason the cap above is learned rather than re-minted.
if (scope === "public") caps.markInPublicStore(doc);
}
/**
* File the caps of the documents a virtual user owns BY BEING one: its three
* stores, and its inbox. They are as much its documents as any entity it creates,
* and without them it cannot even list its own content — the boundary would lock a
* user out of itself.
*
* Scoped to the current holder, like {@link holdOwnCap}: another user's stores are
* emphatically not ours to hold.
*/
export function fileOwnStructure(id: string, record: VirtualUserRecord): void {
const holder = getCurrentUser();
if (holder === null || accountKey(holder) !== accountKey(id)) return;
const caps = getCaps();
if (record.docPublic) caps.open(record.docPublic, "public");
if (record.docProtected) caps.open(record.docProtected, "protected");
if (record.docPrivate) caps.open(record.docPrivate, "private");
}
/** Same, for the user's own inbox — it is its document, and it must be able to
* read it. Depositing into someone else's needs no cap (see `docs.depositInto`). */
export function fileOwnInbox(id: string, inbox: Nuri): void {
const holder = getCurrentUser();
if (holder === null || accountKey(holder) !== accountKey(id)) return;
getCaps().open(inbox, "private");
}
// --- per-entity documents + per-scope index -------------------------------
/**
* Publish WHERE to deposit for `doc`, on its Header branch — the compartment any
* holder of the document can read.
*
* Replacement, not addition: a document has exactly ONE inbox upstream (the verifier's
* `inboxes: PubKey → RepoId` is a function, and `repo.inbox` a single `Option<PrivKey>`),
* so two addresses on one document is a state the model has no meaning for — and a
* depositor picking the stale one writes where nobody reads.
*/
export async function publishInboxAddress(doc: Nuri, inbox: Nuri): Promise<void> {
const s = await session();
try {
// Two separate updates, not one compound statement: `DELETE WHERE { … }` is the
// form verified against the real broker (see
// `docs/decisions/sparql-delete-for-orm-objects.md`), whereas a `;`-joined update
// is not exercised anywhere in this lib.
await sparqlUpdate(
s.sessionId,
`DELETE WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`,
doc,
"publishInboxAddress:clear",
);
await sparqlUpdate(
s.sessionId,
`INSERT DATA { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> "${escapeLiteral(inbox)}" }`,
doc,
"publishInboxAddress",
);
} catch (error) {
console.error(accessLogPrefix() + " publishInboxAddress failed:", error);
}
}
/**
* The ReadCaps recorded on a store's Store branch — its documents, each with its
* key. The emulated replay of `AddRepo`, and the reason a fresh session recovers
* what it owns without recomputing anything.
*/
export async function readStoreCaps(storeDoc: Nuri): Promise<ReadCap[]> {
const s = await session();
const out: ReadCap[] = [];
try {
const res = await sparqlQuery(
s.sessionId,
`SELECT ?c WHERE { <${STORE_BRANCH_SUBJECT}> <${P.readCap}> ?c }`,
undefined,
storeDoc,
"readStoreCaps",
);
for (const row of readBindings(res)) {
const v = bindingValue(row, "c");
if (v && hasReadCap(v)) out.push(v);
}
} catch (error) {
console.error(accessLogPrefix() + " readStoreCaps failed:", error);
}
return out;
}
/**
* WHERE to deposit for `doc` — its inbox address, or `undefined` if its owner never
* opened one. The deposit-side counterpart of {@link openDocumentInbox}, and the
* function an app calls before `inbox.post`.
*
* Readable by whoever can read the document, because it lives on its Header branch —
* an address is public by nature (upstream a depositor needs only the inbox PUBLIC
* key). Conversely someone who cannot read the document learns nothing, which is
* faithful too: upstream the inbox pubkey is not derivable from a RepoId, it has to
* reach you.
*
* **Never creates.** Asking where to deposit must not bring an inbox into existence —
* only its owner opens one, and only on its own document.
*/
export async function documentInboxAddress(doc: Nuri): Promise<Nuri | undefined> {
// RULE 2 — do not even attempt. Not holding the document, we have no address to
// find: upstream the inbox pubkey travels WITH what you can read, so "where do I
// deposit for a document I cannot read" is not a refused question, it is a question
// with no referent. Answering `undefined` here keeps the caller's shape (an address
// or none) instead of turning the boundary into an exception it must catch.
// …but ask the (emulated) network first: a document in a public store serves its cap
// to whoever asks (public-store.ts), and "where do I deposit for this public
// document" is exactly the question a third party arrives with, holding nothing but
// the reference.
await fetchReadCap(doc);
if (mustNotAttempt(doc)) return undefined;
const s = await session();
try {
const res = await sparqlQuery(
s.sessionId,
`SELECT ?a WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`,
undefined,
doc,
"documentInboxAddress",
);
for (const row of readBindings(res)) {
const a = bindingValue(row, "a");
if (a && isNuri(a)) return a;
}
} catch (error) {
// Unreadable document (no cap) or not synced → no address to give. Refusing to
// read is the boundary doing its job, not an error to propagate here.
console.error(accessLogPrefix() + " documentInboxAddress failed:", error);
}
return undefined;
}
/**
* Does the connected user own `doc`? Answered from its **Store branches** — the
* register of the documents it created — across the three scopes, which is the only
* place that records authorship. Holding a cap is NOT ownership: a cap can be
* received, and a recipient must not be able to open an inbox on what it merely reads.
*/
export async function ownsDocument(doc: Nuri): Promise<boolean> {
const holder = getCurrentUser();
if (holder === null) return false;
const record = await resolveAccount(holder);
if (record === null) return false;
for (const scope of ["public", "protected", "private"] as const) {
const store = storeOf(record, scope);
if (!store) continue;
if ((await readUserStore(store)).includes(doc)) return true;
}
return false;
}
/** The `(document, inbox)` pairs recorded on this user's User branch. */
/**
* Encode the `(document, inbox)` pair of an emulated `AddInboxCap` record.
*
* Upstream this is a TYPED structure — `AddInboxCapV0 { repo_id, overlay, priv_key }`
* (`engine/repo/src/types.rs:1973`) — carried by a service commit, not a string. Ours is
* one RDF literal because our User branch is a subject in a document, so the pairing has
* to live inside a value. That is the emulation's shape, and it is what migration
* replaces: the fields become fields again.
*
* The separator is a space, which is safe for a reason worth stating rather than
* assuming: a NURI is `did:ng:` followed by base64url and `:`-separated segments
* (`NuriV0`, `engine/net/src/app_protocol.rs`), an alphabet that contains no space. The
* assertion below turns that from an implicit property into a checked one — a silently
* mis-split pair would file an inbox under a truncated document and lose deposits with
* no error, which is exactly the failure class this whole path already paid for once.
*/
function encodeInboxCap(doc: Nuri, inbox: Nuri): string {
if (doc.includes(" ") || inbox.includes(" ")) {
throw new Error(
"[ng-eventually] branch-registers: a NURI containing a space cannot be paired in " +
`an inbox-cap record — the separator would be ambiguous: ${JSON.stringify([doc, inbox])}`,
);
}
return `${doc} ${inbox}`;
}
export async function readInboxCapPairs(): Promise<Array<{ doc: Nuri; inbox: Nuri }>> {
const holder = getCurrentUser();
if (holder === null) return [];
const record = await resolveAccount(holder);
const store = record?.docPrivate;
if (!store) return [];
const s = await session();
const out: Array<{ doc: Nuri; inbox: Nuri }> = [];
try {
const res = await sparqlQuery(
s.sessionId,
`SELECT ?c WHERE { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> ?c }`,
undefined,
store,
"readInboxCaps",
);
for (const row of readBindings(res)) {
// See `encodeInboxCap` for why a space is a safe separator here, and why this
// pairing exists at all.
const [doc, inbox] = bindingValue(row, "c").split(" ");
if (doc && inbox && isNuri(doc) && isNuri(inbox)) out.push({ doc, inbox });
}
} catch (error) {
console.error(accessLogPrefix() + " readInboxCaps failed:", error);
}
return out;
}
/** The inbox recorded for one document, if this user opened one. */
export async function readInboxCapsFor(doc: Nuri): Promise<Nuri | undefined> {
return (await readInboxCapPairs()).find((p) => p.doc === doc)?.inbox;
}
/**
* Every inbox this user may READ: its own, plus one per document it opened an
* inbox on. What `connect.connectedUser` drains, and what `isOwnInbox` answers from.
*/
export async function myInboxes(): Promise<Nuri[]> {
const holder = getCurrentUser();
if (holder === null) return [];
const out: Nuri[] = [];
// BOTH of the user's inboxes — public and protected — since upstream a site carries
// one on each of those two store repos (`engine/verifier/src/site.rs:127-152`).
if ((await resolveAccount(holder)) !== null) {
for (const scope of ["public", "protected"] as const) out.push(await userInbox(holder, scope));
}
for (const { inbox } of await readInboxCapPairs()) out.push(inbox);
return out;
}
/**
* File a cap received for someone ELSE's document — the emulated
* `AddLink { read_cap }` on the User branch of the current user's private store.
*
* This is what makes a received cap DURABLE. Before it, a shared document survived
* only by re-reading the inbox every session, which uses a queue as a database:
* upstream an inbox is consumed, and processing a message *applies* it. Applying a
* Link means writing it here.
*
* Idempotent — re-applying the same Link is a no-op, so re-processing an inbox
* (a second tab, a reconnect) costs nothing.
*/
export async function addLink(cap: ReadCap): Promise<void> {
const holder = getCurrentUser();
if (holder === null) return;
const record = await ensureAccount(holder);
const store = record.docPrivate;
if (!store) return;
if ((await readLinks()).includes(cap)) return;
const s = await session();
try {
await sparqlUpdate(
s.sessionId,
`INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.link}> "${escapeLiteral(cap)}" }`,
store,
"addLink",
);
} catch (error) {
console.error(accessLogPrefix() + " addLink failed:", error);
}
}
/**
* The caps this user has received and applied — the User branch read back. Called
* at connection to restore what was shared with them, without touching any inbox.
*/
export async function readLinks(): Promise<ReadCap[]> {
const holder = getCurrentUser();
if (holder === null) return [];
const record = await ensureAccount(holder);
const store = record.docPrivate;
if (!store) return [];
const s = await session();
const out: ReadCap[] = [];
await ensureRepoOpen(store);
try {
const res = await sparqlQuery(
s.sessionId,
`SELECT ?c WHERE { <${USER_BRANCH_SUBJECT}> <${P.link}> ?c }`,
undefined,
store,
"readLinks",
);
for (const row of readBindings(res)) {
const v = bindingValue(row, "c");
if (v && hasReadCap(v)) out.push(v);
}
} catch (error) {
console.error(accessLogPrefix() + " readLinks failed:", error);
}
return out;
}
/**
* The inbox of a document this user owns — resolved, and created on first ask.
*
* Upstream a repo carries `inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`):
* an inbox is a keypair on the repo, whose PRIVATE half its owner holds. That half is
* recorded with `AddInboxCap { repo_id, overlay, priv_key }` — *"into the user branch,
* so that a user can share with all its device"* (`engine/repo/src/types.rs:1973`), the
* same branch that carries `AddLink`. So "which inboxes may I read" is answered by the
* User branch, and that is what this emulates.
*
* **The engine SUPPORTS this; nothing exercises it automatically.** Those are two
* different statements, and conflating them is what made an earlier version of this
* comment call the feature an "anticipation". It is not. `inbox: Option<PrivKey>` is a
* field of EVERY `Repo` (`engine/repo/src/repo.rs:126`), not of a store structure;
* `AddInboxCapV0` is keyed by `repo_id` (`engine/repo/src/types.rs:1973`); and
* `update_inbox_cap_v0` applies it with `self.repos.get_mut(repo_id)` and **no
* `is_store` check of any kind** (`engine/verifier/src/verifier.rs:1920`). Generic by
* construction, and at any time (see the User-branch note above).
*
* What is true is narrower: no code path CREATES one for a document — `new_store_default`
* attaches one only `if !private` (`verifier.rs:2994`), `doc_create` leaves `inbox: None`
* (`repo.rs:574`), and the only two `AddInboxCap` commits in the engine are for the
* public and protected STORE repos (`engine/verifier/src/site.rs:128,149`). So the
* capability exists and is simply unexposed above level 1: this function is aligned on
* the engine's model, it does not bet past it.
*
* Lazy on purpose, for the same reason: creating an inbox document for every entity up
* front would double every `createEntityDoc` for inboxes most documents never receive
* anything in. Upstream the keypair is cheap; here an inbox is a document, so it is
* minted when first asked for.
*
* *(Not covered: ROTATING an inbox key — the engine's "update" case with a new
* `priv_key`. This function is idempotent and returns the existing inbox instead. A
* known limit, not an oversight.)*
*
* Only for a document this user OWNS — see {@link ownsDocument}. Opening an inbox on
* someone else's document would be usurpation, not a courtesy: the opener keeps the
* reading half, so it would silently divert to itself the deposits meant for the
* owner. To deposit into someone else's document, resolve
* {@link documentInboxAddress} and `inbox.post` into it.
*/
export async function openDocumentInbox(doc: Nuri): Promise<Nuri> {
const holder = getCurrentUser();
if (holder === null) throw new Error("[ng-eventually] openDocumentInbox: no identity is set");
const known = (await readInboxCapsFor(doc)) ?? null;
if (known) return known;
// OWNERSHIP is the criterion, and holding a cap is NOT ownership — a cap can be
// received. Opening an inbox is what PUBLISHES this document's address, so a
// non-owner doing it would route the owner's deposits to itself, silently, on a
// document it merely reads.
//
// **This guard compensates OUR design, not an upstream constraint** — an earlier
// comment here claimed "upstream only the owner can commit `AddInboxCap`", which is
// false: that commit lands on the committer's OWN User branch, so anyone may write
// one naming anyone's repo. What protects upstream is that an inbox address is never
// PUBLISHED — it is TRANSMITTED (in a `ContactDetails` message, or a profile QR
// code), and `inboxes: PubKey → RepoId` is a per-verifier local table
// (`engine/verifier/src/verifier.rs:105`, rebuilt empty each session). A forged pair
// reaches nobody, because nobody was told about it.
//
// We publish instead of transmitting — the only way a third party can find the
// address at all here — which creates a vector upstream does not have: whoever can
// write the document can redirect its deposits. Hence this guard. It is a real
// divergence, deliberately taken; see `docs/briefs/2026-08-03-document-inbox-addressing.md`.
if (!(await ownsDocument(doc))) {
throw new Error(
"[ng-eventually] openDocumentInbox: refused — you may only open an inbox on a document " +
`you own. Deposit into its published address instead (storeRegistry.documentInboxAddress ` +
`then inbox.post): ${JSON.stringify(doc)}`,
);
}
const inbox = await createDoc();
const s = await session();
const record = await ensureAccount(holder);
const store = record.docPrivate;
getCaps().open(inbox, "private"); // its owner holds it, like any document of theirs
if (store) {
try {
await sparqlUpdate(
s.sessionId,
`INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> "${escapeLiteral(encodeInboxCap(doc, inbox))}" }`,
store,
"openDocumentInbox",
);
} catch (error) {
console.error(accessLogPrefix() + " openDocumentInbox persist failed:", error);
}
}
// …and the PUBLIC half, in the document itself, so a depositor can find it at all.
// Without this the inbox is reachable only by its owner — the opposite of what an
// inbox is for, and the bug this path shipped with.
await publishInboxAddress(doc, inbox);
return inbox;
}
+362
View File
@@ -0,0 +1,362 @@
/**
* Capability emulation — key POSSESSION, not an authorization list.
*
* In NextGraph a ReadCap **is** the document's read key: whoever holds it reads,
* and there is no read-ACL anywhere. This module emulates that shape (see
* `docs/briefs/2026-07-27-p1a-cap-surface.md`), which means it answers exactly one
* question — *do I hold this document's cap?* — and cannot answer "may principal P
* read document D", because the real model cannot either.
*
* ── Where caps come from — and why this is NOT "a keyring" ────────────────
* There is no keyring object in NextGraph, and calling this one invited a wrong
* mental model: that some single place holds every key. It does not. Upstream the
* caps of a user are in **two** places, by origin (see
* `docs/readcap-and-nuri-model.md` §4quater/§4quinquies):
*
* - documents the user CREATED → `AddRepo { read_cap }` on the **Store branch**
* of the store they live in — one such branch per store;
* - caps RECEIVED for someone else's documents → `AddLink { read_cap }` on the
* **User branch** of the private store.
*
* The wallet itself holds exactly one key per user: the private store's read cap,
* from which everything else is reached. Hence the invariant:
*
* > You do not derive a cap from a bare reference. You look it up in what you
* > hold — or you were given it.
*
* This class is the in-memory record of what the connected holder currently holds:
* upstream's local user storage, not a durable register. The durable ones are
* emulated in `store-registry.ts` — for created documents, `holdOwnCap` writes and
* `readStoreCaps` reads the Store branch back; for received ones, `addLink` /
* `readLinks` on the User branch. `connect.ts` restores the Links at connection;
* the own-document caps come back through `listMyEntityDocs`.
*
* One record PER holder, since one shared wallet hosts every identity. Switching
* identity therefore SWITCHES records; it never wipes one (a wipe would make
* durability a lie and bring per-session re-declaration back under another name).
*
* ── Sharing ───────────────────────────────────────────────────────────────
* Not here: the unit of sharing is the document and the recipient is an INBOX, so
* sharing is `inbox.share(doc, toUser)` — a **Link** deposit — and receiving is
* the recipient processing their inbox. Handing over a store's cap is NOT the
* gesture: it would give away everything that store contains, present and future.
*
* And for a document in a PUBLIC store there is no sharing act at all: the store hands
* its cap to whoever asks (`public-store.ts`), so what circulates is the bare
* reference. Filed apart (`learnFromPublicStore`) because it grants reading only.
*
* ── What this module does NOT do ──────────────────────────────────────────
* Enforce. The shape is right after P1a; the isolation is still fake. Per-document
* encryption and closing the read paths that bypass the guard (`docs.sparqlQuery`,
* the inbox, `store-registry`, `subscribe`, `open-repo`) are P1b. Nothing may be
* claimed "anonymous" or "private" until then. The write caps below are likewise
* decorative — the guard they feed (`ng-proxy`) is bypassed by every internal
* writer; they are left as-is and belong to P1b.
*/
import { CAP_SEGMENT, hasReadCap, targetOf } from "../model/nuri";
import type { Nuri, PrincipalId, ReadCap, Scope } from "../model/types";
/**
* The stand-in cap value, and the minting point — moved here from `model/nuri.ts`
* on 2026-08-03 because it did not belong to the model.
*
* `model/` transcribes the target's addressing vocabulary; minting is not part of
* that vocabulary. Upstream nothing on the surface turns a bare reference into a cap:
* the engine mints at repo creation and you afterwards look a cap up in what you hold,
* or you were given it. Keeping `mintCap` in the model module contradicted that module's
* own header, and put the emulation's one invented value in the file that claims to hold
* only verified target vocabulary.
*
* P1b replaces this single constant with a real key; migration deletes both.
*/
const STAND_IN_CAP = "OK";
/**
* Build the cap-bearing form of `nuri` — `{target}:r:OK`. Passing an already
* cap-bearing reference yields the same value. INTERNAL to the emulated verifier.
*/
export function mintCap(nuri: Nuri): ReadCap {
return `${targetOf(nuri)}${CAP_SEGMENT}${STAND_IN_CAP}`;
}
/** The map key of the anonymous holder (no identity established yet). */
const ANONYMOUS = "";
export class CapRegistry {
/** holder → the caps they hold, indexed by the cap-less NURI. */
private heldByHolder = new Map<string, Map<Nuri, ReadCap>>();
/**
* Documents this session knows to sit in a PUBLIC store — a fact about each
* DOCUMENT, so global rather than per-holder, unlike everything else here.
*
* It is not itself a right. What being in a public store buys is that the document's
* cap can be DOWNLOADED by anyone who asks (`emulated-verifier/public-store.ts`,
* emulating `PublicRepoLinkV0`'s *"downloaded from the outerOverlay"*); once it has
* been, the holder holds it like any other and this set records only how it got there.
*/
private inPublicStore = new Set<Nuri>();
/**
* holder → the documents whose cap they hold ONLY because a public store served it
* (see {@link learnFromPublicStore}).
*
* PER HOLDER, unlike the set above, and the difference is the whole point: *"this
* document is in a public store"* is a fact about the document, whereas *"the only
* claim I have on it is that the network handed me its key"* is a fact about one
* holder. Kept global, the owner of a public document would be refused writes to it
* the moment any third party fetched its cap.
*/
private servedByHolder = new Map<string, Set<Nuri>>();
/** doc NURI → principals holding its WRITE cap. Decorative until P1b. */
private writers = new Map<Nuri, Set<PrincipalId>>();
/** Fired whenever a holder gains a cap — a cap delivered asynchronously must
* re-trigger the reads that were empty for want of it. */
private listeners = new Set<() => void>();
/** Has any cap been issued at all? Gates the whole emulation (see {@link isEnforcing}). */
private issued = false;
/**
* @param holder resolves WHO is holding — the current identity. Looked up through it on every
* call, so an identity switch switches records with nothing to reset. Defaults to the anonymous holder.
*/
constructor(private readonly holder: () => PrincipalId | null = () => null) {}
// --- what the holder holds ----------------------------------------------
/** What the current holder holds, created on first use. */
private heldCaps(): Map<Nuri, ReadCap> {
const key = this.holder() ?? ANONYMOUS;
let ring = this.heldByHolder.get(key);
if (!ring) this.heldByHolder.set(key, (ring = new Map()));
return ring;
}
/**
* File `cap` among what the current holder holds — the ONE door in, so
* the invariant is carried here rather than by each caller remembering it.
*
* A reference with no `:r:` is REFUSED. `Nuri` and `ReadCap` are both `string`
* (deliberately — the real SDK takes `nuri: String`), so the compiler cannot
* catch a caller passing the naming form where the reading form is meant. Left
* unchecked, that mistake files a bare reference under its own name, `capFor`
* then returns it, and the document reads — turning "naming is not reading" into
* "naming is reading", which is the exact inversion this batch exists to remove.
* The check is cheap and it is the only thing standing between the two.
*
* Returns whether the cap was new.
*/
private file(cap: ReadCap): boolean {
if (!hasReadCap(cap)) {
throw new Error(
"[ng-eventually] caps: expected a ReadCap (a NURI carrying `:r:`), got a bare " +
`reference — naming is not reading, and no cap derives from one: ${JSON.stringify(cap)}`,
);
}
const target = targetOf(cap);
// Filing is the STRONG claim — I created this document, or its cap was deposited
// for me. Either one supersedes "a public store served it to me", so the read-only
// mark goes. {@link learnFromPublicStore} re-adds it after calling here, and only
// when nothing was held before.
this.servedToHolder().delete(target);
const ring = this.heldCaps();
if (ring.get(target) === cap) return false;
ring.set(target, cap);
this.issued = true;
this.notify();
return true;
}
/**
* The cap of a document I just CREATED, filed among what I hold — the emulated
* `AddRepo { read_cap }`. Idempotent. Returns the cap.
*/
mint(nuri: Nuri): ReadCap {
const cap = mintCap(nuri);
this.file(cap);
return cap;
}
/**
* File a cap I was GIVEN — an inbox deposit of kind `cap`, or a repo link found
* in world-readable content. This is the ONLY way a cap arrives from
* outside: nothing turns a bare reference into a cap.
*
* @throws if `cap` carries no `:r:` — see {@link file}. Passing a bare `Nuri`
* here is the one type confusion that would silently invert the model, and both
* forms are `string`, so it is rejected at runtime instead.
*/
learn(cap: ReadCap): void {
this.file(cap);
}
/**
* File a cap a PUBLIC STORE served me — `emulated-verifier/public-store.ts`, the
* emulated *"downloaded from the outerOverlay"*. Held like any other cap, so reading
* needs no special case anywhere; recorded apart because of what it is NOT.
*
* It is a READ grant and nothing else. Upstream a public store makes its repos
* world-readable, never world-writable — writing needs the write cap, and
* `verify_permission` fires on WRITE only. Here the write guard still consults the
* read cap (write caps are decorative until P1b, see the module header), so without
* this distinction a bare reference to a public document would buy a WRITE — a
* consumer would build on it, and have to unlearn it at migration.
*
* A stronger claim on the same document erases the mark: {@link mint} (I created it)
* and {@link learn} (it was deposited for me) both go through {@link file}, which
* clears it. So a public document of my own is never read-only to me.
*/
learnFromPublicStore(cap: ReadCap): void {
const target = targetOf(cap);
const alreadyHeld = this.heldCaps().has(target);
this.file(cap);
// Only when this is the ONLY reason I hold it — filing never downgrades a claim.
if (!alreadyHeld) this.servedToHolder().add(target);
}
/**
* Is the ONLY reason the current holder holds this document's cap that a public store
* served it? Then it grants reading and nothing more — see {@link learnFromPublicStore}.
*/
isReadOnlyPublicCap(nuri: Nuri): boolean {
return this.servedToHolder().has(targetOf(nuri));
}
/** The current holder's public-store-served set, created on first use. */
private servedToHolder(): Set<Nuri> {
const key = this.holder() ?? ANONYMOUS;
let s = this.servedByHolder.get(key);
if (!s) this.servedByHolder.set(key, (s = new Set()));
return s;
}
/**
* Do I hold the cap of `nuri`? Returns it, or `undefined` when I hold
* none — which is the whole answer the model can give. Absorbs the former
* `canRead(doc, principal)`: there is no principal parameter, because there is
* no list to look a principal up in.
*/
capFor(nuri: Nuri): ReadCap | undefined {
return this.heldCaps().get(targetOf(nuri));
}
// --- publication (the public store) -------------------------------------
/**
* Record that `nuri` sits in a PUBLIC store. A fact about the DOCUMENT, not a right
* of anyone — hence a global set rather than a per-holder one, and hence no minting
* here: what sitting in a public store buys is that the cap is **obtainable** by
* whoever asks (`emulated-verifier/public-store.ts`), which is a separate act from
* this one holding it.
*
* Marking and minting were one method (`recordInPublicStore`) until they were split:
* the fetch path files the cap it DOWNLOADED, and minting a second one beside it
* would produce a different key the day the stand-in constant becomes a real one —
* the same trap `holdOwnCap` already documents.
*
* Upstream nothing corresponds to this call: the store IS public, and the broker
* exposes its outer overlay (`expose_outer`,
* `engine/broker/src/server_storage/core/overlay.rs:103-133`). We record it because
* one broker here serves every virtual user identically.
*
* NOT recursive: a document in a public store may REFERENCE private ones, and the
* reference grants nothing on what it references. That non-recursiveness is what lets
* a public object point at private content without disclosing it.
*/
markInPublicStore(nuri: Nuri): void {
this.inPublicStore.add(targetOf(nuri));
}
/** Is `nuri` recorded as sitting in a public store? A fact about the document. */
isInPublicStore(nuri: Nuri): boolean {
return this.inPublicStore.has(targetOf(nuri));
}
/**
* Record a document the current holder owns in `scope`: its cap lands among what
* they hold, and a `public` one is additionally marked as sitting in a public store.
* Returns the cap. Idempotent — the registry calls it both when creating a document
* and when listing the holder's own documents back, which is how a holder's caps are
* rebuilt on a fresh session.
*
* Deliberately does NOT touch write caps: those are decorative until P1b, and
* arming their guard here would be enforcement this batch does not do.
*/
open(nuri: Nuri, scope: Scope): ReadCap {
const cap = this.mint(nuri);
if (scope === "public") this.markInPublicStore(nuri);
return cap;
}
// --- enforcement gate ---------------------------------------------------
/**
* Is the cap emulation in force? False until the first cap is issued, so a
* consumer that never touches caps keeps reading everything (no regression).
* Once ANY cap exists the regime is possession for EVERY holder — including one
* who holds nothing, which is exactly the isolation being emulated.
*/
isEnforcing(): boolean {
return this.issued;
}
// --- change signal ------------------------------------------------------
/**
* Subscribe to changes in what the holder holds. A cap that arrives asynchronously (an inbox
* deposit) must make the views that were empty for want of it re-read; without
* this signal they stay stale until an unrelated change happens to fire.
*/
onChange(listener: () => void): () => void {
this.listeners.add(listener);
return () => {
this.listeners.delete(listener);
};
}
private notify(): void {
for (const l of this.listeners) {
try {
l();
} catch (error) {
console.error("[caps] change listener threw", error);
}
}
}
// --- write caps (decorative until P1b) ----------------------------------
/** Grant `principal` the WRITE cap of document `doc`. */
grantWrite(doc: Nuri, principal: PrincipalId): void {
const target = targetOf(doc);
let s = this.writers.get(target);
if (!s) this.writers.set(target, (s = new Set()));
s.add(principal);
}
/** Is `doc` under any WRITE-cap policy? */
governsWrite(doc: Nuri): boolean {
return this.writers.has(targetOf(doc));
}
/** Does `principal` hold a WRITE cap for `doc`? */
canWrite(doc: Nuri, principal: PrincipalId | null): boolean {
if (principal === null) return false;
return this.writers.get(targetOf(doc))?.has(principal) ?? false;
}
/** No WRITE policy declared → the write guard stays inert (passthrough). */
hasWritePolicy(): boolean {
return this.writers.size > 0;
}
/** Drop every holder's caps and every publication. Tests / a fresh wallet only —
* NOT what an identity change does (that switches heldByHolder, see the header). */
clear(): void {
this.heldByHolder.clear();
this.servedByHolder.clear();
this.inPublicStore.clear();
this.writers.clear();
this.issued = false;
this.notify();
}
}
@@ -0,0 +1,94 @@
/**
* connect — what the polyfill does when the app connects a virtual user.
*
* ── Processing inboxes is the LIBRARY's job, not the app's ────────────────
* Stated by the PO, 2026-07-30. A consumer must not have to remember to drain its
* inbox for documents shared with it to become readable; forgetting would look
* like "the share did not work" rather than "nobody processed the queue". So the
* moment an identity is connected ({@link setCurrentUser}), this runs.
*
* Two steps, in order, and the order matters:
*
* 1. **Restore** — read the Links already applied (`storeRegistry.readLinks`, the
* emulated `AddLink` records on the User branch of the private store) back into
* what this user holds. This is durable state; it costs one read and needs no inbox.
* 2. **Process** — drain the user's inbox (`inbox.processInbox`), which files any
* new Link durably and puts it among what the user holds.
*
* Restoring first means a reconnecting user can read its shared documents
* immediately, without waiting on the inbox round-trip.
*
* ── Fire-and-forget, on purpose ───────────────────────────────────────────
* `setCurrentUser` is synchronous and every consumer calls it from synchronous
* code. Making it async would push the wait onto the app, which is exactly the
* obligation this removes. So the work runs in the background and announces itself
* through the registry's change signal (`CapRegistry.onChange`), which is what
* `watchShape` already listens to — a view that was empty for want of a cap
* re-reads when the cap lands. {@link connectedUser} is there for a caller that
* genuinely needs to await it (tests, an app that wants a deterministic start).
*
* ── Every inbox, at both levels ───────────────────────────────────────────
* The user's own inbox AND the inbox of every document it opened one on. Upstream
* both are answered by the same place — `AddInboxCap` records on the User branch
* (`engine/repo/src/types.rs:1969`) — so `storeRegistry.myInboxes()` enumerates
* them and this drains each in turn.
*/
import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
import { resolveAccount } from "../shared-wallet/account-registry";
import { myInboxes, readLinks } from "./branch-registers";
import { processInbox } from "../surface/inbox";
/** The in-flight connection work, per user key — so two calls do not race. */
const inFlight = new Map<string, Promise<void>>();
/**
* Restore and drain for the connected user. Idempotent per user while in flight.
*
* Tolerant by construction: it runs on every `setCurrentUser`, including in
* contexts where the store registry was never configured (unit tests, an app
* setting the identity before the session resolves). Those simply have nothing to
* restore, and a failure here must never break connecting.
*/
export async function connectedUser(): Promise<void> {
const holder = getCurrentUser();
if (holder === null) return;
const pending = inFlight.get(holder);
if (pending) return pending;
const run = (async (): Promise<void> => {
try {
// Connecting must not PROVISION. `ensureAccount` would create the user on
// first sight, so connecting an identity that does not exist yet would
// silently mint its stores and their caps — arming the whole emulation as a
// background side effect, at a moment nothing controls. An account that does
// not exist has nothing to restore and no inbox to drain.
if ((await resolveAccount(holder)) === null) return;
// 1. Durable first: what this user has already applied.
for (const cap of await readLinks()) getCaps().learn(cap);
// 2. Then the queues: ALL of them — the user's own inbox, plus one per
// document it opened an inbox on. Both levels, as the PO specified, and
// both are answered by the same User-branch record (`AddInboxCap`).
// Sequential rather than parallel: each `processInbox` writes what it
// applies to the SAME private store, and interleaving those writes buys
// nothing on a queue that is nearly always empty.
for (const inbox of await myInboxes()) await processInbox(inbox);
} catch {
// Not configured yet, or offline. Nothing to restore, and connecting must
// not fail because a queue could not be reached — the next connection, or
// an explicit `connectedUser()`, picks it up.
}
})();
inFlight.set(holder, run);
try {
await run;
} finally {
inFlight.delete(holder);
}
}
/** Fire the connection work without awaiting it. Called by `setCurrentUser`. */
export function startConnect(): void {
void connectedUser();
}
@@ -0,0 +1,42 @@
/**
* The namespace this library reserves for its OWN triples, and the one predicate a
* read path needs about it: *is this subject machinery, or is it the consumer's data?*
*
* ── Why this exists ────────────────────────────────────────────────────────
* The polyfill has no branches, so it emulates each of a repo's compartments with a
* distinct SUBJECT inside a document (`shim:index` for the store's Main branch,
* `shim:storeBranch`, `shim:userBranch`, `shim:headerBranch` — see `store-registry.ts`).
* That was invisible as long as those subjects only ever appeared in documents the
* consumer never reads through the data path — store documents and the doc-shim.
*
* The Header branch broke that: it lives in an ENTITY document, the one the consumer
* reads with `SELECT ?s ?p ?o`. Without a filter, the address of a document's inbox
* would surface as one of that entity's properties — machinery leaking into domain
* data. Filtering by SUBJECT rather than by predicate is what makes this hold for
* every compartment, present and future: a new emulated branch needs no new filter.
*
* Upstream this problem does not exist, because there the separation is real — a
* branch is a different CRDT with its own topic, not a subject in the same graph. This
* module is the seam where our emulation pays for that.
*/
/**
* The URN namespace every triple this library writes for itself lives under —
* `urn:ng-eventually:shim:…` (store-registry's compartments) and
* `urn:ng-eventually:inbox:…` (inbox deposits).
*
* A consumer that writes its own data under this prefix would have it filtered out of
* its reads. That is a deliberate reservation, not a hazard to guard against: the
* namespace names this library.
*/
export const MACHINERY_NS = "urn:ng-eventually:";
/**
* Is `subject` one of this library's own, rather than consumer data?
*
* Tolerant of `undefined` so a read path can hand it a possibly-absent binding
* without a preliminary check — an absent subject is not machinery.
*/
export function isMachinerySubject(subject: string | undefined): boolean {
return subject !== undefined && subject.startsWith(MACHINERY_NS);
}
@@ -0,0 +1,274 @@
/**
* open-repo — cold-start repo opening for the ANCHORED read path (polyfill-era).
*
* ── The cold-start defect this heals ──────────────────────────────────────
* The anchored read path (`surface/read-model.ts` `readDoc`,
* `shared-wallet/account-registry.ts` `readUserStore`) assumes the target repo is
* already usable by the verifier — true within the session that CREATED the doc
* (every `doc_create` opens it), but FALSE on a FRESH session over the same
* persistent wallet (reconnection / new page / re-login): the repos are on the broker
* and in the profile's cache, but this session has not synced them, so a persisted
* document reads as empty.
*
* **The mechanism, corrected 2026-08-03.** This comment used to say the verifier
* "silently returns 0 rows (never a `RepoNotFound`)" for a repo absent from
* `self.repos`. That is FALSE at the source: `resolve_target_for_sparql` does
* `self.repos.get(repo_id).ok_or(NgError::RepoNotFound)?`
* (`engine/verifier/src/request_processor.rs:264,269`), which surfaces as a rejected
* promise. Two things produce the 0 rows actually observed, and neither is silence in
* the verifier: on a persistent profile `Verifier::load` repopulates `self.repos` from
* user storage at construction (`engine/verifier/src/verifier.rs:535-560`), so the repo
* is PRESENT but unsynced and the anchored query legitimately matches nothing; and this
* library's own `readDoc` catches every error and returns `[]`
* (`surface/read-model.ts:122`), so anything that did throw would reach the caller as
* emptiness anyway. The fix below is right; the diagnosis written beside it was not.
*
* The circularity that made this self-inflicted: `doc_subscribe` WOULD open the
* repo, but the reactive layer only subscribes AFTER the listing produced NURIs —
* and the listing (`readUserStore`) is itself an anchored read of a not-yet-open
* index repo → 0 rows → nothing to subscribe → nothing ever opens. Verified fix
* (adversarial pass): on a fresh session, `doc_subscribe(<docNuri>)` THEN the
* anchored re-read returns the data. So we OPEN the repo before the anchored read.
*
* ── How we open ───────────────────────────────────────────────────────────
* We reuse the existing per-document primitive {@link subscribeDoc} (the typed
* wrapper over the platform's `doc_subscribe`) — NOT a parallel channel. On
* subscribe the platform pushes `TabInfo` FIRST (~1-3ms) and then the initial
* `State` (~2-3ms); the FIRST `State` is the sync BARRIER — after it, presence is
* guaranteed and absence definitive (pinned empirically by CONTRACT 3 in `e2e/`).
* So we await that first `State` specifically (identified via the `type` argument
* {@link DocChangeType} `subscribeDoc` now surfaces), NOT the first push of any
* kind — resolving on `TabInfo` would return before the real barrier. The
* subscription is kept ALIVE for the whole session (that is what keeps the repo
* open) — it is a bootstrap open, distinct from any reactive subscription a caller
* later establishes for change signals.
*
* ── Idempotence / perf (once per session, no polling) ─────────────────────
* The registry opens each repo at most ONCE per session: `opened` records completed
* opens (a hit skips everything), `inFlight` de-dupes concurrent opens of the same
* repo. A brand-new page / module instance starts with an empty registry; and when
* the injected session id CHANGES within the same page (an in-page re-login /
* `session_stop`+`session_start`, whose new verifier has an empty `self.repos`) the
* registry auto-resets (`syncSession`) so repos are re-opened against the new session
* rather than wrongly skipped as "already open". No polling: we wait on the first
* `State` push, with a bounded fallback timeout so a missing push can't hang.
*
* ── Sync state (per-nuri, lib-internal) ───────────────────────────────────
* Each nuri carries an explicit sync state, readable via {@link getSyncState}:
* `"syncing"` — subscribed, no `State` yet (barrier not reached);
* `"synced"` — first `State` received (barrier reached — the TRUTH signal);
* `"timed-out"` — the bounded fallback fired with NO `State` (open proceeded so
* the read is never blocked, but this is NOT `"synced"`: a future
* "ready" signal must not mistake a timeout for a real barrier);
* `"unknown"` — never requested (or the fake-ng no-op path: no `State` semantics).
* This distinction is the point — `"synced"` and `"timed-out"` are kept apart so a
* later reactive readiness layer can trust `"synced"` and treat `"timed-out"` as
* "opened best-effort, sync unconfirmed".
*
* ── Migration ─────────────────────────────────────────────────────────────
* At the real multi-store migration this becomes "open the user's store repo by
* cap" (a native broker fetch) done once at bootstrap; the anchored read then
* resolves a same-session repo directly. Polyfill-era, removed with the shim.
*/
import { mustNotAttempt } from "./reach";
import { fetchReadCap } from "./public-store";
import { getConfig, getStoreRegistryDeps } from "../shared-wallet/bootstrap";
import { subscribeDocUnguarded, type Unsubscribe } from "../surface/subscribe";
import { logStage, shortNuri } from "../shared-wallet/access-log";
import type { Nuri } from "../model/types";
/**
* The per-nuri bootstrap sync state (lib-internal). See the module header:
* - `"syncing"` subscribed, first `State` not yet received;
* - `"synced"` first `State` received — the real sync barrier (CONTRACT 3);
* - `"timed-out"` fallback fired without a `State` — open proceeded, sync UNconfirmed;
* `"unknown"` (from {@link getSyncState}) means "never requested / no `State` semantics".
*/
export type SyncState = "syncing" | "synced" | "timed-out";
/** Repos whose bootstrap open has completed (first `State` received OR timed out). */
const opened = new Set<Nuri>();
/** In-flight opens, so concurrent `ensureRepoOpen(nuri)` share one subscription. */
const inFlight = new Map<Nuri, Promise<void>>();
/** Live bootstrap subscriptions, kept for the session (this is what holds repos open). */
const held = new Map<Nuri, Unsubscribe>();
/** Explicit per-nuri sync state — the barrier signal, distinct from `opened`.
* `synced` and `timed-out` are NOT merged (see module header). */
const syncState = new Map<Nuri, SyncState>();
/** The session id the current `opened`/`held` entries belong to. A change means a
* new verifier session (fresh `self.repos`) → the registry must be invalidated. */
let boundSessionId: string | number | null = null;
/**
* Max wait (ms) for the initial-state push before proceeding with the read anyway.
* The push normally lands quickly once the repo loads; the timeout only guards the
* pathological case (a doc that never pushes), so a read is never blocked forever —
* it just proceeds (and yields 0 rows, exactly as before, for a genuinely-absent doc).
*/
let OPEN_TIMEOUT_MS = 8000;
/**
* Override the bootstrap-open fallback timeout (ms). TEST-ONLY: the timed-out
* branch (a doc that never pushes a `State`) is otherwise only reachable after the
* 8s production wait, too slow for a unit test. Production never calls this — the
* default stands. `resetOpenedRepos` restores the default.
*/
export function setOpenTimeoutForTests(ms: number): void {
OPEN_TIMEOUT_MS = ms;
}
/** Reset the open registry (mainly for tests / a switched wallet). Tears down the
* held bootstrap subscriptions so a subsequent open re-subscribes cleanly. */
export function resetOpenedRepos(): void {
for (const unsub of held.values()) {
try {
unsub();
} catch {
/* ignore */
}
}
opened.clear();
inFlight.clear();
held.clear();
syncState.clear();
boundSessionId = null;
OPEN_TIMEOUT_MS = 8000;
}
/**
* The bootstrap sync state of `nuri` (lib-internal accessor; NOT a reactive
* app-facing hook — that is a later phase). Returns `"unknown"` if the repo was
* never opened via {@link ensureRepoOpen} (or was opened on the fake-ng no-op
* path, which has no `State` semantics). Otherwise `"syncing"` (subscribed, no
* `State` yet), `"synced"` (first `State` received — the barrier), or
* `"timed-out"` (fallback fired without a `State`). `"synced"` and `"timed-out"`
* are deliberately distinct — a later readiness signal must not confuse them.
*/
export function getSyncState(nuri: Nuri): SyncState | "unknown" {
return syncState.get(nuri) ?? "unknown";
}
/**
* Invalidate the registry if the active session id changed since it was populated.
* A new session id means a new verifier with an EMPTY `self.repos`, so entries from
* the previous session must NOT suppress re-opening under the new one. Tolerant: if
* the session can't be resolved, keep the current registry (best effort).
*/
async function syncSession(): Promise<void> {
let sid: string | number | null = null;
try {
sid = (await getStoreRegistryDeps().getSession()).sessionId;
} catch {
return; // no session deps wired (unit fake path) — nothing to invalidate against
}
if (boundSessionId !== null && boundSessionId !== sid) resetOpenedRepos();
boundSessionId = sid;
}
/**
* Ensure `nuri`'s repo is OPEN in the current session before an anchored read,
* so the cold-start (fresh session, same persistent wallet) resolves it instead
* of returning 0 rows. Opens via {@link subscribeDoc} and awaits the first `State`
* push — the sync barrier (bounded fallback marks the nuri `"timed-out"`, not
* `"synced"`). Idempotent: a repo already opened (or in flight) is not re-opened.
*
* Tolerant by construction: if the injected `ng` exposes no `doc_subscribe` (e.g.
* the fake `ng` in the unit suite), this is a no-op — the read proceeds unchanged.
* Never throws; a failed open just leaves the read to behave as it did before.
*/
export async function ensureRepoOpen(nuri: Nuri): Promise<void> {
if (!nuri) return;
// A repo in a PUBLIC store hands its cap to whoever asks — upstream by serving it on
// the outer overlay (`PublicRepoLinkV0`, `engine/net/src/types.rs:5098`). So ASK
// before deciding whether we may touch it, or the answer would be "no" purely for
// want of asking, and a bare reference to a public document would never suffice.
// Memoised and inert once the cap is held (see public-store.ts).
await fetchReadCap(nuri);
// RULE 2 — do not even attempt. Opening a repo IS an access: it subscribes and
// pulls its state. A user that holds no cap for it has no business asking.
// (`ensurePhysicalRepoOpen` is the machinery's door — see physical.ts.)
if (mustNotAttempt(nuri)) return;
return openRepoUnguarded(nuri);
}
/**
* The unguarded core. Exported for ONE importer — `shared-wallet/physical.ts` — and
* for nobody else; neither entry point re-exports it. The `Unguarded` suffix is the
* warning, and the single importer is what keeps it honest.
*/
export async function openRepoUnguarded(nuri: Nuri): Promise<void> {
// Drop the registry if the session changed (in-page re-login → fresh verifier).
await syncSession();
if (opened.has(nuri)) return;
const pending = inFlight.get(nuri);
if (pending) return pending;
// No reactive primitive on the injected ng (fake-ng unit suite): nothing to open,
// no `State` to await → preserve the old immediate-resolve behaviour so `bun test`
// does not regress. No sync state is recorded (getSyncState → "unknown"): the fake
// path has no barrier semantics, and claiming "synced" here would be a lie.
const ng = getConfig().ng as { doc_subscribe?: unknown };
if (typeof ng.doc_subscribe !== "function") {
opened.add(nuri);
return;
}
// Subscribed, first `State` not yet seen.
syncState.set(nuri, "syncing");
// Barrier clock: how long the subscribe→first-State (or fallback) round-trip
// took, surfaced on the BARRIER trace line below — the most important line in
// the whole low-level data-path trace: it distinguishes a genuine absence
// (`synced` → a 0-row read means it) from a not-yet-synced read (`timed-out`).
const barrierStartedAt = Date.now();
const p = (async () => {
await new Promise<void>((resolve) => {
let settled = false;
// Resolve on the first `State` (the sync BARRIER), marking the nuri "synced".
const onState = (): void => {
if (settled) return;
settled = true;
syncState.set(nuri, "synced");
logStage("BARRIER " + shortNuri(nuri) + " synced (" + (Date.now() - barrierStartedAt) + "ms)");
resolve();
};
// Bounded fallback: proceed WITHOUT a `State`, but mark "timed-out" — NOT
// "synced". A genuinely-absent doc reads 0 rows anyway (same as before, never
// a hang); the distinct state keeps a future "ready" signal from lying.
const onTimeout = (): void => {
if (settled) return;
settled = true;
syncState.set(nuri, "timed-out");
logStage("BARRIER " + shortNuri(nuri) + " timed-out (" + (Date.now() - barrierStartedAt) + "ms)");
resolve();
};
// The bootstrap subscription is kept ALIVE for the session — holding it open
// is the whole point. We wait for the FIRST `State` event specifically (the
// barrier), NOT any push: the platform pushes `TabInfo` before `State`, and
// resolving on `TabInfo` would return before the real sync barrier.
// Unguarded on purpose: the caller already decided. `ensureRepoOpen` applied
// rule 2 above; `ensurePhysicalRepoOpen` is the machinery's door and is not
// subject to the boundary at all (see physical.ts).
const unsub = subscribeDocUnguarded(nuri, (_r, type) => {
if (type === "State") onState();
});
held.set(nuri, unsub);
setTimeout(onTimeout, OPEN_TIMEOUT_MS);
});
opened.add(nuri);
inFlight.delete(nuri);
})();
inFlight.set(nuri, p);
return p;
}
/**
* Open a SET of repos before an anchored batch read, in parallel, each tolerant
* ({@link ensureRepoOpen} never throws). Empty / falsy entries are ignored.
*/
export async function ensureReposOpen(nuris: Nuri[]): Promise<void> {
const unique = [...new Set(nuris.filter(Boolean))];
if (unique.length === 0) return;
await Promise.all(unique.map((n) => ensureRepoOpen(n)));
}
@@ -0,0 +1,196 @@
/**
* public-store — a document in a PUBLIC store gives up its ReadCap to whoever asks.
*
* ── The upstream mechanism this emulates (VERIFIED) ───────────────────────
* `PublicRepoLinkV0` (`engine/net/src/types.rs:5098-5124`) carries `repo`,
* `public_store` and `peers` — and **no `read_cap`**. Its own doc comment says why:
*
* > *"The latest ReadCap of the branch (or main branch) will be **downloaded from
* > the outerOverlay**, if the peer brokers listed below allow it. […] This link is
* > durable, because the public site are **served differently by brokers**."*
*
* So for a repo in a public store, the key is not something a sender hands over: it is
* something the **network gives to anyone who asks**. The broker decides, by pinning
* the outer overlay (`expose_outer`, `engine/broker/src/server_storage/core/overlay.rs:103-133`).
* That is the whole of the property — nothing about the reader, everything about where
* the document sits and how brokers serve it.
*
* ── What that means for the model, and why nothing is special-cased ───────
* Possession stays the ONE criterion. A public document is readable not because the
* guard makes an exception for it, but because its cap is **obtainable**: you ask, you
* receive, you hold it, and from there the ordinary path applies. `reach.ts` is
* untouched, and "whoever has the reference AND the key reads" still describes
* everything — a public store simply hands the key to whoever has the reference.
*
* The consequence an application must be able to rely on: **a bare reference to a
* document in a public store is enough**, and that is why nothing in this library
* needs to put a key into a link (see `readcap-and-nuri-model.md` § 0 — a call that
* returns a key where a reference was asked for is the failure mode to watch for).
*
* Non-recursive, like everything else here: a public document may REFERENCE a
* protected one, and following that reference gets you a name, not a key. Only the
* document actually sitting in the public store exposes its cap.
*
* ── The two halves, and which door each uses ──────────────────────────────
* - {@link exposeReadCap} — the OWNER's side, at creation: the cap is written on the
* document's Header branch, the compartment meant for what any reader may see. It
* goes through the guarded surface, because the owner holds the document.
* - {@link fetchReadCap} — the NETWORK's side: read through the **physical** door
* (`shared-wallet/physical.ts`), unguarded, because that is precisely the point —
* the broker serving an outer overlay does not ask who is asking. Using the guarded
* read here would be circular: you would need the cap to obtain the cap.
*
* ── Where the emulation is honest about its shape ─────────────────────────
* Upstream nothing is *written* anywhere to make a repo public: the store is public,
* and the broker exposes its outer overlay. Here there is one broker serving every
* virtual user identically, so "which documents are in a public store" has to be
* recorded somewhere the machinery can read — and the document itself is the one place
* that needs no index and no enumeration. At migration this whole module goes: the
* scope stops being a fact we record and becomes the store the document lives in.
*
* The gap that leaves: a reader learns a document is public by ASKING that document,
* so a document it has never heard of stays invisible. Upstream the broker would serve
* it just the same. That limits discovery, not access — an application that holds the
* reference reads, which is the property this module exists to provide.
*/
import { sparqlUpdate } from "../surface/docs";
import { physicalQuery, ensurePhysicalRepoOpen } from "../shared-wallet/physical";
import { getCaps } from "../shared-wallet/bootstrap";
import { escapeLiteral } from "../surface/sparql";
import { hasReadCap, targetOf } from "../model/nuri";
import { accessLogPrefix } from "../shared-wallet/access-log";
import {
P,
HEADER_BRANCH_SUBJECT,
readBindings,
bindingValue,
session,
} from "../shared-wallet/account-registry";
import type { Nuri, ReadCap } from "../model/types";
/**
* Targets whose outer-overlay fetch has already been attempted in this session, with
* its outcome. Memoised in BOTH directions on purpose: a hit spares a physical read,
* and a miss spares repeating one for every read of a document this user cannot reach
* — which is the common case (a protected document someone merely named).
*
* A scope never changes here (a document is created in a store and stays there), so a
* cached miss cannot go stale for a document that existed when it was taken. It CAN
* for one created afterwards by another user in the same page — {@link resetPublicStoreFetches}
* is the way out, and it is what a session change / a wallet reset calls.
*/
const attempted = new Map<Nuri, Promise<boolean>>();
/** Forget every outer-overlay fetch (tests / a switched session or wallet). */
export function resetPublicStoreFetches(): void {
attempted.clear();
}
/**
* Expose `cap` on `doc`'s Header branch — the emulated `expose_outer`. Called when a
* document is created in a PUBLIC store, and only then: this is what makes the cap
* obtainable by anyone, which for a public store is the intended property and for any
* other scope would be a disclosure.
*
* Replacement, not addition, like every Header-branch register: one document has one
* current cap, and two would leave a fetcher picking between them.
*/
export async function exposeReadCap(doc: Nuri, cap: ReadCap): Promise<void> {
const s = await session();
try {
// Two separate updates: `DELETE WHERE { … }` is the form verified against the real
// broker (`docs/decisions/sparql-delete-for-orm-objects.md`); a `;`-joined update
// is not exercised anywhere in this library.
await sparqlUpdate(
s.sessionId,
`DELETE WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.exposedReadCap}> ?c }`,
doc,
"exposeReadCap:clear",
);
await sparqlUpdate(
s.sessionId,
`INSERT DATA { <${HEADER_BRANCH_SUBJECT}> <${P.exposedReadCap}> "${escapeLiteral(cap)}" }`,
doc,
"exposeReadCap",
);
} catch (error) {
console.error(accessLogPrefix() + " exposeReadCap failed:", error);
}
}
/**
* Ask the (emulated) network for `doc`'s ReadCap, and file it if it answers — the
* emulated *"downloaded from the outerOverlay"*. Returns whether a cap was obtained.
*
* Nothing is asked when the cap is already held: a document you can read needs no
* fetching, and skipping it keeps the ordinary path free of physical reads.
*
* Never throws — a document that is not in a public store simply answers nothing, which
* is not an error but the normal case.
*/
export async function fetchReadCap(docLike: Nuri): Promise<boolean> {
const doc = targetOf(docLike);
const caps = getCaps();
// Inert until the emulation is in force, like the guard it serves: before the first
// cap exists everything reads anyway, so there is nothing to obtain and asking would
// be a physical round-trip bought for nothing.
if (!caps.isEnforcing()) return false;
if (caps.capFor(doc) !== undefined) return true;
let pending = attempted.get(doc);
if (pending === undefined) {
pending = downloadReadCap(doc);
attempted.set(doc, pending);
}
return pending;
}
/** The fetch itself, through the machinery's door. See the module header. */
async function downloadReadCap(doc: Nuri): Promise<boolean> {
const s = await session();
try {
// The repo has to be in the session before an anchored read resolves it — the
// cold-start heal, through the PHYSICAL door: this is the emulated broker serving
// an outer overlay, and it does not ask who is asking (see `open-repo.ts`).
await ensurePhysicalRepoOpen(doc);
const res = await physicalQuery(
s.sessionId,
`SELECT ?c WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.exposedReadCap}> ?c }`,
undefined,
doc,
"fetchReadCap",
);
for (const row of readBindings(res)) {
const cap = bindingValue(row, "c");
// `targetOf` guards the one confusion that would matter: a cap exposed on
// document A must not file a cap for document B. A document only ever speaks
// for itself.
if (cap && hasReadCap(cap) && targetOf(cap) === doc) {
// File the cap that was DOWNLOADED — never a freshly minted one. They agree
// today only because the stand-in value is a constant; with a real key (P1b)
// a second mint would produce a different key and the document would not open.
//
// `learnFromPublicStore`, not `learn`: what the network hands out is a READ
// grant. A public store makes its repos world-readable, never world-writable.
getCaps().learnFromPublicStore(cap);
getCaps().markInPublicStore(doc);
return true;
}
}
} catch (error) {
// Not in a public store, not synced, or no such document — all of them mean the
// same thing to the caller: no cap was obtained.
console.error(accessLogPrefix() + " fetchReadCap failed:", error);
}
return false;
}
/**
* Ask for a SET of documents' caps, in parallel — what a batch read does before it
* decides which documents it may touch. Each fetch is independent and tolerant.
*/
export async function fetchReadCaps(docs: Nuri[]): Promise<void> {
const unique = [...new Set(docs.filter(Boolean))];
if (unique.length === 0) return;
await Promise.all(unique.map((d) => fetchReadCap(d)));
}
+158
View File
@@ -0,0 +1,158 @@
/**
* reach — may the CONNECTED virtual user touch this document at all?
*
* The one predicate every path to `ng` consults, so the boundary is decided in a
* single place instead of being re-argued at each call site.
*
* ── The boundary ──────────────────────────────────────────────────────────
* A virtual user must simulate the boundary of the future single-user wallet:
* every access function is confined to the user currently connected
* (`setCurrentUser`), and no cross-user access is permitted. Otherwise the
* consumer is coded against a reach that will never exist — the same failure mode
* as an ACL where the real model is key possession, one level down.
*
* Two ways a document is legitimately reachable, and no others:
*
* 1. **You hold its cap.** Either because you created it (the store refiles the
* cap) or because someone delivered it to you. This is the whole of the
* access model, so it is the whole of the predicate.
* 2. **It is declared INFRASTRUCTURE.** A short, explicitly-registered list —
* never inferred from the shape of a NURI, because an inferred exemption is
* a hole. See {@link declareInfrastructure}.
*
* ── What may be exempt, and why so little ─────────────────────────────────
* > The only reads/writes not confined to a virtual user are those that make
* > multi-user operation possible at all. Nothing common — only the indexing
* > mechanisms that make the virtual users work.
*
* The test an exemption must pass: *does removing it stop the virtual users from
* functioning, or does it merely stop users from seeing each other's content?*
* Only the first qualifies. The shim passes (remove it and no user is resolvable
* at all); a shared index of user content does not (remove it and every user still
* works — you simply have to be given links).
*
* Depositing into another user's inbox is NOT handled here: it is a write to a
* document you do not hold, and it is legitimate — the only channel by which a
* link crosses from one user to another, hence the bootstrap of the whole
* reachability graph. It is allowed at the inbox surface, which is where the
* asymmetry (deposit yes, read no) is expressed.
*
* At migration this module disappears: the boundary becomes the wallet itself.
*/
import { getCaps } from "../shared-wallet/bootstrap";
import { targetOf } from "../model/nuri";
import type { Nuri } from "../model/types";
/**
* NURIs of the polyfill's own scaffolding, registered as they are resolved.
*
* Explicit registration rather than pattern-matching: the store-root and the
* doc-shim are exempt because they ARE the index of virtual users, not because
* they look a certain way. A NURI is in here because some code path put it here,
* knowing what it was.
*/
const infrastructure = new Set<Nuri>();
/**
* Register `nuri` as scaffolding that the boundary does not apply to. Called by
* the store-registry as it resolves the store-root pointer and the doc-shim —
* the only two documents that qualify, because without them no virtual user can
* be resolved at all.
*
* Deliberately NOT exported from the package: nothing outside the library may
* widen the exemption list.
*/
export function declareInfrastructure(nuri: Nuri): void {
infrastructure.add(nuri);
}
/** Is `nuri` registered scaffolding? */
export function isInfrastructure(nuri: Nuri): boolean {
return infrastructure.has(nuri);
}
/** Forget every declared exemption (tests / a fresh wallet). */
export function resetInfrastructure(): void {
infrastructure.clear();
}
/**
* Do we POSSESS the cap of `nuri`? Not "does this string carry one" — a caller may
* legitimately be holding the bare form and possess the cap elsewhere, which is the
* normal case: NURIs travel bare through content and indexes, while the cap sits in
* what the user holds. Possession is what decides; the shape of the reference the
* caller happens to have in hand decides nothing.
*
* `targetOf` first, so a cap-bearing reference and its bare form answer alike.
*
* Inert until the first cap exists (`caps.isEnforcing()`), so a consumer that never
* touches caps keeps working. Once ANY cap has been issued the boundary applies to
* every user, including one holding nothing: that is the isolation.
*/
export function mayReach(nuri: Nuri): boolean {
const caps = getCaps();
if (!caps.isEnforcing()) return true;
const target = targetOf(nuri);
return isInfrastructure(target) || caps.capFor(target) !== undefined;
}
/**
* **Rule 1 — authorization**, at the PASSAGE POINTS (`docs.*`, `subscribe`).
*
* Nothing reaches `ng` unless the connected user possesses the document's cap. This
* is the guard: it fires on a request that should never have been made, and its job
* is to make sure the attempt fails rather than succeeds quietly.
*
* Deliberately duplicated with rule 2 below — see {@link mustNotAttempt}. Two rules,
* two places, one criterion: a lapse in either is caught by the other.
*/
export function assertMayReach(nuri: Nuri, op: string): void {
if (mayReach(nuri)) return;
throw new Error(
`[ng-eventually] ${op}: refused — the connected user does not hold this document's ` +
"cap. Naming a document does not grant access to it: a cap is looked up in what " +
`you hold, or it was delivered to you. ${JSON.stringify(nuri)}`,
);
}
/**
* Reading is not writing — refuse a write on a document whose cap the holder has ONLY
* because a public store served it.
*
* Upstream a public store makes its repos world-readable and never world-writable: the
* outer overlay hands out the ReadCap (`PublicRepoLinkV0`,
* `engine/net/src/types.rs:5098`), writing needs the write cap, and `verify_permission`
* fires on WRITE only. This emulation's write guard otherwise consults the READ cap
* (write caps are decorative until P1b — `caps.ts` header), so without this the
* public-store fetch would turn every bare reference into a write right.
*
* Narrow on purpose: it closes the case this batch opened, not the pre-existing one —
* a cap RECEIVED in an inbox still passes the write guard here, and upstream would not.
* That conflation is P1b's, and widening this check to cover it would be enforcement
* this batch does not do.
*/
export function assertMayWrite(nuri: Nuri, op: string): void {
if (!getCaps().isReadOnlyPublicCap(targetOf(nuri))) return;
throw new Error(
`[ng-eventually] ${op}: refused — this document is in a public store, which serves ` +
"its READ cap to anyone. Reading it is not writing to it: a write needs the write " +
`cap, and no store hands that out. ${JSON.stringify(nuri)}`,
);
}
/**
* **Rule 2 — do not even attempt**, at the CALLERS (`read-model`, `open-repo`,
* `subscribe`'s callers…).
*
* A reader that does not hold a document's cap must not issue the operation at all.
* Not attempting and being refused are different things: the first is a caller that
* knows what it holds, the second is one that hoped and got caught. Only the first
* is the model — upstream you cannot even address a repo you have no cap for.
*
* Practically it also stops the library from asking the broker for documents it has
* no business asking about, which is work, noise, and a leak of intent.
*/
export function mustNotAttempt(nuri: Nuri): boolean {
return !mayReach(nuri);
}
@@ -0,0 +1,93 @@
/**
* Read filter — the polyfill of capability-based read access.
*
* In the target, the broker only delivers documents the holder has the **ReadCap**
* of, so `useShape` already returns an authorized subset. Here (single shared
* wallet, everything readable) we reproduce that with a read-filtered VIEW over
* the reactive set: it keeps only items whose **document** (its `@graph` = the
* repo it lives in) is in what the current holder holds, per the
* {@link CapRegistry}.
*
* Faithful to NextGraph: the access unit is the DOCUMENT, not the item. In a
* mono-store layout (every item in one repo) the filter is therefore all-or-
* nothing on that document — which is exactly the native behavior, and why
* fine-grained isolation requires one document per entity. Removed at migration.
*
* ── What this filter cannot do, and where that shows ──────────────────────
* It is SYNCHRONOUS and decides from what the holder holds at that instant. A document
* in a PUBLIC store hands its cap to whoever asks (`public-store.ts`), but asking is a
* round-trip — so this view drops such a document until some read path has asked.
* Every path this library owns does ask (`readUnion`, `docs.sparqlQuery`,
* `ensureRepoOpen`, `documentInboxAddress`), which covers `watchShape`; what it does
* not cover is `useShape`, whose signature is the real ORM's and has no await to
* spend. An application reaching a public document through `useShape` alone, having
* read it nowhere first, sees nothing. A polyfill-era limit, removed with the module.
*
* Note there is no `user` parameter anywhere below, and that is the point: reading
* is key possession, so the only question askable is "do I hold this document's
* cap?". "May principal P read document D?" is an ACL question the real model
* cannot answer either. Which holder's caps are consulted follows the identity the
* registry resolves, so the view reflects the holder in effect at read time.
*/
import type { CapRegistry } from "./caps";
import { isNuri } from "../model/nuri";
import type { Nuri } from "../model/types";
/** The document (repo NURI) an item lives in — its `@graph`. The ORM boundary:
* `@graph` is an untyped value on a property bag, so it is narrowed here rather
* than cast. Anything that is not a NextGraph reference names no document. */
function docOf(item: unknown): Nuri | null {
const g = (item as Record<string, unknown> | null)?.["@graph"];
return typeof g === "string" && isNuri(g) ? g : null;
}
/**
* Do I hold this item's document? An item with no `@graph` is KEPT (it names no
* document, so there is no cap to hold). Everything else needs the cap: a bare
* reference names without reading.
*/
function readable(item: unknown, caps: CapRegistry): boolean {
const doc = docOf(item);
if (doc === null) return true;
return caps.capFor(doc) !== undefined;
}
/** Pure: keep only the items whose document the current holder holds. */
export function filterReadable<T>(items: Iterable<T>, caps: CapRegistry): T[] {
const out: T[] = [];
for (const item of items) if (readable(item, caps)) out.push(item);
return out;
}
/**
* A read-filtered VIEW over a reactive set (a `DeepSignalSet`, or any Set-like).
* Iteration / `size` / `forEach` yield only readable items; everything else
* (`add`, `delete`, `has`, `getById`, …) forwards to the target, so writes and
* the underlying reactivity are preserved. What the holder holds is consulted lazily, so the
* view reflects the holder in effect at read time.
*/
export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry): S {
const keep = (item: unknown): boolean => readable(item, caps);
return new Proxy(set, {
get(target, prop, receiver) {
if (prop === Symbol.iterator) {
return function* () {
for (const item of target as Iterable<unknown>) if (keep(item)) yield item;
};
}
if (prop === "size") {
let n = 0;
for (const item of target as Iterable<unknown>) if (keep(item)) n++;
return n;
}
if (prop === "forEach") {
return (cb: (v: unknown, v2: unknown, s: unknown) => void) => {
for (const item of target as Iterable<unknown>) if (keep(item)) cb(item, item, receiver);
};
}
const v = Reflect.get(target, prop, target);
return typeof v === "function" ? v.bind(target) : v;
},
}) as S;
}
+102
View File
@@ -0,0 +1,102 @@
/**
* `@ng-eventually/sdk` — the one door. Everything an application imports, it imports
* from here.
*
* ── What the single entry costs, and how that cost is paid ────────────────
* There were two entries until 2026-08-07 (`.` and `./polyfill`), and the second one
* carried a signal worth naming before removing it: *what you import from that path is
* exactly what you will delete at migration*. One door loses that — nothing at an
* import line now distinguishes `configure`, which goes away, from `docs`, which the
* real SDK replaces in place. Three things carry it instead:
*
* 1. **The `POLYFILL-ERA` block below**, which is the deletion list. It is short by
* construction, and it is meant to keep shrinking.
* 2. **`docs/api-contract.md`**, which rules on every symbol with an epistemic label
* (PASSTHROUGH / LEVEL-1 SHAPE / ASSUMPTION / NO COUNTERPART) and whose export
* inventory is pinned by `test/vocabulary.test.ts` — so it cannot go stale
* quietly, which a hand-kept list would.
* 3. **The names themselves.** Every published name is built from the target's own
* vocabulary or carries a marker saying why it exists only here — pinned by the
* same test. A name that has to disappear says so.
*
* ── What is deliberately NOT published ────────────────────────────────────
* The entry publishes what an application CALLS, and nothing else. Not the machinery
* accessors (`getConfig`, `getStoreRegistryDeps` — internal wiring the surface reaches
* through `shared-wallet/bootstrap`), and not the test resets (`resetConfig`,
* `resetStoreRegistry`, `resetCaps` — the suite reaches them by their internal path,
* which is what they are for). Merging the entries made publishing those a visible
* choice rather than an inherited one; the choice is no.
*
* Earlier removals, each because an application coding against it learns something it
* must unlearn — the one failure this library exists to prevent:
*
* - `getCaps` / `CapRegistry` (2026-08-05) — the emulation's engine room. It has
* neither a successor nor an inert form, so anything built on it must be rewritten.
* - `getCurrentUser` (2026-08-05) — an application knows who it signed in; asking the
* library back is a convenience of the shared wallet, not a brick of the model.
* - `virtualUsers` / `IdentityStore` (2026-08-05) — remembering an identity between
* sessions is the application's job upstream too. The gate persists what IT needs.
* - `hasCap(doc)` (2026-08-06) — it read like "may I read this?", and once a public
* store serves its caps to whoever asks (`emulated-verifier/public-store.ts`) the
* two answers part company: a readable document answers `false` right up until
* something asks. Upstream you open a document and find out.
*/
// ── SDK-SHAPED — a target counterpart for every symbol ──────────────────────
// At migration the build alias is removed and these resolve to the real SDK. The
// per-symbol ruling, with its epistemic label, is in `docs/api-contract.md`.
export * from "./model/types";
export { useShape } from "./surface/use-shape";
export { watchShape } from "./surface/watch-shape";
export type { ShapeQuery, ShapeObservable } from "./surface/watch-shape";
export { init, initNg } from "./surface/lifecycle";
export * as inbox from "./surface/inbox";
export * as docs from "./surface/docs";
export { subscribeDoc, subscribeDocs, docChangeType } from "./surface/subscribe";
export type { DocChange, DocChangeType, Unsubscribe } from "./surface/subscribe";
// `readUnion` is exposed as a function, not under a `readModel` namespace: "model" is
// neither the target's vocabulary nor neutral glue, and the namespace bought nothing —
// it held one published function. Renamed 2026-08-03 by the vocabulary check.
export { readUnion } from "./surface/read-model";
export type { UnionSubject } from "./surface/read-model";
export * as storeRegistry from "./surface/placement";
// SDK type re-exports — so the app imports these from @ng-eventually/sdk too, not from
// @ng-org. `export type` is ERASED at build, so this adds NO runtime @ng-org import to
// the lib (no risk of a duplicate SDK copy in the bundle).
export type { ShapeType, BaseType, Schema } from "@ng-org/shex-orm";
export type { DeepSignalSet } from "@ng-org/alien-deepsignals";
export type { NG } from "@ng-org/web";
// ── POLYFILL-ERA — THE DELETION LIST ────────────────────────────────────────
// Everything below exists because one shared wallet hosts every user, and nothing
// below has a target counterpart. At migration each call goes, and the imports with
// them. Keep this block short: an addition here is a promise to delete it later.
/**
* Inject the real SDK, and tell the library about the shared wallet. Upstream nothing
* is injected — an application imports the SDK and opens its own wallet — so this call
* is the shape of that absence. `docs/api-contract.md` § 1.
*/
export { configure, configureStoreRegistry, setCurrentUser } from "./shared-wallet/bootstrap";
export type { EventuallyConfig, StoreRegistryDeps } from "./shared-wallet/bootstrap";
export type { RegistrySession } from "./shared-wallet/account-registry";
/**
* Await the connection work `setCurrentUser` fires: restore what was shared with this
* user, and drain its inboxes. An application need not call it — the work runs anyway —
* but it may want to know it has finished. Upstream this is the session opening.
*/
export { connectedUser } from "./emulated-verifier/connect";
// ── the access gate — polyfill-era in substance, one line in the app ────────
// One call before the app renders. It shows a technical barrier only while the shared
// wallet needs one; the day the wallet supplies the identity it resolves silently, and
// this line stays as it is (`shared-wallet/access-gate.ts`).
export { ensureIdentity } from "./shared-wallet/access-gate";
export type { SharedWalletConfig } from "./shared-wallet/access-gate";
import { makeNg } from "./surface/ng-proxy";
/** SDK-identical `ng` (wrapped). Drop-in replacement for `@ng-org/web`'s `ng`. */
export const ng: Record<string, any> = makeNg();
+115
View File
@@ -0,0 +1,115 @@
/**
* NURI primitives — the cap-less / cap-bearing distinction, kept as ONE object.
*
* Upstream a NURI is a single type, `NuriV0 { target, access }`: a cap-less NURI
* simply has an empty `access`. `did:ng:` is the URI SCHEME prefix (inboxes,
* branches and overlays all carry it) — it does NOT mean "without cap". The
* discriminant is the `:r:` segment:
*
* did:ng:o:{doc}:v:{overlay} — names, does NOT read (a {@link Nuri})
* did:ng:o:{doc}:v:{overlay}:r:{cap} — names AND reads (a {@link ReadCap})
*
* ── Why `:r:` and not `:k:` ────────────────────────────────────────────────
* Reported by NextGraph's developer and verified in the source: a **ReadCap** is
* `r:{base64url(serde_bare(ObjectRef))}` — `BlockRef::readcap_nuri()`,
* `engine/repo/src/types.rs:518-521` — where id AND key are serialized together
* into ONE opaque segment. The `:k:` forms are a different thing: they belong to
* **objects, files and commits** (`j:{id}:k:{key}`, `c:{id}:k:{key}`, `:510`/`:514`),
* where id and key are two separate segments. This library used `:k:` until
* 2026-07-30; it was the wrong letter *and* the wrong structure.
*
* These helpers are INTERNAL to the library. The parsed form {@link parseNuri}
* mirrors `NuriV0 { target, access }` 1:1 but never surfaces in the SDK-identical
* entry's signatures — the real SDK takes plain `String`s and enforces at runtime,
* through cryptography, so no branded type and no parsed struct leaks outward.
*
* ── The stand-in key (deliberately NOT a secret) ───────────────────────────
* This library is deliberately insecure (see docs/vision.md). The only question it
* can answer is **do I hold this document's cap, or not** — so the key value is the
* constant `OK`, which says exactly that and pretends nothing more. What identifies
* the document is the NURI the key is attached to; the value carries no information.
* Real per-document encryption is P1b's job, and it replaces this one constant.
* Until then, possession is a SHAPE, not a protection.
*/
import type { Nuri, ReadCap } from "./types";
/** The URI scheme prefix every NextGraph reference carries. */
const SCHEME = "did:ng:";
/** The segment that turns a naming NURI into a reading one — upstream's ReadCap
* encoding (`readcap_nuri`), NOT the `:k:` used for objects/files/commits. */
/**
* The ReadCap discriminant. Exported because the emulated verifier mints with it
* (`emulated-verifier/caps.ts`); the model owns the grammar, minting is not part of it.
*/
export const CAP_SEGMENT = ":r:";
/**
* Is this string a NextGraph reference at all? A **type guard**: it is the door
* through which an untrusted `string` — a SPARQL binding, an ORM `@graph`, a value
* an app read back from storage or a URL — becomes a {@link Nuri}. Exported from
* the SDK entry so a consumer narrows its own strings the same way, rather than
* casting.
*/
export function isNuri(s: string): s is Nuri {
return s.startsWith(SCHEME);
}
/**
* Does this reference carry a read cap (a `:r:` segment)? A **type guard**: the
* ONLY narrowing from a bare string (or a {@link Nuri}) to a {@link ReadCap}.
* Nothing else may produce a `ReadCap` from a reference that carries no key —
* that would be deriving a cap from a bare reference, which the model forbids.
*/
export function hasReadCap(s: string): s is ReadCap {
return isNuri(s) && s.includes(CAP_SEGMENT);
}
/**
* The cap-less form of a reference — what it NAMES, with any cap stripped.
*
* The one internal cast of this module, and it is load-bearing: `slice` returns
* `string`, yet slicing a `did:ng:…` at the `:r:` boundary can only yield a
* `did:ng:…` — which the compiler cannot know. Keeping the cast HERE, in the
* primitive that defines the contract, is what lets every caller stay typed with
* no cast of its own.
*/
export function targetOf(nuri: Nuri): Nuri {
const i = nuri.indexOf(CAP_SEGMENT);
return i === -1 ? nuri : (nuri.slice(0, i) as Nuri);
}
/**
* The parsed form — a 1:1 mirror of upstream `NuriV0 { target, access }`, where a
* cap-less NURI has no `readCap`. Library-internal (see the module header).
*/
export function parseNuri(nuri: Nuri): { target: Nuri; readCap?: ReadCap } {
return hasReadCap(nuri) ? { target: targetOf(nuri), readCap: nuri } : { target: nuri };
}
/**
* The one door a caller's string comes through — validated, then typed.
*
* Public entry points take {@link NuriLike} so a consumer never has to narrow what it
* read from a URL, from storage or from JSON: the SDK will take a plain string too
* (`doc_subscribe(repo_o: String)`, `sdk/js/lib-wasm/src/lib.rs:1908`), so demanding a
* refined type here would manufacture a step to unlearn — and would force this library
* to publish a type guard the SDK will never have.
*
* This is where that permissive edge is paid for: once, at the boundary. Past it the
* whole library works on `Nuri`.
*
* Throws rather than returning `undefined`: a reference that is not one is a caller
* mistake, and swallowing it would produce an empty read with no explanation — the
* failure mode this library keeps paying for elsewhere.
*/
export function toNuri(s: string, op: string): Nuri {
if (!isNuri(s)) {
throw new Error(
`[ng-eventually] ${op}: not a NextGraph reference — expected a "did:ng:…" string, ` +
`got ${JSON.stringify(s)}`,
);
}
return s;
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Generic, NextGraph-shaped types. ZERO application domain.
*/
/**
* A NextGraph URI (document / store / inbox) in its **cap-less** form — it NAMES
* and locates, it does not grant the right to read: `did:ng:o:{doc}:v:{overlay}`.
* `did:ng:` is the URI scheme prefix, not a "without cap" marker; the discriminant
* is the `:r:` segment (see {@link ReadCap}).
*/
export type Nuri = `did:ng:${string}`;
/**
* A NextGraph URI that carries the document's read cap — `…:r:{cap}`. It NAMES
* *and* READS: reading is key possession, never an authorization list. This is the
* upstream name (`ReadCap`).
*
* ── Why a template literal type, and not a branded one ────────────────────
* Both this and {@link Nuri} are **still strings** — assignable to `string`,
* JSON-serializable, no wrapper object — so nothing has to be *un*-typed when the
* real SDK arrives and takes `nuri: String`. What the template buys is the one
* direction that matters: a `ReadCap` is freely usable wherever a `Nuri` is
* expected (a cap IS a NURI with the key inside — upstream's single `NuriV0`),
* while a bare `Nuri` passed where a `ReadCap` is required is a **compile error**.
* That confusion, left to runtime, silently turns "naming is not reading" into
* "naming is reading" — the exact inversion this model exists to remove.
*
* A consumer holding a plain `string` (from storage, a URL, JSON, a form) does NOT
* have to narrow it: every public entry takes {@link NuriLike} and validates at the
* door (`toNuri`), which is why no type guard is exported. Permissive in, precise
* out. The runtime checks stay regardless — a JavaScript consumer never meets the
* compiler, and a cast bypasses it.
*/
export type ReadCap = `did:ng:${string}:r:${string}`;
/** NextGraph-native store scopes. The *mapping* of entities to scopes is the
* consumer's concern; this layer only knows the three scopes exist. */
export type Scope = "public" | "protected" | "private";
/** The current identity id. Target: the wallet user (`session.user`). Polyfill:
* a chosen id, because everyone shares one wallet. */
export type PrincipalId = string;
/**
* Loose shape of the real `@ng-org/web` `ng` object that we wrap. Injected by
* the consumer at {@link configure} — we never hard-import the SDK, which keeps
* the build-alias safe (the app's `@ng-org/web` import can resolve to us) and
* makes the wrapper testable with a fake. Permissive on purpose: the real `ng`
* carries non-function members too, so we accept any property bag.
*/
export type NgLike = Record<string, any>;
/** Loose shape of `@ng-org/orm`'s `useShape` (a generic hook). */
export type UseShapeLike = (...args: any[]) => any;
/**
* The scopes that can carry an inbox. NOT `Scope`: upstream only the public and
* protected store repos get one — `new_store_default` attaches an inbox solely
* `if !private` (`engine/verifier/src/verifier.rs:2994`), and the engine's only two
* `AddInboxCap` commits are for those two (`engine/verifier/src/site.rs:127-152`).
*
* Typing it out means "the private inbox" cannot be written, rather than being written
* and returning nothing.
*/
/**
* A reference as a CALLER may hand it over: any string.
*
* The library returns precise `Nuri`s and accepts loose ones, and that asymmetry is not
* politeness — it is what keeps a consumer from writing something to unlearn. The wasm
* binding takes `nuri: String` (`doc_subscribe(repo_o: String)`,
* `sdk/js/lib-wasm/src/lib.rs:1908`), so the real SDK will accept a plain string too.
* Demanding a `Nuri` here would force every caller to narrow whatever it read from a URL
* or from storage — and therefore force this library to publish a type guard the SDK
* will never have. The need would be manufactured by our own signature.
*
* So: precise on the way out, permissive on the way in, and validated inside
* (`assertNuri`). The guards remain, internal, where the validation happens.
*/
export type NuriLike = Nuri | string;
export type InboxScope = Extract<Scope, "public" | "protected">;
@@ -0,0 +1,252 @@
/**
* The access gate — the whole shared-wallet sign-in, moved out of consumer applications.
*
* ── Why this lives in the library ─────────────────────────────────────────
* Every step below exists ONLY because one wallet hosts several identities. An
* application that implements them is writing code it will have to delete, and worse,
* code that teaches its authors a model NextGraph does not have: *"I name my identity"*.
* The first consumer had ~300 lines of it (a gate component, a screen, a wallet module,
* an identity context, three BDD features). That is the library's work, not theirs.
*
* Upstream, none of this exists. A user opens THEIR wallet, it contains THEIR site
* (`SensitiveWalletV0.personal_identity()`, `engine/wallet/src/types.rs:576-579`), and
* `session_start(wallet_name, user_id)` takes an id that came FROM the wallet. There is
* nothing to name and nothing to choose. So this module is pure scaffolding: it
* evaporates whole, and the one call it exposes becomes a plain "open the session".
*
* ── The three steps, and why each is here ─────────────────────────────────
* 1. **Hand over the wallet file.** A hosted broker cannot import a wallet inline during
* web-app auth — a first-time device has no wallet, so the redirect dead-ends. So the
* user downloads the `.ngw` and imports it once on the wallet app. The FILE is the
* right primitive: a TextCode is a transient 5-minute device-to-device transfer,
* unusable to embed.
* 2. **Show the shared password**, for that import.
* 3. **Take an identifier**, which names the virtual space. This is the step that
* inverts the model, and the reason the whole gate is scaffolding.
*
* ── The identifier crosses a storage boundary, and that is not incidental ──
* The flow runs in TWO contexts with SEPARATE localStorage partitions: the top-level
* page and the broker iframe (browsers partition storage by top-level site). A value
* written top-level is NOT the value the iframe reads. What DOES cross is the URL: the
* redirect embeds the full app URL, query included, and reloads it in the iframe. Hence
* the resolution order, which must not be "simplified":
*
* 1. `?ng-id=` in the URL — wins whenever present, because it is the only thing that
* crosses the frontier;
* 2. otherwise localStorage — same-partition convenience, and prefill on reload.
*
* Getting this wrong does not fail loudly: the iframe reads an empty identity, provisions
* a second virtual user, and the returning user silently lands in an empty space.
*/
import {
getConfig,
getCurrentUser,
getStoreRegistryDeps,
setCurrentUser,
} from "./bootstrap";
/**
* Normalize an identifier the SAME way the shim keys accounts on.
*
* Not a detail: the identifier arrives from three places — typed at the gate, read from
* the URL after the broker round-trip, read from storage — and if any of them normalizes
* differently, that path keys onto a DIFFERENT virtual user. `@Erin` from the URL and
* `erin` typed at the gate must be one space, not two. So there is one normalizer, the
* injected one, and the gate borrows it rather than keeping its own `toLowerCase()`.
*
* Falls back to the library's own default when the registry is not configured yet, which
* is possible since the gate can run before anything else.
*/
function normalizeIdentity(raw: string): string {
try {
return getStoreRegistryDeps().normalizeId(raw);
} catch {
return raw.trim().replace(/^@/, "").toLowerCase();
}
}
/** Where the gate stashes the identifier so a plain reload prefills it. */
const STORAGE_KEY = "ng-eventually:identity";
/** The URL parameter — the only channel that survives the broker round-trip. */
const URL_PARAM = "ng-id";
/**
* What a deployment must supply for the gate to run. These are not settings a user
* tunes: they are the shared wallet this deployment hands out, so they belong to
* whoever deploys, and they disappear with the gate.
*
* **The library reads no environment variable, ever.** The application resolves these at
* its own build — copying the `.ngw` into its bundle, injecting the password — and
* passes the VALUES here. A library that read `process.env` would impose its build
* system on every consumer, and would be untestable with other values.
*/
export interface SharedWalletConfig {
/** URL of the `.ngw` file served by the application's own bundle. */
fileUrl: string;
/** The shared password, shown for the one-time import. Zero-security by design. */
password: string;
/** The wallet app where the import happens. Defaults to the public one. */
importUrl?: string;
}
const DEFAULT_IMPORT_URL = "https://nextgraph.eu/#/wallet/login";
/** The identifier this device already used, from the URL first, then storage. */
function storedIdentity(): string | null {
try {
const fromUrl = new URLSearchParams(globalThis.location?.search ?? "").get(URL_PARAM);
if (fromUrl && fromUrl.trim()) {
// Normalized on the way IN: the URL carries whatever a user or a link put there
// (`@Erin`), and an un-normalized value keys onto a different virtual user than the
// same identifier typed at the gate.
const normalized = normalizeIdentity(fromUrl);
globalThis.localStorage?.setItem(STORAGE_KEY, normalized);
return normalized;
}
const stored = globalThis.localStorage?.getItem(STORAGE_KEY);
return stored ? normalizeIdentity(stored) : null;
} catch {
return null; // storage blocked (private mode, sandboxed iframe) — the gate asks again
}
}
/** Put the identifier where the round-trip can find it, then remember it locally. */
function rememberIdentity(id: string): void {
try {
globalThis.localStorage?.setItem(STORAGE_KEY, id);
const url = new URL(globalThis.location!.href);
url.searchParams.set(URL_PARAM, id);
globalThis.history?.replaceState(null, "", url.toString());
} catch {
// Nothing to do: without the param the round-trip loses the identity and the gate
// will ask again, which is the safe failure.
}
}
/**
* Show the gate and resolve with the identifier the user entered.
*
* No prefill parameter, deliberately: the gate is shown ONLY when no identity is known,
* so there is never a value to prefill. The consumer this was moved from did prefill,
* because its screen reappeared after the broker round-trip — here the URL carries the
* identity across that round-trip, so a returning user does not see the barrier at all.
* The need is met one level up rather than papered over in the form.
*
* Deliberately plain DOM: this is a technical barrier shown before an application
* renders, like a password prompt on a closed beta. Binding it to a UI framework would
* make every consumer adopt that framework for a screen that is going away.
*/
function askForIdentity(cfg: SharedWalletConfig): Promise<string> {
const importUrl = cfg.importUrl ?? DEFAULT_IMPORT_URL;
return new Promise((resolve) => {
const host = document.createElement("div");
host.setAttribute("data-ng-eventually", "access-gate");
// A shadow root so the application's stylesheet cannot reshape the barrier, and the
// barrier's cannot leak into the application.
const root = host.attachShadow({ mode: "open" });
root.innerHTML = `
<style>
:host { all: initial; }
.veil { position: fixed; inset: 0; z-index: 2147483647; display: flex;
align-items: center; justify-content: center; background: #fff;
font: 15px/1.5 system-ui, sans-serif; color: #222; padding: 24px; }
.card { width: 100%; max-width: 420px; }
h1 { font-size: 26px; margin: 0 0 2px; text-align: center; }
.sub { text-align: center; color: #888; margin: 0 0 22px; }
.step { display: flex; gap: 12px; margin-bottom: 16px; }
.n { flex: 0 0 24px; height: 24px; border-radius: 50%; background: #444; color: #fff;
display: flex; align-items: center; justify-content: center; font-size: 13px; }
.t { font-weight: 600; font-size: 14px; margin: 1px 0 6px; }
a, button, input { font: inherit; }
a { color: #0b5ed7; }
code { background: #f2f2f2; padding: 2px 6px; border-radius: 4px; user-select: all; }
input { width: 100%; padding: 9px 10px; border: 1px solid #bbb; border-radius: 6px; box-sizing: border-box; }
button.go { width: 100%; margin-top: 10px; padding: 10px; border: 0; border-radius: 6px;
background: #222; color: #fff; cursor: pointer; }
button.go[disabled] { opacity: .45; cursor: default; }
.hint { color: #999; font-size: 12px; margin: 6px 0 0; }
</style>
<div class="veil"><div class="card">
<h1>Accès</h1>
<p class="sub">Environnement de test</p>
<div class="step"><div class="n">1</div><div>
<div class="t">Télécharger le portefeuille</div>
<a href="${cfg.fileUrl}" download>Télécharger le fichier</a>
</div></div>
<div class="step"><div class="n">2</div><div>
<div class="t">Mot de passe</div>
<code>${cfg.password}</code>
</div></div>
<div class="step"><div class="n">3</div><div>
<div class="t">Importer une fois</div>
<a href="${importUrl}" target="_blank" rel="noreferrer">Ouvrir l'application portefeuille</a>
</div></div>
<div class="step"><div class="n">4</div><div>
<div class="t">Votre identifiant</div>
<input data-testid="ng-identity-input" placeholder="votre identifiant" />
<p class="hint">Il identifie votre espace (mis en minuscules).</p>
<button class="go" data-testid="ng-identity-enter" disabled>Entrer</button>
</div></div>
</div></div>`;
const input = root.querySelector("input") as HTMLInputElement;
const go = root.querySelector("button.go") as HTMLButtonElement;
const sync = (): void => { go.disabled = input.value.trim().length === 0; };
const enter = (): void => {
const value = input.value.trim();
if (!value) return;
host.remove();
resolve(value);
};
input.addEventListener("input", sync);
input.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Enter") enter(); });
go.addEventListener("click", enter);
sync();
document.body.appendChild(host);
input.focus();
});
}
/**
* Ensure an identity is set for this session, showing the gate only if one is missing.
*
* The application calls this once, before it renders. It does NOT pass an identifier:
* naming one is the step that will disappear, so it must not appear in the signature —
* the day the wallet supplies the identity, this resolves without showing anything and
* the caller's code is unchanged.
*
* A returning user never sees the gate: the identifier survives the broker round-trip in
* the URL, and a plain reload finds it in storage.
*/
export async function ensureIdentity(): Promise<void> {
if (getCurrentUser() !== null) return;
const known = storedIdentity();
if (known) {
setCurrentUser(known);
return;
}
const cfg = getConfig().sharedWallet;
if (!cfg) {
// Not a misconfiguration to paper over: without a shared wallet there is nothing to
// hand the user, and silently continuing would provision an anonymous space.
throw new Error(
"[ng-eventually] access gate: no shared wallet configured. Pass `sharedWallet` to " +
"`configure()` — the wallet file URL and its password — or set the identity yourself.",
);
}
if (typeof document === "undefined") {
throw new Error(
"[ng-eventually] access gate: no identity set and no DOM to ask on (server-side or " +
"test context). Set one explicitly before calling.",
);
}
const chosen = await askForIdentity(cfg);
const normalized = normalizeIdentity(chosen);
rememberIdentity(normalized);
setCurrentUser(normalized);
}
@@ -0,0 +1,126 @@
/**
* access-log — an OFF-by-default observability probe for document access.
*
* Diagnostic tool for the shared-wallet isolation footgun: on ONE physical
* wallet, several virtual identities coexist, and a read must never surface a
* document scoped to another identity. When it does (identity B reading identity
* A's doc), the leak is invisible in the data — it looks like a normal read. This
* probe makes it VISIBLE: every real read/write is logged, prefixed by the ACTIVE
* identity (the discriminating virtual identity, NOT the constant physical user
* id), so replaying the scenario shows the exact line where a doc is accessed
* under the wrong identity.
*
* OFF by default → zero overhead, zero output. Turned on either by the SDK config
* option `debugAccessLog: true` (via {@link setAccessLog}) or, without touching
* the calling code, by the env var `NG_EVENTUALLY_ACCESS_LOG=1`. The `enabled()`
* gate is a single boolean read on the hot path when off.
*
* Polyfill-era, like the rest of /polyfill; removed at the real multi-store
* migration where the broker/verifier enforces isolation natively.
*/
import { getCurrentUser } from "./bootstrap";
/** Access kind: a document READ or a document WRITE. */
export type AccessOp = "READ" | "WRITE";
// Config-driven toggle (set by configure() via setAccessLog); default OFF.
let configEnabled = false;
/**
* Env override: `NG_EVENTUALLY_ACCESS_LOG=1` (or `true`) turns the log on without
* a code change in the caller. Read once, tolerant of env access throwing (e.g.
* a locked-down runtime), so it never breaks the hot path.
*/
function envEnabled(): boolean {
try {
const v = (globalThis as any)?.process?.env?.NG_EVENTUALLY_ACCESS_LOG;
return v === "1" || v === "true";
} catch {
return false;
}
}
/** Set the config-driven toggle (called from configure()). */
export function setAccessLog(on: boolean): void {
configEnabled = on;
}
/** Whether access logging is currently on (config OR env). */
export function enabled(): boolean {
return configEnabled || envEnabled();
}
/**
* The identity to prefix an access line with: the ACTIVE virtual identity
* (`getCurrentUser`) — the account/space the operation is scoped under, which is
* the discriminating signal for the isolation leak. NOT the physical user id
* (shared, constant → useless). `(none)` when no identity is set yet (startup).
* Exported so every other polyfill-layer log site (store-registry, inbox,
* outbox-log, …) shares the exact same identity resolution as the access log,
* instead of re-deriving it — see {@link accessLogPrefix}.
*/
export function activeIdentity(): string {
return getCurrentUser() ?? "(none)";
}
/**
* The common line prefix for every polyfill-layer low-level-data-path log:
* `[<identity>][polyfill]` — identity FIRST (the discriminating scan signal),
* `[polyfill]` glued right after with no space between the two brackets. Used by
* {@link logAccess} itself, by the unified `console.error`s in store-registry.ts /
* inbox.ts, and by the BARRIER / stage-resolution / OUTBOX lines (open-repo.ts,
* store-registry.ts, outbox-log.ts) — one single prefix builder so every polyfill
* log line is visually groupable by identity when scanning a live session.
*/
export function accessLogPrefix(): string {
return "[" + activeIdentity() + "][polyfill]";
}
/**
* Emit one CONCISE diagnostic line — ONLY when {@link enabled} — prefixed by
* {@link accessLogPrefix}. Used for the precise data-path trace (BARRIER
* resolution, per-stage resolution outcome, the empty-OUTBOX line): a single
* line per stage/event, never a dump. Callers pass the fully-composed suffix
* (e.g. `"BARRIER " + shortNuri(nuri) + " synced (842ms)"`).
*/
export function logStage(line: string): void {
if (!enabled()) return;
console.log(accessLogPrefix() + " " + line);
}
/**
* Shorten a NURI for the access log: the full form (`did:ng:o:<RepoID>:v:<...>`,
* ~100 chars) is too verbose to scan. Drop the `did:ng:o:` prefix and the `:v:<...>`
* overlay suffix, and keep the first 8 chars of the RepoID + an ellipsis — short but
* still identifiable (`vDlwbZio…`). A NURI that doesn't match the expected shape is
* returned unchanged (best-effort, this is only a diagnostic label).
*/
export function shortNuri(nuri: string): string {
const withoutPrefix = nuri.replace(/^did:ng:o:/, "");
const repoId = withoutPrefix.split(":v:")[0] ?? withoutPrefix;
return repoId.length > 8 ? repoId.slice(0, 8) + "…" : repoId;
}
/**
* Log one document access — but ONLY when {@link enabled}. Off → returns
* immediately, prints nothing. Format:
* `[<identity>][polyfill] READ <shortNuri> (<label>)` — identity FIRST (the
* discriminating scan signal), `[polyfill]` glued right after with no space
* between the two brackets — optionally with `<extra>` appended (e.g. ` → 3
* rows`, a strong signal a doc rendered data under an identity that should see
* nothing). The `[polyfill]` tag marks these as SDK-layer access logs (distinct
* from the consumer app's own logs). The NURI is shortened by {@link shortNuri}
* to keep the line scannable.
*/
export function logAccess(
op: AccessOp,
nuri: string,
label: string,
extra?: string,
): void {
if (!enabled()) return;
console.log(
accessLogPrefix() + " " + op + " " + shortNuri(nuri) + " (" + label + ")" + (extra ?? ""),
);
}
File diff suppressed because it is too large Load Diff
+244
View File
@@ -0,0 +1,244 @@
/**
* The injection store — where the consumer application plugs the real SDK in, and
* where the emulation keeps the state that only exists because one wallet hosts every
* identity: the injected `ng`/`useShape`, the registry dependencies, WHO is currently
* connected, and the `CapRegistry` singleton keyed by that holder.
*
* **NO COUNTERPART at any layer, by construction.** Upstream nothing is injected: the
* app imports the SDK, and "who am I" is the session — there is no current-user relay
* because a wallet has exactly one user. This module is the shape of that absence, so
* it belongs with the shared-wallet machinery and evaporates whole at migration.
*
* Extracted from `polyfill.ts` on 2026-08-03. Before that, every internal module
* imported the published ENTRY to reach the config, which made the entry a dependency
* of the code it publishes — cycles `polyfill` <-> `connect` and `polyfill` <-> `inbox`.
* The entry now only re-exports; the internals import this module instead.
*/
import type { NgLike, UseShapeLike, Nuri, NuriLike, PrincipalId, ReadCap } from "../model/types";
import type { SharedWalletConfig } from "./access-gate";
import { toNuri } from "../model/nuri";
import type { RegistrySession } from "./account-registry";
import { CapRegistry } from "../emulated-verifier/caps";
import { resetPublicStoreFetches } from "../emulated-verifier/public-store";
import { setAccessLog } from "./access-log";
import { inspectOutbox } from "./outbox-log";
import { startConnect } from "../emulated-verifier/connect";
/**
* Consumer-injected dependencies of the storeRegistry (polyfill-era). The
* registry itself is generic (it knows only native scopes); the consumer wires
* up how to reach the shared-wallet session and how to normalize an identity id
* used as the shim key. Removed at migration along with the whole shim.
*/
export interface StoreRegistryDeps {
/** Resolve the current shared-wallet session (id + private-store anchor). */
getSession: () => Promise<RegistrySession>;
/** Normalize an identity id for shim keying. Default: trim (identity-ish). */
normalizeId?: (id: string) => string;
/**
* POINTER micro-guard budget. The account records now live in a subscribable
* doc-shim (`did:ng:o:...`) reached through a well-known write-once POINTER triple
* in the store-root graph. The doc-shim read is barrier-AUTHORITATIVE, so accounts
* need NO retry (this replaces the deleted account-level `provisionRetry`). The
* ONLY residual sync-lag window is the store-root pointer read itself — one
* write-once triple. This bounded guard re-reads JUST that pointer a few times if a
* fresh cold read misses it; it can never provision or fork an account (worst case:
* a couple extra reads before an existing pointer is seen). Enable it where the REAL
* broker is used (app + e2e). Left UNSET (the default) → `attempts: 1` = single
* read, keeping the synchronous unit fakes fast and unchanged.
*/
pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number };
}
export interface EventuallyConfig {
/**
* The shared wallet this deployment hands out, and what the access gate needs to do
* it (`shared-wallet/access-gate.ts`). Absent → no gate; the caller sets the identity
* itself. Disappears with the gate: upstream a user opens their own wallet.
*/
sharedWallet?: SharedWalletConfig;
/** The REAL `@ng-org/web` `ng` (injected to avoid a hard import / alias loop). */
ng: NgLike;
/** The REAL `@ng-org/orm` `useShape`. */
useShape: UseShapeLike;
/** Initial current user; may also be set later via {@link setCurrentUser}. */
currentUser?: PrincipalId;
/**
* Turn on the OFF-by-default document access log (see {@link ./access-log}):
* every real read/write is printed, prefixed by the active identity, to
* diagnose the shared-wallet isolation leak. Also enablable without a code
* change via the env var `NG_EVENTUALLY_ACCESS_LOG=1`. Default: false.
*/
debugAccessLog?: boolean;
/** REAL `@ng-org/web` `init` (lifecycle) — forwarded by the lib's `init()`. */
init?: (...args: any[]) => any;
/** REAL `@ng-org/orm` `initNg` (ORM signals) — forwarded by the lib's `initNg()`. */
initNg?: (...args: any[]) => any;
}
let cfg: EventuallyConfig | null = null;
let currentUser: PrincipalId | null = null;
/** Required fields of StoreRegistryDeps after defaults are applied. `pointerGuard`
* defaults to `{ attempts: 1 }` (single read) when the consumer leaves it unset. */
type ResolvedRegistryDeps = Required<
Pick<StoreRegistryDeps, "getSession" | "normalizeId" | "pointerGuard">
>;
let registryDeps: ResolvedRegistryDeps | null = null;
/**
* The map key of the current identity — deliberately NOT the raw id.
*
* A virtual user IS a shim account, and the shim keys accounts by the
* consumer-injected `normalizeId` ("@Alice" and "alice" are ONE account, with one
* set of scope documents). This record must key the same way, or a consumer that
* spells its own id differently between two calls gets a SECOND record and stops
* reading its own documents — the caps are filed under one spelling and looked up
* under the other. Falls back to the raw id while the registry deps are not yet
* configured (nothing can be filed before that anyway).
*/
function capsHolder(): PrincipalId | null {
if (currentUser === null) return null;
return registryDeps ? registryDeps.normalizeId(currentUser) : currentUser;
}
/**
* The emulated cap registry — one record PER identity (per virtual user),
* resolved through {@link capsHolder} on every call. So switching identity
* SWITCHES heldByHolder (nothing to reset, nothing wiped); see `caps.ts`. Empty until
* the first cap is issued, and while it is empty the read filter passes through
* (no regression).
*/
let caps = new CapRegistry(capsHolder);
export function configure(c: EventuallyConfig): void {
cfg = c;
currentUser = c.currentUser ?? null;
setAccessLog(c.debugAccessLog ?? false);
}
/** @internal — used by the SDK-shaped wrappers to reach the injected real SDK. */
export function getConfig(): EventuallyConfig {
if (!cfg) throw new Error("[ng-eventually] configure() must be called before use");
return cfg;
}
/** Reset the injected config back to un-configured (mainly for tests, so a
* suite that calls configure() can restore the not-configured guard state). */
export function resetConfig(): void {
cfg = null;
currentUser = null;
}
/**
* Wire the storeRegistry's consumer-injected dependencies (session + identity-id
* normalization). Must be called before any storeRegistry.* use. Separate from
* {@link configure} because it's storeRegistry-specific and, like the shim,
* disappears at migration.
*/
export function configureStoreRegistry(deps: StoreRegistryDeps): void {
// Fire the outbox inspection (Volet 3 of the low-level data-path trace) once,
// on the FIRST successful `getSession()` resolution — the most reliable
// "a session is established" signal available: every low-level reader/writer
// (store-registry, open-repo, read-model, subscribe, inbox) reaches its
// session through this SAME injected `getSession`, so wrapping it HERE catches
// the first success from whichever caller happens to run first, instead of
// tying the probe to one particular call site. Only on SUCCESS (an error
// propagates untouched, exactly as before) and only ONCE per
// `configureStoreRegistry()` call (a fresh session config → a fresh check).
let outboxInspected = false;
const getSession = async (): Promise<RegistrySession> => {
const session = await deps.getSession();
if (!outboxInspected) {
outboxInspected = true;
inspectOutbox();
}
return session;
};
registryDeps = {
getSession,
normalizeId: deps.normalizeId ?? ((id: string) => id.trim()),
// Default: single read (no re-read). Only the real-broker consumers (app + e2e)
// opt into the bounded pointer micro-guard; unit fakes stay synchronous.
pointerGuard: deps.pointerGuard ?? { attempts: 1 },
};
}
/** @internal — used by the storeRegistry to reach its injected dependencies. */
export function getStoreRegistryDeps(): ResolvedRegistryDeps {
if (!registryDeps) {
throw new Error("[ng-eventually] configureStoreRegistry() must be called before use");
}
return registryDeps;
}
/** Reset storeRegistry deps (mainly for tests). */
export function resetStoreRegistry(): void {
registryDeps = null;
}
/**
* 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 {
const changed = currentUser !== id;
currentUser = id;
// Connecting a user is what triggers inbox processing — the library's job, not
// the app's. Fire-and-forget: this setter is synchronous and every consumer calls
// it from synchronous code, so the work announces itself through the cap
// registry's change signal instead of making callers await. See `connect.ts`.
//
// Gated on the registry being configured, and that is not a test convenience: an
// identity set before the session resolves has nothing to restore and no inbox to
// reach, so firing would be I/O that can only fail. The consumer's real sequence
// is `configureStoreRegistry` then `setCurrentUser`; anything else can call
// `connectedUser()` explicitly.
if (changed && id !== null && registryDeps !== null) startConnect();
}
export function getCurrentUser(): PrincipalId | null {
return currentUser;
}
/** The emulated cap registry — what the current identity holds, plus the emulated
* public store. The read filter and the read-model consult it. */
export function getCaps(): CapRegistry {
return caps;
}
/**
* Do I hold this document's key?
*
* The only question the model admits. There is no "may principal P read D" anywhere
* upstream and there cannot be: reading IS key possession, so a cap-introspection API
* would have to invent an ACL the engine does not have (`docs/api-contract.md` § 10).
*
* Returns a BOOLEAN, not the cap. It used to hand the value back, and the only consumer
* that used it did so to pass it to `share` — which now takes the document instead.
* Nothing an application does requires holding a key: upstream it never sees one, the
* verifier fills `ContactDetails.read_cap` itself. So the surface answers the question
* and keeps the key.
*/
export function hasCap(nuri: NuriLike): boolean {
return caps.capFor(toNuri(nuri, "hasCap")) !== undefined;
}
/**
* Drop EVERY holder's caps (tests / a fresh wallet). This is **not** what an identity
* change does: switching identity switches heldByHolder, it never wipes one — if it
* wiped, durability would be a lie and per-session re-declaration would come back
* under another name. Nothing in the library calls this on `setCurrentUser`.
*/
export function resetCaps(): void {
// Clear IN PLACE rather than rebuilding: whoever subscribed to the registry's
// change signal (`watchShape`) stays subscribed to the live instance instead of
// silently holding a listener on an orphaned one.
caps.clear();
// …and forget which documents were already asked about, or the emulated public-store
// fetch would answer from a memo taken before the wipe and hand back caps this
// registry no longer holds.
resetPublicStoreFetches();
}
@@ -0,0 +1,98 @@
/**
* outbox-log — read-only diagnostic inspection of `@ng-org/web`'s offline write
* outbox, at session bootstrap (polyfill-era, low-level-data-path trace).
*
* ── What this surfaces ──────────────────────────────────────────────────────
* `@ng-org/web` (the real injected SDK) queues writes made while offline/
* disconnected in an "outbox", persisted client-side in `sessionStorage` by the
* WASM verifier (see `sdk/rust/src/local_broker.rs` `JsStorageConfig::
* get_js_storage_config` in the `nextgraph-rs` core repo — read-only reference,
* NOT vendored here). A non-empty outbox at session start is an ANOMALY worth
* surfacing unconditionally: it means writes from a previous (disconnected)
* session are still queued and haven't reached the broker yet.
*
* ── sessionStorage key shapes (verified in the core repo, not guessed) ──────
* The outbox is keyed per LOCAL PEER id (`peer_id`, the persistent local peer's
* pubkey — NOT the ng-eventually shim's `account`/`identity` concept), via two
* key families written by `session_write`/read by `session_read`:
* - `ng_peer_last_seq@<peerId>` — the peer's last reserved seq number.
* - `ng_outboxes@<peerId>@start` — the seq number the outbox starts at.
* - `ng_outboxes@<peerId>@<00000-idx>` — one queued (base64url + BARE-encoded)
* event per zero-padded index, contiguous from 0 until the first miss (the
* exact shape `outbox_read_function` walks — see `local_broker.rs`).
* We don't know `peerId` ahead of time (it's internal to the injected SDK), so
* we DISCOVER it by scanning `sessionStorage` for `@start` markers instead of
* requiring it to be passed in — this also means the probe works unchanged
* however many peers/wallets the browser session has touched.
*
* ── Read-only, defensive, best-effort ────────────────────────────────────────
* This NEVER writes or deletes a key (unlike the real `outbox_read_function`,
* which drains on read) — it only counts. The queued event bytes are opaque
* (BARE-encoded Rust structs, base64url'd); decoding them to report concrete
* write TARGETS (topics/docs) would mean duplicating the WASM verifier's wire
* format in this polyfill, which is explicitly out of scope (SDK internals live
* in the `@ng-eventually/sdk`-independent core repo, per this repo's
* doctrine) — so only the pending COUNT is reported, never fabricated targets.
* `sessionStorage` access itself can throw (sandboxed iframe, disabled storage —
* see the exact error string handled in the core repo's `main.ts`
* `convert_error`), so the whole probe is wrapped in one try/catch: unavailable
* → skip silently, never throw into the caller.
*
* Polyfill-era; removed at the real multi-store migration alongside the rest of
* this low-level trace instrumentation.
*/
import { accessLogPrefix, logStage } from "./access-log";
/** Matches an outbox "start" marker key, capturing the peer id. */
const OUTBOX_START_KEY = /^ng_outboxes@(.+)@start$/;
/** Safety bound on the per-peer index walk, so a corrupted/mocked storage
* (e.g. a `@start` marker with no matching index gaps) can't spin forever.
* Real outboxes are queued-while-offline writes — nowhere near this size. */
const MAX_SCAN_PER_PEER = 10_000;
/**
* Inspect the outbox NOW and log its state — count only, never targets (see
* module doc). Non-empty → `console.warn`, ALWAYS printed (anomaly, not gated
* by the access-log flag). Empty → a normal {@link logStage} line, gated by the
* access-log flag like the rest of the low-level trace. Read-only: never
* mutates `sessionStorage`. Never throws.
*/
export function inspectOutbox(): void {
try {
const storage = (globalThis as any)?.sessionStorage;
if (!storage) return;
const peers = new Set<string>();
const length: number = storage.length ?? 0;
for (let i = 0; i < length; i++) {
const key = storage.key?.(i);
if (!key) continue;
const m = OUTBOX_START_KEY.exec(key);
const peerId = m?.[1];
if (peerId) peers.add(peerId);
}
let total = 0;
for (const peer of peers) {
let idx = 0;
while (idx < MAX_SCAN_PER_PEER) {
const idxKey = "ng_outboxes@" + peer + "@" + String(idx).padStart(5, "0");
if (storage.getItem(idxKey) === null) break;
total++;
idx++;
}
}
if (total > 0) {
// Anomaly: ALWAYS visible, regardless of the access-log flag.
console.warn(accessLogPrefix() + " OUTBOX " + total + " pending write(s)");
} else {
logStage("OUTBOX empty");
}
} catch {
// sessionStorage unavailable / access denied — skip silently. Diagnostic
// only, never a hard dependency of the read/write path.
}
}
+137
View File
@@ -0,0 +1,137 @@
/**
* physical — the polyfill's OWN machinery, operating on the PHYSICAL user.
*
* ── Two levels, two APIs, and only one of them is the app's ───────────────
* NextGraph sees exactly one user: the physical one, whose wallet everybody opens.
* On top of it the library fabricates **virtual users** — what the consumer calls
* an identity. Those are two different levels, and conflating them is how a
* boundary gets a hole in it:
*
* | | Level | Who calls it | Guarded |
* |---|---|---|---|
* | `docs.*`, `subscribeDoc` | the **virtual user** | the consumer app, and the library on the user's behalf | YES — confined to the connected user (`reach.ts`) |
* | this module | the **physical user** | the library's own machinery, and nothing else | no — it *is* the machinery the boundary is built on |
*
* **Nothing here is exported from the package.** `index.ts` must never re-export
* this module: an app holding these functions could read any document of any
* virtual user, which is precisely the boundary they exist below.
*
* ── Why a separate module rather than exemptions ──────────────────────────
* The store-root pointer and the doc-shim — the index of virtual users — cannot be
* subject to the boundary: resolving *which* documents a virtual user owns is what
* makes virtual users exist at all. An earlier version handled that with a list of
* exempt NURIs consulted by the guard. Separating the FUNCTIONS is stronger: the
* machinery does not call the guarded primitive and get waved through, it calls a
* different primitive that was never guarded. There is no exemption list to widen,
* to get wrong, or to infer.
*
* The rule for deciding which side a call belongs to:
*
* > Does this operate on the index of virtual users (the shim), or on the content
* > of one virtual user? The first is machinery; everything else is the user's,
* > and is confined.
*
* A virtual user's own stores, its inbox and its documents are the user's — they go
* through `docs.*` and are guarded, even though the library is what calls them.
*
* At migration this module disappears with the shim: there is no physical/virtual
* split once each user opens their own wallet.
*/
import { getConfig } from "./bootstrap";
import { logAccess } from "./access-log";
import { subscribeDocUnguarded } from "../surface/subscribe";
import { openRepoUnguarded } from "../emulated-verifier/open-repo";
import type { DocChange, DocChangeType, Unsubscribe } from "../surface/subscribe";
import { isNuri } from "../model/nuri";
import type { Nuri } from "../model/types";
/**
* Create a document as the PHYSICAL user — the shim's own documents (the doc-shim,
* a virtual user's store documents at provisioning time, an inbox document).
*
* Creation is the one operation with no boundary to check: the document does not
* exist yet, so nobody can hold its cap. What matters is who is credited with it
* afterwards, which the caller decides by filing the cap among the caps that holder holds.
*/
export async function physicalCreate(
sessionId: string,
crdt = "Graph",
cls = "data:graph",
dest = "store",
store?: unknown,
): Promise<Nuri> {
const { ng } = getConfig();
const nuri = await ng.doc_create(sessionId, crdt, cls, dest, store);
if (typeof nuri !== "string" || !isNuri(nuri)) {
throw new Error(
`[ng-eventually] physicalCreate: the broker returned something that is not a NextGraph reference: ${JSON.stringify(nuri)}`,
);
}
logAccess("WRITE", nuri, "physicalCreate");
return nuri;
}
/**
* Read as the PHYSICAL user — for the shim only (the store-root pointer, the
* doc-shim's account records).
*
* Unguarded by design: this is how the library learns which documents a virtual
* user owns, so it cannot itself depend on knowing that. Do not reach for it to
* read a virtual user's content — that is `docs.sparqlQuery`, which is confined.
*/
export async function physicalQuery(
sessionId: string,
query: string,
base: string | undefined,
anchor: Nuri,
label = "physicalQuery",
): Promise<unknown> {
const { ng } = getConfig();
const result = await ng.sparql_query(sessionId, query, base, anchor);
logAccess("READ", anchor, label, " (physical)");
return result;
}
/** Write as the PHYSICAL user — the shim's own records. See {@link physicalQuery}. */
export async function physicalUpdate(
sessionId: string,
query: string,
anchor: Nuri,
label = "physicalUpdate",
): Promise<void> {
const { ng } = getConfig();
logAccess("WRITE", anchor, label, " (physical)");
return ng.sparql_update(sessionId, query, anchor);
}
// --- the rest of the privileged door ---------------------------------------
//
// Moved here 2026-08-03 so that ONE module is the machinery's entire unguarded API,
// which is what this module's own doctrine asked for (see the header: separate
// functions, never exemptions). Before this they lived beside their guarded twins in
// `surface/subscribe.ts` and `emulated-verifier/open-repo.ts` — one import away from
// being reached by mistake.
/**
* Subscribe as the PHYSICAL user — the shim's own documents. The machinery's
* counterpart to `subscribeDoc`; never exported from the package.
*/
export function subscribePhysicalDoc(
nuri: Nuri,
onChange: (r: DocChange, type: DocChangeType) => void,
): Unsubscribe {
return subscribeDocUnguarded(nuri, onChange);
}
/**
* Open a repo as the PHYSICAL user — the shim's own documents (store-root, doc-shim).
* The machinery's counterpart to `ensureRepoOpen`: resolving WHICH documents a virtual
* user owns cannot itself be confined to that user.
*
* Never exported from the package.
*/
export async function ensurePhysicalRepoOpen(nuri: Nuri): Promise<void> {
if (!nuri) return;
return openRepoUnguarded(nuri);
}
@@ -0,0 +1,96 @@
/**
* accounts — a framework-agnostic store for the current identity id.
*
* The identity a session acts as is established when its wallet is imported; the
* SDK is told who that is via the current-identity call. This small store just
* persists that id (in an injected storage) so it survives reloads and a second
* device, re-opening the same wallet, lands on the same identity. It carries no
* notion of a login step, a password, or a username — only an opaque identity id.
*
* Framework-agnostic on purpose: no React, no DOM assumption beyond an optional
* storage. A consumer's React `Context`/`Provider` wraps `useState` around
* {@link IdentityStore.set}/{@link IdentityStore.clear}. The lib does not force a
* React dependency. Removed against real NextGraph, where the wallet session is
* the source of the identity id.
*/
/** localStorage key holding the current identity id. */
export const ACCOUNT_STORAGE_KEY = "ng-eventually.account.id";
/**
* Minimal storage contract (a subset of the Web `Storage` interface). The
* consumer injects one — `window.localStorage` in a browser, a fake in tests —
* so this stays framework/DOM-agnostic. When none is available (SSR, no
* `window`), pass `null` and the store degrades to in-memory-null (no persist).
*/
export interface VirtualUserStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
/**
* The persisted current identity id. A tiny store around an injected
* {@link VirtualUserStorage}. It holds no framework state; the consumer's Provider
* mirrors `get()` into framework state and re-reads after `set`/`clear`.
*/
export class IdentityStore {
private readonly storage: VirtualUserStorage | null;
private readonly key: string;
constructor(storage: VirtualUserStorage | null, key: string = ACCOUNT_STORAGE_KEY) {
this.storage = storage;
this.key = key;
}
/** The current identity id. null = no identity set yet. */
get(): string | null {
if (!this.storage) return null;
try {
return this.storage.getItem(this.key);
} catch {
return null;
}
}
/**
* Persist the (trimmed) identity id. An empty/blank value is ignored and the
* previous id is kept (returns the resulting id, or null). No NextGraph call.
*/
set(id: string): string | null {
const clean = id.trim();
if (!clean) return this.get();
if (this.storage) {
try {
this.storage.setItem(this.key, clean);
} catch {
/* ignore — staging, no security */
}
}
return clean;
}
/** Clear the persisted identity id. No NextGraph call. */
clear(): void {
if (this.storage) {
try {
this.storage.removeItem(this.key);
} catch {
/* ignore */
}
}
}
}
/**
* Convenience factory using `globalThis.localStorage` when present, else a
* null (non-persisting) store — so the same call is safe in browser and SSR.
*/
export function browserIdentityStore(key: string = ACCOUNT_STORAGE_KEY): IdentityStore {
const ls =
typeof globalThis !== "undefined" &&
(globalThis as { localStorage?: VirtualUserStorage }).localStorage
? (globalThis as { localStorage: VirtualUserStorage }).localStorage
: null;
return new IdentityStore(ls, key);
}
+175
View File
@@ -0,0 +1,175 @@
/**
* Low-level document + SPARQL primitives.
*
* These call the real injected `ng` (`getConfig().ng`) directly — never the
* public `ng` proxy (`makeNg`). This is a validated hard constraint, not a style
* 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
* with **`DataCloneError: function ... could not be cloned`** — the footgun this
* rule exists to prevent. Reaching the real `ng` held in the config avoids the
* double-proxy. Do not import from `./ng-proxy`.
*
* Signatures mirror the real `@ng-org/web` `ng` surface (verified against the
* app's storeRegistry usage), so this is a drop-in for those raw calls.
*/
import { getCaps, getConfig } from "../shared-wallet/bootstrap";
import { logAccess, enabled as accessLogEnabled } from "../shared-wallet/access-log";
import { isNuri, toNuri } from "../model/nuri";
import { assertMayReach, assertMayWrite } from "../emulated-verifier/reach";
import { fetchReadCap } from "../emulated-verifier/public-store";
import type { Nuri, NuriLike } from "../model/types";
// The low common point for ALL document access: every read in the SDK routes
// through `sparqlQuery`, every write through `sparqlUpdate` (+ container creation
// through `docCreate`) — each ultimately calling the real injected `ng` here. The
// access log is therefore instrumented HERE so no access path escapes it. Callers
// pass a semantic `label` (readDoc|readUnion|listMyEntityDocs|writeEntity|deposit
// |…); it is a lib-internal probe param, NOT forwarded to the real `ng` (the docs
// primitives forward the exact SDK signature — see test/docs.test.ts). When the
// log is OFF (default) the extra param is inert and costs one boolean read.
/** Count rows in a raw SPARQL SELECT result, tolerant of the possible shapes. */
function rowCount(result: unknown): number {
if (!result) return 0;
if (Array.isArray(result)) return result.length;
const anyRes = result as { results?: { bindings?: unknown[] } };
return anyRes.results?.bindings?.length ?? 0;
}
/**
* Create one document → its NURI.
*
* Mirrors `ng.doc_create(session_id, crdt, cls, dest, store_repo?)`. For a graph
* document in the (shared) private store: `docCreate(sid, "Graph", "data:graph",
* "store")` (store_repo left undefined → private store).
*/
export async function docCreate(
sessionId: string,
crdt: string,
cls: string,
dest: string,
store?: unknown,
): Promise<Nuri> {
const { ng } = getConfig();
const nuri = await ng.doc_create(sessionId, crdt, cls, dest, store);
// The BROKER boundary. `ng` is a permissive property bag (`NgLike`), so what
// comes back is `any` and this function's `Promise<Nuri>` would otherwise be an
// unchecked promise — every typed NURI downstream rests on it. Validate once,
// here, rather than let a non-reference propagate as a document.
if (typeof nuri !== "string" || !isNuri(nuri)) {
throw new Error(
`[ng-eventually] docCreate: the broker returned something that is not a NextGraph reference: ${JSON.stringify(nuri)}`,
);
}
// **Creating a document gives you its cap.** Upstream that is not a courtesy but
// the mechanism: `doc_create` commits `AddRepo { read_cap }` to the store's Store
// branch, so the creator holds it from the first instant. Without this, a caller
// could create a document through this primitive and then be refused reading or
// writing it — which is what the e2e run against the live broker exposed.
//
// `physical.ts`'s counterpart deliberately does NOT do this: the shim's own
// documents belong to no virtual user, and `store-registry` files their caps
// itself, where it knows whose they are.
getCaps().mint(nuri);
// A container creation is a WRITE; the NURI only exists after the call.
logAccess("WRITE", nuri, "docCreate");
return nuri;
}
/**
* Run a SPARQL UPDATE (INSERT/DELETE DATA, etc.).
*
* Mirrors `ng.sparql_update(session_id, query, anchor?)`, where `anchor` is the
* document NURI the update is scoped/base'd to (optional).
*/
export async function sparqlUpdate(
sessionId: string,
query: string,
anchorLike?: NuriLike,
label = "sparqlUpdate",
): Promise<void> {
const { ng } = getConfig();
const anchor = anchorLike === undefined ? undefined : toNuri(anchorLike, "docs.sparqlUpdate");
// The boundary: a write may only touch what the connected virtual user reaches.
//
// NO public-store fetch here, unlike the read below, and that asymmetry is the point:
// a public store makes its repos world-READABLE. Writing needs the write cap, which
// it never serves. A cap this holder has only because the network handed it over is
// therefore refused a write outright — otherwise a bare reference to a public
// document would buy one, and a consumer would build on something that fails upstream.
if (anchor !== undefined) {
assertMayReach(anchor, "docs.sparqlUpdate");
assertMayWrite(anchor, "docs.sparqlUpdate");
}
// `label` is a lib-internal access-log tag, NOT forwarded to `ng`.
logAccess("WRITE", anchor ?? "(no anchor)", label);
return ng.sparql_update(sessionId, query, anchor);
}
/**
* Deposit into ANOTHER virtual user's inbox — the one write that legitimately
* crosses the boundary, and therefore the one that skips {@link assertMayReach}.
*
* Why this is a separate primitive rather than a flag: depositing is not "a write
* that happens to be allowed", it is a different act. You cannot read the inbox you
* deposit into, you hold no cap for it, and you get nothing back — upstream it is an
* anonymous sealed box. Naming the exception makes it greppable and keeps
* {@link sparqlUpdate} free of a bypass that would otherwise be reusable for
* anything.
*
* The recipient's ownership of the inbox is what bounds this: `inbox.post` is the
* only caller, and reading is guarded separately (`inbox.read`).
*/
export async function depositInto(
sessionId: string,
query: string,
targetInbox: Nuri,
label = "deposit",
): Promise<void> {
const { ng } = getConfig();
logAccess("WRITE", targetInbox, label, " (cross-user deposit)");
return ng.sparql_update(sessionId, query, targetInbox);
}
/**
* Run a SPARQL SELECT/CONSTRUCT/ASK query → the raw SDK result.
*
* Mirrors `ng.sparql_query(session_id, query, base?, anchor?)`. `base` is the
* query base IRI (usually `undefined`); `anchor` is the document NURI to query.
*/
export async function sparqlQuery(
sessionId: string,
query: string,
base?: string,
anchorLike?: NuriLike,
label = "sparqlQuery",
): Promise<unknown> {
const { ng } = getConfig();
const anchor = anchorLike === undefined ? undefined : toNuri(anchorLike, "docs.sparqlQuery");
// The boundary: an ANCHORED read may only touch what the connected virtual user
// reaches. An anchorless query spans the local union — a different problem (it is
// O(wallet size), and the read path never uses it), not one this guard can bound.
//
// Asking the (emulated) network first, as `ensureRepoOpen` does: a document in a
// public store gives its cap to whoever asks, and this is a door an application can
// reach with nothing but a bare reference. Inert once the cap is held, memoised
// otherwise — see public-store.ts.
if (anchor !== undefined) {
await fetchReadCap(anchor);
assertMayReach(anchor, "docs.sparqlQuery");
}
// `label` is a lib-internal access-log tag, NOT forwarded to `ng`.
const result = await ng.sparql_query(sessionId, query, base, anchor);
// Log AFTER the read so the row count (a strong leak signal: a doc rendering
// rows under an identity that should see nothing) can be appended. Skip the
// rowCount work entirely when the log is off.
if (accessLogEnabled()) {
// `rows` here are raw RDF triple bindings (the SPARQL `?s ?p ?o` result), NOT
// domain objects — one document's entity is spread across several triple rows.
// Spell that out so the log isn't mistaken for an object count (the app-level
// object/shape count is logged separately by useShapeQuery → dataStats).
logAccess("READ", anchor ?? "(no anchor)", label, " → " + rowCount(result) + " triple-rows");
}
return result;
}
+587
View File
@@ -0,0 +1,587 @@
/**
* Inbox — a generic deposit + read/materialize mechanism the consumer reuses for
* its own purposes (same `inbox.post` API, same watcher — see the discovery-model
* decision). The mechanism itself knows no application domain: the consumer
* supplies the inbox document NURI and interprets the `payload`. (An example
* consumer mapping, purely illustrative: a consumer might use one inbox for a
* registration deposit and another for submitting a reference to an index.)
*
* ── Real target vs this emulation ─────────────────────────────────────────
* In real NextGraph, a message is sealed to the recipient's key and queued into
* their inbox; the recipient's own verifier unseals each queued message and
* applies it inline as it processes the inbox — there is no separate curator
* process. There is NO sender-side JS call for this today: the verifier has no
* `InboxPost` arm and `@ng-org/web` exposes no inbox method at all. (`inbox_post_link`,
* named elsewhere in these docs, is OUR proposal from `docs/fork-inbox-fallback.md` —
* no such symbol exists in `nextgraph-rs`. Do not cite it as a planned API.)
*
* Here, on one shared wallet where everything is readable, both sides run in-lib:
* - `post` appends a deposit `{ from, payload, ts }` as RDF into the inbox
* document (in the shared wallet) via the `docs.sparqlUpdate` primitive;
* - `read` / `watch` read the deposits back via `docs.sparqlQuery` and expose
* them. This in-lib read stands in for the recipient's own inbox processing
* until a sealed-inbox path is exposed to JS.
*
* All NextGraph I/O routes through the `docs` primitives (the real injected `ng`,
* never `makeNg`), so this module imports no `@ng-org` package.
*/
import { depositInto, sparqlQuery } from "./docs";
import { subscribeDoc } from "./subscribe";
import { ensureRepoOpen } from "../emulated-verifier/open-repo";
import { getCaps, getCurrentUser, getStoreRegistryDeps } from "../shared-wallet/bootstrap";
import { addLink, documentInboxAddress, isOwnInbox } from "../emulated-verifier/branch-registers";
import { userInbox } from "../shared-wallet/account-registry";
import { escapeLiteral } from "./sparql";
import { hasReadCap, toNuri } from "../model/nuri";
import {
accessLogPrefix,
enabled as accessLogEnabled,
logAccess,
logStage,
shortNuri,
} from "../shared-wallet/access-log";
import type { Nuri, NuriLike, PrincipalId, ReadCap } from "../model/types";
// --- deposit model --------------------------------------------------------
/** One deposit as materialized from an inbox document. */
export interface Deposit {
/** The sender, if identified; `null` when the deposit was anonymous. */
from: PrincipalId | null;
/** The consumer-defined payload (opaque here — JSON-serialized in storage). */
payload: unknown;
/** Deposit timestamp (ms epoch). Caller may pass one for determinism. */
ts: number;
}
/** Options for {@link post}. `from` and `ts` are both optional. */
export interface PostOptions {
/**
* Who is depositing. Omit (or pass `null`) for an ANONYMOUS deposit; pass a
* principal id to identify the sender. Defaults to the current polyfill user
* ({@link getCurrentUser}) when the property is entirely absent, so callers
* that want anonymity must pass `from: null` explicitly.
*/
from?: PrincipalId | null;
/** The payload to deposit (interpreted only by the consumer). */
payload: unknown;
/** Optional deposit timestamp (ms epoch). Omitted → `Date.now()`. Passing it
* keeps tests deterministic. */
ts?: number;
}
const SHIM = "urn:ng-eventually:inbox";
const P = {
type: `${SHIM}:Deposit`,
from: `${SHIM}:from`,
payload: `${SHIM}:payload`,
ts: `${SHIM}:ts`,
} as const;
// --- session access (shared with the storeRegistry) -----------------------
/** The inbox documents live in the shared wallet, so we reuse the registry's
* injected session provider for the sessionId. Disappears at migration. */
async function sessionId(): Promise<string> {
return (await getStoreRegistryDeps().getSession()).sessionId;
}
// --- diagnostic logging helper ---------------------------------------------
/**
* Best-effort, length-capped JSON rendering of a deposit payload for the
* inbox diagnostic log (see {@link enabled}/{@link logAccess}). This module
* stays domain-agnostic (see module header) — it never interprets payload
* fields, it only dumps them verbatim so the consumer's own shape (e.g. a
* Festipod participation: `{ participantId, eventId, … }`) is visible in the
* log without this module knowing that shape. Capped so one oversized payload
* can't blow up a log line; a payload that fails to stringify (e.g. a
* circular structure a caller mistakenly passed) falls back to `String()`.
*/
function summarizePayload(payload: unknown): string {
let s: string;
try {
s = JSON.stringify(payload) ?? String(payload);
} catch {
s = String(payload);
}
return s.length > 200 ? s.slice(0, 200) + "…" : s;
}
// --- SPARQL result helpers ------------------------------------------------
/** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */
function readBindings(result: unknown): Array<Record<string, { value: string }>> {
if (!result) return [];
if (Array.isArray(result)) return result as Array<Record<string, { value: string }>>;
const anyRes = result as {
results?: { bindings?: Array<Record<string, { value: string }>> };
};
return anyRes.results?.bindings ?? [];
}
// --- deposit (client side) ------------------------------------------------
/**
* Deposit a payload into `targetInbox`.
*
* Appends `{ from, payload, ts }` into the inbox document via `docs.sparqlUpdate`
* (the real injected `ng`). Each deposit is a fresh RDF subject in the inbox
* graph, so concurrent deposits don't collide.
*
* `from` is bound to the current identity — it is authenticated, not
* caller-supplied. Omit it to stamp the current identity; pass `null` to deposit
* anonymously (a legitimate choice — identified if known, anonymous otherwise).
* A `from` naming another identity is rejected as a spoof: in the target the
* broker seals the sender from the wallet's own key, so a client cannot forge
* another's identity. This check is redundant once the seal enforces it, but
* until then it closes the spoof the shared wallet would otherwise allow.
*/
export async function post(targetInboxLike: NuriLike, opts: PostOptions): Promise<void> {
const targetInbox = toNuri(targetInboxLike, "inbox.post");
const current = getCurrentUser();
let from: PrincipalId | null;
if (opts.from === undefined) {
from = current; // default: stamp the current identity
} else if (opts.from === null) {
from = null; // explicit anonymous deposit
} else if (opts.from === current) {
from = opts.from; // identifying as self — allowed
} else {
throw new Error(
"[ng-eventually] inbox.post: `from` must be the current identity or null " +
"(anonymous) — depositing as another principal is a spoof.",
);
}
const ts = opts.ts ?? Date.now();
const sid = await sessionId();
// A unique subject per deposit (in the inbox graph) — no collisions.
const subject = `${SHIM}:deposit:${ts}:${Math.random().toString(36).slice(2)}`;
const payloadLiteral = escapeLiteral(JSON.stringify(opts.payload ?? null));
const fromTriple =
from == null ? "" : ` ;\n <${P.from}> "${escapeLiteral(from)}"`;
// NO explicit `GRAPH <…>` wrapper — write the anchored DEFAULT graph:
// `sparqlUpdate(sid, update, targetInbox)` scopes the write to that repo's
// default graph (same shape as read-model.ts readDoc/readUnion). This is the
// CANONICAL, always-safe shape and the one the anchored default-graph read
// queries. (Not a round-trip necessity on the current broker: the e2e harness
// `packages/sdk/e2e/` verified that an anchored `GRAPH <plainNuri>` write
// ALSO round-trips here — it resolves to the same repo graph, no phantom graph.
// The no-GRAPH form is kept as a simplicity/safety convention; re-verify with
// that harness if the broker version changes.)
const update = `
INSERT DATA {
<${subject}> a <${P.type}> ;
<${P.payload}> "${payloadLiteral}" ;
<${P.ts}> "${ts}"${fromTriple} .
}`;
// A deposit crosses the boundary on purpose — see docs.depositInto.
await depositInto(sid, update, targetInbox, "deposit");
// Domain-level diagnostic (on top of docs.ts's generic access-path WRITE log):
// who deposited WHAT into which inbox — the decoded payload, not just the
// triple-write. Gated by the same access-log flag; skip the JSON work when off.
if (accessLogEnabled()) {
logAccess(
"WRITE",
targetInbox,
"inbox deposit",
" from=" + (from ?? "anonymous") + " payload=" + summarizePayload(opts.payload ?? null),
);
}
}
/**
* Deposit into the inbox of a DOCUMENT — resolve where, then deposit there.
*
* The call an app makes to reach a document's owner: it needs the document (which it
* must be able to read) and nothing else. Where the inbox is, and whether the owner
* ever opened one, are the library's business.
*
* **No target-document field on the deposit, deliberately.** Upstream an inbox belongs
* to exactly one repo — the verifier routes by `inboxes: PubKey → RepoId` and unseals
* with that repo's key (`engine/verifier/src/verifier.rs:1677`) — and `InboxMsgBody`
* carries no document (`engine/net/src/types.rs:4265`), because the address already
* identifies it. Tagging deposits with their document would be an invention consumers
* would have to unlearn at migration, so this resolves the address and stops there.
*
* @throws if the document has no inbox — its owner never opened one, so there is
* nowhere for this to go. Throwing rather than returning quietly is the whole lesson of
* this path: a deposit that vanishes without an error is worse than a refusal, and it
* is exactly the bug per-document inboxes shipped with
* (`docs/briefs/2026-08-03-document-inbox-addressing.md`). Call
* `storeRegistry.documentInboxAddress(doc)` first when "no inbox" is an expected case.
*/
export async function postToDocument(docLike: NuriLike, opts: PostOptions): Promise<void> {
const doc = toNuri(docLike, "inbox.postToDocument");
const target = await documentInboxAddress(doc);
if (target === undefined) {
throw new Error(
"[ng-eventually] inbox.postToDocument: this document has no inbox — either its owner " +
"never opened one, or you cannot read the document (the address rides on it): " +
JSON.stringify(doc),
);
}
return post(target, opts);
}
// --- cap delivery ---------------------------------------------------------
/**
* A **Link** — the deposit that carries a ReadCap. The word is upstream's, and it
* is the same one at all three stages: `InboxMsgContent::Link` is the message
* (`engine/net/src/types.rs:4249-4261`, declared but payload-less so far),
* `AddLink { read_cap }` is where the recipient files it (`repo/types.rs:1934-1950`),
* `RemoveLink` withdraws it. So giving access is: deposit a Link, and on connection
* the recipient processes their inbox and files it.
*
* It travels the SAME channel as any other deposit, which is why key ROTATION needs
* no special case on the surface — a re-delivered cap is just another Link.
*/
const LINK_KIND = "urn:ng-eventually:inbox:link";
/** Links observed during the last read of an inbox, awaiting durable filing. */
const seenByInbox = new Map<Nuri, ReadCap[]>();
function capsSeenIn(inbox: Nuri): ReadCap[] {
return seenByInbox.get(inbox) ?? [];
}
/** The cap a deposit carries, if it is a Link rather than consumer data. */
function capOfPayload(payload: unknown): ReadCap | null {
const p = payload as { kind?: unknown; cap?: unknown } | null;
if (!p || typeof p !== "object" || p.kind !== LINK_KIND) return null;
return typeof p.cap === "string" && hasReadCap(p.cap) ? p.cap : null;
}
/**
* Share ONE document with ONE recipient.
*
* The unit of sharing is the DOCUMENT: never hand over a store's cap, which would
* give away everything the store contains, present and future. The recipient needs
* no dedicated operation to receive it — the cap arrives as a deposit that their
* existing {@link watch} absorbs into what they hold (see {@link read}).
*
* Reaching several recipients means calling this once per inbox, which is what the
* real model does too: each delivery is sealed to one recipient.
*
* Upstream this path is a GAP, not a disagreement — verified at both ends:
* - the field exists, `ContactDetails.read_cap: Option<ReadCap>`
* (`engine/net/src/types.rs:4233`), but building a message that carries one is
* `read_cap: if with_readcap { unimplemented!() }` (`types.rs:3786`);
* - and the receiver ignores it: `InboxMsgContent::ContactDetails` writes only
* `ng:site`/`ng:protected` + `ng:*_inbox` into a fresh contact document
* (`engine/verifier/src/inbox_processor.rs:778-830`), never `details.read_cap`.
*
* Do NOT read `InboxMsgContent::Link` as the intended channel either: it is a **unit
* variant carrying nothing** (`engine/net/src/types.rs:4251`).
*
* The shape is right; the implementation is absent at both ends, so we emulate it
* meanwhile.
*/
export async function share(doc: NuriLike, toUser: string): Promise<void> {
const target = toNuri(doc, "inbox.share");
// Names the DOCUMENT and the PERSON — the two things an application has. Neither the
// key nor the address appears, because a caller will handle neither once this is
// native: upstream the verifier fills `ContactDetails.read_cap` itself, and an inbox
// is resolved from a profile. This took `(cap, toInbox)` at first, then `(cap, toUser)`;
// both made the caller hold something it will not hold later.
const cap = getCaps().capFor(target);
if (!cap) {
throw new Error(
"[ng-eventually] inbox.share: this document is not yours to share — you hold no cap " +
`for it. A cap is looked up in what you hold, or it was delivered to you: ${JSON.stringify(target)}`,
);
}
// ── KNOWN DIVERGENCE: the protected inbox is hard-coded here ──────────────
// Upstream the choice is not fixed. A contact record picks its inbox from the PROFILE
// through which the person was reached: `a_or_b = if details.profile.is_public()
// { "site" } else { "protected" }` (`engine/verifier/src/inbox_processor.rs:787`,
// written as `ng:site_inbox` vs `ng:protected_inbox` at `:823-824`). Reach someone by
// their public profile and the deposit goes to their public store's inbox; by their
// protected profile, to the protected one.
//
// This library has no notion of "the profile by which I know this person", so it
// always uses the protected one. Minor today — a consumer names a user and gets one
// answer — but it flattens a distinction the model makes, and the day an application
// shares with someone met through a public profile, this picks the wrong inbox.
//
// Not fixable in isolation: it needs a notion this library does not have, and about
// which nothing has been established here. What IS verified: a wallet holds `sites`,
// a `SiteV0` has an `id: PubKey`, a `name`, a `site_type` (Individual | Org) and three
// stores (`engine/verifier/src/site.rs:23-40`); the `Identity` enum that would name
// the rest is entirely COMMENTED OUT upstream (`engine/repo/src/types.rs:586-595`).
// Do not build on an assumed profile model — there is none to read yet.
//
// (The private store has no inbox at all — `new_store_default` attaches one only
// `if !private`, `verifier.rs:2994` — hence `InboxScope`, which makes "the private
// inbox" unwritable rather than merely empty.)
await post(await userInbox(toUser, "protected"), { payload: { kind: LINK_KIND, cap } });
}
/**
* The messages left on a document YOU own — the read side of {@link postToDocument}.
*
* Named by the DOCUMENT, like the deposit side: an owner reading their own messages has
* no more reason to handle an inbox address than a depositor does. Empty when the
* document has no inbox, which is a state and not an error.
*/
export async function readForDocument(docLike: NuriLike): Promise<Deposit[]> {
const doc = toNuri(docLike, "inbox.readForDocument");
const address = await documentInboxAddress(doc);
return address ? read(address) : [];
}
// --- the read guard ------------------------------------------------------
/**
* Refuse to READ an inbox that is not the current wallet's.
*
* Depositing into someone else's inbox is the one legitimate cross-wallet act (it
* is how a link reaches another wallet at all — see {@link post} / {@link share});
* READING one is not, and it is not symmetric with it. Since caps travel as
* deposits, an unguarded read let anyone who knew an inbox NURI collect the caps
* addressed to its owner, which defeats directed sharing entirely.
*
* Anonymous owns no inbox, so it can read none — an identity has to be established
* first. At migration this disappears: an inbox is sealed to its owner's key, and
* the guard is the cryptography.
*/
async function assertOwnInbox(targetInbox: Nuri, op: string): Promise<void> {
if (getCurrentUser() === null) {
throw new Error(
`[ng-eventually] inbox.${op}: no identity is set, so no inbox belongs to this ` +
"session — call setCurrentUser() first. Depositing (post/share) stays open.",
);
}
if (!(await isOwnInbox(targetInbox))) {
throw new Error(
`[ng-eventually] inbox.${op}: refusing to read an inbox that does not belong to ` +
"the connected wallet. You may DEPOSIT into anyone's inbox; you may only READ " +
"your own — otherwise the caps addressed to its owner would be collectable by " +
`whoever knows its NURI: ${JSON.stringify(targetInbox)}`,
);
}
}
// --- read --------------------------------------------------------------
/**
* Read every deposit currently in `targetInbox`, sorted by `ts` ascending. In
* real NextGraph the recipient's own verifier applies queued messages inline as
* it processes the inbox; here this read stands in for that until the
* sealed-inbox path is available. The consumer interprets each deposit's
* `payload`.
*
* Cap deliveries ({@link share}) are applied inline and NOT returned: they land
* in what the current holder holds, like the verifier applying a queued message.
* That is why receiving a cap needs no dedicated operation — a consumer already
* watching its inbox gets them, and the resulting change re-triggers the
* reads that were empty for want of that cap.
*/
export async function read(targetInboxLike: NuriLike): Promise<Deposit[]> {
const targetInbox = toNuri(targetInboxLike, "inbox.read");
await assertOwnInbox(targetInbox, "read");
const sid = await sessionId();
// NOTE: cold-start repo opening is done by the COLD DIRECT readers that need it
// (a cold reader that opens the repo before reading), NOT here — `inbox.watch`
// already holds the repo open via its own `subscribeDoc`, so opening a second
// bootstrap subscription from inside a watch's re-read would be redundant and can
// race the watch's own initial-`State` delivery. Keeping `read` a pure anchored
// read leaves both callers correct: the watch path stays event-driven, and the
// cold direct-read path opens the repo explicitly before calling `read`.
// NO explicit `GRAPH <…>` clause — read the anchored DEFAULT graph (see the
// note in `post`). The anchor (`targetInbox`) scopes the query to that repo's
// default graph, exactly where `post` writes.
const query = `
SELECT ?payload ?ts ?from WHERE {
?d a <${P.type}> ;
<${P.payload}> ?payload ;
<${P.ts}> ?ts .
OPTIONAL { ?d <${P.from}> ?from }
}`;
const result = await sparqlQuery(sid, query, undefined, targetInbox, "inboxRead");
const deposits: Deposit[] = [];
for (const row of readBindings(result)) {
const rawPayload = row.payload?.value ?? "null";
let payload: unknown;
try {
payload = JSON.parse(rawPayload);
} catch {
payload = rawPayload; // tolerate a non-JSON literal
}
const tsRaw = row.ts?.value ?? "0";
const ts = Number.parseInt(tsRaw, 10) || 0;
const fromValue = row.from?.value;
deposits.push({ from: fromValue ? fromValue : null, payload, ts });
}
deposits.sort((a, b) => a.ts - b.ts);
// Links are infrastructure, not consumer data: they never reach the caller. They
// are only KEPT here (in memory, for this session) — FILING them durably is
// `processInbox`'s job, because reading an inbox must not quietly write to a
// user's store. Filing fires the registry's change signal, which is what makes a
// view that was empty for want of that cap re-read instead of staying stale.
const delivered: Deposit[] = [];
const links: ReadCap[] = [];
for (const d of deposits) {
const cap = capOfPayload(d.payload);
if (cap) {
getCaps().learn(cap);
links.push(cap);
continue;
}
delivered.push(d);
}
if (links.length > 0) seenByInbox.set(targetInbox, links);
// Domain-level diagnostic (on top of docs.ts's generic access-path READ log
// of raw triple-rows): how many DEPOSITS were found, and the decoded data of
// each — the exact visibility needed to trace materialization at the owner
// side. Gated by the same access-log flag; skip the JSON work when off.
if (accessLogEnabled()) {
logAccess(
"READ",
targetInbox,
"inbox materialize",
" → " + delivered.length + " message(s)" +
(deposits.length !== delivered.length
? " (+" + (deposits.length - delivered.length) + " cap deliver(y/ies) absorbed)"
: ""),
);
for (const d of delivered) {
logAccess(
"READ",
targetInbox,
"inbox message",
" ts=" + d.ts + " from=" + (d.from ?? "anonymous") + " payload=" + summarizePayload(d.payload),
);
}
}
return delivered;
}
/** Alias for {@link read} — the name that reads as "process the inbox now". */
export const materialize = read;
/**
* COLD, BARRIER-GATED read of `targetInbox` — the reliable "process the inbox at
* (re)connection" read. Opens/subscribes the inbox repo and AWAITS its first
* `State` (the deterministic sync barrier — after it, presence is guaranteed and
* absence definitive, {@link ensureRepoOpen}) BEFORE the anchored {@link read}.
*
* Why this over a plain {@link read}: on a FRESH session over the persistent
* wallet (a (re)connection / new page), the inbox repo is not yet in the verifier's
* `self.repos`, so a plain anchored `read` resolves an unopened repo and silently
* returns 0 deposits — even for a deposit a remote session already synced to the
* broker. Gating on the sync barrier makes the read see the synced deposits. This
* is the same cold-read heal any cold direct reader needs.
*
* NOT for the `watch` path: {@link watch} already holds the repo open via its own
* `subscribeDoc`, so opening a second bootstrap subscription from inside a watch
* re-read would be redundant and could race the watch's own initial-`State`
* delivery. Use this from a COLD reader (materialize-at-connection), like
* `discovery.readIndex` does. Idempotent per session (no polling); a no-op open on
* the unit fake-ng path (no `doc_subscribe`) so `bun test` is unaffected.
*/
export async function readSynced(targetInboxLike: NuriLike): Promise<Deposit[]> {
const targetInbox = toNuri(targetInboxLike, "inbox.readSynced");
// Marks the cold, connection-triggered entry point in the trace — the BARRIER
// line (open-repo.ts) and the "inbox materialize"/"inbox message" lines below
// (from the read() this wraps) follow right after, so a live session shows
// the whole owner-reconnect sequence together.
logStage("READSYNCED " + shortNuri(targetInbox) + " (cold, barrier-gated)");
await assertOwnInbox(targetInbox, "readSynced");
await ensureRepoOpen(targetInbox);
return read(targetInbox);
}
/**
* PROCESS an inbox: read it, and **apply** what it contains.
*
* Applying a {@link share} Link means filing it durably — `storeRegistry.addLink`,
* the emulated `AddLink { read_cap }` on the User branch of the private store — so
* the cap survives the session. Upstream this is what a verifier does when it
* processes queued messages: an inbox is a **queue you consume**, not a store you
* re-read. Re-reading an inbox every session to recover caps is using a queue as a
* database, and it is the thing this replaces.
*
* Idempotent: `addLink` ignores a Link it already holds, so processing twice (a
* second tab, a reconnect) costs nothing. Returns the consumer deposits, exactly as
* {@link read} does — Links are never surfaced.
*/
export async function processInbox(targetInboxLike: NuriLike): Promise<Deposit[]> {
const targetInbox = toNuri(targetInboxLike, "inbox.processInbox");
const deposits = await readSynced(targetInbox);
// `readSynced` already put every Link in memory for this session; now make
// them durable. Reading the raw deposits again would mean re-parsing, so the caps
// are taken from what the read just observed.
for (const cap of capsSeenIn(targetInbox)) await addLink(cap);
seenByInbox.delete(targetInbox);
return deposits;
}
/**
* Subscription over an inbox — **event-driven, not polled**. Subscribes to the
* inbox document via {@link subscribeDoc} (the platform's `doc_subscribe` push):
* `onDeposits` fires once on the initial state push and again on every subsequent
* change to the inbox document — a local deposit OR a broker-synced remote one.
* Returns an unsubscribe function.
*
* On each push it re-reads the full deposit list ({@link read}) and invokes
* `onDeposits` only when the deposit count changed (grew), keeping the same
* "fires on change" contract the polling watcher had — same callback signature
* and same behaviour, just event-driven instead of `setInterval`.
*
* The `intervalMs` option is accepted for signature compatibility but IGNORED:
* there is no polling. (The inbox document is a single doc, so this is immune to
* the ORM fan-out hang — see {@link subscribeDoc}.)
*/
export function watch(
targetInbox: Nuri,
onDeposits: (deposits: Deposit[]) => void,
_opts?: { intervalMs?: number },
): () => void {
let stopped = false;
let lastCount = -1;
// Re-read on every push; fire onDeposits only when the set changed (grew).
const refresh = async (): Promise<void> => {
if (stopped) return;
try {
const deposits = await read(targetInbox);
const changed = deposits.length !== lastCount;
// Owner-side processing decision: did this push actually grow the
// deposit set (→ onDeposits fires, the polyfill's stand-in for
// materialization) or was it a no-op push (→ skipped)? This is the
// exact line to check for the "must reconnect an extra time" symptom:
// a push whose read still sees the OLD count means the barrier/read
// raced the write, not that watch itself failed to fire.
if (accessLogEnabled()) {
logAccess(
"READ",
targetInbox,
"inbox watch",
" → " + deposits.length + " message(s)" + (changed ? " (materializing)" : " (unchanged, skip)"),
);
}
if (!stopped && changed) {
lastCount = deposits.length;
onDeposits(deposits);
}
} catch (error) {
console.error(accessLogPrefix() + " watch read failed:", error);
}
};
// Subscribe to the inbox document: the initial State push fires the first read
// (so onDeposits fires once immediately, as before), each later Patch a re-read.
// The ownership guard runs inside `read`, so a watch on someone else's inbox
// yields nothing but logged refusals rather than their deposits.
const unsubscribe = subscribeDoc(targetInbox, () => void refresh());
return () => {
stopped = true;
unsubscribe();
};
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Lifecycle re-exports — SDK-shaped forwarders so the app imports `init` /
* `initNg` from `@ng-eventually/sdk` rather than from `@ng-org/*`. They
* delegate to the REAL functions injected at `configure()`. Passthrough today;
* a hook point later (e.g. opening the shared wallet on `init`).
*/
import { getConfig } from "../shared-wallet/bootstrap";
/** Forwards to the real `@ng-org/web` `init`. */
export function init(...args: any[]): any {
const f = getConfig().init;
if (!f) throw new Error("[ng-eventually] init() not injected — pass it to configure()");
return f(...args);
}
/** Forwards to the real `@ng-org/orm` `initNg` (ORM signals). */
export function initNg(...args: any[]): any {
const f = getConfig().initNg;
if (!f) throw new Error("[ng-eventually] initNg() not injected — pass it to configure()");
return f(...args);
}
+65
View File
@@ -0,0 +1,65 @@
/**
* The wrapped `ng`: a Proxy that forwards every method to the real SDK and
* overrides only what the broker/verifier will do natively at migration. The
* surface stays identical to `@ng-org/web`'s `ng`.
*/
import { getConfig, getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
import type { Nuri } from "../model/types";
export function makeNg(): Record<string, any> {
return new Proxy({} as Record<string, any>, {
get(_target, prop: string) {
const { ng } = getConfig();
// session_start → open the SHARED wallet invisibly.
//
// `login` used to be listed here too. `@ng-org/web` exposes no such method —
// zero occurrences in the installed declarations and in `sdk/js/lib-wasm/src/lib.rs`
// — so the proxy FABRICATED a member: `ng.login` answered a function instead of
// `undefined`, and calling it threw. The one place this wrapper added to the SDK
// surface, against its own header. Removed 2026-08-03.
if (prop === "session_start") {
return (...args: any[]) => {
// TODO(polyfill): supply shared-wallet credentials so no wallet UI
// is shown. For now, passthrough.
return ng[prop]!(...args);
};
}
// sparql_update → write guard (emulated write-cap check).
// Mirrors the target broker/verifier: a write is refused unless the wallet
// holds the document's WRITE cap. Emulated per-document via CapRegistry.
// args = (session_id, query, anchor?) — `anchor` is the target doc NURI.
if (prop === "sparql_update") {
return (...args: any[]) => {
const anchor = args[2] as Nuri | undefined;
const caps = getCaps();
// Passthrough (no regression) unless a WRITE policy exists AND this
// specific document is governed by it. Ungoverned docs (mono-store
// default, no cap declared) flow through exactly as before.
if (
typeof anchor === "string" &&
caps.hasWritePolicy() &&
caps.governsWrite(anchor) &&
!caps.canWrite(anchor, getCurrentUser())
) {
return Promise.reject(
new Error(
`[ng-eventually] write denied: current user lacks the write cap for ${anchor}`,
),
);
}
return ng.sparql_update!(...args);
};
}
// TODO(anticipated API): a sealed inbox deposit + capability operations — expose
// here with their anticipated signatures, emulated for now.
// Everything else: passthrough to the real SDK, unchanged.
const real = ng[prop];
return typeof real === "function" ? real.bind(ng) : real;
},
});
}
+55
View File
@@ -0,0 +1,55 @@
/**
* The app-facing slice of `store-registry` — and the reason it exists as a file.
*
* `store-registry.ts` holds two things that must not be exported together: the
* placement/addressing calls a consumer application legitimately makes, and the
* shim machinery that makes virtual users work at all (account resolution, the
* durable cap registers, the inbox-ownership predicate, cache resets). Until now
* `index.ts` did `export * as storeRegistry from "../shared-wallet/account-registry"` and shipped
* both, so an application could reach `ensureAccount`, `addLink` or
* `resetRegistryCache` from the SDK-identical entry — machinery it must never call,
* on the entry whose whole promise is "this survives migration unchanged".
*
* What is re-exported here is only what an application needs to do its own work,
* and each has a target-SDK counterpart (see `docs/api-contract.md`). Everything
* else stays reachable at `./store-registry` for the library's own modules, the
* unit tests and the e2e harness — an internal path, not a published one.
*
* At migration this file disappears: placement becomes the user's real per-scope
* stores and the calls below become native SDK ones.
*
* **No inbox ADDRESS is published here**, deliberately (`userInbox`,
* `documentInboxAddress`, removed 2026-08-05). An application deposits with
* `inbox.postToDocument(doc, …)`, shares with `inbox.share(doc, toUser)` and reads
* its own with `inbox.readForDocument(doc)` — always naming a document or a person,
* never an address. Upstream an address is resolved from a profile and never handled by
* a caller, so exposing one taught a step that has to be unlearned. The example
* application is the check: it must never name an inbox.
*/
export {
/** Create a document for ONE entity in `scope`, and record it in that scope's store. */
createEntityDoc,
/** The entity documents this user owns in `scope` — with their caps recovered. */
listMyEntityDocs,
/** The NURI to use as a READ scope for `scope` (what `useShape` is pointed at). */
resolveScopeGraph,
/** The NURI where GROUPED entities of `scope` are written (no per-entity document). */
resolveWriteGraph,
/** Open an inbox on a document you OWN, so others can deposit into it. */
/** WHERE to deposit for a document — readable by any holder of it. `undefined` if none. */
} from "../shared-wallet/account-registry";
export { openDocumentInbox } from "../emulated-verifier/branch-registers";
// No `linkTo` here, and its absence is deliberate (it existed 2026-08-06, one day).
//
// It returned a document's KEY where a caller would ask for its reference, which turns
// the access rule from "whoever has the reference AND the key reads" into "whoever has
// the reference reads" — see `docs/readcap-and-nuri-model.md` § 0. That is not a leak of
// hygiene, it is the rule changing: a document one circulates would grant everything it
// MENTIONS, and confidentiality could no longer be composed inside a shared document.
//
// An application names a document with the reference it already has (every call here
// returns bare ones), and grants access with `inbox.share(doc, toUser)`. What travels
// with a key in it is a deliberate act, not the result of asking for a link.
+213
View File
@@ -0,0 +1,213 @@
/**
* read-model — the listing primitive of the polyfill: read a bounded, by-need set
* of documents, each with its own anchored `sparql_query`, and return the triples
* grouped per subject. This is the mechanism documented in docs/read-model.md.
*
* ── Why per-doc anchored, rather than an anchorless union-scan ─────────────
* An anchored `sparql_query(sid, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", base, doc)`
* is restricted to the anchor repo's graph: `resolve_target_for_sparql(Repo)` →
* `Some(repo_graph_name)`, which becomes the query's default graph. A body with no
* `GRAPH` wrapper reads only that default graph → only that doc's triples, O(1) per
* doc, independent of how many other graphs the local store holds.
*
* The footgun this avoids: an anchorless query (`anchor` undefined → `UserSite` →
* `set_default_graph_as_union`) spans EVERY named graph currently in the session
* store. On a shared / bloated wallet that accumulates across runs, that is
* O(wallet size) → the observed ~90s timeouts. So the read path never union-scans
* all graphs — it reads exactly the bounded by-need set, one anchored query per doc.
*
* NB (verified, docs/read-model.md § probe step 4): an explicit `GRAPH ?g { … }`
* body iterates the named graphs regardless of the default graph, so an anchor does
* not bound such a body. The per-doc read therefore uses a default-graph body (no
* `GRAPH` wrapper) so the anchor's one-repo restriction actually applies.
*
* ── Why not the reactive ORM fan-out ──────────────────────────────────────
* `useShape({ graphs: […manyDocs] })` drives `orm_start_graph` over a fan-out of
* per-entity graphs; a freshly-created / not-yet-synced doc in that fan-out makes
* `RepoNotFound` abort the whole subscription → the readyPromise never resolves →
* the ~75s hang (docs/nextgraph-current-state.md § The ORM fan-out hang). Listing
* is instead a set of one-shot anchored `sparql_query`s. There is no reactive
* union query, so reactivity is assembled by re-querying on a change signal.
*
* ── Generic by construction ───────────────────────────────────────────────
* No application domain here: the consumer passes the doc NURIs to read (from
* the discovery index for public events, or its own scope docs for my-entities)
* and interprets the returned per-subject property bags. All NextGraph I/O routes
* through the T01.a `docs` primitives (the real injected `ng`), so this module
* imports no `@ng-org` package.
*
* At the real multi-store migration the per-doc anchored read is unchanged (native
* SPARQL, anchored to one repo); only bringing a repo into the session (open by cap)
* changes — the anchored query already resolves a same-session repo directly.
*/
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
import { getCaps, getStoreRegistryDeps } from "../shared-wallet/bootstrap";
import { mustNotAttempt } from "../emulated-verifier/reach";
import { ensureReposOpen } from "../emulated-verifier/open-repo";
import { assertNuri } from "./sparql";
import { toNuri } from "../model/nuri";
import { isMachinerySubject } from "../emulated-verifier/machinery";
import type { Nuri, NuriLike } from "../model/types";
// Keep the primitives referenced so tree-shaking never drops the import used by
// the (side-effecting) open step below; `docCreate`/`sparqlUpdate` are not used
// here but the module intentionally depends only on the docs primitive surface.
void docCreate;
void sparqlUpdate;
/** One subject read from a doc, with its properties (predicate → values). */
export interface UnionSubject {
/**
* The subject IRI (`?s`) — in the polyfill, the doc's own NURI.
*
* Typed `Nuri`, not `string`: both fields are always document references here (the
* read is anchored per document and the subject is pinned to the anchor), and typing
* them loosely forced a consumer to cast whatever it had just read before it could
* pass it back — `shareNote(note.doc)`, `leaveMessage(note.doc)`. A cast at that
* boundary re-opens exactly the confusion the template literal types exist to close.
* Found by writing the example application (`examples/notebook`).
*/
subject: Nuri;
/** The graph (doc NURI) the subject was read from. */
graph: Nuri;
/** predicate IRI → the list of object values (literals or IRIs) for it. */
props: Record<string, string[]>;
}
/** Tolerant extraction of SPARQL SELECT bindings across possible shapes. */
function bindings(
result: unknown,
): Array<Record<string, { value: string } | undefined>> {
if (!result) return [];
if (Array.isArray(result))
return result as Array<Record<string, { value: string }>>;
const anyRes = result as {
results?: { bindings?: Array<Record<string, { value: string }>> };
};
return anyRes.results?.bindings ?? [];
}
async function sessionId(): Promise<string> {
return (await getStoreRegistryDeps().getSession()).sessionId;
}
/**
* 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
* graph (`resolve_target_for_sparql(Repo)` → `Some(repo_graph_name)`); a body with
* no `GRAPH` wrapper reads exactly that default graph → only this doc's triples.
* This is O(1) in the doc's own size and independent of the rest of the (possibly
* bloated / shared) session store — it never iterates other graphs.
*
* COLD-START (fresh session, same persistent wallet): the repo is NOT in
* `self.repos` until something opens it, and an anchored query against an unopened
* repo silently returns 0 rows (never `RepoNotFound`). {@link readUnion} therefore
* opens the batch's repos ({@link ensureReposOpen}) BEFORE this read runs, so the
* anchored query resolves a same-session repo directly. A genuinely-absent repo
* still yields `[]` (in isolation, never aborting the others). Returns the doc's
* rows, or `[]` on failure.
*
* At the real multi-store migration this becomes a real sync: opening a per-user
* store repo by cap is a native broker fetch (`verifier.rs:1423` `OpenRepo` TODO).
*/
async function readDoc(
sid: string,
doc: Nuri,
): Promise<Array<Record<string, { value: string } | undefined>>> {
try {
const nuri = assertNuri(doc);
// Anchored to `nuri` → default graph = this repo. No `GRAPH ?g` wrapper, so
// the anchor's one-repo restriction applies (an explicit `GRAPH ?g` body would
// iterate all named graphs regardless of the anchor — see docs § probe step 4).
const res = await sparqlQuery(
sid,
"SELECT ?s ?p ?o WHERE { ?s ?p ?o }",
undefined,
nuri,
"readDoc",
);
return bindings(res);
} catch (error) {
console.error("[read-model] read failed for", doc, error);
return [];
}
}
/**
* Read a BOUNDED, by-need set of docs — each with its OWN anchored query — and
* return the triples grouped per subject. `docs` are the NURIs to read (the
* consumer resolves them by need — index for public, own scope docs for mine).
* Docs that fail are skipped (see {@link readDoc}); a failing doc never aborts the
* batch.
*
* Never an anchorless union-scan over all graphs (which is O(wallet size) and wrong
* on a shared / bloated wallet — the footgun this path exists to avoid). Each doc is
* read with an anchored default-graph query, O(1) per doc, independent of wallet
* size — a non-empty wallet no longer matters. Reads run in parallel via `Promise.all`.
*/
export async function readUnion(docsLike: NuriLike[]): Promise<UnionSubject[]> {
const sid = await sessionId();
// Drop the empties BEFORE validating, not after: this call has always tolerated a
// list with holes in it — a scope index can carry a blank entry, and a caller
// building a list from optional values should not have to compact it. Validating
// first turned that tolerance into a throw, which took down a whole reconnect run.
// Empty is absence, and absence is not a malformed reference.
const unique = [...new Set(docsLike.filter(Boolean))].map((d) => toNuri(d, "readUnion"));
if (unique.length === 0) return [];
// COLD-START heal (polyfill-era): on a fresh session over a persistent wallet the
// target repos are not yet in `self.repos`, so an anchored read would return 0
// rows. Open/subscribe each repo ONCE (idempotent, per session) and await its
// initial-state push before the anchored reads. No-op once opened / when the
// injected `ng` has no `doc_subscribe` (unit fake). See open-repo.ts.
//
// Called on the WHOLE set, before the boundary is consulted, because opening is also
// where a document in a PUBLIC store hands over its cap (see public-store.ts): a
// document filtered out first would never get the chance to answer. `ensureRepoOpen`
// still refuses to open what this user may not touch — it asks, it does not enter.
await ensureReposOpen(unique);
// RULE 2 — do not even attempt. Drop the documents whose cap this user does not
// hold before reading anything: upstream you cannot address a repo you have no cap
// for, so asking about one is not "a read that will be refused", it is a read that
// has no meaning. (The passage points enforce rule 1 regardless — see reach.ts — so
// a lapse here is caught, not exploited.)
const reachable = unique.filter((d) => !mustNotAttempt(d));
// One anchored query per doc, in parallel, tolerant (a bad doc yields []).
const perDoc = await Promise.all(
reachable.map(async (d) => ({ doc: assertNuri(d), rows: await readDoc(sid, d) })),
);
// Possession gate, kept as defence in depth behind rule 2 above: `reachable`
// already excluded these, so this loop should never drop anything. In this
// polyfill each subject IRI is its own document NURI, so the cap key is the doc NURI.
const caps = getCaps();
const bySubject = new Map<string, UnionSubject>();
for (const { doc, rows } of perDoc) {
if (caps.isEnforcing() && caps.capFor(doc) === undefined) continue;
// Anchored to `doc`, so every row belongs to `doc`; the subject is the doc NURI
// (writeEntity invariant). Pin subject/graph to the doc NURI (the anchor), which
// is stable regardless of the repo_graph_name overlay suffix the store carries.
for (const row of rows) {
// The polyfill's own compartments live as reserved SUBJECTS inside the very
// documents the consumer reads (the Header branch carrying a document's inbox
// address is the first). They are machinery, not this entity's properties —
// drop them here, once, for every compartment present and future.
if (isMachinerySubject(row.s?.value)) continue;
const p = row.p?.value;
const o = row.o?.value;
if (!p || o === undefined) continue;
let entry = bySubject.get(doc);
if (!entry) {
entry = { subject: doc, graph: doc, props: {} };
bySubject.set(doc, entry);
}
(entry.props[p] ??= []).push(o);
}
}
return [...bySubject.values()];
}
+107
View File
@@ -0,0 +1,107 @@
/**
* SPARQL string-building safety helpers — shared by every module that builds
* SPARQL by interpolation (inbox, store-registry).
*
* These exist because of SPARQL injection. When an untrusted value (an identity
* id, a payload) is spliced verbatim into a query, a `"` closes a literal and a
* `>` closes an IRI, letting the value inject arbitrary triples (or corrupt the
* shim graph, the trust root mapping accounts → document NURIs). Every value that
* reaches a query passes through one of these helpers first.
*
* Two positions, two strategies:
* - Literal position (`"..."`): {@link escapeLiteral}. Escape rather than reject,
* because literals legitimately carry arbitrary text (JSON payloads, display
* names). Escaping is lossless and reversible.
* - IRI position (`<...>`): two cases.
* · Trusted-shaped NURIs coming back from `ng` (`did:ng:...`): validate with
* {@link assertNuri} — they should never contain IRI-breaking chars; if one
* does, something upstream is wrong, so it throws rather than silently
* building a broken/injected query.
* · Untrusted values embedded into an IRI (an identity id used to mint an
* account-subject IRI): {@link escapeIri} percent-encodes every IRI-hostile
* character. Encode rather than reject so any id (spaces, unicode,
* punctuation) stays usable, while `<`, `>`, `"`, whitespace and control
* chars can never break out of the IRI.
*/
/**
* Escape a value for embedding inside a SPARQL string literal (`"..."`).
* Escapes backslash, double-quote and the C0 whitespace controls that would
* otherwise terminate or corrupt the literal. Lossless / reversible.
*/
export function escapeLiteral(value: string): string {
return value
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\t/g, "\\t");
}
/**
* Delimiter characters that must never appear raw inside a SPARQL/Turtle IRI
* ref (`<...>`): the space plus `< > " { } | ^ backtick \`. Whitespace beyond
* the space and all C0/C1 control characters are handled by the code-point
* check in {@link isIriForbidden}. Any of these would let a value break out of
* the `<...>` and inject arbitrary syntax.
*/
const IRI_FORBIDDEN_DELIMS = /[<>"{}|^`\\ ]/;
/** True if `ch` (a single code point) may not appear raw inside an IRI ref. */
function isIriForbidden(ch: string): boolean {
const code = ch.codePointAt(0)!;
return IRI_FORBIDDEN_DELIMS.test(ch) || code < 0x20 || code === 0x7f;
}
/**
* Percent-encode every IRI-hostile character in `value` so it is safe to embed
* inside a SPARQL IRI ref (`<PREFIX:${escapeIri(value)}>`). Use this for
* untrusted values (e.g. an identity id minted into an account-subject IRI):
* encoding keeps every id usable while making breakout impossible.
*
* NOTE: this encodes only the delimiter/whitespace/control set, so ordinary
* printable characters (including `:` `/` `.` `-` `_` and unicode letters) pass
* through unchanged and the resulting IRI stays human-readable.
*/
export function escapeIri(value: string): string {
let out = "";
for (const ch of value) {
if (isIriForbidden(ch)) {
// Percent-encode each UTF-8 byte of the offending character. Also encode
// the chars encodeURIComponent leaves alone but which are IRI-hostile.
out += encodeURIComponent(ch).replace(
/[!'()*]/g,
(c) => "%" + c.charCodeAt(0).toString(16).toUpperCase(),
);
} else {
out += ch;
}
}
return out;
}
/**
* Assert that `nuri` is safe to embed verbatim inside a SPARQL IRI ref. NURIs
* that come back from `ng` are trusted-SHAPED (`did:ng:...` or `urn:...`) and
* should never carry IRI-breaking characters; if one does, we throw rather than
* emit a query that could be malformed or injected. Returns the value unchanged
* so it can be used inline: `<${assertNuri(doc)}>`.
*
* Generic in its argument so the caller's type flows THROUGH: passing a `Nuri`
* gives back a `Nuri`, not a widened `string`. This function checks characters,
* not the `did:ng:` shape (it legitimately accepts `urn:…` IRIs too), so it must
* not be the thing that mints a `Nuri` — that is {@link isNuri}'s job.
*/
export function assertNuri<T extends string>(nuri: T): T {
if (typeof nuri !== "string" || nuri.length === 0) {
throw new Error(`[sparql] invalid NURI (empty): ${JSON.stringify(nuri)}`);
}
for (const ch of nuri) {
if (isIriForbidden(ch)) {
throw new Error(
`[sparql] NURI contains IRI-forbidden characters, refusing to embed: ${JSON.stringify(nuri)}`,
);
}
}
return nuri;
}
+205
View File
@@ -0,0 +1,205 @@
/**
* Reactive single-document subscription — the polyfill's typed wrapper over the
* platform's `doc_subscribe` primitive. This is the canonical NextGraph reactive
* read at the document granularity: subscribe once, get the initial state pushed,
* then a push on every subsequent commit to that document — whether the write was
* local (this session) or a broker-synced remote change. NO POLLING.
*
* ── Why call the REAL injected `ng` directly (never `makeNg`) ──────────────
* Same hard constraint as `docs.ts`: the public `ng` is a JS `Proxy` over
* `@ng-org/web`'s iframe-RPC proxy. `doc_subscribe` is a STREAMED method — the
* `@ng-org/web` RPC strips the callback (by positional index) BEFORE it posts to
* the iframe and drives it locally via a `MessageChannel` port (the function is
* never structured-cloned, so no `DataCloneError`). Layering our own Proxy on top
* risks re-wrapping that surface; reaching the real `ng` held in the config avoids
* the double-proxy exactly as the raw `docs` primitives do. Do not import from
* `./ng-proxy`.
*
* ── The primitive shape (verified against nextgraph-rs) ────────────────────
* `ng.doc_subscribe(repo_o: string, session_id, callback)`
* (`sdk/js/lib-wasm/src/lib.rs:1907`) is **per-document** — one repo NURI, one
* callback. It is `async`, resolving to a JS **unsubscribe function**. The
* callback is invoked `callback(appResponse)` with a serialized `AppResponse`:
* `{ V0: { State | Patch | TabInfo | ... } }`. It pushes an initial `State`
* (plus a `TabInfo`) on subscribe, then a `Patch` per verified commit on the
* branch. Returning `true` from the callback also cancels; we cancel by calling
* the returned unsubscribe fn.
*
* ── Why per-document, never `orm_start_graph(graphs:[…])` ──────────────────
* A single not-yet-synced repo in an ORM graph fan-out makes `RepoNotFound` abort
* the WHOLE subscription (`initialize.rs:125-128`), so the readyPromise never
* resolves → the ~75s hang. `doc_subscribe` is per-branch/per-doc and has no
* fan-out: an absent doc breaks only its own subscription. {@link subscribeDocs}
* builds a set of these with per-doc error isolation to preserve that property.
*/
import { getConfig, getStoreRegistryDeps } from "../shared-wallet/bootstrap";
import { assertMayReach } from "../emulated-verifier/reach";
import { toNuri } from "../model/nuri";
import type { Nuri, NuriLike } from "../model/types";
/**
* A push from the platform to a document subscriber. Loosely typed: the raw
* serialized `AppResponse` (`{ V0: { State | Patch | TabInfo | ... } }`). The
* consumer typically ignores the payload and uses the push purely as a
* change SIGNAL (re-query on change — the read-model pattern), so this stays
* permissive rather than modelling every AppResponse variant.
*/
export type DocChange = unknown;
/**
* The discriminant of a {@link DocChange} — the single variant key of the raw
* `AppResponse` payload (`{ V0: { State | Patch | TabInfo | … } }`). It is NOT a
* closed enum: the platform may push other variants, so this is a bare `string`
* (e.g. `"State"`, `"Patch"`, `"TabInfo"`), or `undefined` when the shape can't
* be read. Verified against the CONTRACT-3 e2e probe (`e2e/sdk-entry.ts`): the
* variant is `Object.keys(resp.V0)[0]`. Exposed so a caller that needs the SYNC
* BARRIER (the first `State`, per CONTRACT 3) can distinguish it from the earlier
* `TabInfo`/`Patch` pushes — see `open-repo.ts`. Most callers ignore it and use
* any push as a plain change signal.
*/
export type DocChangeType = string | undefined;
/**
* Extract the variant key from a raw {@link DocChange}. Reads `resp.V0` (case-
* tolerant to `v0`) and returns its first key — the AppResponse variant name
* (`"State"` / `"Patch"` / `"TabInfo"` / …). Returns `undefined` if the payload
* is not a recognisable `{ V0: { <Variant>: … } }` object. Inspects the variant
* proplerly (no `any`-cast to force it) so a `State` push is identifiable.
*/
export function docChangeType(resp: DocChange): DocChangeType {
if (!resp || typeof resp !== "object") return undefined;
const outer = resp as { V0?: unknown; v0?: unknown };
const v0 = outer.V0 ?? outer.v0;
if (!v0 || typeof v0 !== "object") return undefined;
const keys = Object.keys(v0 as Record<string, unknown>);
return keys.length > 0 ? keys[0] : undefined;
}
/** An unsubscribe function — idempotent (calling it twice is a no-op). */
export type Unsubscribe = () => void;
async function sessionId(): Promise<string> {
return (await getStoreRegistryDeps().getSession()).sessionId;
}
/**
* Subscribe to ONE document. `onChange` fires on the initial state push and on
* every subsequent change to that doc (local write OR broker-synced remote
* change). Returns an unsubscribe function.
*
* The wrapper is synchronous-returning (an unsubscribe fn) even though the
* underlying `ng.doc_subscribe` is async: the real unsubscribe is captured when
* the promise resolves; if the caller unsubscribes before setup completes, the
* cancellation is honoured as soon as the real unsubscribe is available (and no
* further `onChange` fires after unsubscribe).
*
* `onChange` receives the raw payload AND its variant type ({@link docChangeType},
* e.g. `"State"`). The type is a NON-BREAKING second argument: existing callers
* that ignore it (the change-signal pattern — `discovery.ts`, `inbox.ts`) are
* unaffected; a caller that needs the sync barrier (`open-repo.ts`) reads it to
* act only on the first `State`.
*
* Calls the REAL injected `ng.doc_subscribe` directly (never `makeNg`).
*/
export function subscribeDoc(
nuriLike: NuriLike,
onChange: (r: DocChange, type: DocChangeType) => void,
): Unsubscribe {
const nuri = toNuri(nuriLike, "subscribeDoc");
// RULE 1 — a subscription IS an access: the push carries the document's state.
// Guarding the read paths while leaving this open would be a door beside the gate.
assertMayReach(nuri, "subscribeDoc");
return subscribeDocUnguarded(nuri, onChange);
}
/**
* The unguarded core. Exported for ONE importer — `shared-wallet/physical.ts`, which
* owns the machinery's entire privileged door — and for nobody else. It is not
* re-exported by either entry point; the `Unguarded` suffix is the warning.
*/
export function subscribeDocUnguarded(
nuri: Nuri,
onChange: (r: DocChange, type: DocChangeType) => void,
): Unsubscribe {
const { ng } = getConfig();
let stopped = false;
let realUnsub: (() => void) | null = null;
const cb = (resp: DocChange): void => {
if (stopped) return;
try {
onChange(resp, docChangeType(resp));
} catch (error) {
console.error("[subscribe] onChange handler threw for", nuri, error);
}
};
// Kick off the async subscription. Errors are isolated to this doc (they never
// reject a shared batch — see subscribeDocs). If setup fails, this doc simply
// never fires; the caller's unsubscribe stays a safe no-op.
void (async () => {
try {
const sid = await sessionId();
const unsub = (await ng.doc_subscribe(nuri, sid, cb)) as (() => void) | undefined;
if (stopped) {
// Unsubscribed before setup resolved — cancel immediately.
if (typeof unsub === "function") unsub();
return;
}
realUnsub = typeof unsub === "function" ? unsub : null;
} catch (error) {
console.error("[subscribe] doc_subscribe failed for", nuri, error);
}
})();
return () => {
if (stopped) return;
stopped = true;
if (realUnsub) {
try {
realUnsub();
} catch (error) {
console.error("[subscribe] unsubscribe failed for", nuri, error);
}
realUnsub = null;
}
};
}
/**
* Subscribe to a SET of documents, one {@link subscribeDoc} per NURI, with
* PER-DOC error isolation. `onChange(nuri, r)` fires for whichever doc changed.
* Returns a single unsubscribe that tears down all of them.
*
* The per-doc isolation is the point: a bad / not-yet-synced doc breaks only its
* own subscription and NEVER aborts the others (this is precisely what avoids the
* ORM fan-out hang — do NOT replace this with `orm_start_graph(graphs:[…])`). The
* set is deduplicated; an empty set returns a no-op unsubscribe.
*/
export function subscribeDocs(
nuris: Nuri[],
onChange: (nuri: Nuri, r: DocChange, type: DocChangeType) => void,
): Unsubscribe {
const unique = [...new Set(nuris.filter(Boolean))];
const unsubs = unique.map((nuri) => {
// Each subscription is independent: subscribeDoc already isolates its own
// async setup failure (logged, never thrown), so one bad doc cannot abort the
// construction of the others here.
try {
return subscribeDoc(nuri, (r, type) => onChange(nuri, r, type));
} catch (error) {
console.error("[subscribe] subscribeDocs: failed to subscribe", nuri, error);
return () => {};
}
});
return () => {
for (const u of unsubs) {
try {
u();
} catch (error) {
console.error("[subscribe] subscribeDocs: unsubscribe failed", error);
}
}
};
}
+17
View File
@@ -0,0 +1,17 @@
/**
* Wrapped `useShape`: same signature as `@ng-org/orm`. Once the cap emulation is
* in force, the returned set is a read-filtered VIEW (only items in documents the
* current holder has the ReadCap of); before the first cap is issued it passes the
* real set through unchanged. At migration the filtering disappears — the broker
* only delivers documents whose cap the wallet holds.
*/
import { getConfig, getCaps } from "../shared-wallet/bootstrap";
import { makeReadFilteredView } from "../emulated-verifier/read-filter";
export function useShape(shapeType: unknown, scope: unknown): unknown {
const set = getConfig().useShape(shapeType, scope) as object;
const caps = getCaps();
if (!caps.isEnforcing()) return set; // no cap issued yet → passthrough
return makeReadFilteredView(set, caps);
}
+386
View File
@@ -0,0 +1,386 @@
/**
* watch-shape — a REACTIVE, TanStack-`useQuery`-shaped read over one SHEX shape in
* one logical scope. This is the surface the consuming app will bind (phase B) with
* `useSyncExternalStore` — the polyfill deliberately exposes an OBSERVABLE, never a
* React hook (the lib has NO React dependency, same constraint as `subscribe.ts`).
*
* ── Why an observable, and why this exact shape ────────────────────────────
* It anticipates NextGraph's planned `useShape(shape, scope)` upgrade, which will
* natively distinguish "sync in progress" from "synced but empty". That distinction
* ALREADY exists lib-internally (`open-repo.ts` `getSyncState`: syncing / synced /
* timed-out); `watchShape` merely SURFACES it as a `useQuery`-minimal snapshot:
* `ShapeQuery<T> = { data: T[]; isPending; isSuccess; isError; error }`.
* `data` is ALWAYS an array (never `undefined`), so a synced-but-empty scope reads
* `{ data: [], isPending: false, isSuccess: true }` — the key distinction — while a
* scope still syncing reads `{ data: [], isPending: true, isSuccess: false }`.
*
* ── What the observable OWNS (the whole read pipeline) ─────────────────────
* 1. Resolve the logical scope → the doc set: the CURRENT wallet's own per-entity
* documents for that scope (`storeRegistry.listMyEntityDocs`), and nothing
* else. There is no "everything public" to fold in — **you cannot discover,
* you can only follow links** (see docs/readcap-and-nuri-model.md §4ter-bis),
* and a link reaches you through an inbox or through a document you already
* hold. A document whose cap you were given is read by NAMING it
* (`readUnion`), not by turning up in a scope you never put it in.
* 2. Open the docs (`ensureReposOpen`) — this AWAITS the sync BARRIER (first
* `State` per doc, `getSyncState` → `synced`, or `timed-out` on the bounded
* fallback). `isPending` holds until the barrier is reached for the current
* doc set AND the first `readUnion` has rendered.
* 3. `readUnion(docs)` — the read-model (cap filter already applied inside; we do
* NOT double-filter), then FILTER the union by the requested shape's `@type`
* (a `readUnion` union spans multiple types; each `watchShape` yields only the
* subjects of its shape). Non-domain: the type IRI is read from the SHEX
* ShapeType, not from any application concept.
*
* ── Reactivity WITHOUT polling (no `setInterval`) ──────────────────────────
* Reactivity is push-only (rule no-broker-polling). It has TWO sources: document
* pushes, and the KEYRING — a cap that arrives asynchronously (an inbox delivery
* absorbed by the consumer's `inbox.watch`) makes documents readable that were not,
* so `CapRegistry.onChange` re-reads. Without that, a view stays stale until an
* unrelated push happens to fire. On the document side, `subscribeDoc` on every doc
* in the current set re-runs `readUnion` on any push. The set is DYNAMIC (creating an
* entity appends a NURI to the scope-index doc), so we ALSO subscribe to the
* scope-index document: a push there re-RESOLVES the scope and re-keys the
* subscribed set. Subscriptions are idempotent — an already-followed
* doc is not re-subscribed. Everything reuses `subscribe.ts` / `open-repo.ts`; no
* parallel channel.
*
* ── timed-out → isSuccess (best-effort), NOT isError ───────────────────────
* A doc whose barrier fell back to `timed-out` still counts as "barrier reached"
* (`isSuccess`): a slow-but-empty wallet must read as empty-success, not error.
* `isError` fires ONLY on a real thrown exception in the pipeline.
*/
import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
import { ensureReposOpen, getSyncState } from "../emulated-verifier/open-repo";
import { readUnion, type UnionSubject } from "./read-model";
import { subscribeDoc, type Unsubscribe } from "./subscribe";
import { listMyEntityDocs, userStoreDoc } from "../shared-wallet/account-registry";
import type { Nuri, Scope } from "../model/types";
/**
* The RDF `type` predicate IRI. A SHEX shape pins its class via a triple
* constraint on this predicate; we filter the read union by it.
*/
const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
/**
* A minimal TanStack-`useQuery`-shaped read snapshot. `data` is ALWAYS an array
* (never `undefined`). Defaults `T` to {@link UnionSubject} — `watchShape` yields
* the generic per-subject property bags of the read-model (NO application domain);
* the app maps them to its own entity types in phase B.
*/
export interface ShapeQuery<T = UnionSubject> {
/** The subjects of the requested shape/scope. Empty array when none (never undefined). */
data: T[];
/** True while the sync barrier for the current doc set is not yet reached OR the
* first `readUnion` has not rendered. Mutually exclusive with `isSuccess`. */
isPending: boolean;
/** True once the barrier is reached (all docs `synced` OR `timed-out`) AND the
* first `readUnion` has rendered. A synced-but-EMPTY scope is `isSuccess` with
* `data: []` — the distinction this surface exists for. */
isSuccess: boolean;
/** True ONLY on a real thrown exception in the read pipeline (never for `timed-out`). */
isError: boolean;
/** The caught error when `isError`, else `undefined`. */
error: unknown;
}
/** The observable a caller binds with `useSyncExternalStore` (phase B). */
export interface ShapeObservable<T = UnionSubject> {
/** The current snapshot. STABLE across calls until it actually changes (so
* `useSyncExternalStore` does not loop): the same reference is returned until a
* state transition produces a new one. */
getSnapshot(): ShapeQuery<T>;
/** Register a change listener; returns an unsubscribe. The last listener's
* unsubscribe tears down the underlying doc subscriptions. */
subscribe(onChange: () => void): () => void;
/** Force a re-resolve + re-read now (e.g. an imperative refresh). Idempotent
* w.r.t. subscriptions; never polls. */
refetch(): void;
}
/**
* Derive the class IRI(s) a SHEX {@link ShapeType} pins on `rdf:type`, if any.
* A generated shape constrains its subject's type via a triple constraint on the
* `rdf:type` predicate whose `literals` carry the class IRI(s). Returns the set of
* those IRIs, or `null` when the shape pins NO type (then no type-filter is applied
* and every subject in the doc set flows through). Purely structural — reads only
* the SHEX schema, no application domain.
*/
function shapeTypeIris(shapeType: unknown): Set<string> | null {
try {
const st = shapeType as {
shape?: string;
schema?: Record<string, { predicates?: Array<{ iri?: string; dataTypes?: Array<{ literals?: unknown[] }> }> }>;
};
const shape = st?.shape && st.schema ? st.schema[st.shape] : undefined;
const preds = shape?.predicates ?? [];
const iris = new Set<string>();
for (const p of preds) {
if (p?.iri !== RDF_TYPE) continue;
for (const dt of p.dataTypes ?? []) {
for (const lit of dt.literals ?? []) {
if (typeof lit === "string") iris.add(lit);
}
}
}
return iris.size > 0 ? iris : null;
} catch {
return null;
}
}
/** Whether a subject satisfies the shape's `@type` constraint (or the shape pins none). */
function matchesShape(subject: UnionSubject, typeIris: Set<string> | null): boolean {
if (!typeIris) return true; // shape pins no rdf:type → accept every subject
const types = subject.props[RDF_TYPE] ?? [];
return types.some((t) => typeIris.has(t));
}
/**
* Whether the sync BARRIER is reached for the whole doc set. Called only AFTER
* `ensureReposOpen(docs)` has resolved, so each doc has been requested; the only
* state that still holds the barrier open is `syncing` (subscribed, first `State`
* not yet received). `synced` and `timed-out` both count as reached (`timed-out` is
* best-effort, not an error). `unknown` means the injected `ng` has no
* `doc_subscribe` (the fake/no-op open path, which has NO barrier semantics) — after
* a completed open it can only mean that path, so it counts as reached (opened,
* nothing to wait on). An EMPTY doc set is trivially past the barrier.
*/
function barrierReached(docs: Nuri[]): boolean {
for (const d of docs) {
if (getSyncState(d) === "syncing") return false;
}
return true;
}
/**
* Create a reactive, `useQuery`-shaped observable over one SHEX `shapeType` in one
* logical `scope` (`'public' | 'protected' | 'private'`). See the module header for
* the full pipeline. The returned observable is inert until its first
* {@link ShapeObservable.subscribe} (or {@link ShapeObservable.refetch}) — that is
* what kicks off resolution, opening and the first read; before then `getSnapshot`
* reports the initial pending snapshot.
*/
export function watchShape<T = UnionSubject>(
shapeType: unknown,
scope: Scope,
): ShapeObservable<T> {
const typeIris = shapeTypeIris(shapeType);
// The current, STABLE snapshot (same reference until a transition rebuilds it).
let snapshot: ShapeQuery<UnionSubject> = {
data: [],
isPending: true,
isSuccess: false,
isError: false,
error: undefined,
};
const listeners = new Set<() => void>();
let started = false;
// The docs currently subscribed for change signals, keyed by NURI → unsubscribe.
// Idempotent: a doc already here is not re-subscribed. Excludes the container
// (scope-index / discovery-index) subscriptions, held separately.
const docSubs = new Map<Nuri, Unsubscribe>();
// Container subscriptions (scope-index doc; discovery-index doc for `public`) —
// a push here means the doc SET may have changed → re-resolve.
const containerSubs = new Map<Nuri, Unsubscribe>();
// Unsubscribe from the held-caps change signal (see the subscription in `start`).
let capsUnsub: (() => void) | null = null;
// True while `resolveDocs` runs. Folding a repo link files a cap, which fires the
// held-caps signal; the resolution in progress already accounts for it, so the
// signal is ignored during that window instead of restarting the cycle.
let resolving = false;
// Monotonic token so a slow in-flight refresh cannot clobber a newer one.
let refreshToken = 0;
function emit(): void {
for (const l of listeners) {
try {
l();
} catch (error) {
console.error("[watch-shape] listener threw", error);
}
}
}
function setSnapshot(next: ShapeQuery<UnionSubject>): void {
snapshot = next;
emit();
}
/** Resolve the logical scope → the current doc set: the CURRENT wallet's own
* entity documents for that scope, and nothing else. Tolerant: a resolution
* failure yields whatever resolved.
*
* There is no "everything public" to fold in. You cannot discover; you can only
* follow links, and a link reaches you through an inbox or through a document
* you already hold — never through a shared index. A document someone gave you
* the cap for is read by naming it (`readUnion`), not by appearing in
* a scope you did not put it in. */
async function resolveDocs(): Promise<Nuri[]> {
const user = getCurrentUser();
const set = new Set<Nuri>();
resolving = true;
try {
if (user) {
try {
for (const d of await listMyEntityDocs(user, scope)) set.add(d);
} catch (error) {
console.error("[watch-shape] listMyEntityDocs failed", error);
}
}
} finally {
resolving = false;
}
return [...set];
}
/** Subscribe to the CONTAINER document (the scope index) so a change to the doc
* SET re-resolves. Idempotent per NURI. */
async function ensureContainerSubs(): Promise<void> {
const containers: Nuri[] = [];
const user = getCurrentUser();
if (user) {
try {
containers.push(await userStoreDoc(user, scope));
} catch (error) {
console.error("[watch-shape] userStoreDoc failed", error);
}
}
for (const c of containers) {
if (!c || containerSubs.has(c)) continue;
// A push on a container doc means the set may have changed → full re-resolve.
containerSubs.set(c, subscribeDoc(c, () => void refresh()));
}
}
/** Re-key the per-doc change subscriptions to exactly `docs` (idempotent adds,
* prune removed). A push on any of these re-reads (data-only, no re-resolve). */
function syncDocSubs(docs: Nuri[]): void {
const wanted = new Set(docs.filter(Boolean));
for (const [nuri, unsub] of docSubs) {
if (!wanted.has(nuri)) {
try {
unsub();
} catch {
/* ignore */
}
docSubs.delete(nuri);
}
}
for (const nuri of wanted) {
if (docSubs.has(nuri)) continue;
docSubs.set(nuri, subscribeDoc(nuri, () => void reread()));
}
}
/** Read (union + shape filter) the CURRENT doc set and publish a snapshot.
* Derives isPending/isSuccess from the barrier + whether the read rendered. */
async function readAndPublish(docs: Nuri[], token: number): Promise<void> {
let subjects: UnionSubject[];
try {
subjects = await readUnion(docs);
} catch (error) {
if (token !== refreshToken) return;
setSnapshot({ data: [], isPending: false, isSuccess: false, isError: true, error });
return;
}
if (token !== refreshToken) return; // superseded by a newer refresh/reread
const data = subjects.filter((s) => matchesShape(s, typeIris));
const past = barrierReached(docs);
setSnapshot({
data,
isPending: !past,
isSuccess: past,
isError: false,
error: undefined,
});
}
/** Full cycle: resolve the scope, (re)establish container subs, open the docs
* (await the barrier), sync per-doc subs, then read + publish. */
async function refresh(): Promise<void> {
const token = ++refreshToken;
try {
await ensureContainerSubs();
const docs = await resolveDocs();
if (token !== refreshToken) return;
syncDocSubs(docs);
// Open/await the barrier (first State per doc, or timed-out). No-op once open.
await ensureReposOpen(docs);
if (token !== refreshToken) return;
await readAndPublish(docs, token);
} catch (error) {
if (token !== refreshToken) return;
setSnapshot({ data: [], isPending: false, isSuccess: false, isError: true, error });
}
}
/** A push on an already-open doc: re-read the CURRENT set only (no re-resolve,
* the set is unchanged). Reuses the docs we are subscribed to. */
async function reread(): Promise<void> {
const token = ++refreshToken;
const docs = [...docSubs.keys()];
await readAndPublish(docs, token);
}
function start(): void {
if (started) return;
started = true;
// A cap that arrives ASYNCHRONOUSLY (an inbox deposit absorbed by the
// consumer's `inbox.watch`) makes documents readable that were not. Without
// this the view would stay stale until some unrelated push happened to fire —
// so re-read whenever they change. This is the delivery channel key
// ROTATION uses too, which is why keeping an access needs no subscription
// obligation on the consumer's side.
capsUnsub = getCaps().onChange(() => {
if (!resolving) void refresh();
});
void refresh();
}
return {
getSnapshot(): ShapeQuery<T> {
return snapshot as unknown as ShapeQuery<T>;
},
subscribe(onChange: () => void): () => void {
listeners.add(onChange);
start();
return () => {
listeners.delete(onChange);
if (listeners.size === 0) {
// Last listener gone → tear down the underlying subscriptions. A later
// subscribe restarts a fresh cycle.
for (const u of docSubs.values()) {
try {
u();
} catch {
/* ignore */
}
}
for (const u of containerSubs.values()) {
try {
u();
} catch {
/* ignore */
}
}
docSubs.clear();
containerSubs.clear();
if (capsUnsub) {
capsUnsub();
capsUnsub = null;
}
started = false;
}
};
},
refetch(): void {
start();
void refresh();
},
};
}
+119
View File
@@ -0,0 +1,119 @@
/**
* The access gate's identity resolution.
*
* This is the piece whose failure is SILENT: get the order wrong and the broker iframe
* reads an empty identity, provisions a second virtual user, and the returning user
* lands in an empty space with no error anywhere. So the order is pinned, not trusted.
*/
import { getCurrentUser } from "../src/shared-wallet/bootstrap";
import { test, expect, afterEach } from "bun:test";
import { configure, configureStoreRegistry, setCurrentUser } from "../src/index";
import { resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import { ensureIdentity } from "../src/shared-wallet/access-gate";
const KEY = "ng-eventually:identity";
/** A localStorage double — the real one is absent in `bun test`. */
function fakeStorage(initial: Record<string, string> = {}) {
const map = new Map(Object.entries(initial));
return {
getItem: (k: string) => map.get(k) ?? null,
setItem: (k: string, v: string) => void map.set(k, v),
removeItem: (k: string) => void map.delete(k),
get size() { return map.size; },
};
}
/** Put the page in a given URL + storage state, as the browser would. */
function inPage(search: string, storage: ReturnType<typeof fakeStorage>) {
(globalThis as any).location = { search, href: "https://app.example" + search };
(globalThis as any).localStorage = storage;
(globalThis as any).history = { replaceState: () => {} };
}
afterEach(() => {
setCurrentUser(null);
resetConfig();
resetStoreRegistry();
delete (globalThis as any).location;
delete (globalThis as any).localStorage;
delete (globalThis as any).history;
});
function configured() {
configureStoreRegistry({
getSession: async () => ({ sessionId: "s", privateStoreId: "did:ng:o:p" }),
normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(),
});
configure({
ng: {} as never,
useShape: (() => {}) as never,
sharedWallet: { fileUrl: "/w.ngw", password: "pw" },
});
}
test("an identity already set is left alone — the gate never re-asks", async () => {
configured();
inPage("", fakeStorage());
setCurrentUser("alice");
await ensureIdentity();
expect(getCurrentUser()).toBe("alice");
});
test("the URL parameter WINS over storage — it is the only thing that crosses the frontier", async () => {
// The top-level page and the broker iframe have separate localStorage partitions, so a
// value written on one side is not the value the other reads. The URL survives the
// round-trip; storage does not. If storage won here, a user entering a second
// identifier would keep being sent back to the first one's space.
configured();
inPage("?ng-id=fromurl", fakeStorage({ [KEY]: "fromstorage" }));
await ensureIdentity();
expect(getCurrentUser()).toBe("fromurl");
});
test("the URL parameter is copied into THIS partition, so a plain reload still knows", async () => {
configured();
const storage = fakeStorage();
inPage("?ng-id=carol", storage);
await ensureIdentity();
expect(storage.getItem(KEY)).toBe("carol");
});
test("with no parameter, storage answers — a reload does not re-ask", async () => {
configured();
inPage("", fakeStorage({ [KEY]: "dana" }));
await ensureIdentity();
expect(getCurrentUser()).toBe("dana");
});
test("nothing known and no DOM to ask on → it refuses loudly", async () => {
// Continuing silently would provision an anonymous virtual space, which is the failure
// this module exists to prevent. The error names what the caller must do.
configured();
inPage("", fakeStorage());
await expect(ensureIdentity()).rejects.toThrow(/no DOM to ask on/i);
});
test("no shared wallet configured → it refuses, rather than inventing a space", async () => {
configure({ ng: {} as never, useShape: (() => {}) as never });
inPage("", fakeStorage());
await expect(ensureIdentity()).rejects.toThrow(/no shared wallet configured/i);
});
test("the URL value is NORMALIZED on the way in — `@Erin` and `erin` are one space", async () => {
// Ported from the consumer's `identifiant-resolution` feature, and it caught a real
// defect here: the gate normalized what a user TYPED but not what the URL carried, so
// a link with `?ng-id=@Erin` keyed onto a different virtual user than the same person
// typing `erin`. One normalizer — the injected one — for all three entry paths.
configured();
inPage("?ng-id=@Erin", fakeStorage());
await ensureIdentity();
expect(getCurrentUser()).toBe("erin");
});
test("a stored value is normalized too — an old entry cannot key onto a second space", async () => {
configured();
inPage("", fakeStorage({ [KEY]: "@Frank" }));
await ensureIdentity();
expect(getCurrentUser()).toBe("frank");
});
+306
View File
@@ -0,0 +1,306 @@
/**
* access-log.test.ts — behavioral tests for logAccess / setAccessLog / enabled
* (src/access-log.ts), as wired through the docs primitives (src/docs.ts).
*
* Tests:
* (a) OFF by default: reads + writes via sparqlQuery / sparqlUpdate / docCreate
* emit nothing to console.log.
* (b) ON via configure({ debugAccessLog: true }): each read/write emits a line
* matching `[<identity>][polyfill] READ/WRITE <shortNuri> (<label>)` (identity
* FIRST, `[polyfill]` glued right after) plus row-count suffix on READs. The
* NURI is shortened by shortNuri (did:ng:o: prefix + :v: suffix stripped,
* RepoID truncated to 8 chars + ellipsis).
* (c) ON via env var NG_EVENTUALLY_ACCESS_LOG=1: same behavior without changing
* calling code.
* (d) Identity follows setCurrentUser: after setCurrentUser the prefix changes.
*
* Spy approach: replace console.log with a mock, restore it after each test.
* Env var tests set/delete process.env.NG_EVENTUALLY_ACCESS_LOG and restore it.
*/
import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun:test";
import { setAccessLog, enabled, shortNuri } from "../src/shared-wallet/access-log";
import { docCreate, sparqlUpdate, sparqlQuery } from "../src/surface/docs";
import { configure, configureStoreRegistry, connectedUser, setCurrentUser } from "../src/index";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function fakeNg() {
return {
doc_create: mock(async (..._a: unknown[]) => "did:ng:o:log-doc"),
sparql_update: mock(async (..._a: unknown[]) => undefined),
sparql_query: mock(async (..._a: unknown[]) => ({
results: { bindings: [{ x: { value: "v" } }] }, // 1 row so row-count is visible
})),
};
}
function injectFake(debugAccessLog = false) {
const ng = fakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any, debugAccessLog });
configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-log", privateStoreId: "P" }),
});
return ng;
}
// Capture console.log lines for the duration of a test.
// Returns the captured lines array and a restore function.
function spyConsoleLog(): { lines: string[]; restore: () => void } {
const lines: string[] = [];
const orig = console.log;
console.log = (...args: unknown[]) => {
lines.push(args.map(String).join(" "));
};
return { lines, restore: () => { console.log = orig; } };
}
// ---------------------------------------------------------------------------
// Lifecycle: restore config state after each test to avoid cross-test bleed.
// ---------------------------------------------------------------------------
// Save the env var value that was present BEFORE any test ran, so tests
// that run in an environment where NG_EVENTUALLY_ACCESS_LOG is already set
// don't permanently destroy that value.
const _originalEnvVar = process.env?.NG_EVENTUALLY_ACCESS_LOG;
afterEach(() => {
setAccessLog(false); // always reset the config toggle
setCurrentUser(null); // clear active identity
// Restore the original env var value (don't just delete — it may have existed before)
if ((globalThis as any)?.process?.env) {
if (_originalEnvVar === undefined) {
delete process.env.NG_EVENTUALLY_ACCESS_LOG;
} else {
process.env.NG_EVENTUALLY_ACCESS_LOG = _originalEnvVar;
}
}
});
afterAll(() => {
resetConfig();
resetStoreRegistry();
setAccessLog(false);
setCurrentUser(null);
});
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
// Two process-wide things bite this suite, which only wants to watch the log:
// - the cap registry: once ANY cap exists the reach guard applies to every reader;
// - connecting a user does WORK (restore + drain its inbox, see connect.ts), which
// both logs and files caps, asynchronously.
// So: let any in-flight connection finish, THEN clear. Awaiting rather than hoping
// is what makes this deterministic — `setCurrentUser` is fire-and-forget by design.
beforeEach(async () => {
await connectedUser();
resetCaps();
});
describe("access-log: OFF by default", () => {
beforeEach(() => {
// Force the env var OFF for these tests, regardless of the shell environment.
if ((globalThis as any)?.process?.env) {
delete process.env.NG_EVENTUALLY_ACCESS_LOG;
}
setAccessLog(false);
});
it("no console.log output for sparqlQuery when disabled", async () => {
injectFake(false);
const { lines, restore } = spyConsoleLog();
try {
await sparqlQuery("sid-log", "SELECT * {}");
} finally {
restore();
}
expect(lines.length).toBe(0);
});
it("no console.log output for sparqlUpdate when disabled", async () => {
injectFake(false);
const { lines, restore } = spyConsoleLog();
try {
await sparqlUpdate("sid-log", "INSERT DATA {}", "did:ng:o:x");
} finally {
restore();
}
expect(lines.length).toBe(0);
});
it("no console.log output for docCreate when disabled", async () => {
injectFake(false);
const { lines, restore } = spyConsoleLog();
try {
await docCreate("sid-log", "Graph", "data:graph", "store");
} finally {
restore();
}
expect(lines.length).toBe(0);
});
it("enabled() returns false when disabled", () => {
setAccessLog(false);
if (process.env) delete process.env.NG_EVENTUALLY_ACCESS_LOG;
expect(enabled()).toBe(false);
});
});
describe("access-log: ON via configure({ debugAccessLog: true })", () => {
beforeEach(async () => {
setCurrentUser("alice");
await connectedUser(); // drain the connection work before counting log lines
resetCaps();
});
it("sparqlQuery emits a READ line with identity, nuri, label, and row-count", async () => {
injectFake(true);
setCurrentUser("alice");
// `setCurrentUser` FIRES the connection work; draining it here (and only then
// dropping the caps it filed) is what keeps this test about the log and not about
// whether a background connect happened to win the race.
await connectedUser();
resetCaps();
const { lines, restore } = spyConsoleLog();
try {
await sparqlQuery("sid-log", "SELECT * {}", undefined, "did:ng:o:q", "myLabel");
} finally {
restore();
}
expect(lines.length).toBe(1);
expect(lines[0]).toMatch(/^\[alice\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
expect(lines[0]).toMatch(/READ/);
expect(lines[0]).toContain(shortNuri("did:ng:o:q")); // NURI shortened
expect(lines[0]).not.toContain("did:ng:o:"); // full prefix stripped
expect(lines[0]).toMatch(/myLabel/);
expect(lines[0]).toMatch(/→ 1 triple-rows/); // triple-count from the 1-row fake result
});
it("sparqlUpdate emits a WRITE line with identity, anchor nuri, and label", async () => {
injectFake(true);
setCurrentUser("alice");
// `setCurrentUser` FIRES the connection work; draining it here (and only then
// dropping the caps it filed) is what keeps this test about the log and not about
// whether a background connect happened to win the race.
await connectedUser();
resetCaps();
const { lines, restore } = spyConsoleLog();
try {
await sparqlUpdate("sid-log", "INSERT DATA {}", "did:ng:o:w", "writeLabel");
} finally {
restore();
}
expect(lines.length).toBe(1);
expect(lines[0]).toMatch(/^\[alice\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
expect(lines[0]).toMatch(/WRITE/);
expect(lines[0]).toContain(shortNuri("did:ng:o:w"));
expect(lines[0]).toMatch(/writeLabel/);
});
it("docCreate emits a WRITE line with identity and the returned nuri", async () => {
injectFake(true);
setCurrentUser("alice");
// `setCurrentUser` FIRES the connection work; draining it here (and only then
// dropping the caps it filed) is what keeps this test about the log and not about
// whether a background connect happened to win the race.
await connectedUser();
resetCaps();
const { lines, restore } = spyConsoleLog();
try {
await docCreate("sid-log", "Graph", "data:graph", "store");
} finally {
restore();
}
expect(lines.length).toBe(1);
expect(lines[0]).toMatch(/^\[alice\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
expect(lines[0]).toMatch(/WRITE/);
// The nuri is the value returned by ng.doc_create, shortened by shortNuri.
expect(lines[0]).toContain(shortNuri("did:ng:o:log-doc"));
});
it("enabled() returns true when set via setAccessLog", () => {
setAccessLog(true);
expect(enabled()).toBe(true);
});
});
describe("access-log: ON via env var NG_EVENTUALLY_ACCESS_LOG=1", () => {
it("emits READ line when env var is set, even without configure() setting", async () => {
// Set env var but do NOT pass debugAccessLog=true to configure
if (!(globalThis as any)?.process?.env) return; // skip in env-less runtimes
process.env.NG_EVENTUALLY_ACCESS_LOG = "1";
injectFake(false); // debugAccessLog = false explicitly
setCurrentUser("bob");
const { lines, restore } = spyConsoleLog();
try {
await sparqlQuery("sid-log", "SELECT * {}", undefined, "did:ng:o:env-q");
} finally {
restore();
}
expect(lines.length).toBe(1);
expect(lines[0]).toMatch(/^\[bob\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
expect(lines[0]).toMatch(/READ/);
expect(lines[0]).toContain(shortNuri("did:ng:o:env-q"));
});
it("env var NG_EVENTUALLY_ACCESS_LOG=true also enables the log", async () => {
if (!(globalThis as any)?.process?.env) return;
process.env.NG_EVENTUALLY_ACCESS_LOG = "true";
injectFake(false);
setCurrentUser("charlie");
const { lines, restore } = spyConsoleLog();
try {
await sparqlUpdate("sid-log", "INSERT DATA {}", "did:ng:o:env-w");
} finally {
restore();
}
expect(lines.length).toBe(1);
expect(lines[0]).toMatch(/^\[charlie\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
expect(lines[0]).toMatch(/WRITE/);
});
it("enabled() returns true when env var is set", () => {
if (!(globalThis as any)?.process?.env) return;
process.env.NG_EVENTUALLY_ACCESS_LOG = "1";
setAccessLog(false); // config toggle is off
expect(enabled()).toBe(true);
});
});
describe("access-log: identity follows setCurrentUser", () => {
it("prefix changes after setCurrentUser", async () => {
injectFake(true);
setCurrentUser("first-user");
const { lines, restore } = spyConsoleLog();
try {
await sparqlUpdate("sid-log", "INSERT DATA {}", "did:ng:o:id1", "step1");
setCurrentUser("second-user");
await sparqlUpdate("sid-log", "INSERT DATA {}", "did:ng:o:id2", "step2");
} finally {
restore();
}
// Only this test's own lines: connecting a user legitimately logs its own reads.
const mine = lines.filter((l) => l.includes("step1") || l.includes("step2"));
expect(mine.length).toBe(2);
expect(mine[0]).toMatch(/^\[first-user\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
expect(mine[1]).toMatch(/^\[second-user\]\[polyfill\] /);
});
it("prefix is (none) when no identity is set", async () => {
injectFake(true);
setCurrentUser(null); // no active identity
const { lines, restore } = spyConsoleLog();
try {
await sparqlUpdate("sid-log", "INSERT DATA {}", "did:ng:o:anon");
} finally {
restore();
}
expect(lines.length).toBe(1);
expect(lines[0]).toMatch(/^\[\(none\)\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
});
});
+75
View File
@@ -0,0 +1,75 @@
import { test, expect } from "bun:test";
import {
IdentityStore,
browserIdentityStore,
ACCOUNT_STORAGE_KEY,
type VirtualUserStorage,
} from "../src/shared-wallet/virtual-users";
// In-memory fake of the Storage subset — keeps this framework/DOM-agnostic.
function fakeStorage(): VirtualUserStorage & { map: Map<string, string> } {
const map = new Map<string, string>();
return {
map,
getItem: (k) => (map.has(k) ? (map.get(k) as string) : null),
setItem: (k, v) => void map.set(k, v),
removeItem: (k) => void map.delete(k),
};
}
test("IdentityStore: set persists a trimmed id, get reads it back", () => {
const s = fakeStorage();
const store = new IdentityStore(s);
expect(store.get()).toBeNull();
expect(store.set(" marie ")).toBe("marie"); // trimmed
expect(store.get()).toBe("marie");
expect(s.map.get(ACCOUNT_STORAGE_KEY)).toBe("marie");
});
test("IdentityStore: a blank id is ignored, keeps the previous value", () => {
const store = new IdentityStore(fakeStorage());
store.set("bob");
expect(store.set(" ")).toBe("bob");
expect(store.get()).toBe("bob");
});
test("IdentityStore: clear removes the id (no throw)", () => {
const store = new IdentityStore(fakeStorage());
store.set("bob");
store.clear();
expect(store.get()).toBeNull();
});
test("IdentityStore: null storage degrades to non-persisting (SSR-safe)", () => {
const store = new IdentityStore(null);
expect(store.get()).toBeNull();
expect(store.set("bob")).toBe("bob"); // returns the value, just doesn't persist
expect(store.get()).toBeNull();
store.clear(); // no throw
});
test("IdentityStore: swallows storage errors on read and write", () => {
const throwing: VirtualUserStorage = {
getItem: () => {
throw new Error("boom");
},
setItem: () => {
throw new Error("boom");
},
removeItem: () => {
throw new Error("boom");
},
};
const store = new IdentityStore(throwing);
expect(store.get()).toBeNull(); // read error swallowed → null
expect(() => store.set("bob")).not.toThrow();
expect(() => store.clear()).not.toThrow();
});
test("browserIdentityStore returns a working store (uses global localStorage if present)", () => {
const store = browserIdentityStore("ng-eventually.test.account");
expect(store).toBeInstanceOf(IdentityStore);
// Behaves regardless of environment: set returns the value.
expect(store.set("zoe")).toBe("zoe");
});
+325
View File
@@ -0,0 +1,325 @@
/**
* anti-fork.test.ts — behavioral tests for the reconnection fix in the
* polyfill-era shim (src/store-registry.ts), redesigned around the
* pointer → doc-shim indirection. Two groups, one file:
*
* (1) DETERMINISTIC RESOLUTION — a doc-shim whose account subject carries
* DUPLICATE scope-doc values (fork residue: several `shim:docPublic`) must
* resolve to the SAME canonical doc every time (lexicographically-smallest
* NURI), so the session that WROTE an entity and a fresh page that RESOLVES
* the doc never disagree. Robustness against PAST fork residue.
*
* (2) BARRIER-AUTHORITATIVE RECONNECT (the core fix) — a fresh page over a
* persistent wallet resolves the SAME account through the doc-shim's
* first-`State` BARRIER, with NO account-level retry. The account records
* live in a subscribable doc-shim (`did:ng:o:...`) reached via a write-once
* POINTER triple in the store-root; opening the doc-shim makes a cold read
* authoritative, so a genuinely-present account is found on the first read
* and never re-provisioned (no fork). This replaced the deleted
* `resolveAccountReliably` / `provisionRetry` account retry.
*/
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
import {
ensureAccount,
resolveAccount,
resetRegistryCache,
} from "../src/shared-wallet/account-registry";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
import { configure, configureStoreRegistry } from "../src/index";
import { resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
afterAll(() => {
resetConfig();
resetStoreRegistry();
resetRegistryCache();
resetOpenedRepos();
});
const SESSION: RegistrySession = { sessionId: "sid-af", privateStoreId: "PRIV-AF" };
const ROOT = "did:ng:PRIV-AF";
// ---------------------------------------------------------------------------
// Fake ng — in-memory quad store modelling the pointer → doc-shim indirection.
//
// - The POINTER (`<shim:root> <shim:shimDoc> <docShim>`) lives in the store-root
// graph (keyed by GRAPH <ROOT>).
// - AccountRecords live in the doc-shim (anchored default graph, keyed by the
// anchor arg = the doc-shim NURI).
// - doc_subscribe pushes an initial `State` so ensureRepoOpen resolves at once
// (the barrier).
// ---------------------------------------------------------------------------
interface Quad { g: string; s: string; p: string; o: string }
function unescapeLiteral(s: string): string {
let out = "";
for (let i = 0; i < s.length; i++) {
if (s[i] === "\\" && i + 1 < s.length) {
const next = s[++i];
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next;
} else {
out += s[i];
}
}
return out;
}
function makeSparqlUpdate(quads: Quad[]) {
return mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[2] as string | undefined;
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
let g: string;
let body: string;
if (gm) {
g = gm[1]!;
body = gm[2]!;
} else {
if (!anchor) return undefined;
g = anchor;
body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
}
const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
const s = sm[1]!;
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
while ((m = pairRe.exec(after)) !== null) {
const p = m[1] ?? "urn:ng-eventually:shim:Account";
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
quads.push({ g, s, p, o });
}
return undefined;
});
}
/** Build the account-SELECT bindings from the quads in ONE graph (the doc-shim),
* grouped per subject. A subject with DUPLICATE scope docs yields a cross-product
* of bindings — a corrupted shim. */
function accountBindings(quads: Quad[], anchor: string | undefined, onlySubject: string | null) {
const bySubject = new Map<string, { id: string; pub: string[]; prot: string[]; priv: string[] }>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (onlySubject !== null && q.s !== onlySubject) continue;
const rec = bySubject.get(q.s) ?? { id: "", pub: [], prot: [], priv: [] };
if (q.p === "urn:ng-eventually:shim:id") rec.id = q.o;
if (q.p === "urn:ng-eventually:shim:docPublic") rec.pub.push(q.o);
if (q.p === "urn:ng-eventually:shim:docProtected") rec.prot.push(q.o);
if (q.p === "urn:ng-eventually:shim:docPrivate") rec.priv.push(q.o);
bySubject.set(q.s, rec);
}
const bindings: Array<Record<string, { value: string }>> = [];
for (const rec of bySubject.values()) {
if (!rec.id) continue;
const pubs = rec.pub.length ? rec.pub : [""];
const prots = rec.prot.length ? rec.prot : [""];
const privs = rec.priv.length ? rec.priv : [""];
for (const pub of pubs)
for (const prot of prots)
for (const priv of privs)
bindings.push({
id: { value: rec.id },
docPublic: { value: pub },
docProtected: { value: prot },
docPrivate: { value: priv },
});
}
return bindings;
}
/** Reactive fake ng modelling the pointer → doc-shim indirection. `doc_subscribe`
* pushes a first `State` so the doc-shim barrier resolves synchronously. */
function makeFakeNg() {
const quads: Quad[] = [];
let docCounter = 0;
// Count account SELECTs per anchor graph. The AUTHORITATIVE account read is anchored
// to the doc-shim (a did:ng:o: NURI). Counting per-anchor lets a test assert "exactly
// one doc-shim account read" (no account RETRY).
const accountReadsByAnchor = new Map<string, number>();
const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`);
const sparql_update = makeSparqlUpdate(quads);
const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[3] as string | undefined;
// Pointer SELECT (store-root).
if (query.includes("<urn:ng-eventually:shim:shimDoc>")) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc")
.map((q) => ({ shimDoc: { value: q.o } }));
return { results: { bindings } };
}
// Account SELECT — anchored to the doc-shim (the authoritative read).
if (query.includes("<urn:ng-eventually:shim:id>")) {
accountReadsByAnchor.set(anchor ?? "", (accountReadsByAnchor.get(anchor ?? "") ?? 0) + 1);
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
return { results: { bindings: accountBindings(quads, anchor, subjM ? subjM[1]! : null) } };
}
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains")
.map((q) => ({ e: { value: q.o } }));
return { results: { bindings } };
});
// Push a `State` on subscribe (the sync barrier) so ensureRepoOpen resolves at once.
const doc_subscribe = mock(async (_repo: unknown, _sid: unknown, cb: Function) => {
if (typeof cb === "function") cb({ V0: { State: {} } });
return () => {};
});
return {
doc_create, sparql_update, sparql_query, doc_subscribe,
_quads: quads,
// Total account reads across all anchors.
getAccountQueryCount: () => [...accountReadsByAnchor.values()].reduce((a, b) => a + b, 0),
// Account reads anchored to a did:ng:o: doc-shim.
getDocShimAccountReads: () =>
[...accountReadsByAnchor.entries()]
.filter(([g]) => g.startsWith("did:ng:o:"))
.reduce((a, [, n]) => a + n, 0),
};
}
function inject(
fakeNg: unknown,
pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number },
) {
configure({ ng: fakeNg as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => SESSION,
normalizeId: (u) => u.trim().toLowerCase(),
pointerGuard,
});
resetRegistryCache();
resetOpenedRepos();
}
// ---------------------------------------------------------------------------
// (1) Deterministic resolution over fork residue (in the doc-shim)
// ---------------------------------------------------------------------------
describe("deterministic resolution over a doc-shim corrupted by fork residue", () => {
beforeEach(() => { resetRegistryCache(); resetOpenedRepos(); });
it("(1a) a subject with MULTIPLE docPublic values always resolves the SAME canonical (lexicographically-smallest)", async () => {
const fakeNg = makeFakeNg();
inject(fakeNg);
const docShim = "did:ng:o:shimdoc";
// Seed the pointer (store-root → doc-shim) and the corrupted record IN the doc-shim.
fakeNg._quads.push({ g: ROOT, s: "urn:ng-eventually:shim:root", p: "urn:ng-eventually:shim:shimDoc", o: docShim });
const subj = "urn:ng-eventually:shim:account:dupuser";
const dupPublics = [
"did:ng:o:pub-m", "did:ng:o:pub-a", "did:ng:o:pub-z", "did:ng:o:pub-c", "did:ng:o:pub-a",
];
fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:id", o: "dupuser" });
for (const p of dupPublics)
fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:docPublic", o: p });
fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:docProtected", o: "did:ng:o:prot-1" });
fakeNg._quads.push({ g: docShim, s: subj, p: "urn:ng-eventually:shim:docPrivate", o: "did:ng:o:priv-1" });
const r1 = await resolveAccount("dupuser");
resetRegistryCache();
const r2 = await resolveAccount("dupuser");
resetRegistryCache();
const viaShim = await resolveAccount("dupuser");
// Canonical = lexicographically smallest → "did:ng:o:pub-a".
expect(r1?.docPublic).toBe("did:ng:o:pub-a");
expect(r2?.docPublic).toBe(r1?.docPublic);
expect(viaShim?.docPublic).toBe(r1?.docPublic);
expect(viaShim?.docProtected).toBe("did:ng:o:prot-1");
expect(viaShim?.docPrivate).toBe("did:ng:o:priv-1");
});
});
// ---------------------------------------------------------------------------
// (2) Barrier-authoritative reconnect — the core fix (no account retry)
// ---------------------------------------------------------------------------
describe("reconnect resolves the SAME account through the doc-shim barrier (no fork, no account retry)", () => {
beforeEach(() => { resetRegistryCache(); resetOpenedRepos(); });
it("(2a) NO-FORK: account already in the doc-shim → reused, 0 new scope docs", async () => {
const fakeNg = makeFakeNg();
inject(fakeNg);
// First login: provisions the account (1 doc-shim + 3 scope docs).
const first = await ensureAccount("LauraBarrier");
expect(fakeNg.doc_create).toHaveBeenCalledTimes(4);
// Fresh page over the SAME persistent quads: reset all in-memory caches, keep the
// quads. Reconnect must find the SAME account via the doc-shim barrier, NO new docs.
resetRegistryCache();
resetOpenedRepos();
const second = await ensureAccount("LauraBarrier");
expect(second.docPublic).toBe(first.docPublic);
expect(second.docProtected).toBe(first.docProtected);
expect(second.docPrivate).toBe(first.docPrivate);
// Still 4 — no doc-shim re-created (pointer reused), no scope docs re-created.
expect(fakeNg.doc_create).toHaveBeenCalledTimes(4);
});
it("(2b) barrier-authoritative: a fresh session finds the persisted account on the FIRST read — single doc-shim read, NO retry", async () => {
// Seed session 1; capture the account NURIs + the persistent quads.
const seed = makeFakeNg();
inject(seed);
const orig = await ensureAccount("BarrierUser");
expect(seed.doc_create).toHaveBeenCalledTimes(4);
// Fresh reactive session over the SAME persistent quads. The doc-shim pushes a
// `State` on subscribe → resolveShimDoc opens the barrier → the account read is
// authoritative on the FIRST attempt. Give a MULTI-attempt pointer guard to prove
// it is NOT used for the account.
const reconnect = makeFakeNg();
reconnect._quads.push(...seed._quads);
inject(reconnect, { attempts: 8, baseMs: 1, maxStepMs: 2 });
const resolved = await ensureAccount("BarrierUser");
expect(resolved.docPublic).toBe(orig.docPublic);
expect(resolved.docProtected).toBe(orig.docProtected);
expect(resolved.docPrivate).toBe(orig.docPrivate);
expect(reconnect.doc_create).toHaveBeenCalledTimes(0); // no new docs → no fork
// Barrier-authoritative: found on the FIRST doc-shim read.
expect(reconnect.getDocShimAccountReads()).toBe(1);
});
it("(2c) GENUINELY NEW: a cold doc-shim reads 0 → provisioned exactly once, no retry", async () => {
// Pointer + doc-shim exist but the doc-shim holds NO record for this account.
const fakeNg = makeFakeNg();
inject(fakeNg, { attempts: 5, baseMs: 1, maxStepMs: 2 });
const docShim = "did:ng:o:preexisting-shim";
fakeNg._quads.push({ g: ROOT, s: "urn:ng-eventually:shim:root", p: "urn:ng-eventually:shim:shimDoc", o: docShim });
const rec = await ensureAccount("BrandNewUser");
// Provisioned exactly ONE set of 3 scope docs (the doc-shim already existed).
expect(fakeNg.doc_create).toHaveBeenCalledTimes(3);
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
expect(rec.docProtected).not.toBe(rec.docPublic);
// Barrier-authoritative: exactly ONE doc-shim account read (the 0 is definitive).
expect(fakeNg.getDocShimAccountReads()).toBe(1);
});
it("(2d) default budget (unset pointer guard): genuinely-new account → single doc-shim account read", async () => {
const fakeNg = makeFakeNg();
inject(fakeNg); // pointerGuard unset → attempts:1 (synchronous default)
const rec = await ensureAccount("SyncUser");
// 1 doc-shim + 3 scope docs.
expect(fakeNg.doc_create).toHaveBeenCalledTimes(4);
expect(fakeNg.getDocShimAccountReads()).toBe(1);
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
});
it("(2e) idempotence within a session: ensureAccount twice never creates 2 sets", async () => {
const fakeNg = makeFakeNg();
inject(fakeNg);
const a = await ensureAccount("SameUser");
const b = await ensureAccount("SameUser");
expect(b).toEqual(a);
expect(fakeNg.doc_create).toHaveBeenCalledTimes(4); // 1 doc-shim + 3 scope docs
});
});
+193
View File
@@ -0,0 +1,193 @@
/**
* caps.test.ts — the cap surface as KEY POSSESSION.
*
* What these prove is a SHAPE, not a protection (the library is deliberately
* insecure until P1b): the only question the registry can answer is "do I hold
* this document's cap?", there is no principal to look up in a list, and no
* function turns a bare reference into a cap.
*/
import { test, expect } from "bun:test";
import { CapRegistry, mintCap } from "../src/emulated-verifier/caps";
import { hasReadCap, targetOf } from "../src/model/nuri";
import type { ReadCap } from "../src/model/types";
/** A registry whose holder the test drives. */
function registry(initial: string | null = "alice") {
let holder = initial;
const caps = new CapRegistry(() => holder);
return { caps, become: (id: string | null) => (holder = id) };
}
test("a cap NAMES and READS; the bare reference only names", () => {
const { caps } = registry();
const doc = "did:ng:o:doc1:v:overlay";
// Before anything: naming a document tells you nothing about reading it.
expect(caps.capFor(doc)).toBeUndefined();
const cap = caps.mint(doc);
expect(hasReadCap(cap)).toBe(true); // carries `:r:`
expect(hasReadCap(doc)).toBe(false);
expect(targetOf(cap)).toBe(doc); // same object, key inside
expect(caps.capFor(doc)).toBe(cap);
// Looking the cap up by the cap-bearing form resolves the same document.
expect(caps.capFor(cap)).toBe(cap);
});
test("no cap is derivable from a bare reference — you look it up or you were given it", () => {
const { caps } = registry();
caps.mint("did:ng:o:mine");
// A document that never entered the held caps stays unreadable, however well-formed
// its reference is. There is no `grantRead`, and no principal to name.
expect(caps.capFor("did:ng:o:someone-else")).toBeUndefined();
});
// Passing the naming form where the reading form is meant is now a COMPILE error
// (`ReadCap` is a template literal type). The runtime refusal still has to hold,
// because a JavaScript consumer — or a cap read back from storage, a URL or JSON
// and cast rather than narrowed — never meets the compiler. The `as` below is
// exactly that consumer: it is how the mistake reaches the library at all.
// Unchecked, it would file a bare reference as its own cap and make the document
// read — the exact inversion this batch removes.
test("learn REFUSES a bare reference, even when the compiler was bypassed", () => {
const { caps } = registry();
const bare = "did:ng:o:someone-elses-doc" as ReadCap; // a JS consumer / an unchecked cast
expect(() => caps.learn(bare)).toThrow(/naming is not reading|bare reference/i);
expect(caps.capFor("did:ng:o:someone-elses-doc")).toBeUndefined(); // nothing was filed
expect(caps.isEnforcing()).toBe(false); // and nothing was issued
});
test("holding one document's cap grants nothing on another (no inheritance)", () => {
const { caps } = registry();
caps.mint("did:ng:o:doc1");
expect(caps.capFor("did:ng:o:doc1")).toBeDefined();
expect(caps.capFor("did:ng:o:doc2")).toBeUndefined(); // separate repo, separate cap
});
test("one set of held caps PER holder: switching identity switches heldByHolder, it does not wipe", () => {
const { caps, become } = registry("alice");
const doc = "did:ng:o:alice-doc";
const cap = caps.mint(doc);
become("bob");
expect(caps.capFor(doc)).toBeUndefined(); // bob holds nothing of alice's
become("alice");
expect(caps.capFor(doc)).toBe(cap); // …and alice did not lose hers
});
test("a cap received (learn) reads, exactly like one minted", () => {
const alice = registry("alice");
const doc = "did:ng:o:shared";
const cap = alice.caps.mint(doc);
const bob = registry("bob");
expect(bob.caps.capFor(doc)).toBeUndefined();
bob.caps.learn(cap); // delivered to bob's inbox, absorbed
expect(bob.caps.capFor(doc)).toBe(cap);
});
// A public store SERVES its documents' caps (`emulated-verifier/public-store.ts`).
// This registry is one level below that: it records WHERE a document sits, and it
// files a served cap apart from one that was minted or deposited — because the two
// grant different things.
test("markInPublicStore records where a document sits, and mints nothing", () => {
const { caps, become } = registry("alice");
const doc = "did:ng:o:public-doc";
caps.markInPublicStore(doc);
expect(caps.isInPublicStore(doc)).toBe(true);
expect(caps.isInPublicStore("did:ng:o:other")).toBe(false);
// Marking is not holding: the fact is about the document, the cap is about a holder.
expect(caps.capFor(doc)).toBeUndefined();
become("bob");
expect(caps.capFor(doc)).toBeUndefined();
});
test("a cap SERVED by a public store reads, and is refused a write", () => {
const { caps, become } = registry("alice");
const doc = "did:ng:o:public-doc";
const served = mintCap(doc);
become("bob");
caps.learnFromPublicStore(served);
expect(caps.capFor(doc)).toBe(served); // he reads it, like any held cap
expect(caps.isReadOnlyPublicCap(doc)).toBe(true); // …and only that
// A stronger claim supersedes it: a cap DEPOSITED for me is not the network's copy.
caps.learn(served);
expect(caps.isReadOnlyPublicCap(doc)).toBe(false);
});
test("the owner of a public document is never read-only on it", () => {
const { caps, become } = registry("alice");
const doc = "did:ng:o:mine";
caps.open(doc, "public"); // alice created it
// A third party fetching the same document must not affect her claim on it.
become("bob");
caps.learnFromPublicStore(mintCap(doc));
expect(caps.isReadOnlyPublicCap(doc)).toBe(true);
become("alice");
expect(caps.isReadOnlyPublicCap(doc)).toBe(false);
});
test("open(): a public document is marked as sitting in a public store, a private one is not", () => {
const { caps } = registry();
const pub = caps.open("did:ng:o:pub", "public");
const prot = caps.open("did:ng:o:prot", "protected");
const priv = caps.open("did:ng:o:priv", "private");
expect(caps.isInPublicStore("did:ng:o:pub")).toBe(true);
expect(caps.isInPublicStore("did:ng:o:prot")).toBe(false);
expect(caps.isInPublicStore("did:ng:o:priv")).toBe(false);
// All three are readable BY THEIR OWNER — a creator is never locked out.
for (const [doc, cap] of [["did:ng:o:pub", pub], ["did:ng:o:prot", prot], ["did:ng:o:priv", priv]] as const) {
expect(caps.capFor(doc)).toBe(cap);
}
});
test("open() is idempotent — re-listing my own documents refiles the same caps", () => {
const { caps } = registry();
const first = caps.open("did:ng:o:doc", "protected");
let fired = 0;
caps.onChange(() => (fired += 1));
expect(caps.open("did:ng:o:doc", "protected")).toBe(first);
expect(fired).toBe(0); // nothing changed → no spurious re-read
});
test("isEnforcing is false until the first cap exists, then holds for every holder", () => {
const { caps, become } = registry("alice");
expect(caps.isEnforcing()).toBe(false);
caps.mint("did:ng:o:doc1");
expect(caps.isEnforcing()).toBe(true);
// …including for a holder whose own holds nothing: that IS the isolation.
become("bob");
expect(caps.isEnforcing()).toBe(true);
expect(caps.capFor("did:ng:o:doc1")).toBeUndefined();
});
test("a cap arriving fires the change signal — an asynchronous delivery must re-trigger reads", () => {
const { caps } = registry();
let fired = 0;
const unsub = caps.onChange(() => (fired += 1));
caps.learn(caps.mint("did:ng:o:doc1")); // mint fires once; the learn is a no-op
expect(fired).toBe(1);
unsub();
caps.mint("did:ng:o:doc2");
expect(fired).toBe(1); // unsubscribed
});
test("write is restricted to write-cap holders (decorative until P1b)", () => {
const { caps } = registry();
expect(caps.hasWritePolicy()).toBe(false);
caps.grantWrite("did:ng:o:doc", "alice");
expect(caps.hasWritePolicy()).toBe(true);
expect(caps.governsWrite("did:ng:o:doc")).toBe(true);
expect(caps.governsWrite("did:ng:o:unknown")).toBe(false); // not declared → not enforced
expect(caps.canWrite("did:ng:o:doc", "alice")).toBe(true);
expect(caps.canWrite("did:ng:o:doc", "bob")).toBe(false);
expect(caps.canWrite("did:ng:o:doc", null)).toBe(false);
});
+205
View File
@@ -0,0 +1,205 @@
/**
* cold-start-anchor.test.ts — the shim ANCHOR (private-store-root) must be OPENED
* before the registry reads/writes it, or a cold anchor throws `RepoNotFound`.
*
* ── The gap this pins ──────────────────────────────────────────────────────
* The shim lives in the private-store-root graph (`did:ng:${privateStoreId}`, the
* "anchor"). Unlike a per-entity doc — whose anchored read on an unopened repo
* SILENTLY returns 0 rows — the private/store target resolves through the verifier's
* `resolve_target_for_sparql`, which HARD-errors `RepoNotFound` when the repo is not
* in `self.repos` (verified in nextgraph-rs `request_processor.rs`). On a wallet whose
* anchor repo is not yet loaded, both the shim READ (`resolveAccount`/`loadShim`) and
* the provision WRITE (`ensureAccount`) throw — so the account never provisions.
*
* The heal: `resolveAccount`/`loadShim`/`ensureAccount` call `ensureRepoOpen(anchor)`
* (open-repo.ts, via `doc_subscribe` + first-`State` barrier) before touching the
* shim — the same open-before-read guard `readUserStore` already applies to its
* index doc. This suite models a fake `ng` where the anchor throws `RepoNotFound`
* UNTIL it has been `doc_subscribe`-d, and asserts the registry provisions cleanly.
*
* RED without the heal (ensureAccount would throw on the cold anchor); GREEN with it.
*/
import { describe, it, expect, mock, afterAll, beforeEach } from "bun:test";
import { ensureAccount, resolveWriteGraph, resetRegistryCache } from "../src/shared-wallet/account-registry";
import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
import { configure, configureStoreRegistry, setCurrentUser } from "../src/index";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import { resetInfrastructure } from "../src/emulated-verifier/reach";
const SESSION = { sessionId: "sid-cold", privateStoreId: "PRIV-COLD" };
const ANCHOR = `did:ng:${SESSION.privateStoreId}`;
afterAll(() => {
resetConfig();
resetStoreRegistry();
resetRegistryCache();
resetOpenedRepos();
resetCaps();
resetInfrastructure();
setCurrentUser(null);
});
// The reach guard is process-wide and so is the cap registry: once ANY cap exists
// the boundary applies to every reader. A suite that declares none must therefore
// start from an empty one, or it inherits another suite's enforcement.
beforeEach(() => {
resetRegistryCache();
resetOpenedRepos();
resetCaps();
resetInfrastructure();
setCurrentUser(null);
});
interface Quad { g: string; s: string; p: string; o: string }
function unescapeLiteral(s: string): string {
let out = "";
for (let i = 0; i < s.length; i++) {
if (s[i] === "\\" && i + 1 < s.length) {
const next = s[++i];
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!;
} else out += s[i];
}
return out;
}
/**
* A fake `ng` whose ANCHOR repo behaves like the real private-store target:
* `sparql_query`/`sparql_update` anchored to it THROW `RepoNotFound` until the
* anchor has been `doc_subscribe`-d (i.e. opened into `self.repos`). Any OTHER
* anchor (per-entity docs) behaves normally. `doc_subscribe` fires the first
* `State` so `ensureRepoOpen` crosses the barrier.
*/
function makeColdAnchorNg() {
const quads: Quad[] = [];
let docCounter = 0;
const opened = new Set<string>();
let anchorSubscribes = 0;
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
const doc_subscribe = mock(async (nuri: string, _sid: string, cb: (r: unknown) => void) => {
if (nuri === ANCHOR) anchorSubscribes += 1;
opened.add(nuri);
setTimeout(() => cb({ V0: { State: {} } }), 0);
return () => {};
});
const sparql_update = mock(async (_sid: string, query: string, anchor?: string) => {
if (anchor === ANCHOR && !opened.has(ANCHOR)) throw new Error("RepoNotFound");
// TWO shapes: the POINTER write uses `GRAPH <root>` (keyed by IRI); the account
// record write into the doc-shim has NO explicit GRAPH (keyed by the anchor arg).
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
let g: string;
let body: string;
if (gm) {
g = gm[1]!;
body = gm[2]!;
} else {
if (!anchor) return undefined;
g = anchor;
body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
}
const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
const s = sm[1]!;
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
while ((m = pairRe.exec(after)) !== null) {
const p = m[1] ?? "urn:ng-eventually:shim:Account";
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
quads.push({ g, s, p, o });
}
return undefined;
});
const sparql_query = mock(async (_sid: string, query: string, _base: unknown, anchor?: string) => {
if (anchor === ANCHOR && !opened.has(ANCHOR)) throw new Error("RepoNotFound");
// Pointer SELECT (store-root -> doc-shim).
if (query.includes("<urn:ng-eventually:shim:shimDoc>")) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc")
.map((q) => ({ shimDoc: { value: q.o } }));
return { results: { bindings } };
}
const subjM = query.match(
/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/,
);
const onlySubject = subjM ? subjM[1]! : null;
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (onlySubject !== null && q.s !== onlySubject) continue;
const rec = bySubject.get(q.s) ?? {};
if (q.p === "urn:ng-eventually:shim:id") rec.id = q.o;
if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o;
if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = q.o;
if (q.p === "urn:ng-eventually:shim:docPrivate") rec.docPrivate = q.o;
bySubject.set(q.s, rec);
}
const bindings = [...bySubject.values()]
.filter((r) => r.id)
.map((r) => ({
id: { value: r.id! },
docPublic: { value: r.docPublic ?? "" },
docProtected: { value: r.docProtected ?? "" },
docPrivate: { value: r.docPrivate ?? "" },
}));
return { results: { bindings } };
});
return {
doc_create, doc_subscribe, sparql_update, sparql_query,
_quads: quads,
anchorSubscribeCount: () => anchorSubscribes,
};
}
function inject(ng: ReturnType<typeof makeColdAnchorNg>) {
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => SESSION,
normalizeId: (u: string) => u.trim().replace(/^@+/, "").toLowerCase(),
});
resetRegistryCache();
resetOpenedRepos();
}
describe("cold-start anchor heal", () => {
it("ensureAccount provisions over a COLD anchor (RepoNotFound-until-opened) without throwing", async () => {
const ng = makeColdAnchorNg();
inject(ng);
// Without the open-before-shim heal, the read AND the provision write would both
// throw RepoNotFound on the cold anchor and the account would never persist.
const rec = await ensureAccount("@cold-alice");
expect(rec.docPublic).toBeTruthy();
expect(rec.docProtected).toBeTruthy();
expect(rec.docPrivate).toBeTruthy();
// The anchor repo was actually opened (doc_subscribe-d) before the shim op.
expect(ng.anchorSubscribeCount()).toBeGreaterThan(0);
});
it("the provisioned account re-resolves from the shim (real persistence, no RepoNotFound)", async () => {
const ng = makeColdAnchorNg();
inject(ng);
const first = await ensureAccount("@cold-bob");
// Fresh cache → a real anchored re-read of the shim (anchor already opened → OK).
resetRegistryCache();
const again = await ensureAccount("@cold-bob");
expect(again.docPublic).toBe(first.docPublic);
expect(again.docProtected).toBe(first.docProtected);
expect(again.docPrivate).toBe(first.docPrivate);
});
it("resolveWriteGraph (scope resolver) works over a cold anchor", async () => {
const ng = makeColdAnchorNg();
inject(ng);
const g = await resolveWriteGraph("@cold-carol", "protected");
expect(g).toBeTruthy();
});
});
+535
View File
@@ -0,0 +1,535 @@
/**
* Cross-user access — the scenario that proves the model end to end.
*
* Alice owns a PROTECTED document and a PUBLIC one, and the public one carries a
* REFERENCE to the protected one. Then:
*
* - **Bob** has the public document's link. He reads it, sees the reference, and
* cannot read what it points at. Naming is not reading, and publication is
* **not recursive**: a public object may point at private content without
* disclosing it.
* - **Charlie** has the public document's link AND was given the protected
* document's cap. Same reference, same path — he reads through it.
* - **Bob, dynamically**: Alice delivers the cap to Bob's inbox. Processing the
* inbox files it, which fires the held-caps signal, which re-runs the read — the
* protected document appears with nothing else happening.
*
* The difference between Bob and Charlie is ONLY each of them holds. There is
* no authorization list anywhere, and nobody was named to the registry.
*/
import { getCaps } from "../src/shared-wallet/bootstrap";
import { test, expect, mock, afterAll } from "bun:test";
import {
createEntityDoc,
resetRegistryCache,
userInbox,
} from "../src/shared-wallet/account-registry";
import { documentInboxAddress, openDocumentInbox } from "../src/emulated-verifier/branch-registers";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
import { configure, configureStoreRegistry, connectedUser, setCurrentUser } from "../src/index";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import { share } from "../src/surface/inbox";
import { post, postToDocument, read as readInbox } from "../src/surface/inbox";
import { readUnion } from "../src/surface/read-model";
import { sparqlUpdate } from "../src/surface/docs";
import type { Nuri } from "../src/model/types";
/**
* Do I hold this document's cap? Possession, asked of the internal registry — the
* polyfill door stopped publishing this (see `polyfill.ts`), because as an app-facing
* question it reads like "may I read this?" and a public store's document answers
* `false` until something has asked for its cap.
*/
function hasCap(nuri: Nuri): boolean {
return getCaps().capFor(nuri) !== undefined;
}
afterAll(() => {
resetConfig();
resetStoreRegistry();
resetCaps();
setCurrentUser(null);
});
const SESSION: RegistrySession = { sessionId: "sid-x", privateStoreId: "PRIV-X" };
const SHIM = "urn:ng-eventually:shim";
const INBOX = "urn:ng-eventually:inbox";
/** The predicate Alice uses to point from her public doc at her protected one. */
const REFERS_TO = "urn:e2e:refersTo";
const SECRET = "urn:e2e:secret";
interface Quad { g: string; s: string; p: string; o: string }
function unescapeLiteral(s: string): string {
let out = "";
for (let i = 0; i < s.length; i++) {
if (s[i] === "\\" && i + 1 < s.length) {
const next = s[++i];
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!;
} else out += s[i];
}
return out;
}
/** A stateful fake `ng`: the shim SPARQL, the inbox SPARQL, and the anchored
* per-doc `?s ?p ?o` read the read-model uses. */
function makeFakeNg() {
const quads: Quad[] = [];
let docCounter = 0;
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[2] as string | undefined;
if (!anchor) return undefined;
// `DELETE WHERE { <s> <p> ?var }` — the form the lib uses to REPLACE a value
// (see docs/decisions/sparql-delete-for-orm-objects.md). Without this arm the
// fake would treat the delete as an insert and the replacement would silently
// become an accumulation — the exact bug a replacement exists to prevent.
const del = query.match(/^\s*DELETE\s+WHERE\s*\{\s*<([^>]+)>\s+<([^>]+)>\s+\?/);
if (del) {
const [s0, p0] = [del[1]!, del[2]!];
for (let i = quads.length - 1; i >= 0; i--) {
const q = quads[i]!;
if (q.g === anchor && q.s === s0 && q.p === p0) quads.splice(i, 1);
}
return undefined;
}
const body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
const s = sm[1]!;
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
while ((m = pairRe.exec(after)) !== null) {
const p = m[1] ?? (query.includes(`${INBOX}:Deposit`) ? `${INBOX}:Deposit` : `${SHIM}:Account`);
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
quads.push({ g: anchor, s, p, o });
}
return undefined;
});
const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[3] as string | undefined;
if (query.includes(`<${SHIM}:shimDoc>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:shimDoc`).map((q) => ({ shimDoc: { value: q.o } })) } };
}
if (query.includes(`<${SHIM}:id>`)) {
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
const only = subjM ? subjM[1]! : null;
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (only !== null && q.s !== only) continue;
const rec = bySubject.get(q.s) ?? {};
if (q.p === `${SHIM}:id`) rec.id = q.o;
if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o;
if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o;
if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o;
bySubject.set(q.s, rec);
}
return {
results: {
bindings: [...bySubject.values()].filter((r) => r.id).map((r) => ({
id: { value: r.id! },
docPublic: { value: r.docPublic ?? "" },
docProtected: { value: r.docProtected ?? "" },
docPrivate: { value: r.docPrivate ?? "" },
})),
},
};
}
if (query.includes(`<${INBOX}:payload>`)) {
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
const rec = bySubject.get(q.s) ?? {};
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
if (q.p === `${INBOX}:from`) rec.from = q.o;
bySubject.set(q.s, rec);
}
return {
results: {
bindings: [...bySubject.values()]
.filter((r) => r.payload !== undefined && r.ts !== undefined)
.map((r) => {
const row: Record<string, { value: string }> = { payload: { value: r.payload! }, ts: { value: r.ts! } };
if (r.from !== undefined) row.from = { value: r.from };
return row;
}),
},
};
}
// User-branch `link` SELECT (the emulated AddLink records).
// User-branch `inboxCap` SELECT (the emulated AddInboxCap records).
if (query.includes(`<${SHIM}:inboxCap>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxCap`).map((q) => ({ c: { value: q.o } })) } };
}
// Header-branch `inboxAddress` SELECT (where to deposit for this document).
if (query.includes(`<${SHIM}:inboxAddress>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxAddress`).map((q) => ({ a: { value: q.o } })) } };
}
// Store-branch `readCap` SELECT (the emulated AddRepo records).
if (query.includes(`<${SHIM}:readCap>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:readCap`).map((q) => ({ c: { value: q.o } })) } };
}
if (query.includes(`<${SHIM}:link>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:link`).map((q) => ({ c: { value: q.o } })) } };
}
// Header-branch `exposedReadCap` SELECT — what a PUBLIC store serves to anyone.
if (query.includes(`<${SHIM}:exposedReadCap>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:exposedReadCap`).map((q) => ({ c: { value: q.o } })) } };
}
if (query.includes(`<${SHIM}:contains>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:contains`).map((q) => ({ e: { value: q.o } })) } };
}
// Anchored per-doc read (readUnion `SELECT ?s ?p ?o`) — the document's content.
return {
results: {
bindings: quads
.filter((q) => q.g === anchor)
.map((q) => ({ s: { value: q.s }, p: { value: q.p }, o: { value: q.o } })),
},
};
});
return { doc_create, sparql_update, sparql_query, _quads: quads };
}
function inject() {
const ng = makeFakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim().toLowerCase() });
resetRegistryCache();
resetCaps();
setCurrentUser(null);
return ng;
}
/** Write one triple into `doc`, as the consumer's write path would. */
async function write(doc: Nuri, p: string, o: string): Promise<void> {
await sparqlUpdate(SESSION.sessionId, `INSERT DATA { <${doc}> <${p}> "${o}" }`, doc, "test");
}
/** The values `p` carries in the documents `docs`, as the current holder reads them. */
async function readValues(docs: Nuri[], p: string): Promise<string[]> {
const subjects = await readUnion(docs);
return subjects.flatMap((s) => s.props[p] ?? []);
}
/**
* Alice's world: a protected document holding a secret, and a public document that
* REFERS to it by bare NURI.
*
* What crosses to the other actors is **the bare reference of the public document and
* nothing else** — no cap, no link with a key in it. That is the whole discipline of
* this file: an application circulates references, and if a test had to hand a key
* across an identity boundary through a JS variable, the feature it claims to prove
* would have no path in any real application.
*/
async function aliceSetsUpHerDocuments() {
setCurrentUser("alice");
const protDoc = await createEntityDoc("alice", "protected");
await write(protDoc, SECRET, "the-protected-content");
const pubDoc = await createEntityDoc("alice", "public");
// The reference is the BARE NURI of the protected document: it names it, and
// grants nothing. This is the whole point of the scenario.
await write(pubDoc, REFERS_TO, protDoc);
return { protDoc, pubDoc };
}
/** Follow the reference found in the public document — what a reader actually does. */
function referenceFoundIn(values: string[]): Nuri {
const ref = values[0];
expect(ref).toBeDefined();
return ref as Nuri;
}
test("Bob: reads the public document, sees the reference, and cannot read through it", async () => {
inject();
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
setCurrentUser("bob");
// Bob holds the BARE reference and nothing else. The document sits in a public
// store, so the store serves him its cap — he never received a key from anyone.
// He reads the public document and finds the reference.
const refs = await readValues([pubDoc], REFERS_TO);
const ref = referenceFoundIn(refs);
expect(ref).toBe(protDoc); // he can NAME Alice's protected document
// …and that is all it gets him: no cap, no read. Publication is NOT recursive.
expect(hasCap(ref)).toBe(false);
expect(await readValues([ref], SECRET)).toEqual([]);
});
test("Charlie: same public document, same reference — and he reads through it", async () => {
inject();
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
const CHARLIE_INBOX = await userInbox("charlie", "protected");
// Alice decides Charlie may read that ONE document, and delivers its cap to his
// inbox. She names no principal to the registry; she addresses an inbox.
setCurrentUser("alice");
await share(protDoc, "charlie");
setCurrentUser("charlie");
await readInbox(CHARLIE_INBOX); // processing the inbox files the cap
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
expect(ref).toBe(protDoc);
expect(hasCap(ref)).toBe(true);
expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]);
});
test("the ONLY difference between Bob and Charlie is each of them holds", async () => {
inject();
const { protDoc } = await aliceSetsUpHerDocuments();
const CHARLIE_INBOX = await userInbox("charlie", "protected");
setCurrentUser("alice");
await share(protDoc, "charlie");
setCurrentUser("bob");
const bobSees = await readValues([protDoc], SECRET);
setCurrentUser("charlie");
await readInbox(CHARLIE_INBOX);
const charlieSees = await readValues([protDoc], SECRET);
expect(bobSees).toEqual([]);
expect(charlieSees).toEqual(["the-protected-content"]);
});
// The dynamic version: Bob is refused, then the cap lands in his inbox and the read
// that was empty becomes full — with nothing re-declared and nobody re-authorized.
test("dynamic: a cap delivered to Bob's inbox makes the refused document readable, and signals it", async () => {
inject();
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
const BOB_INBOX = await userInbox("bob", "protected");
setCurrentUser("bob");
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
// Before: named, unreadable.
expect(await readValues([ref], SECRET)).toEqual([]);
// A reader that re-reads whenever what it holds changes — this is exactly what
// `watchShape` wires internally, played here on an ad-hoc read.
let reread = 0;
let latest: string[] = [];
const unsub = getCaps().onChange(() => {
reread += 1;
void readValues([ref], SECRET).then((v) => (latest = v));
});
// Alice delivers the cap. Bob's client processes his inbox — the only thing that
// happens; no "receive" call exists.
setCurrentUser("alice");
await share(protDoc, "bob");
setCurrentUser("bob");
await readInbox(BOB_INBOX);
// Filing the cap fired the signal…
expect(reread).toBeGreaterThan(0);
await Promise.resolve();
await new Promise((r) => setTimeout(r, 0));
// …and the read that was empty now yields the content.
expect(hasCap(ref)).toBe(true);
expect(latest).toEqual(["the-protected-content"]);
expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]);
unsub();
});
// The property this whole batch exists for, stated on its own: WHERE a document sits
// decides whether a bare reference is enough. Upstream a public store's repos are
// served on the outer overlay and their ReadCap is downloaded from it
// (`PublicRepoLinkV0`, `engine/net/src/types.rs:5098`) — so the same value transmitted
// (a bare reference) yields a different outcome depending on the store, and never
// because a key travelled.
test("a bare reference is enough for a PUBLIC document, and not for a protected one", async () => {
inject();
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
setCurrentUser("bob");
// Bob has been given nothing but the two NURIs.
expect((await readValues([pubDoc], REFERS_TO)).length).toBe(1);
expect(await readValues([protDoc], SECRET)).toEqual([]);
// And what he obtained for the public one is a READ grant, not a write right: a
// public store serves its read cap, no store hands out the write cap.
await expect(write(pubDoc, SECRET, "bob-was-here")).rejects.toThrow(/public store/i);
});
// THE POINT OF THE LINK: a cap survives because it was APPLIED, not because the
// inbox is re-read. Upstream, processing an inbox message files it — `AddLink
// { read_cap }` on the User branch of the private store — and the queue is consumed.
// Re-reading a queue to recover state is using it as a database.
test("a Link is APPLIED durably: the cap survives with the inbox emptied", async () => {
const ng = inject();
const { protDoc } = await aliceSetsUpHerDocuments();
const bobInbox = await userInbox("bob", "protected");
setCurrentUser("alice");
await share(protDoc, "bob");
// Bob connects: the library restores + drains, with nothing asked of the app.
setCurrentUser("bob");
await connectedUser();
expect(await readValues([protDoc], SECRET)).toEqual(["the-protected-content"]);
// Now EMPTY the inbox — as a consumed queue would be — and drop every in-memory
// cap, then re-arm the emulation so the boundary is actually in force again.
for (let k = ng._quads.length - 1; k >= 0; k--) {
if (ng._quads[k]!.g === bobInbox) ng._quads.splice(k, 1);
}
resetCaps();
setCurrentUser("alice");
await createEntityDoc("alice", "private"); // re-arms: a cap exists again
setCurrentUser("bob");
// Checked SYNCHRONOUSLY, before yielding: `setCurrentUser` fires the connection work
// itself, and that work is precisely what restores the cap. An awaited check here
// would be asserting who won a race, not what the library does.
expect(hasCap(protDoc)).toBe(false); // bob holds nothing yet
// Connecting restores it — from the User branch, since the inbox has nothing left.
await connectedUser();
expect(hasCap(protDoc)).toBe(true);
expect(await readValues([protDoc], SECRET)).toEqual(["the-protected-content"]);
});
test("connecting a user that does not exist provisions nothing", async () => {
inject();
setCurrentUser("nobody");
await connectedUser();
// No account, no stores, no caps — connecting must not create a user as a side
// effect, or the emulation would arm itself in the background.
expect(getCaps().isEnforcing()).toBe(false);
});
// PER-DOCUMENT INBOXES. Upstream a repo carries `inbox: Option<PrivKey>` and its
// owner records the private half with `AddInboxCap` on the User branch — the same
// branch as `AddLink`. So "which inboxes may I read" has one answer, and connecting
// drains them all: the user's own, and one per document it opened an inbox on.
test("a document has its own inbox: anyone deposits, only the owner reads", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
const aliceInbox = await openDocumentInbox(doc);
expect(aliceInbox).not.toBe(await userInbox("alice", "protected"));
// Bob RESOLVES the address himself, from the BARE reference — the only thing he is
// handed, and the only thing an application circulates. The document is in a public
// store, so the store serves him its read cap; the address is not passed to him,
// because if it had to be there would be no way for an app to get it.
setCurrentUser("bob");
const bobTarget = await documentInboxAddress(doc);
expect(bobTarget).toBe(aliceInbox); // …and it is the SAME inbox alice reads
await post(bobTarget!, { payload: { joining: true }, ts: 1 });
// …and he cannot read it back: depositing grants nothing.
await expect(readInbox(bobTarget!)).rejects.toThrow(/does not belong to the connected wallet/i);
// Alice reads her document's inbox, because she opened it.
setCurrentUser("alice");
const deposits = await readInbox(aliceInbox);
expect(deposits.map((d) => d.payload)).toEqual([{ joining: true }]);
});
test("opening an inbox on someone else's document is refused, not silently forked", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
const aliceInbox = await openDocumentInbox(doc);
// Bob can READ the document (it is in a public store) — and reading is not ownership.
setCurrentUser("bob");
await expect(openDocumentInbox(doc)).rejects.toThrow(/already has an inbox|you may only open an inbox/i);
// The address he resolves is still alice's, so his deposits reach her.
expect(await documentInboxAddress(doc)).toBe(aliceInbox);
});
test("a fresh document has NO inbox — one belongs to one document, and only its owner opens it", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
// Not "the owner's inbox by default": upstream an inbox belongs to exactly ONE repo
// (the verifier routes by `inboxes: PubKey → RepoId`), so pointing several documents
// at one inbox is a relation the model cannot express.
setCurrentUser("bob");
expect(await documentInboxAddress(doc)).toBeUndefined();
// …and depositing THROWS rather than vanishing — a lost deposit is the bug this
// whole path exists to close.
await expect(postToDocument(doc, { payload: { x: 1 } })).rejects.toThrow(/has no inbox/i);
});
test("opening an inbox publishes ONE address, and re-opening does not accumulate", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
const dedicated = await openDocumentInbox(doc);
expect(await openDocumentInbox(doc)).toBe(dedicated); // idempotent
setCurrentUser("bob");
expect(await documentInboxAddress(doc)).toBe(dedicated);
// The deposit reaches the owner, addressed by the document alone.
await postToDocument(doc, { payload: { signingUp: true } });
setCurrentUser("alice");
expect((await readInbox(dedicated)).map((d) => d.payload)).toEqual([{ signingUp: true }]);
});
test("the inbox address is machinery: it never surfaces as the document's data", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public");
await write(doc, SECRET, "s1");
await openDocumentInbox(doc);
// The consumer read returns the entity's properties and nothing of the compartment
// that carries the address — the Header branch is beside the content, not in it.
const subjects = await readUnion([doc]);
const props = subjects[0]?.props ?? {};
expect(Object.keys(props)).toEqual([SECRET]);
});
test("connecting drains BOTH levels: the user's inbox and its documents'", async () => {
inject();
setCurrentUser("alice");
const protDoc = await createEntityDoc("alice", "protected");
const pubDoc = await createEntityDoc("alice", "public");
const docInbox = await openDocumentInbox(pubDoc);
const aliceInbox = await userInbox("alice", "protected");
// Two deposits, one at each level, both made by someone else.
setCurrentUser("carol");
const carolDoc = await createEntityDoc("carol", "protected");
await share(carolDoc, "alice"); // a Link, to alice herself
await post(docInbox, { payload: { onTheDocument: true }, ts: 2 });
// Alice connects: one call, both queues.
setCurrentUser("alice");
await connectedUser();
expect(hasCap(carolDoc)).toBe(true); // the Link was applied
expect(await readValues([protDoc], SECRET)).toEqual([]); // (protDoc holds no secret here)
const left = await readInbox(docInbox);
expect(left.map((d) => d.payload)).toEqual([{ onTheDocument: true }]); // consumer data stays
});
// The same resolution property one level up: a user's own inbox.
test("a third party resolves another user's inbox (the wallet level)", async () => {
inject();
setCurrentUser("alice");
const aliceView = await userInbox("alice", "protected");
setCurrentUser("bob");
const bobView = await userInbox("alice", "protected");
expect(bobView).toBe(aliceView);
});
+99
View File
@@ -0,0 +1,99 @@
import { test, expect, mock, beforeEach } from "bun:test";
import { docCreate, sparqlUpdate, sparqlQuery } from "../src/surface/docs";
// The reach guard is process-wide: once ANY cap exists it applies to every reader.
// This suite declares none, so it must not inherit another suite's enforcement.
beforeEach(() => {
resetCaps();
setCurrentUser(null);
});
import * as ngProxy from "../src/surface/ng-proxy";
// NOTE ORDER: the "not configured → throw" case MUST run before any configure()
// call, because configure() sets a module-level singleton with no public reset.
test("throws a clear error when configure() was not called", async () => {
await expect(docCreate("sid", "Graph", "data:graph", "store")).rejects.toThrow(
/configure\(\) must be called before use/,
);
await expect(sparqlUpdate("sid", "INSERT DATA {}")).rejects.toThrow(
/configure\(\) must be called before use/,
);
await expect(sparqlQuery("sid", "SELECT * {}")).rejects.toThrow(
/configure\(\) must be called before use/,
);
});
// From here on, a fake real `ng` is injected via configure().
import { configure, setCurrentUser } from "../src/index";
import { resetCaps } from "../src/shared-wallet/bootstrap";
function fakeNg() {
return {
doc_create: mock(async (..._a: unknown[]) => "did:ng:o:new-doc"),
sparql_update: mock(async (..._a: unknown[]) => undefined),
sparql_query: mock(async (..._a: unknown[]) => ({ results: { bindings: [] } })),
// A sentinel: makeNg(), if ever used, would `.bind` and call THIS through
// the JS Proxy. We assert the primitives call the raw fns above directly.
};
}
function inject() {
const ng = fakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any });
return ng;
}
test("docCreate calls the real injected ng.doc_create with the exact args", async () => {
const ng = inject();
const nuri = await docCreate("sid-1", "Graph", "data:graph", "store", undefined);
expect(nuri).toBe("did:ng:o:new-doc");
expect(ng.doc_create).toHaveBeenCalledTimes(1);
expect(ng.doc_create.mock.calls[0]).toEqual(["sid-1", "Graph", "data:graph", "store", undefined]);
});
test("sparqlUpdate forwards (sessionId, query, anchor) to the real ng.sparql_update", async () => {
const ng = inject();
await sparqlUpdate("sid-2", "INSERT DATA { GRAPH <did:ng:o:a> { <s> <p> <o> } }", "did:ng:o:a");
expect(ng.sparql_update).toHaveBeenCalledTimes(1);
expect(ng.sparql_update.mock.calls[0]).toEqual([
"sid-2",
"INSERT DATA { GRAPH <did:ng:o:a> { <s> <p> <o> } }",
"did:ng:o:a",
]);
});
test("sparqlUpdate passes anchor=undefined when omitted", async () => {
const ng = inject();
await sparqlUpdate("sid-3", "INSERT DATA {}");
expect(ng.sparql_update.mock.calls[0]).toEqual(["sid-3", "INSERT DATA {}", undefined]);
});
test("sparqlQuery forwards (sessionId, query, base, anchor) and returns the raw result", async () => {
const ng = inject();
const res = await sparqlQuery("sid-4", "SELECT ?e { GRAPH <g> { ?s ?p ?e } }", undefined, "did:ng:o:g");
expect(res).toEqual({ results: { bindings: [] } });
expect(ng.sparql_query).toHaveBeenCalledTimes(1);
expect(ng.sparql_query.mock.calls[0]).toEqual([
"sid-4",
"SELECT ?e { GRAPH <g> { ?s ?p ?e } }",
undefined,
"did:ng:o:g",
]);
});
test("the primitives do NOT route through the public ng proxy (makeNg)", async () => {
// makeNg builds a JS Proxy over the injected ng. If a primitive went through
// it, calls would land on the proxy's `get` trap, not on our raw mock fns.
// Spy on makeNg: it must never be invoked by the docs primitives.
const spy = mock(ngProxy.makeNg);
const ng = inject();
await docCreate("sid", "Graph", "data:graph", "store");
await sparqlUpdate("sid", "INSERT DATA {}");
await sparqlQuery("sid", "SELECT * {}");
expect(spy).toHaveBeenCalledTimes(0);
// And the raw injected fns were reached directly:
expect(ng.doc_create).toHaveBeenCalledTimes(1);
expect(ng.sparql_update).toHaveBeenCalledTimes(1);
expect(ng.sparql_query).toHaveBeenCalledTimes(1);
});
+258
View File
@@ -0,0 +1,258 @@
import { test, expect, mock, beforeEach, afterAll } from "bun:test";
import { post, read, materialize, watch } from "../src/surface/inbox";
import { userInbox, resetRegistryCache } from "../src/shared-wallet/account-registry";
import type { Deposit } from "../src/surface/inbox";
import { configure, configureStoreRegistry, setCurrentUser } from "../src/index";
import { resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
// This suite injects a fake `ng` via configure() and reuses the storeRegistry's
// injected session provider (inbox docs live in the shared wallet). Restore the
// un-configured state at the end so docs.test.ts's guard still sees null config.
afterAll(() => {
resetConfig();
resetStoreRegistry();
setCurrentUser(null);
});
// NOTE ORDER: the "not configured → throw" case runs first — it exercises the
// registry-deps guard before any configureStoreRegistry() call.
test("throws a clear error when configureStoreRegistry() was not called", async () => {
resetStoreRegistry();
await expect(post("did:ng:o:inbox", { payload: { hi: 1 } })).rejects.toThrow(
/configureStoreRegistry\(\) must be called before use/,
);
await expect(read("did:ng:o:inbox")).rejects.toThrow(
/configureStoreRegistry\(\) must be called before use/,
);
});
// --- A stateful fake `ng`: parses the inbox INSERT DATA and answers the read
// SELECT over an in-memory quad store.
interface Quad { g: string; s: string; p: string; o: string }
const INBOX = "urn:ng-eventually:inbox";
/** Reverse of the lib's escapeLiteral: single left-to-right pass over `\x`. */
function unescapeLiteral(s: string): string {
let out = "";
for (let i = 0; i < s.length; i++) {
if (s[i] === "\\" && i + 1 < s.length) {
const next = s[++i];
out +=
next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next;
} else {
out += s[i];
}
}
return out;
}
function makeFakeNg() {
const quads: Quad[] = [];
// Reactive subscriptions: doc_subscribe registers a callback per anchor and
// fires an initial State push; a matching sparql_update pushes a Patch to that
// anchor's subscribers. This mirrors the real broker's local-push behaviour so
// inbox.watch (now event-driven, no polling) can be tested without a timer.
const subs = new Map<string, Set<(r: unknown) => void>>();
const doc_subscribe = mock(async (nuri: string, _sid: unknown, cb: (r: unknown) => void) => {
let set = subs.get(nuri);
if (!set) {
set = new Set();
subs.set(nuri, set);
}
set.add(cb);
queueMicrotask(() => cb({ V0: { State: { doc: nuri } } })); // initial push
return () => set!.delete(cb);
});
const pushTo = (anchor: string): void => {
for (const cb of subs.get(anchor) ?? []) cb({ V0: { Patch: { doc: anchor } } });
};
const doc_create = mock(async (..._a: unknown[]) => "did:ng:o:new");
// Parses one deposit: `<subj> a <Deposit> ; <payload> "..." ; <ts> "..." [; <from> "..."] .`
//
// The REAL broker keys triples by the ANCHORED repo's default graph, not by an
// explicit `GRAPH <…>` IRI (repo_graph_name(repo_id, overlay_id)). So this mock
// keys stored quads by the ANCHOR arg (a[2]) — the default graph of the anchored
// repo — and REJECTS any explicit `GRAPH <…>` wrapper, so the old wrong shape
// does NOT round-trip and can never regress silently.
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[2] as string | undefined;
if (/GRAPH\s*</.test(query)) return undefined; // explicit-GRAPH write → dropped
if (!anchor) return undefined;
const g = anchor;
const body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
const s = sm[1]!;
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
// predicate/object pairs: `a <type>` or `<p> "literal"`.
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
while ((m = pairRe.exec(after)) !== null) {
const p = m[1] ?? `${INBOX}:Deposit`; // `a` → rdf:type-ish
// Un-escape the SPARQL literal so payload JSON round-trips. Single pass
// over `\x` sequences (reverses the lib's escapeLiteral without the
// double-processing that chained .replace() would cause).
const rawLit = m[2];
const o = rawLit !== undefined ? unescapeLiteral(rawLit) : (m[3] ?? "");
quads.push({ g, s, p, o });
}
// A write to `g` (the anchored default graph) pushes a Patch to that doc's
// subscribers — the local-push the real broker performs on a verified commit.
pushTo(g);
return undefined;
});
const sparql_query = mock(async (...a: unknown[]) => {
const anchor = a[3] as string | undefined;
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (q.p === `${INBOX}:Deposit`) {
// rdf:type marker — ensure the subject exists.
if (!bySubject.has(q.s)) bySubject.set(q.s, {});
continue;
}
const rec = bySubject.get(q.s) ?? {};
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
if (q.p === `${INBOX}:from`) rec.from = q.o;
bySubject.set(q.s, rec);
}
const bindings = [...bySubject.values()]
.filter((r) => r.payload !== undefined && r.ts !== undefined)
.map((r) => {
const row: Record<string, { value: string }> = {
payload: { value: r.payload! },
ts: { value: r.ts! },
};
if (r.from !== undefined) row.from = { value: r.from };
return row;
});
return { results: { bindings } };
});
return { doc_create, doc_subscribe, sparql_update, sparql_query, _quads: quads };
}
const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" };
/** Resolved per test: an inbox BELONGS to a wallet, and only its owner may read it. */
let TARGET: `did:ng:${string}`;
function inject() {
const ng = makeFakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any });
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
configureStoreRegistry({ getSession: async () => SESSION });
setCurrentUser(null);
return ng;
}
let fake: ReturnType<typeof makeFakeNg>;
beforeEach(async () => {
fake = inject();
resetRegistryCache();
setCurrentUser("alice");
TARGET = await userInbox("alice", "protected");
});
test("post writes via the real injected ng.sparql_update (not makeNg), scoped to the inbox", async () => {
setCurrentUser("alice"); // `from` is bound to the current identity
// Count from HERE: resolving this wallet's own inbox already wrote to the shim.
const before = fake.sparql_update.mock.calls.length;
await post(TARGET, { from: "alice", payload: { kind: "join" }, ts: 100 });
expect(fake.sparql_update.mock.calls.length).toBe(before + 1);
const call = fake.sparql_update.mock.calls[before]!;
expect(call[0]).toBe("sid-1"); // sessionId from the injected session
expect(call[2]).toBe(TARGET); // anchored to the target inbox
// The write targets the anchored DEFAULT graph — NO explicit `GRAPH <…>`
// wrapper (which the real broker would route to a phantom graph).
expect(call[1] as string).not.toContain("GRAPH <");
});
test("post → read round-trips payload, from and ts", async () => {
setCurrentUser("alice"); // `from` is bound to the current identity
await post(TARGET, { from: "alice", payload: { kind: "join", n: 3 }, ts: 100 });
const deposits = await read(TARGET);
expect(deposits).toHaveLength(1);
expect(deposits[0]).toEqual({ from: "alice", payload: { kind: "join", n: 3 }, ts: 100 });
});
// (c) `from` is BOUND to the current identity — a spoof (naming another
// principal) is REJECTED; identifying as self or anonymous (null) is allowed.
test("(c) post rejects a spoofed `from` (naming another principal); self/null allowed", async () => {
setCurrentUser("alice");
// SPOOF: alice tries to deposit AS bob → rejected.
await expect(post(TARGET, { from: "bob", payload: { x: 1 }, ts: 1 })).rejects.toThrow(
/spoof|current identity/i,
);
// Identifying as self → allowed.
await post(TARGET, { from: "alice", payload: { x: 2 }, ts: 2 });
// Explicit anonymous → allowed.
await post(TARGET, { from: null, payload: { x: 3 }, ts: 3 });
const froms = (await read(TARGET)).map((d) => d.from);
expect(froms).toEqual(["alice", null]);
});
test("from is optional — omitting it defaults to the current user", async () => {
setCurrentUser("bob");
await post(TARGET, { payload: { hi: 1 }, ts: 200 });
const deposits = await read(TARGET);
expect(deposits[0]!.from).toBe("bob");
});
test("from: null makes an anonymous deposit even when a current user is set", async () => {
setCurrentUser("bob");
await post(TARGET, { from: null, payload: { hi: 1 }, ts: 200 });
const deposits = await read(TARGET);
expect(deposits[0]!.from).toBeNull();
});
test("read returns deposits sorted by ts ascending and materialize is an alias", async () => {
await post(TARGET, { from: null, payload: "second", ts: 300 });
await post(TARGET, { from: null, payload: "first", ts: 100 });
await post(TARGET, { from: null, payload: "third", ts: 500 });
const deposits = await materialize(TARGET);
expect(deposits.map((d) => d.payload)).toEqual(["first", "second", "third"]);
});
test("read is scoped to one inbox — deposits in another inbox are not returned", async () => {
await post(TARGET, { from: null, payload: "mine", ts: 1 });
await post("did:ng:o:other-inbox", { from: null, payload: "theirs", ts: 2 });
const deposits = await read(TARGET);
expect(deposits.map((d) => d.payload)).toEqual(["mine"]);
});
test("payload with quotes/newlines/backslashes survives the round-trip", async () => {
const payload = { text: 'a "quoted"\nline\\path\ttab' };
await post(TARGET, { from: null, payload, ts: 1 });
const deposits = await read(TARGET);
expect(deposits[0]!.payload).toEqual(payload);
});
test("watch fires immediately then on each new deposit, and unsubscribe stops it", async () => {
const seen: Deposit[][] = [];
const stop = watch(TARGET, (d) => seen.push(d), { intervalMs: 5 });
// Give the immediate tick a chance to run (empty inbox → still fires once).
await new Promise((r) => setTimeout(r, 20));
expect(seen.length).toBeGreaterThanOrEqual(1);
expect(seen[seen.length - 1]).toEqual([]);
await post(TARGET, { from: null, payload: "x", ts: 1 });
await new Promise((r) => setTimeout(r, 20));
const last = seen[seen.length - 1]!;
expect(last.map((d) => d.payload)).toEqual(["x"]);
stop();
const countAfterStop = seen.length;
await post(TARGET, { from: null, payload: "y", ts: 2 });
await new Promise((r) => setTimeout(r, 20));
expect(seen.length).toBe(countAfterStop); // no more callbacks after unsubscribe
});
+418
View File
@@ -0,0 +1,418 @@
/**
* ReadCap ACTIVE — end-to-end proof that the emulated SDK enforces per-DOCUMENT
* isolation, driven by per-entity documents + KEY POSSESSION.
*
* Mirrors what the app does: create an entity document through the REAL registry
* (`createEntityDoc`) — which files its cap in the creator's held caps, the emulated
* `AddRepo { read_cap }` — and, when the app decides two identities are related,
* SHARE that one document's cap to the other's inbox (`shareCap`). The recipient
* needs no dedicated operation: processing their inbox absorbs it.
*
* What the read filter then shows:
* (a) a document nobody shared is unreadable, and stays unreadable for a third
* party after a share to someone else — sharing is per-document, per-inbox;
* (b) the read-filtered VIEW decides on possession alone — it is synchronous, so it
* asks no store anything (a public store WOULD serve its cap; that is proven on
* the read paths, in `cross-user-access.test.ts`);
* (c) switching identity SWITCHES heldByHolder — it never wipes one.
*/
import { getCaps } from "../src/shared-wallet/bootstrap";
import { test, expect, mock, afterAll } from "bun:test";
import { createEntityDoc, resetRegistryCache, userInbox, listMyEntityDocs } from "../src/shared-wallet/account-registry";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
import type { Nuri, ReadCap } from "../src/model/types";
import { configure, configureStoreRegistry, setCurrentUser } from "../src/index";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import { share } from "../src/surface/inbox";
import { read as readInbox } from "../src/surface/inbox";
import { filterReadable } from "../src/emulated-verifier/read-filter";
afterAll(() => {
resetConfig();
resetStoreRegistry();
resetCaps();
setCurrentUser(null);
});
const SESSION: RegistrySession = { sessionId: "sid", privateStoreId: "PRIV" };
const SHIM = "urn:ng-eventually:shim";
const INBOX = "urn:ng-eventually:inbox";
/** Possession, asked of the internal registry — see `polyfill.ts` on why the door
* stopped publishing it. */
function hasCap(nuri: Nuri): boolean {
return getCaps().capFor(nuri) !== undefined;
}
interface Quad { g: string; s: string; p: string; o: string }
/** Reverse of the lib's escapeLiteral: single left-to-right pass over `\x`. */
function unescapeLiteral(s: string): string {
let out = "";
for (let i = 0; i < s.length; i++) {
if (s[i] === "\\" && i + 1 < s.length) {
const next = s[++i];
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!;
} else out += s[i];
}
return out;
}
/** A stateful fake `ng` serving BOTH the shim SPARQL and the inbox SPARQL. */
function makeFakeNg() {
const quads: Quad[] = [];
let docCounter = 0;
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[2] as string | undefined;
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
let g: string;
let body: string;
if (gm) {
g = gm[1]!;
body = gm[2]!;
} else {
if (!anchor) return undefined;
g = anchor;
body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
}
const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
const s = sm[1]!;
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
while ((m = pairRe.exec(after)) !== null) {
const p = m[1] ?? (query.includes(`${INBOX}:Deposit`) ? `${INBOX}:Deposit` : `${SHIM}:Account`);
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
quads.push({ g, s, p, o });
}
return undefined;
});
const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[3] as string | undefined;
// Pointer SELECT (store-root → doc-shim).
if (query.includes(`<${SHIM}:shimDoc>`)) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === `${SHIM}:shimDoc`)
.map((q) => ({ shimDoc: { value: q.o } }));
return { results: { bindings } };
}
// Account SELECT.
if (query.includes(`<${SHIM}:id>`)) {
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
const onlySubject = subjM ? subjM[1]! : null;
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (onlySubject !== null && q.s !== onlySubject) continue;
const rec = bySubject.get(q.s) ?? {};
if (q.p === `${SHIM}:id`) rec.id = q.o;
if (q.p === `${SHIM}:docPublic`) rec.docPublic = q.o;
if (q.p === `${SHIM}:docProtected`) rec.docProtected = q.o;
if (q.p === `${SHIM}:docPrivate`) rec.docPrivate = q.o;
bySubject.set(q.s, rec);
}
const bindings = [...bySubject.values()]
.filter((r) => r.id)
.map((r) => ({
id: { value: r.id! },
docPublic: { value: r.docPublic ?? "" },
docProtected: { value: r.docProtected ?? "" },
docPrivate: { value: r.docPrivate ?? "" },
}));
return { results: { bindings } };
}
// Inbox deposit SELECT.
if (query.includes(`<${INBOX}:payload>`)) {
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
const rec = bySubject.get(q.s) ?? {};
if (q.p === `${INBOX}:payload`) rec.payload = q.o;
if (q.p === `${INBOX}:ts`) rec.ts = q.o;
if (q.p === `${INBOX}:from`) rec.from = q.o;
bySubject.set(q.s, rec);
}
const bindings = [...bySubject.values()]
.filter((r) => r.payload !== undefined && r.ts !== undefined)
.map((r) => {
const row: Record<string, { value: string }> = {
payload: { value: r.payload! },
ts: { value: r.ts! },
};
if (r.from !== undefined) row.from = { value: r.from };
return row;
});
return { results: { bindings } };
}
// User-branch `link` SELECT (the emulated AddLink records).
// User-branch `inboxCap` SELECT (the emulated AddInboxCap records).
if (query.includes(`<${SHIM}:inboxCap>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:inboxCap`).map((q) => ({ c: { value: q.o } })) } };
}
// Store-branch `readCap` SELECT (the emulated AddRepo records).
if (query.includes(`<${SHIM}:readCap>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:readCap`).map((q) => ({ c: { value: q.o } })) } };
}
if (query.includes(`<${SHIM}:link>`)) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === `${SHIM}:link`)
.map((q) => ({ c: { value: q.o } }));
return { results: { bindings } };
}
// Scope-index `contains` SELECT.
if (query.includes(`<${SHIM}:contains>`)) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === `${SHIM}:contains`)
.map((q) => ({ e: { value: q.o } }));
return { results: { bindings } };
}
return { results: { bindings: [] } };
});
return { doc_create, sparql_update, sparql_query, _quads: quads };
}
function inject(normalizeId: (id: string) => string = (id) => id.trim()) {
const ng = makeFakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({ getSession: async () => SESSION, normalizeId });
resetRegistryCache();
resetCaps();
setCurrentUser(null);
return ng;
}
/** The items an ORM set would carry, one per document. */
const item = (doc: string, id: string) => ({ "@graph": doc, "@id": id });
/** What the current holder reads out of `items`. */
const view = (items: Array<{ "@graph": string; "@id": string }>) =>
filterReadable(items, getCaps()).map((i) => i["@id"]).sort();
test("a created document is readable by its creator and by nobody else", async () => {
inject();
setCurrentUser("alice");
const aliceDoc = await createEntityDoc("alice", "private");
setCurrentUser("bob");
const bobDoc = await createEntityDoc("bob", "private");
const items = [item(aliceDoc, "a1"), item(bobDoc, "b1")];
setCurrentUser("alice");
expect(view(items)).toEqual(["a1"]);
setCurrentUser("bob");
expect(view(items)).toEqual(["b1"]);
setCurrentUser(null);
expect(view(items)).toEqual([]); // anonymous holds nothing
expect(getCaps().isEnforcing()).toBe(true);
});
// (a) Sharing is per-document AND per-recipient: a share to bob leaves carol out.
test("(a) sharing one document's cap to ONE inbox reveals it there, and only there", async () => {
inject();
setCurrentUser("alice");
const shared = await createEntityDoc("alice", "protected");
const kept = await createEntityDoc("alice", "protected");
const items = [item(shared, "s1"), item(kept, "k1")];
// BEFORE the share: bob reads nothing of alice's.
setCurrentUser("bob");
expect(view(items)).toEqual([]);
// The app decides alice↔bob are related: alice shares ONE document's cap into
// bob's OWN inbox — the only cross-wallet act there is.
const bobInbox = await userInbox("bob", "protected");
setCurrentUser("alice");
await share(shared, "bob");
// bob processes his inbox — no dedicated "receive" operation exists.
setCurrentUser("bob");
await readInbox(bobInbox);
expect(view(items)).toEqual(["s1"]); // the shared one only — not `kept`
// carol, who was not shared with, still reads nothing.
setCurrentUser("carol");
await readInbox(await userInbox("carol", "protected"));
expect(view(items)).toEqual([]);
});
test("a cap deposit is absorbed, not surfaced as a consumer deposit", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "protected");
const bobInbox = await userInbox("bob", "protected");
await share(doc, "bob");
setCurrentUser("bob");
const deposits = await readInbox(bobInbox);
expect(deposits).toEqual([]); // infrastructure, not consumer data
expect(hasCap(doc)).toBe(true); // …but it landed in bob's held caps
});
// (b) The ORM read filter is PURE POSSESSION — it asks nothing of anyone.
//
// Note what this does NOT say: that a bare reference to a public document is
// unreadable. It is readable, through the read paths, because a public store serves
// its cap (`emulated-verifier/public-store.ts`, and `cross-user-access.test.ts` proves
// it). This filter sits below that: it is synchronous, it decides from what the holder
// holds AT THAT MOMENT, and a document whose cap was never obtained is filtered out
// whatever store it sits in. The library's own read paths ask first; the reactive ORM
// view has no door to ask through, and that limit is recorded in `read-filter.ts`.
test("(b) the read-filtered view decides on possession alone, with no lookup", async () => {
inject();
setCurrentUser("alice");
const pub = await createEntityDoc("alice", "public");
const items = [item(pub, "u1")];
expect(getCaps().isInPublicStore(pub)).toBe(true);
const cap = getCaps().capFor(pub)!;
// bob HAS the document's bare NURI (it is right there in `items`), holds no cap for
// it, and the view drops it — no question asked of any store.
setCurrentUser("bob");
expect(view(items)).toEqual([]);
// Once the cap IS among what he holds — however it got there — the same view yields it.
getCaps().learn(cap);
expect(view(items)).toEqual(["u1"]);
});
// (c) Identity change switches heldByHolder; it does not wipe them.
test("(c) switching identity switches heldByHolder — a returning identity keeps its caps", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "protected");
expect(hasCap(doc)).toBe(true);
setCurrentUser("bob");
expect(hasCap(doc)).toBe(false);
setCurrentUser("alice");
expect(hasCap(doc)).toBe(true); // durable across the switch — nothing re-declared
});
// A virtual user IS a shim account, and the shim keys accounts through the
// consumer's `normalizeId`. The held caps must key the SAME way: otherwise an app
// that spells its own identity differently between two calls ("@Alice" at login,
// "alice" later) gets a second held caps and stops reading its own documents.
test("one held caps per virtual WALLET, not per spelling of its id", async () => {
inject((id) => id.trim().replace(/^@+/, "").toLowerCase());
setCurrentUser("@Alice");
const doc = await createEntityDoc("@Alice", "protected");
expect(hasCap(doc)).toBe(true);
// Same account, spelled differently — same shim account, so the same held caps.
setCurrentUser("alice");
expect(hasCap(doc)).toBe(true);
setCurrentUser(" ALICE ");
expect(hasCap(doc)).toBe(true);
// A genuinely different account still holds nothing.
setCurrentUser("bob");
expect(hasCap(doc)).toBe(false);
});
// THE BREACH P1a OPENED. Caps travel as inbox deposits, so an unguarded inbox read
// let anyone who knew an inbox NURI collect the caps addressed to its owner —
// defeating directed sharing entirely. Depositing stays open (it is the only way a
// link crosses between wallets at all); reading does not.
test("an inbox may be DEPOSITED into by anyone, and READ only by its owner", async () => {
inject();
setCurrentUser("alice");
const secret = await createEntityDoc("alice", "protected");
const bobInbox = await userInbox("bob", "protected");
// Alice deposits into bob's inbox — allowed, and it grants her nothing back.
await share(secret, "bob");
await expect(readInbox(bobInbox)).rejects.toThrow(/does not belong to the connected wallet/i);
expect(hasCap(secret)).toBe(true); // still hers, obviously
// Mallory knows the NURI of bob's inbox and tries to pocket what is in it.
setCurrentUser("mallory");
await expect(readInbox(bobInbox)).rejects.toThrow(/does not belong to the connected wallet/i);
expect(hasCap(secret)).toBe(false); // nothing was absorbed
// Anonymous owns no inbox at all.
setCurrentUser(null);
await expect(readInbox(bobInbox)).rejects.toThrow(/no identity is set/i);
// Bob reads his own, and only then does the cap land.
setCurrentUser("bob");
await readInbox(bobInbox);
expect(hasCap(secret)).toBe(true);
});
test("a fresh session rebuilds the held caps from the scope index (the emulated AddRepo)", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "protected");
const items = [item(doc, "p1")];
// Simulate a new session over the same wallet: caps are in memory, so they go —
// the registry cache too. Only the persisted documents remain.
resetCaps();
resetRegistryCache();
expect(view(items)).toEqual([]);
// Listing my own documents refiles their caps: this is the store branch that
// carries `AddRepo { read_cap }` upstream.
const { listMyEntityDocs } = await import("../src/shared-wallet/account-registry");
expect(await listMyEntityDocs("alice", "protected")).toEqual([doc]);
expect(view(items)).toEqual(["p1"]);
});
// The Store branch exists so a cap is READ back, not recomputed. Without this test
// the two are indistinguishable: with a stand-in value, re-minting happens to give
// the same string. So corrupt the stored cap and check the corruption wins — proof
// the value comes from the store, and proof that P1b's real key will too.
test("a document's cap is READ from the Store branch, never recomputed", async () => {
const ng = inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "protected");
// The store recorded `AddRepo { read_cap }` beside the `contains` listing.
const stored = ng._quads.filter((q) => q.p === "urn:ng-eventually:shim:readCap");
expect(stored.length).toBe(1);
expect(stored[0]!.o).toBe(`${doc}:r:OK`);
// Rewrite it to a DIFFERENT value, then start a fresh session.
stored[0]!.o = `${doc}:r:FROM-THE-STORE`;
resetCaps();
resetRegistryCache();
expect(await listMyEntityDocs("alice", "protected")).toEqual([doc]);
// Recomputing would have produced `:r:OK`; this is what was stored.
expect(getCaps().capFor(doc)).toBe(`${doc}:r:FROM-THE-STORE` as ReadCap);
});
// The listing and the keys are separate upstream (Main vs Store branch), and the
// separation has to survive here or a document could be listed without its cap.
test("the listing and the caps are two separate records", async () => {
const ng = inject();
setCurrentUser("alice");
await createEntityDoc("alice", "private");
const subjects = new Set(ng._quads.filter((q) => q.p.startsWith("urn:ng-eventually:shim:")).map((q) => q.s));
expect(subjects.has("urn:ng-eventually:shim:index")).toBe(true); // Main branch: contains
expect(subjects.has("urn:ng-eventually:shim:storeBranch")).toBe(true); // Store branch: readCap
});
// P1b will make the stand-in value a real, non-derivable key. The moment it does,
// any path that mints a SECOND cap instead of using the stored one breaks: the
// creator would hold a key that does not open its own document. This pins that the
// creation path mints exactly once.
test("creation mints the cap ONCE — the stored value is the one held", async () => {
const ng = inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "protected");
const stored = ng._quads.find((q) => q.p === "urn:ng-eventually:shim:readCap")!;
expect(getCaps().capFor(doc)).toBe(stored.o as ReadCap); // same value, not two mints that agree by luck
});
+51
View File
@@ -0,0 +1,51 @@
/**
* The reserved-namespace predicate, in isolation.
*
* It is one `startsWith`, but it is the seam that keeps the polyfill's emulated
* branches out of the consumer's data (see `machinery.ts`), so its edges are worth
* pinning: get it wrong in one direction and machinery leaks into domain properties;
* wrong in the other and real data silently disappears from reads.
*/
import { test, expect } from "bun:test";
import { MACHINERY_NS, isMachinerySubject } from "../src/emulated-verifier/machinery";
test("the emulated branch subjects are all machinery", () => {
// The four compartments store-registry emulates, verbatim.
for (const s of [
"urn:ng-eventually:shim:index",
"urn:ng-eventually:shim:storeBranch",
"urn:ng-eventually:shim:userBranch",
"urn:ng-eventually:shim:headerBranch",
]) {
expect(isMachinerySubject(s)).toBe(true);
}
});
test("inbox deposits are machinery too — a second prefix under the same namespace", () => {
expect(isMachinerySubject("urn:ng-eventually:inbox:deposit:1700:abc")).toBe(true);
});
test("consumer subjects are not machinery — including a NURI, which is what entities use", () => {
expect(isMachinerySubject("did:ng:o:doc1")).toBe(false);
expect(isMachinerySubject("urn:e2e:secret")).toBe(false);
expect(isMachinerySubject("http://example.org/thing")).toBe(false);
});
test("a look-alike prefix is NOT machinery — the boundary is exact, not fuzzy", () => {
// Anything that merely resembles the namespace must fall on the data side, or a
// consumer's own vocabulary could vanish from its reads.
expect(isMachinerySubject("urn:ng-eventuallyX:thing")).toBe(false);
expect(isMachinerySubject("urn:ng-event:thing")).toBe(false);
expect(isMachinerySubject("x-urn:ng-eventually:shim:index")).toBe(false);
});
test("an absent subject is not machinery — read paths hand bindings straight in", () => {
expect(isMachinerySubject(undefined)).toBe(false);
expect(isMachinerySubject("")).toBe(false);
});
test("the namespace is the prefix both writers actually use", () => {
// Guards against the constant drifting away from store-registry/inbox.
expect("urn:ng-eventually:shim".startsWith(MACHINERY_NS)).toBe(true);
expect("urn:ng-eventually:inbox".startsWith(MACHINERY_NS)).toBe(true);
});
+86
View File
@@ -0,0 +1,86 @@
import { getCaps } from "../src/shared-wallet/bootstrap";
import { test, expect, mock, afterEach } from "bun:test";
import { makeNg } from "../src/surface/ng-proxy";
import { configure, setCurrentUser } from "../src/index";
import { resetCaps, resetConfig } from "../src/shared-wallet/bootstrap";
// This suite injects a fake `ng` via configure() and declares WRITE caps —
// which stay an authorization list on purpose: only READING is key possession
// (P1a). The write axis is decorative until P1b (every internal writer bypasses
// this proxy). Reset after each test so the docs.test.ts "not configured" guard
// still holds and no cap leaks into another suite.
afterEach(() => {
resetConfig();
resetCaps();
setCurrentUser(null);
});
function fakeNg() {
return { sparql_update: mock(async (..._a: unknown[]) => undefined) };
}
function inject() {
const ng = fakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any });
return ng;
}
const DOC = "did:ng:o:doc";
const UPDATE = `INSERT DATA { GRAPH <${DOC}> { <s> <p> <o> } }`;
test("write guard: passthrough when NO write policy is declared (no regression)", async () => {
const ng = inject();
setCurrentUser("bob"); // not a writer, but there's no policy at all
const proxy = makeNg();
await proxy.sparql_update("sid", UPDATE, DOC);
expect(ng.sparql_update).toHaveBeenCalledTimes(1);
});
test("write guard: passthrough for an UNGOVERNED doc even when a policy exists elsewhere", async () => {
const ng = inject();
getCaps().grantWrite("did:ng:o:other", "alice"); // policy on another doc
setCurrentUser("bob");
const proxy = makeNg();
await proxy.sparql_update("sid", UPDATE, DOC); // DOC itself is ungoverned
expect(ng.sparql_update).toHaveBeenCalledTimes(1);
});
test("write guard: REJECTS when the doc is governed and the user lacks the write cap", async () => {
const ng = inject();
getCaps().grantWrite(DOC, "alice"); // alice holds the write cap
setCurrentUser("bob"); // bob does not
const proxy = makeNg();
await expect(proxy.sparql_update("sid", UPDATE, DOC)).rejects.toThrow(
/write denied/,
);
expect(ng.sparql_update).toHaveBeenCalledTimes(0); // never reached the real ng
});
test("write guard: REJECTS an anonymous (null) user on a governed doc", async () => {
const ng = inject();
getCaps().grantWrite(DOC, "alice");
setCurrentUser(null);
const proxy = makeNg();
await expect(proxy.sparql_update("sid", UPDATE, DOC)).rejects.toThrow(
/write denied/,
);
expect(ng.sparql_update).toHaveBeenCalledTimes(0);
});
test("write guard: ALLOWS the write-cap holder", async () => {
const ng = inject();
getCaps().grantWrite(DOC, "alice");
setCurrentUser("alice"); // owner always holds the write cap
const proxy = makeNg();
await proxy.sparql_update("sid", UPDATE, DOC);
expect(ng.sparql_update).toHaveBeenCalledTimes(1);
});
test("write guard: passthrough when anchor is omitted (cannot scope the guard)", async () => {
const ng = inject();
getCaps().grantWrite(DOC, "alice");
setCurrentUser("bob");
const proxy = makeNg();
await proxy.sparql_update("sid", "INSERT DATA {}"); // no anchor → passthrough
expect(ng.sparql_update).toHaveBeenCalledTimes(1);
});
+220
View File
@@ -0,0 +1,220 @@
/**
* open-repo.test.ts — behavioral tests for ensureRepoOpen / ensureReposOpen
* (src/open-repo.ts).
*
* Core invariant: on a fresh session over a persistent wallet, a scope-index
* or entity repo is NOT yet in `self.repos`, so an anchored sparql_query returns
* 0 rows. `ensureRepoOpen(nuri)` calls `doc_subscribe(nuri, …)` FIRST (which
* pushes the repo into the session), then the anchored read returns data.
*
* Fake design:
* - sparql_query returns EMPTY for a nuri UNTIL doc_subscribe has been called
* for that nuri (tracked in a Set).
* - doc_subscribe is a mock that records calls, fires the callback once
* (simulating the initial State push), then returns an unsubscribe fn.
*
* We test ensureRepoOpen via readUnion (from read-model) because that is the
* production caller — it gates on ensureReposOpen internally.
*/
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
import { ensureRepoOpen, ensureReposOpen, resetOpenedRepos } from "../src/emulated-verifier/open-repo";
import { readUnion } from "../src/surface/read-model";
import { configure, configureStoreRegistry, setCurrentUser } from "../src/index";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import { resetInfrastructure } from "../src/emulated-verifier/reach";
import { resetRegistryCache } from "../src/shared-wallet/account-registry";
afterAll(() => {
resetConfig();
resetStoreRegistry();
resetRegistryCache();
resetOpenedRepos();
});
// The reach guard and the cap registry are process-wide: once ANY cap exists the
// boundary applies to every reader. A suite that declares none must start from an
// empty one, or it inherits another suite's enforcement.
beforeEach(() => {
resetOpenedRepos();
resetRegistryCache();
resetCaps();
resetInfrastructure();
setCurrentUser(null);
});
const SESSION = { sessionId: "sid-or", privateStoreId: "PRIV-OR" };
const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
const FP = "http://festipod.org/";
// ---------------------------------------------------------------------------
// Fake ng builder: tracks which nuris have been doc_subscribe-d.
// sparql_query returns rows only AFTER the corresponding nuri is subscribed.
// ---------------------------------------------------------------------------
function makeFakeNgWithSubscribe(
triplesByDoc: Record<string, Array<[string, string]>>,
) {
const subscribed = new Set<string>();
const subscribeCallOrder: string[] = [];
// doc_subscribe: record the call, fire callback immediately (initial push), return unsub
const doc_subscribe = mock(async (nuri: string, _sid: string, cb: (r: unknown) => void) => {
subscribed.add(nuri);
subscribeCallOrder.push(nuri);
// Simulate initial State push (synchronously deferred so the subscription
// setup promise path in ensureRepoOpen can resolve it).
setTimeout(() => cb({ V0: { State: {} } }), 0);
return () => {}; // unsubscribe fn
});
const sparql_query = mock(async (_sid: string, _query: string, _base: unknown, anchor: unknown) => {
const doc = anchor as string | undefined;
if (!doc) return { results: { bindings: [] } };
// Only return data if the repo has been subscribed (i.e. opened)
if (!subscribed.has(doc)) return { results: { bindings: [] } };
const triples = triplesByDoc[doc];
if (!triples) return { results: { bindings: [] } };
const bindings = triples.map(([p, o]) => ({
s: { value: doc },
p: { value: p },
o: { value: o },
}));
return { results: { bindings } };
});
const doc_create = mock(async () => "did:ng:o:new");
const sparql_update = mock(async () => undefined);
return { doc_subscribe, sparql_query, doc_create, sparql_update, subscribed, subscribeCallOrder };
}
function inject(ng: ReturnType<typeof makeFakeNgWithSubscribe>) {
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => SESSION,
normalizeId: (u: string) => u,
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe("ensureRepoOpen", () => {
it("calls doc_subscribe BEFORE the anchored read returns data", async () => {
const ng = makeFakeNgWithSubscribe({
"did:ng:o:a": [[TYPE, `${FP}Event`], [`${FP}title`, "Alpha"]],
});
inject(ng);
// Directly call ensureRepoOpen then verify read sees data
await ensureRepoOpen("did:ng:o:a");
// doc_subscribe was called for the nuri
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
expect(ng.subscribeCallOrder[0]).toBe("did:ng:o:a");
// sparql_query was called AFTER subscribe (ensureRepoOpen guarantees ordering)
const result = await readUnion(["did:ng:o:a"]);
expect(result.length).toBe(1);
expect(result[0]!.props[`${FP}title`]).toEqual(["Alpha"]);
});
it("WITHOUT doc_subscribe, sparql_query returns 0 rows (verifies fake mechanics)", async () => {
const ng = makeFakeNgWithSubscribe({
"did:ng:o:a": [[TYPE, `${FP}Event`], [`${FP}title`, "Alpha"]],
});
inject(ng);
// Do NOT call ensureRepoOpen — subscribed Set remains empty
// Query directly (bypass readUnion which calls ensureReposOpen internally)
const result = await ng.sparql_query("sid-or", "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", undefined, "did:ng:o:a");
const bindings = (result as any).results.bindings;
expect(bindings.length).toBe(0); // not subscribed → 0 rows (confirms fake design)
});
it("idempotence: a 2nd ensureRepoOpen for the same nuri does NOT re-subscribe", async () => {
const ng = makeFakeNgWithSubscribe({
"did:ng:o:b": [[TYPE, `${FP}Event`]],
});
inject(ng);
await ensureRepoOpen("did:ng:o:b");
await ensureRepoOpen("did:ng:o:b"); // second call
// doc_subscribe must have been called exactly ONCE
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
});
it("no-op when the fake ng has no doc_subscribe (unit fake path)", async () => {
// Fake ng WITHOUT doc_subscribe
const noSubscribeNg = {
doc_create: mock(async () => "did:ng:o:new"),
sparql_update: mock(async () => undefined),
sparql_query: mock(async () => ({ results: { bindings: [] } })),
};
configure({ ng: noSubscribeNg as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => SESSION,
normalizeId: (u: string) => u,
});
// Must not throw; nuri is added to opened Set (guard skips subscribe)
await expect(ensureRepoOpen("did:ng:o:c")).resolves.toBeUndefined();
// Calling again should also be a no-op (idempotent, already in opened)
await expect(ensureRepoOpen("did:ng:o:c")).resolves.toBeUndefined();
});
});
describe("ensureReposOpen", () => {
it("opens all provided nuris in parallel (one subscribe per unique nuri)", async () => {
const ng = makeFakeNgWithSubscribe({
"did:ng:o:x": [[TYPE, `${FP}Event`]],
"did:ng:o:y": [[TYPE, `${FP}Event`]],
});
inject(ng);
await ensureReposOpen(["did:ng:o:x", "did:ng:o:y"]);
expect(ng.doc_subscribe).toHaveBeenCalledTimes(2);
expect(ng.subscribed.has("did:ng:o:x")).toBe(true);
expect(ng.subscribed.has("did:ng:o:y")).toBe(true);
});
it("deduplicates: repeated nuri in input leads to exactly one subscribe", async () => {
const ng = makeFakeNgWithSubscribe({
"did:ng:o:dup": [[TYPE, `${FP}Event`]],
});
inject(ng);
await ensureReposOpen(["did:ng:o:dup", "did:ng:o:dup", "did:ng:o:dup"]);
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
});
it("empty or all-falsy input is a no-op (no subscribe calls)", async () => {
const ng = makeFakeNgWithSubscribe({});
inject(ng);
await ensureReposOpen([]);
await ensureReposOpen(["" as any]);
expect(ng.doc_subscribe).toHaveBeenCalledTimes(0);
});
it("readUnion triggers doc_subscribe then returns data (integration path)", async () => {
const ng = makeFakeNgWithSubscribe({
"did:ng:o:p": [[TYPE, `${FP}Participation`], [`${FP}event`, "did:ng:o:e"]],
});
inject(ng);
const subjects = await readUnion(["did:ng:o:p"]);
// doc_subscribe was called as part of ensureReposOpen inside readUnion
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
expect(subjects.length).toBe(1);
expect(subjects[0]!.props[`${FP}event`]).toEqual(["did:ng:o:e"]);
});
});
+146
View File
@@ -0,0 +1,146 @@
/**
* public-store.test.ts — the emulated *"downloaded from the outerOverlay"*, in isolation.
*
* `cross-user-access.test.ts` proves the consequence end to end (Bob reads Alice's
* public document from a bare reference). This file pins the primitive itself: what it
* asks, what it refuses, and when it says nothing at all.
*/
import { test, expect, mock, afterEach } from "bun:test";
import { exposeReadCap, fetchReadCap, resetPublicStoreFetches } from "../src/emulated-verifier/public-store";
import { mintCap } from "../src/emulated-verifier/caps";
import { getCaps } from "../src/shared-wallet/bootstrap";
import { configure, configureStoreRegistry, setCurrentUser } from "../src/index";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import type { Nuri } from "../src/model/types";
const SHIM = "urn:ng-eventually:shim";
const SESSION = { sessionId: "sid-ps", privateStoreId: "PRIV-PS" };
interface Quad { g: string; s: string; p: string; o: string }
/** A fake `ng` holding just enough to answer the Header-branch `exposedReadCap` query. */
function inject() {
const quads: Quad[] = [];
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[2] as string;
if (/^\s*DELETE WHERE/.test(query)) {
for (let i = quads.length - 1; i >= 0; i--) if (quads[i]!.g === anchor) quads.splice(i, 1);
return undefined;
}
const m = query.match(/<([^>]+)>\s+<([^>]+)>\s+"([^"]*)"/);
if (m) quads.push({ g: anchor, s: m[1]!, p: m[2]!, o: m[3]! });
return undefined;
});
const sparql_query = mock(async (...a: unknown[]) => ({
results: {
bindings: quads
.filter((q) => q.g === (a[3] as string) && q.p === `${SHIM}:exposedReadCap`)
.map((q) => ({ c: { value: q.o } })),
},
}));
configure({ ng: { doc_create: mock(async () => "did:ng:o:x"), sparql_update, sparql_query } as any, useShape: (() => {}) as any });
configureStoreRegistry({ getSession: async () => SESSION });
resetCaps();
resetPublicStoreFetches();
setCurrentUser(null);
return { sparql_query, quads };
}
afterEach(() => {
resetConfig();
resetStoreRegistry();
resetCaps();
setCurrentUser(null);
});
/** Arm the emulation without giving the current holder anything: some OTHER document. */
function armEmulation(): void {
setCurrentUser("someone-else");
getCaps().mint("did:ng:o:unrelated");
}
const PUB = "did:ng:o:pub" as Nuri;
test("a cap exposed on a document is downloaded by a holder that has nothing", async () => {
inject();
setCurrentUser("alice");
await exposeReadCap(PUB, mintCap(PUB));
setCurrentUser("bob");
armEmulation();
setCurrentUser("bob");
expect(getCaps().capFor(PUB)).toBeUndefined();
expect(await fetchReadCap(PUB)).toBe(true);
expect(getCaps().capFor(PUB)).toBe(mintCap(PUB));
// …and what he got is a READ grant, recorded as such.
expect(getCaps().isReadOnlyPublicCap(PUB)).toBe(true);
expect(getCaps().isInPublicStore(PUB)).toBe(true);
});
test("a document that exposes nothing yields nothing — that is the normal case, not an error", async () => {
inject();
armEmulation();
setCurrentUser("bob");
expect(await fetchReadCap("did:ng:o:protected" as Nuri)).toBe(false);
expect(getCaps().capFor("did:ng:o:protected" as Nuri)).toBeUndefined();
});
// A document speaks for itself and for nothing else. Without this, whoever can write
// into one public document could file caps for every document they care to name.
test("a cap naming ANOTHER document is refused, not filed", async () => {
const { quads } = inject();
setCurrentUser("alice");
await exposeReadCap(PUB, mintCap(PUB));
// Forge the exposed value so it names a different document.
quads[0]!.o = mintCap("did:ng:o:someone-elses" as Nuri);
armEmulation();
setCurrentUser("bob");
expect(await fetchReadCap(PUB)).toBe(false);
expect(getCaps().capFor(PUB)).toBeUndefined();
expect(getCaps().capFor("did:ng:o:someone-elses" as Nuri)).toBeUndefined();
});
test("inert while no cap has been issued at all — nothing to obtain, nothing asked", async () => {
const { sparql_query } = inject();
setCurrentUser("bob");
expect(await fetchReadCap(PUB)).toBe(false);
expect(sparql_query).toHaveBeenCalledTimes(0);
});
test("asked once per document: the outcome is memoised, in both directions", async () => {
const { sparql_query } = inject();
setCurrentUser("alice");
await exposeReadCap(PUB, mintCap(PUB));
armEmulation();
setCurrentUser("bob");
await fetchReadCap(PUB);
const afterHit = sparql_query.mock.calls.length;
await fetchReadCap(PUB); // held now → not even the memo is consulted
expect(sparql_query.mock.calls.length).toBe(afterHit);
const absent = "did:ng:o:nothing-here" as Nuri;
await fetchReadCap(absent);
const afterMiss = sparql_query.mock.calls.length;
await fetchReadCap(absent); // a miss is remembered too
expect(sparql_query.mock.calls.length).toBe(afterMiss);
});
test("resetting the caps forgets the memo — a stale yes would hand back what is no longer held", async () => {
const { sparql_query } = inject();
setCurrentUser("alice");
await exposeReadCap(PUB, mintCap(PUB));
armEmulation();
setCurrentUser("bob");
await fetchReadCap(PUB);
resetCaps(); // also calls resetPublicStoreFetches
armEmulation();
setCurrentUser("bob");
const before = sparql_query.mock.calls.length;
expect(await fetchReadCap(PUB)).toBe(true);
expect(sparql_query.mock.calls.length).toBeGreaterThan(before); // asked again
});
+214
View File
@@ -0,0 +1,214 @@
/**
* reach.test.ts — the virtual user boundary, at the passage points.
*
* A virtual user must simulate the boundary of the future single-user wallet: the
* access functions are confined to the user currently connected, and no cross-user
* access is permitted. Before this, `docs.sparqlQuery`/`sparqlUpdate` — both
* exported from the SDK entry — reached ANY document of ANY identity given a
* session id and a NURI.
*
* The one act that legitimately crosses: DEPOSITING into someone's inbox. It is
* how a link travels between users at all, and it gives the depositor nothing back.
*/
import { test, expect, mock, afterAll } from "bun:test";
import { sparqlQuery, sparqlUpdate, depositInto } from "../src/surface/docs";
import { createEntityDoc, resetRegistryCache, userInbox } from "../src/shared-wallet/account-registry";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
import { configure, configureStoreRegistry, setCurrentUser } from "../src/index";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import { mayReach, mustNotAttempt } from "../src/emulated-verifier/reach";
import { hasReadCap } from "../src/model/nuri";
afterAll(() => {
resetConfig();
resetStoreRegistry();
resetCaps();
setCurrentUser(null);
});
const SESSION: RegistrySession = { sessionId: "sid-reach", privateStoreId: "PRIV-REACH" };
function inject() {
let n = 0;
const quads: Array<{ g: string; s: string; p: string; o: string }> = [];
const ng = {
doc_create: mock(async () => `did:ng:o:reach${++n}`),
sparql_update: mock(async (...a: unknown[]) => {
quads.push({ g: String(a[2]), s: "", p: "", o: String(a[1]) });
return undefined;
}),
sparql_query: mock(async () => ({ results: { bindings: [] } })),
};
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
resetRegistryCache();
resetCaps();
setCurrentUser(null);
return { ng, quads };
}
const READ = "SELECT ?s ?p ?o WHERE { ?s ?p ?o }";
test("the guard is inert until the first cap exists (no regression for a cap-free consumer)", async () => {
const { ng } = inject();
// Nothing has been created, so no cap has been issued: everything flows.
expect(mayReach("did:ng:o:anything")).toBe(true);
await sparqlQuery(SESSION.sessionId, READ, undefined, "did:ng:o:anything");
expect(ng.sparql_query).toHaveBeenCalledTimes(1);
});
test("once caps exist, a document outside the connected user's reach is refused — read AND write", async () => {
inject();
setCurrentUser("alice");
const mine = await createEntityDoc("alice", "private");
// Mine: reachable.
expect(mayReach(mine)).toBe(true);
await sparqlQuery(SESSION.sessionId, READ, undefined, mine);
// A well-formed NURI I hold nothing for: named, unreachable. Both directions.
const theirs = "did:ng:o:someone-elses-doc" as const;
expect(mayReach(theirs)).toBe(false);
await expect(sparqlQuery(SESSION.sessionId, READ, undefined, theirs)).rejects.toThrow(
/does not hold this document.s cap/i,
);
await expect(
sparqlUpdate(SESSION.sessionId, "INSERT DATA { <a> <b> \"c\" }", theirs),
).rejects.toThrow(/does not hold this document.s cap/i);
});
test("the boundary follows the connected user — one user's document is another's forbidden NURI", async () => {
inject();
setCurrentUser("alice");
const aliceDoc = await createEntityDoc("alice", "private");
setCurrentUser("bob");
const bobDoc = await createEntityDoc("bob", "private");
expect(mayReach(bobDoc)).toBe(true);
expect(mayReach(aliceDoc)).toBe(false); // bob is connected
await expect(sparqlQuery(SESSION.sessionId, READ, undefined, aliceDoc)).rejects.toThrow();
setCurrentUser("alice");
expect(mayReach(aliceDoc)).toBe(true);
expect(mayReach(bobDoc)).toBe(false);
});
test("a user reaches its OWN stores and inbox — the boundary must not lock it out of itself", async () => {
inject();
setCurrentUser("alice");
await createEntityDoc("alice", "protected"); // provisions alice's account
const inbox = await userInbox("alice", "protected");
expect(mayReach(inbox)).toBe(true);
await sparqlQuery(SESSION.sessionId, READ, undefined, inbox);
// …and not another user's inbox.
setCurrentUser("bob");
expect(mayReach(inbox)).toBe(false);
});
test("DEPOSITING into another user's inbox crosses the boundary, and gives nothing back", async () => {
const { ng } = inject();
setCurrentUser("bob");
const bobInbox = await userInbox("bob", "protected");
setCurrentUser("alice");
await createEntityDoc("alice", "private"); // alice now holds caps → guard is armed
expect(mayReach(bobInbox)).toBe(false); // she holds no cap for it
// The deposit goes through anyway — it is the one legitimate cross-user act.
const before = ng.sparql_update.mock.calls.length;
await depositInto(SESSION.sessionId, 'INSERT DATA { <a> <b> "c" }', bobInbox);
expect(ng.sparql_update.mock.calls.length).toBe(before + 1);
// …and it grants her nothing: she still cannot read that inbox.
expect(mayReach(bobInbox)).toBe(false);
await expect(sparqlQuery(SESSION.sessionId, READ, undefined, bobInbox)).rejects.toThrow(
/does not hold this document.s cap/i,
);
});
test("the shim is reached by the MACHINERY, not by an exemption in the boundary", async () => {
inject();
setCurrentUser("alice");
await createEntityDoc("alice", "private"); // arms the emulation, resolves the shim
// The store-root and the doc-shim are NOT reachable through the virtual-user
// surface — there is no exemption list any more. The machinery reaches them
// through its own primitives (`physical.ts`), which the boundary never sees and
// which are never exported from the package.
expect(mayReach(`did:ng:${SESSION.privateStoreId}`)).toBe(false);
await expect(
sparqlQuery(SESSION.sessionId, READ, undefined, `did:ng:${SESSION.privateStoreId}`),
).rejects.toThrow(/does not hold this document's cap/i);
// …yet the registry works, because it never asked through that door.
const doc = await createEntityDoc("alice", "protected");
expect(mayReach(doc)).toBe(true);
});
// The two rules are deliberately redundant, and this is what that buys.
test("rule 1 and rule 2 are independent — the guard still holds if a caller forgets to check", async () => {
inject();
setCurrentUser("alice");
await createEntityDoc("alice", "private"); // arms the emulation
const theirs = "did:ng:o:not-mine" as const;
// RULE 2 — a caller that checks first simply does not issue the operation.
expect(mustNotAttempt(theirs)).toBe(true);
// RULE 1 — and a caller that does NOT check is refused anyway. This is the whole
// point of implementing the same criterion in two places: rule 2 is where the
// model lives (you cannot address what you hold no cap for), rule 1 is what makes
// a lapse in rule 2 fail loudly instead of quietly succeeding.
await expect(sparqlQuery(SESSION.sessionId, READ, undefined, theirs)).rejects.toThrow(
/does not hold this document's cap/i,
);
});
// Possession decides, not the shape of the reference the caller happens to hold.
test("a BARE reference is reachable when the cap is possessed elsewhere", async () => {
inject();
setCurrentUser("alice");
const doc = await createEntityDoc("alice", "private");
// `doc` is the bare form — it carries no cap — yet alice possesses that cap, so
// reaching it is legitimate. Manipulating a bare NURI is normal: references travel
// bare through content and indexes while the cap sits in what the user holds.
expect(hasReadCap(doc)).toBe(false);
expect(mayReach(doc)).toBe(true);
await sparqlQuery(SESSION.sessionId, READ, undefined, doc);
// The cap-bearing form of the same document answers alike.
expect(mayReach(`${doc}:r:OK`)).toBe(true);
// And bob, holding neither, cannot reach it in either form.
setCurrentUser("bob");
expect(mayReach(doc)).toBe(false);
expect(mayReach(`${doc}:r:OK`)).toBe(false);
});
// The whole point of splitting the machinery out: one API is the app's, the other
// must never be. A regression here is silent and total — an app holding the
// machinery reaches every virtual user's documents.
test("the machinery is NOT part of the package's public surface", async () => {
const entry: Record<string, unknown> = await import("../src/index");
for (const name of Object.keys(entry)) {
expect(name).not.toMatch(/^physical/);
}
// Named explicitly, so adding one and forgetting the rule fails here.
for (const forbidden of ["physicalQuery", "physicalUpdate", "physicalCreate", "subscribePhysicalDoc"]) {
expect(entry[forbidden]).toBeUndefined();
}
// …and the machinery accessors the merged entry deliberately stopped publishing
// (2026-08-07): internal wiring and test resets are reached by their internal path.
for (const unpublished of ["getConfig", "getStoreRegistryDeps", "resetConfig", "resetStoreRegistry", "resetCaps", "getCaps", "getCurrentUser"]) {
expect(entry[unpublished]).toBeUndefined();
}
// The cross-account fan-out is gone from the registry entirely.
const registry = entry.storeRegistry as Record<string, unknown>;
for (const gone of ["listEntityDocs", "resolveReadGraphs", "allAccounts", "loadShim"]) {
expect(registry[gone]).toBeUndefined();
}
});
+84
View File
@@ -0,0 +1,84 @@
import { test, expect } from "bun:test";
import { filterReadable, makeReadFilteredView } from "../src/emulated-verifier/read-filter";
import { CapRegistry } from "../src/emulated-verifier/caps";
// The access unit is the DOCUMENT (an item's `@graph` = the repo it lives in),
// not the item. Items here carry `@graph`; each holder holds caps per document.
interface Item { id: string; "@graph"?: string }
const MINE: Item = { id: "a", "@graph": "did:ng:o:alice" }; // alice's doc
const LINKED: Item = { id: "p", "@graph": "did:ng:o:public" }; // a published doc
const FOREIGN: Item = { id: "n", "@graph": "did:ng:o:other" }; // no cap held
const NOGRAPH: Item = { id: "x" }; // names no document
/** A registry whose holder the test drives; alice created one doc and published one. */
function setup(initial: string | null = "alice") {
let holder = initial;
const caps = new CapRegistry(() => holder);
const before = holder;
holder = "alice";
caps.mint("did:ng:o:alice");
const link = caps.open("did:ng:o:public", "public");
holder = before;
return { caps, link, become: (id: string | null) => (holder = id) };
}
test("filterReadable keeps only documents whose cap is held; a graphless item names none", () => {
const items = [MINE, LINKED, FOREIGN, NOGRAPH];
const { caps, become } = setup("alice");
expect(filterReadable(items, caps).map((i) => i.id)).toEqual(["a", "p", "x"]);
// bob holds nothing — including the published doc, until he receives its link.
become("bob");
expect(filterReadable(items, caps).map((i) => i.id)).toEqual(["x"]);
});
test("a bare reference yields nothing — naming is not reading", () => {
const { caps } = setup("alice");
// `did:ng:o:other` is perfectly well-formed and perfectly unreadable.
expect(filterReadable([FOREIGN], caps)).toEqual([]);
});
test("receiving the repo link is what opens a published document", () => {
const { caps, link, become } = setup("alice");
become("bob");
expect(filterReadable([LINKED], caps)).toEqual([]);
caps.learn(link);
expect(filterReadable([LINKED], caps).map((i) => i.id)).toEqual(["p"]);
});
test("makeReadFilteredView filters iteration/size, and follows the holder in effect", () => {
const set = new Set<Item>([MINE, LINKED, FOREIGN, NOGRAPH]);
const { caps, become } = setup("bob");
const view = makeReadFilteredView(set, caps);
expect([...view].map((i) => i.id)).toEqual(["x"]);
expect(view.size).toBe(1);
become("alice"); // the held caps are read lazily → the view updates without rewrapping
expect([...view].map((i) => i.id)).toEqual(["a", "p", "x"]);
expect(view.size).toBe(3);
});
test("makeReadFilteredView forwards mutations and membership to the target", () => {
const set = new Set<Item>([LINKED]);
const { caps } = setup("alice");
const view = makeReadFilteredView(set, caps);
const C: Item = { id: "c", "@graph": "did:ng:o:public" };
view.add(C);
expect(set.has(C)).toBe(true); // mutation reached the real set
expect([...view].map((i) => i.id)).toEqual(["p", "c"]);
view.delete(C);
expect(set.has(C)).toBe(false);
});
test("forEach is filtered too", () => {
const set = new Set<Item>([MINE, LINKED]);
const seen: string[] = [];
const { caps, become } = setup("alice");
become("bob");
makeReadFilteredView(set, caps).forEach((i) => seen.push((i as Item).id));
expect(seen).toEqual([]);
});
+152
View File
@@ -0,0 +1,152 @@
import { getCaps } from "../src/shared-wallet/bootstrap";
import { test, expect, mock, afterAll } from "bun:test";
import { readUnion } from "../src/surface/read-model";
import type { Nuri } from "../src/model/types";
import { configure, configureStoreRegistry, setCurrentUser } from "../src/index";
import { resetCaps } from "../src/shared-wallet/bootstrap";
// The cap registry is process-wide, so each inject() starts from an empty one:
// once ANY cap exists the possession gate is in force for every reader, and a
// suite that never declares caps must not inherit another suite's.
afterAll(() => {
resetCaps();
setCurrentUser(null);
});
// A fake `ng` whose sparql_query answers the ANCHORED per-doc query (SELECT ?s ?p ?o
// WHERE { ?s ?p ?o }, anchor = the doc NURI) with ONLY that doc's triples. There is
// NO anchorless union scan: each doc is read independently by its own anchor. Each
// entity subject IRI IS its own document NURI (writeEntity convention), so the
// fixture keys triples by the doc NURI and returns them for the matching anchor.
function fakeNgWith(triplesByDoc: Record<string, Array<[string, string]>>) {
return {
doc_create: mock(async () => "did:ng:o:new"),
sparql_update: mock(async () => undefined),
sparql_query: mock(async (_sid: string, _query: string, _base: unknown, anchor: unknown) => {
// Every read is ANCHORED to one doc NURI — never anchorless.
if (anchor === undefined) {
throw new Error("read-model must NEVER run an anchorless (union) query");
}
const doc = anchor as string;
const triples = triplesByDoc[doc];
if (!triples) return { results: { bindings: [] } };
const bindings = triples.map(([p, o]) => ({
s: { value: doc },
p: { value: p },
o: { value: o },
}));
return { results: { bindings } };
}),
};
}
function inject(triplesByDoc: Record<string, Array<[string, string]>>) {
const ng = fakeNgWith(triplesByDoc);
resetCaps();
setCurrentUser(null);
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
normalizeId: (u: string) => u,
});
return ng;
}
const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
const FP = "http://festipod.org/";
test("readUnion reads each doc with its OWN anchored query (never anchorless)", async () => {
const ng = inject({
"did:ng:o:a": [[TYPE, `${FP}Event`], [`${FP}title`, "A"]],
"did:ng:o:b": [[TYPE, `${FP}Event`], [`${FP}title`, "B"]],
});
const subjects = await readUnion(["did:ng:o:a", "did:ng:o:b"]);
// One anchored query per doc = 2 sparql_query calls, each anchored (c[3] set).
expect(ng.sparql_query).toHaveBeenCalledTimes(2);
const anchored = ng.sparql_query.mock.calls.filter((c: unknown[]) => c[3] !== undefined);
expect(anchored.length).toBe(2);
// The anchors are exactly the requested doc NURIs.
expect(new Set(anchored.map((c: unknown[]) => c[3]))).toEqual(
new Set(["did:ng:o:a", "did:ng:o:b"]),
);
expect(subjects.length).toBe(2);
const a = subjects.find((s) => s.subject === "did:ng:o:a")!;
expect(a.props[`${FP}title`]).toEqual(["A"]);
expect(a.graph).toBe("did:ng:o:a");
});
test("readUnion groups predicates per subject", async () => {
inject({
"did:ng:o:p": [
[TYPE, `${FP}Participation`],
[`${FP}event`, "did:ng:o:e"],
[`${FP}user`, "urn:festipod:user:x"],
],
});
const s = (await readUnion(["did:ng:o:p"]))[0]!;
expect(s.subject).toBe("did:ng:o:p");
expect(s.props[`${FP}event`]).toEqual(["did:ng:o:e"]);
expect(s.props[`${FP}user`]).toEqual(["urn:festipod:user:x"]);
});
test("readUnion returns [] for an empty doc set (no query)", async () => {
const ng = inject({});
const subjects = await readUnion([]);
expect(subjects).toEqual([]);
expect(ng.sparql_query).toHaveBeenCalledTimes(0);
});
test("a doc that fails to read is skipped, not aborting the batch", async () => {
const ng = fakeNgWith({ "did:ng:o:ok": [[TYPE, `${FP}Event`], [`${FP}title`, "ok"]] });
const orig = ng.sparql_query;
// Make the anchored read throw for the bad doc only.
ng.sparql_query = mock(async (sid: string, query: string, base: unknown, anchor: unknown) => {
if (anchor === "did:ng:o:bad") throw new Error("RepoNotFound");
return orig(sid, query, base, anchor);
}) as any;
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => ({ sessionId: "sid-rm", privateStoreId: "priv" }),
normalizeId: (u: string) => u,
});
const subjects = await readUnion(["did:ng:o:ok", "did:ng:o:bad"]);
// The bad doc failed its read but the good one still lists.
expect(subjects.map((s) => s.subject)).toEqual(["did:ng:o:ok"]);
});
// The possession gate, at the read-model's own level: once ANY cap exists, a doc
// whose cap is not in what the current holder holds is dropped — however well its
// NURI resolves. Before the first cap the gate is inert (no regression).
test("readUnion drops a doc whose cap the holder does not hold", async () => {
inject({
"did:ng:o:mine": [[TYPE, `${FP}Event`], [`${FP}title`, "mine"]],
"did:ng:o:theirs": [[TYPE, `${FP}Event`], [`${FP}title`, "theirs"]],
});
const both: Nuri[] = ["did:ng:o:mine", "did:ng:o:theirs"];
// Inert: no cap issued yet → everything flows through.
expect((await readUnion(both)).map((s) => s.subject).sort()).toEqual(both);
// One cap issued → possession is now the rule for every document.
setCurrentUser("alice");
getCaps().mint("did:ng:o:mine");
expect((await readUnion(both)).map((s) => s.subject)).toEqual(["did:ng:o:mine"]);
// …and for every holder: bob holds nothing, so bob reads nothing.
setCurrentUser("bob");
expect(await readUnion(both)).toEqual([]);
});
test("readUnion tolerates holes in the list, and refuses a malformed reference", async () => {
// Two different things that must not be conflated, and conflating them broke a whole
// reconnect run: an EMPTY entry is absence — a scope index can carry one, and a caller
// assembling a list from optional values should not have to compact it — while a
// non-reference is a caller mistake worth a loud error. Validating before filtering
// turned the first into the second.
inject({});
await expect(readUnion(["", null as never, undefined as never])).resolves.toEqual([]);
await expect(readUnion(["not-a-nuri"])).rejects.toThrow(/not a NextGraph reference/i);
});
+78
View File
@@ -0,0 +1,78 @@
import { test, expect } from "bun:test";
import { escapeLiteral, escapeIri, assertNuri } from "../src/surface/sparql";
// --- escapeLiteral --------------------------------------------------------
test("escapeLiteral escapes backslash, quote and whitespace controls", () => {
expect(escapeLiteral('a"b')).toBe('a\\"b');
expect(escapeLiteral("a\\b")).toBe("a\\\\b");
expect(escapeLiteral("a\nb")).toBe("a\\nb");
expect(escapeLiteral("a\rb")).toBe("a\\rb");
expect(escapeLiteral("a\tb")).toBe("a\\tb");
});
test("escapeLiteral backslash-then-quote order does not double-escape", () => {
// `\` first so a raw `"` never becomes `\"` before the quote pass mangles it.
expect(escapeLiteral('\\"')).toBe('\\\\\\"');
});
test("escapeLiteral output can no longer close a SPARQL literal", () => {
const injected = '" ; <urn:evil> "pwn';
const escaped = escapeLiteral(injected);
// No RAW double-quote survives — every `"` is preceded by a backslash.
expect(/(^|[^\\])"/.test(escaped)).toBe(false);
});
// --- escapeIri ------------------------------------------------------------
test("escapeIri passes ordinary printable identifier chars through unchanged", () => {
expect(escapeIri("alice")).toBe("alice");
expect(escapeIri("a.b-c_d:e/f")).toBe("a.b-c_d:e/f");
});
test("escapeIri percent-encodes every IRI-breaking character", () => {
expect(escapeIri("a>b")).toBe("a%3Eb");
expect(escapeIri("a<b")).toBe("a%3Cb");
expect(escapeIri('a"b')).toBe("a%22b");
expect(escapeIri("a b")).toBe("a%20b");
expect(escapeIri("a\nb")).toBe("a%0Ab");
expect(escapeIri("a\tb")).toBe("a%09b");
expect(escapeIri("a\\b")).toBe("a%5Cb");
});
test("escapeIri neutralises a full breakout attempt", () => {
const attack = 'x> <urn:evil> "pwn';
const encoded = escapeIri(attack);
// The encoded id cannot contain a raw `>`, `<`, `"` or space, so it
// cannot escape the surrounding <PREFIX:...> IRI.
expect(encoded).not.toMatch(/[<>" ]/);
});
test("escapeIri handles unicode without corrupting it (round-trips via decode)", () => {
const u = "élan";
// "é" is a printable letter → left as-is; a space would be encoded.
expect(escapeIri(u)).toBe("élan");
expect(escapeIri("é ")).toBe("é%20");
});
// --- assertNuri -----------------------------------------------------------
test("assertNuri returns valid NURIs unchanged", () => {
expect(assertNuri("did:ng:o:doc1")).toBe("did:ng:o:doc1");
expect(assertNuri("urn:ng-eventually:shim")).toBe("urn:ng-eventually:shim");
expect(assertNuri("did:ng:PRIV")).toBe("did:ng:PRIV");
});
test("assertNuri throws on empty / non-string", () => {
expect(() => assertNuri("")).toThrow(/invalid NURI/);
// @ts-expect-error deliberately wrong type
expect(() => assertNuri(null)).toThrow(/invalid NURI/);
});
test("assertNuri throws on IRI-breaking characters", () => {
expect(() => assertNuri("did:ng:o> <urn:evil")).toThrow(/IRI-forbidden/);
expect(() => assertNuri('did:ng:"x')).toThrow(/IRI-forbidden/);
expect(() => assertNuri("did:ng: x")).toThrow(/IRI-forbidden/);
expect(() => assertNuri("did:ng:\nx")).toThrow(/IRI-forbidden/);
expect(() => assertNuri("did:ng:\tx")).toThrow(/IRI-forbidden/);
});
+392
View File
@@ -0,0 +1,392 @@
import { test, expect, mock, beforeEach, afterAll } from "bun:test";
import {
ensureAccount,
resolveWriteGraph,
resolveAccount,
listMyEntityDocs,
resolveScopeGraph,
userInbox,
createEntityDoc,
resetRegistryCache,
} from "../src/shared-wallet/account-registry";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
import { configure, configureStoreRegistry } from "../src/index";
import { resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
// This suite injects a fake `ng` via configure(); bun runs test files in a
// shared process with a single module singleton, and may run this file BEFORE
// docs.test.ts's order-dependent "not configured" guard. Restore the un-
// configured state when we're done so that guard still sees a null config.
afterAll(() => {
resetConfig();
resetStoreRegistry();
});
// NOTE ORDER: the "not configured → throw" case MUST run first — configure*()
// sets module-level singletons and this suite never fully un-injects the real
// `ng` (docs' getConfig has no reset), so we exercise the registry-deps guard.
test("throws a clear error when configureStoreRegistry() was not called", async () => {
resetStoreRegistry();
resetRegistryCache();
await expect(ensureAccount("alice")).rejects.toThrow(
/configureStoreRegistry\(\) must be called before use/,
);
});
// --- A stateful fake `ng` that emulates just enough SPARQL over an in-memory
// quad store: INSERT DATA parsing + the two SELECT shapes the registry issues.
interface Quad { g: string; s: string; p: string; o: string }
/** Reverse of the lib's escapeLiteral: single left-to-right pass over `\x`. */
function unescapeLiteral(s: string): string {
let out = "";
for (let i = 0; i < s.length; i++) {
if (s[i] === "\\" && i + 1 < s.length) {
const next = s[++i];
out +=
next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next;
} else {
out += s[i];
}
}
return out;
}
function makeFakeNg() {
const quads: Quad[] = [];
let docCounter = 0;
const doc_create = mock(async (..._a: unknown[]) => `did:ng:o:doc${++docCounter}`);
// Parses `INSERT DATA { GRAPH <g> { <s> <p> "o"/<o>/;-lists } }`. The literal
// pattern honours backslash-escapes (`\"`, `\\`, `\n`…) so an escaped quote
// inside a value does NOT terminate the literal — this is what proves the
// injection escaping keeps the query well-formed.
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[2] as string | undefined;
// TWO shapes coexist here:
// - the shim account write STILL uses `GRAPH <${privateStore}>` — the
// private STORE repo's graph name equals the plain store NURI, so it
// round-trips (login must not regress). Key it by that GRAPH IRI.
// - the per-entity INDEX write has NO explicit GRAPH — the real broker keys
// it by the ANCHORED repo's default graph (repo_graph_name(id, overlay)),
// so key it by the ANCHOR arg (a[2]). An explicit `GRAPH <indexDoc>`
// would target a phantom graph → must NOT round-trip.
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
let g: string;
let body: string;
if (gm) {
g = gm[1]!;
body = gm[2]!;
} else {
if (!anchor) return undefined;
g = anchor;
body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
}
// Subject is the first <...> token in the body.
const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
const s = sm[1]!;
// Predicate/object pairs: `<p> "o"` (escape-aware) or `<p> <o>`; `a <type>`.
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
// Skip the subject token so we don't treat it as a predicate.
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
while ((m = pairRe.exec(after)) !== null) {
const p = m[1] ?? "urn:ng-eventually:shim:Account"; // `a` → rdf:type-ish
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
quads.push({ g, s, p, o });
}
return undefined;
});
const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[3] as string | undefined;
// Pointer SELECT: `<shim:root> <shim:shimDoc> ?shimDoc` in the store-root graph.
if (query.includes("<urn:ng-eventually:shim:shimDoc>")) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc")
.map((q) => ({ shimDoc: { value: q.o } }));
return { results: { bindings } };
}
if (query.includes("<urn:ng-eventually:shim:id>")) {
// Account SELECT, anchored to the doc-shim's default graph (records live in the
// doc-shim now, no GRAPH wrapper). Two shapes: the full scan (`?acc a <Account>`)
// and the TARGETED bounded resolve (`<subj> a <Account>`) — honour the subject.
const subjM = query.match(/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/);
const onlySubject = subjM ? subjM[1]! : null;
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (onlySubject !== null && q.s !== onlySubject) continue;
const rec = bySubject.get(q.s) ?? {};
if (q.p === "urn:ng-eventually:shim:id") rec.id = q.o;
if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o;
if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = q.o;
if (q.p === "urn:ng-eventually:shim:docPrivate") rec.docPrivate = q.o;
bySubject.set(q.s, rec);
}
const bindings = [...bySubject.values()]
.filter((r) => r.id)
.map((r) => ({
id: { value: r.id! },
docPublic: { value: r.docPublic ?? "" },
docProtected: { value: r.docProtected ?? "" },
docPrivate: { value: r.docPrivate ?? "" },
}));
return { results: { bindings } };
}
// Entity-index SELECT: `<index> <contains> ?e` in the anchor graph.
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains")
.map((q) => ({ e: { value: q.o } }));
return { results: { bindings } };
});
return { doc_create, sparql_update, sparql_query, _quads: quads };
}
const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" };
function inject() {
const ng = makeFakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => SESSION,
normalizeId: (u) => u.trim().replace(/^@+/, "").toLowerCase(),
});
resetRegistryCache();
return ng;
}
let fake: ReturnType<typeof makeFakeNg>;
beforeEach(() => {
fake = inject();
});
test("ensureAccount creates 3 scope docs and persists them to the doc-shim", async () => {
const rec = await ensureAccount("Alice");
expect(rec.id).toBe("Alice");
// 4 doc_create: 1 doc-shim (first login, resolveShimDoc) + 3 scope docs.
expect(fake.doc_create).toHaveBeenCalledTimes(4);
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
expect(rec.docProtected).not.toBe(rec.docPublic);
expect(rec.docPrivate).not.toBe(rec.docProtected);
// The pointer (store-root -> doc-shim) was written into the store-root graph.
const pointerWrite = fake.sparql_update.mock.calls.find(
(c) => (c[1] as string).includes("<urn:ng-eventually:shim:shimDoc>"),
);
expect(pointerWrite?.[2]).toBe("did:ng:PRIV");
// The account record was persisted into the doc-shim (a did:ng:o: repo), not the root.
const recordWrite = fake.sparql_update.mock.calls.find(
(c) => (c[1] as string).includes("<urn:ng-eventually:shim:docPublic>"),
);
expect(recordWrite?.[2]).toMatch(/^did:ng:o:doc/);
});
test("ensureAccount is idempotent (case/@-insensitive key), no extra docs", async () => {
const a = await ensureAccount("Alice");
const b = await ensureAccount("@alice");
expect(b).toEqual(a);
expect(fake.doc_create).toHaveBeenCalledTimes(4); // 1 doc-shim + 3 scope docs, not 7
});
test("ensureAccount de-dupes CONCURRENT provisions (anti-fork): one account, 3 docs", async () => {
// The reconnection FORK: several callers (watchShape public+protected, the
// container subs, the owned-events effect) hit ensureAccount(SAME id) BEFORE
// the shim has synced, so each reads 0 rows and independently provisions a new
// set of scope docs — N forks, N×3 docs, duplicate docPublic/docProtected in the
// shim → a fresh reader picks a different canonical doc than the writer wrote to.
// The in-flight de-dup collapses N concurrent provisions into ONE.
const results = await Promise.all([
ensureAccount("Bob"),
ensureAccount("Bob"),
ensureAccount("@bob"),
ensureAccount("BOB"),
ensureAccount("bob"),
]);
// ONE set of 3 scope docs + 1 doc-shim (resolveShimDoc de-dupes concurrent pointer
// resolution too) — 4 total, not 5×3.
expect(fake.doc_create).toHaveBeenCalledTimes(4);
// Every caller got the SAME record (same docs), so writer/reader can never
// disagree on the canonical scope doc.
for (const r of results) expect(r).toEqual(results[0]!);
});
test("resolveWriteGraph returns the per-scope index doc", async () => {
const rec = await ensureAccount("Carol");
expect(await resolveWriteGraph("carol", "protected")).toBe(rec.docProtected);
});
test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to the caller)", async () => {
// Session with all three store ids: private → private store; public+protected
// co-locate on the protected native store (the polyfill's Axis-A placement).
const ng = makeFakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => ({
sessionId: "sid-2",
privateStoreId: "PRIV",
protectedStoreId: "PROT",
publicStoreId: "PUB",
}),
});
resetRegistryCache();
expect(await resolveScopeGraph("private")).toBe("did:ng:PRIV");
expect(await resolveScopeGraph("protected")).toBe("did:ng:PROT");
expect(await resolveScopeGraph("public")).toBe("did:ng:PROT"); // co-located
// An inbox belongs to ONE virtual user — it is a dedicated document (from
// docCreate), not the private-store root, so deposits never bloat the shim graph.
// Stable per wallet, and DISJOINT between wallets: reading someone else's inbox
// would collect the caps addressed to them (see inbox.ts's read guard).
const mine = await userInbox("@alice", "protected");
expect(mine).toMatch(/^did:ng:o:doc/);
expect(mine).not.toBe("did:ng:PRIV");
expect(await userInbox("@alice", "protected")).toBe(mine); // stable
expect(await userInbox("@bob", "protected")).not.toBe(mine); // another wallet, another inbox
});
test("resolveScopeGraph falls back to the private store when no protected id is injected", async () => {
// The default SESSION carries only privateStoreId — non-private scopes fall
// back to the private store rather than emitting a broken NURI.
expect(await resolveScopeGraph("protected")).toBe("did:ng:PRIV");
expect(await resolveScopeGraph("public")).toBe("did:ng:PRIV");
});
test("createEntityDoc + listMyEntityDocs round-trip via the per-scope index", async () => {
const rec = await ensureAccount("Dave");
const e1 = await createEntityDoc("dave", "public");
const e2 = await createEntityDoc("dave", "public");
const other = await createEntityDoc("dave", "protected");
// Public listing unions dave's public entities only.
const pub = await listMyEntityDocs("dave", "public");
expect(pub.sort()).toEqual([e1, e2].sort());
const prot = await listMyEntityDocs("dave", "protected");
expect(prot).toEqual([other]);
// The index append targets the account's public index doc.
expect(rec.docPublic).toMatch(/^did:ng:o:doc/);
});
// --- SPARQL injection hardening (F1) --------------------------------------
//
// A malicious id must NOT be able to break out of the literal / IRI it
// lands in and inject arbitrary triples into the shim (the account→doc trust
// root). We inspect the exact SPARQL string the registry hands to sparql_update.
/** The raw INSERT DATA string produced by ensureAccount for `id`. */
async function insertFor(id: string): Promise<string> {
await ensureAccount(id);
const calls = fake.sparql_update.mock.calls;
return calls[calls.length - 1]![1] as string;
}
/** Count RAW (un-escaped) double-quotes — i.e. `"` not preceded by a `\`.
* Strip escaped pairs (`\\`, `\"`, …) first so only delimiter quotes remain. */
function rawQuoteCount(s: string): number {
const withoutEscapes = s.replace(/\\./g, "");
return (withoutEscapes.match(/"/g) ?? []).length;
}
test("injection: id with a quote cannot open extra literals", async () => {
const evil = 'x" ; <urn:evil> "pwn';
const update = await insertFor(evil);
// A well-formed INSERT DATA with 4 predicate literals has exactly 8 raw
// quotes (the delimiters). The injected `"` must have been escaped, so the
// count stays 8 — no extra literal was opened.
expect(rawQuoteCount(update)).toBe(8);
// The escaped id is present as a single literal value — the injected
// `<urn:evil>` survives only as INERT text inside that literal (its
// surrounding quotes are escaped `\"`), never as query syntax.
expect(update).toContain('"x\\" ; <urn:evil> \\"pwn"');
});
test("injection: id with '>' cannot break out of the account-subject IRI", async () => {
const evil = "x> <urn:evil";
const update = await insertFor(evil);
// The account subject is `<urn:ng-eventually:shim:account:...>` — the encoded
// id must NOT contain a raw `>` that would close the IRI early.
const subjMatch = update.match(/<urn:ng-eventually:shim:account:([^>]*)>/)!;
expect(subjMatch).not.toBeNull();
expect(subjMatch[1]).not.toMatch(/[<>" ]/); // fully percent-encoded
expect(subjMatch[1]).toContain("%3E"); // the `>` became %3E
});
test("injection: newline / control chars in id are neutralised", async () => {
const evil = "a\nb\tc";
const update = await insertFor(evil);
// In the literal: escaped to \n / \t (no raw control char).
expect(update).toContain('"a\\nb\\tc"');
// In the IRI subject: percent-encoded.
const subjMatch = update.match(/<urn:ng-eventually:shim:account:([^>]*)>/)!;
expect(subjMatch[1]).toContain("%0A");
expect(subjMatch[1]).toContain("%09");
});
test("injection: '; DELETE'-style payload stays inert inside the literal", async () => {
const evil = 'x"} ; DELETE WHERE { ?s ?p ?o } ; INSERT DATA { <a> <b> "';
const update = await insertFor(evil);
// The whole attack survives, escaped, as ONE literal value — the injected
// `"}` cannot close the literal/graph, so DELETE/second-INSERT stay text.
expect(update).toContain(escapeLiteralRef(evil));
// Quote count stays even (all delimiters balanced; the injected `"` escaped):
// 4 predicate literals → 8 raw delimiter quotes, nothing extra opened.
expect(rawQuoteCount(update)).toBe(8);
// The injected `"}` did not survive as raw syntax (it was escaped to `\"}`).
expect(update).not.toMatch(/[^\\]"} ; DELETE/);
});
// Local mirror of the lib's escapeLiteral so the assertion is self-checking.
function escapeLiteralRef(v: string): string {
return `"${v
.replace(/\\/g, "\\\\")
.replace(/"/g, '\\"')
.replace(/\n/g, "\\n")
.replace(/\r/g, "\\r")
.replace(/\t/g, "\\t")}"`;
}
test("injection: a malicious id still round-trips through the shim", async () => {
const evil = 'eve" ; <urn:evil> "x';
const rec = await ensureAccount(evil);
expect(rec.id).toBe(evil);
resetRegistryCache();
// The stored id comes back verbatim (escaping is lossless) when resolved by its
// own key — and no injected extra subject answers in its place.
const back = await resolveAccount(evil);
expect(back?.id).toBe(evil);
expect(back?.docPublic).toBe(rec.docPublic);
});
test("normalizeId defaults to trim when not provided", async () => {
const ng = makeFakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any });
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
configureStoreRegistry({ getSession: async () => SESSION });
resetRegistryCache();
const a = await ensureAccount(" Ivy ");
const b = await ensureAccount("Ivy"); // trimmed key matches
expect(b).toEqual(a);
expect(ng.doc_create).toHaveBeenCalledTimes(4); // 1 doc-shim + 3 scope docs
});
test("a user has TWO inboxes — public and protected — and they are distinct documents", async () => {
// Upstream a site carries an inbox on its public store repo and another on its
// protected one (`engine/verifier/src/site.rs:127-152`), addressed separately down to
// the contact predicates (`ng:site_inbox` vs `ng:protected_inbox`). Exposing one was a
// cardinality this library invented; neither the ORM nor the wasm binding says
// anything about inboxes, so the engine's model is what decides.
resetRegistryCache();
const pub = await userInbox("@dana", "public");
const prot = await userInbox("@dana", "protected");
expect(pub).not.toBe(prot);
// …and each is stable for its own scope.
expect(await userInbox("@dana", "public")).toBe(pub);
expect(await userInbox("@dana", "protected")).toBe(prot);
});
+144
View File
@@ -0,0 +1,144 @@
import { test, expect, mock, afterAll } from "bun:test";
import { subscribeDoc, subscribeDocs } from "../src/surface/subscribe";
import { configure, configureStoreRegistry } from "../src/index";
import { resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
// subscribeDoc/subscribeDocs wrap the REAL injected `ng.doc_subscribe`. This
// suite injects a fake `ng` whose `doc_subscribe` records the callback per doc
// and hands back an unsubscribe, so we can assert routing + isolation without a
// broker. Restore the un-configured state at the end.
afterAll(() => {
resetConfig();
resetStoreRegistry();
});
const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" };
/**
* A fake reactive `ng`: `doc_subscribe(nuri, sid, cb)` registers `cb` for `nuri`,
* fires it once (initial State push), and returns an unsubscribe. `push(nuri)`
* drives a later change to that doc's subscribers. A per-doc `failFor` set makes
* `doc_subscribe` reject for chosen NURIs (a not-yet-synced doc).
*/
function makeFakeNg(failFor: Set<string> = new Set()) {
const subs = new Map<string, Set<(r: unknown) => void>>();
const doc_subscribe = mock(async (nuri: string, _sid: unknown, cb: (r: unknown) => void) => {
if (failFor.has(nuri)) throw new Error(`RepoNotFound: ${nuri}`);
let set = subs.get(nuri);
if (!set) {
set = new Set();
subs.set(nuri, set);
}
set.add(cb);
// Initial State push, delivered async (as the real RPC does).
queueMicrotask(() => cb({ V0: { State: { doc: nuri } } }));
return () => set!.delete(cb);
});
const push = (nuri: string): void => {
for (const cb of subs.get(nuri) ?? []) cb({ V0: { Patch: { doc: nuri } } });
};
const isSubscribed = (nuri: string): boolean => (subs.get(nuri)?.size ?? 0) > 0;
return { doc_subscribe, push, isSubscribed, _subs: subs };
}
function inject(failFor?: Set<string>) {
const ng = makeFakeNg(failFor);
configure({ ng: ng as any, useShape: (() => {}) as any });
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
configureStoreRegistry({ getSession: async () => SESSION });
return ng;
}
const A = "did:ng:o:docA";
const B = "did:ng:o:docB";
const tick = () => new Promise((r) => setTimeout(r, 5));
test("subscribeDoc calls ng.doc_subscribe with (nuri, sessionId, callback)", async () => {
const ng = inject();
const onChange = mock(() => {});
subscribeDoc(A, onChange);
await tick();
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
const call = ng.doc_subscribe.mock.calls[0]!;
expect(call[0]).toBe(A);
expect(call[1]).toBe("sid-1"); // sessionId from the injected session
expect(typeof call[2]).toBe("function"); // the callback
});
test("subscribeDoc routes the initial State push and every later change", async () => {
const ng = inject();
const seen: unknown[] = [];
subscribeDoc(A, (r) => seen.push(r));
await tick();
expect(seen).toHaveLength(1); // initial State push
ng.push(A);
ng.push(A);
expect(seen).toHaveLength(3); // + 2 patches
});
test("subscribeDoc unsubscribe stops further callbacks", async () => {
const ng = inject();
const seen: unknown[] = [];
const stop = subscribeDoc(A, (r) => seen.push(r));
await tick();
expect(seen).toHaveLength(1);
stop();
expect(ng.isSubscribed(A)).toBe(false); // real unsubscribe was invoked
ng.push(A); // ignored — no subscriber
expect(seen).toHaveLength(1);
});
test("subscribeDoc unsubscribe BEFORE async setup resolves cancels cleanly", async () => {
const ng = inject();
const seen: unknown[] = [];
const stop = subscribeDoc(A, (r) => seen.push(r));
stop(); // before the microtask/promise setup resolved
await tick();
// The subscription was cancelled the moment setup resolved: no callbacks, and
// no lingering subscriber.
expect(seen).toHaveLength(0);
expect(ng.isSubscribed(A)).toBe(false);
});
test("subscribeDocs fans out one subscription per doc and reports the source nuri", async () => {
const ng = inject();
const seen: Array<[string, unknown]> = [];
subscribeDocs([A, B], (nuri, r) => seen.push([nuri, r]));
await tick();
// Two initial pushes, one per doc.
expect(seen.map((s) => s[0]).sort()).toEqual([A, B]);
ng.push(B);
expect(seen.filter((s) => s[0] === B)).toHaveLength(2); // initial + patch
expect(seen.filter((s) => s[0] === A)).toHaveLength(1); // isolated: A didn't fire
});
test("subscribeDocs isolates a failing doc — the others still fire", async () => {
const ng = inject(new Set([A])); // A's subscription throws (RepoNotFound)
const seen: Array<[string, unknown]> = [];
subscribeDocs([A, B], (nuri, r) => seen.push([nuri, r]));
await tick();
// A failed to subscribe (logged, not thrown); B is unaffected and fired.
expect(seen.map((s) => s[0])).toEqual([B]);
ng.push(B);
expect(seen.filter((s) => s[0] === B)).toHaveLength(2);
});
test("subscribeDocs unsubscribe tears down all subscriptions", async () => {
const ng = inject();
const stop = subscribeDocs([A, B], () => {});
await tick();
expect(ng.isSubscribed(A)).toBe(true);
expect(ng.isSubscribed(B)).toBe(true);
stop();
expect(ng.isSubscribed(A)).toBe(false);
expect(ng.isSubscribed(B)).toBe(false);
});
test("subscribeDocs deduplicates repeated NURIs", async () => {
const ng = inject();
subscribeDocs([A, A, A], () => {});
await tick();
expect(ng.doc_subscribe).toHaveBeenCalledTimes(1);
});
+230
View File
@@ -0,0 +1,230 @@
/**
* The published names may only use words the TARGET uses, or a marker that says why
* they exist here.
*
* ── Why this is a test and not a rule ─────────────────────────────────────
* The library corrected its vocabulary on 2026-07-30 — upstream a *wallet* is only a
* keyring, and what owns stores is a **user** (a *site*) — by a manual pass over the
* code and docs. `walletInbox` survived that pass and lived on for weeks, and it did
* damage: the name made "one inbox per wallet" sound obvious, hiding that a user
* upstream has **two** (public store repo and protected store repo — the only two
* `AddInboxCap` commits in the engine, `engine/verifier/src/site.rs:128,149`). A
* discipline applied by hand misses one; a test does not.
*
* So this pins the naming half of the design principle (`README.md`): a name either
* belongs to the target's vocabulary — in which case it needs no translation and
* survives migration — or it carries a marker saying WHY it exists only here, which
* also says when it disappears.
*
* ── What it checks, and what it deliberately does not ─────────────────────
* Only the PUBLISHED names, the ones a consumer application types. Internal names are
* held to the same intent but not mechanically: the folder they live in already states
* their fate, and pinning every internal identifier would fight refactoring for little.
*/
import { test, expect } from "bun:test";
import * as fs from "node:fs";
import * as path from "node:path";
/**
* Words the TARGET itself uses, verified in `nextgraph-rs`. A published name built
* from these needs no translation at migration.
*/
const TARGET_WORDS = new Set([
// addressing and objects
"nuri", "doc", "docs", "document", "repo", "store", "stores", "branch", "graph",
"overlay", "cap", "caps", "read", "write", "link", "links", "shape", "shapes",
// actors and containers
"user", "users", "session", "wallet", "inbox", "inboxes", "site", "principal",
// scopes (upstream store types, `StoreRepo::from_type_and_repo`)
"public", "protected", "private", "group", "dialog", "scope",
// acts the target performs
"create", "subscribe", "unsubscribe", "query", "update", "post", "share", "open",
"fetch", "init", "watch", "sparql", "ng", "orm", "type", "types",
// RDF / SPARQL terms the engine's own query paths use
"subject", "base", "schema", "connected", "identity", "identities",
// `publisher` is upstream's word for a pub/sub role on a topic (`as_publisher`,
// `publisher_advert`, 126 occurrences in the engine). Our own "publish a document" is
// banned as ambiguous, but that ban never reaches upstream's term — see the traps
// block in `docs/readcap-and-nuri-model.md`.
"publisher", "topic", "advert",
// the reactive model the ORM exposes (`OrmSubscription`, `DeepSignalSet`)
"observable", "deep", "signal", "set",
]);
/**
* Markers that name WHY something exists only in this library. Each says when it
* disappears, which a bare `fake`/`tmp` would not.
*/
const EMULATION_MARKERS = new Set([
"virtual", "physical", "shim", "emulated", "polyfill",
// `shared` as in "shared wallet" — the single fact every piece of scaffolding in this
// library descends from. A name carrying it says both what it is and when it goes.
"shared",
]);
/** Glue with no domain meaning — never the load-bearing part of a name. */
const NEUTRAL = new Set([
"get", "set", "is", "has", "to", "for", "of", "my", "own", "all", "by", "with",
"current", "reset", "configure", "config", "deps", "id", "ids", "address", "entity",
"list", "resolve", "assert", "escape", "literal", "iri", "record", "registry",
"change", "changed", "state", "value", "data", "info", "count", "the", "a", "an",
"options", "opts", "result", "error", "signal", "filter", "placement", "and", "or",
"make", "use", "on", "off", "from", "into", "at", "in", "out", "up", "down",
// `union` is OURS — the bounded multi-document read — but it names an operation,
// not a domain notion a consumer would have to unlearn. `eventually` is the
// library's own name.
"union", "eventually", "ensure",
// `…Like` is a structural-typing suffix (`NgLike` = "whatever has ng's shape"), not
// a domain word: it describes how the injection is typed, not what the thing is.
"like",
]);
/** `documentInboxAddress` → ["document","inbox","address"] ; `NG` → ["ng"]. */
function words(name: string): string[] {
return name
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
.split(/[\s_]+/)
.map((w) => w.toLowerCase())
.filter(Boolean);
}
const SRC = path.join(import.meta.dir, "..", "src");
/** Every identifier the entry point publishes, read from its `export` statements. */
function publishedNames(): string[] {
const out = new Set<string>();
for (const entry of ["index.ts"]) {
const text = fs.readFileSync(path.join(SRC, entry), "utf8");
// `export * as ns from "…"`
for (const m of text.matchAll(/export \* as (\w+) from/g)) out.add(m[1]!);
// `export { a, b as c }` / `export type { … }`, single- and multi-line
for (const m of text.matchAll(/export (?:type )?\{([^}]*)\}/g)) {
for (const raw of m[1]!.split(",")) {
const name = raw.trim().replace(/^type /, "").split(/\s+as\s+/).pop()?.trim();
if (name) out.add(name);
}
}
// `export const x` / `export function x` / `export interface x`
for (const m of text.matchAll(/export (?:declare )?(?:const|function|class|interface|type) (\w+)/g)) {
out.add(m[1]!);
}
// `export * from "./x"` — re-exports every name that module declares.
for (const m of text.matchAll(/export \* from "\.\/([^"]+)"/g)) {
const file = path.join(SRC, m[1]! + ".ts");
if (!fs.existsSync(file)) continue;
const t = fs.readFileSync(file, "utf8");
for (const mm of t.matchAll(/^export (?:declare )?(?:const|function|class|interface|type) (\w+)/gm)) {
out.add(mm[1]!);
}
}
}
return [...out];
}
test("every published name is built from the target's vocabulary, or carries an emulation marker", () => {
const offenders: string[] = [];
for (const name of publishedNames()) {
const ws = words(name);
// A marker anywhere in the name licenses the whole name: it declares the thing
// as ours and says when it goes.
if (ws.some((w) => EMULATION_MARKERS.has(w))) continue;
const unknown = ws.filter((w) => !TARGET_WORDS.has(w) && !NEUTRAL.has(w));
if (unknown.length > 0) offenders.push(`${name}${unknown.join(", ")}`);
}
// A failure here is not "rename to satisfy the test": it is a question. Does the
// target have a word for this? Use it. Does the thing exist only here? Say so with a
// marker. Is the word genuinely neutral glue? Add it to NEUTRAL, deliberately.
expect(offenders).toEqual([]);
});
test("no published name says `wallet` where the target says `user`", () => {
// The specific regression that motivated this file. `wallet` is a legitimate target
// word (a keyring IS a wallet upstream), so the generic check above cannot catch it —
// what is wrong is using it for the thing that owns stores and inboxes.
const wrong = publishedNames().filter((n) =>
/wallet/i.test(n) && /(inbox|store|doc|cap)/i.test(n),
);
expect(wrong).toEqual([]);
});
// --- the invariant the internal contract flagged as a migration risk -------
test("a reserved-namespace key cannot be produced by a consumer's normalizeId", async () => {
// The reserved namespace hosts infrastructure accounts, and its guarantee is that no
// user id lands there. That guarantee is not the library's to make — `normalizeId` is
// injected by the consumer — so a careless one must be refused, not trusted. A
// collision would key a user onto an infrastructure account: reads and writes on
// documents that are not theirs.
const { configureStoreRegistry, resetStoreRegistry } = await import("../src/shared-wallet/bootstrap");
const { ensureAccount, resetRegistryCache } = await import(
"../src/shared-wallet/account-registry"
);
configureStoreRegistry({
getSession: async () => ({ sessionId: "s", privateStoreId: "did:ng:o:p" }),
normalizeId: () => "reserved:index", // pretends to be infrastructure
});
resetRegistryCache();
await expect(ensureAccount("mallory")).rejects.toThrow(/reserved namespace/i);
resetStoreRegistry();
resetRegistryCache();
});
// --- the contract's inventory must match the code ------------------------
/**
* Read the appendix of `docs/api-contract.md` back into `{ group: names }`.
* The appendix is a generated block; this parses the same shape.
*/
function contractInventory(): Record<string, string[]> {
const md = fs.readFileSync(
path.join(import.meta.dir, "..", "..", "..", "docs", "api-contract.md"),
"utf8",
);
const appendix = md.slice(md.indexOf("## Appendix — full export inventory"));
const out: Record<string, string[]> = {};
for (const block of appendix.matchAll(/```text\n([\s\S]*?)```/g)) {
for (const line of block[1]!.trim().split("\n")) {
const i = line.indexOf(":");
if (i < 0) continue;
const group = line.slice(0, i).trim();
const names = line.slice(i + 1).split(",").map((n) => n.trim()).filter(Boolean);
out[group] = [...(out[group] ?? []), ...names].sort();
}
}
return out;
}
test("the api-contract appendix lists exactly what the entry exports", () => {
// The appendix is the instrument a reader diffs against when the surface moves. It
// went stale once — still naming `storeRegistry`'s shim internals after the entry had
// been narrowed to seven functions — and a stale inventory is worse than none: it
// reads as verified. So the code decides, and this test is what makes the document
// follow rather than drift.
const inventory = contractInventory();
const direct = new Set(publishedNames());
// The namespace names themselves are the appendix's group headings, not entries.
for (const group of Object.keys(inventory)) if (group !== "direct") direct.delete(group);
const missing = [...direct].filter(
(n) => !Object.values(inventory).some((names) => names.includes(n)),
);
const extra = Object.values(inventory)
.flat()
.filter((n) => !direct.has(n) && !isNamespaceMember(n));
expect({ missing, extra }).toEqual({ missing: [], extra: [] });
});
/** Names that live inside a re-exported namespace rather than on the entry itself. */
function isNamespaceMember(name: string): boolean {
const src = path.join(import.meta.dir, "..", "src");
for (const entry of ["index.ts"]) {
const text = fs.readFileSync(path.join(src, entry), "utf8");
for (const m of text.matchAll(/export \* as \w+ from "\.\/([^"]+)"/g)) {
const file = path.join(src, m[1]! + ".ts");
if (fs.existsSync(file) && new RegExp(`\\b${name}\\b`).test(fs.readFileSync(file, "utf8"))) {
return true;
}
}
}
return false;
}
+384
View File
@@ -0,0 +1,384 @@
/**
* watch-shape.test.ts — behavioural tests for `watchShape` (src/watch-shape.ts),
* against a STATEFUL fake `ng` with a CONTROLLABLE `doc_subscribe`.
*
* The fake emulates just enough of the broker:
* - `doc_create` mints monotonic doc NURIs.
* - `sparql_update` parses the shim account writes + the per-entity index
* `contains` append + arbitrary anchored triple writes into an in-memory quad
* store (same tolerant parser shape as store-registry.test / read-model.test).
* - `sparql_query` answers the shim account SELECT, the scope-index `contains`
* SELECT, and the anchored per-doc `?s ?p ?o` read (readUnion) — each scoped to
* the anchor graph.
* - `doc_subscribe` models the platform push order TabInfo→State: on subscribe it
* records the callback and fires a `TabInfo` immediately, but the sync BARRIER
* `State` is fired only when the TEST releases it (`fireState`) — so we can
* assert isPending BEFORE the barrier and isSuccess AFTER. A later write to a
* subscribed doc fires a `Patch` push (reactivity).
*
* These prove the four distinctions the surface exists for:
* (a) isPending at first, isSuccess after the first State (barrier);
* (b) isSuccess + data:[] on a synced-but-EMPTY scope (the key distinction);
* (c) a write then push → data updates (reactivity, no polling);
* (d) timed-out → isSuccess (best-effort), NOT isError.
*/
import { describe, it, expect, mock, beforeEach, afterEach, afterAll } from "bun:test";
import { watchShape } from "../src/surface/watch-shape";
import { configure, configureStoreRegistry, setCurrentUser } from "../src/index";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import { resetRegistryCache, createEntityDoc } from "../src/shared-wallet/account-registry";
import { resetOpenedRepos, setOpenTimeoutForTests, getSyncState } from "../src/emulated-verifier/open-repo";
const TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
const FP = "http://festipod.org/";
const SESSION = { sessionId: "sid-ws", privateStoreId: "PRIV-WS" };
interface Quad { g: string; s: string; p: string; o: string }
/** Reverse of escapeLiteral: single left-to-right pass over `\x`. */
function unescapeLiteral(s: string): string {
let out = "";
for (let i = 0; i < s.length; i++) {
if (s[i] === "\\" && i + 1 < s.length) {
const next = s[++i];
out += next === "n" ? "\n" : next === "r" ? "\r" : next === "t" ? "\t" : next!;
} else out += s[i];
}
return out;
}
interface SubRec { nuri: string; cb: (r: unknown) => void }
/**
* The stateful fake with a controllable doc_subscribe. `holdState: true` means a
* fresh subscription does NOT auto-fire its `State` — the test fires it via
* `fireState(nuri)`. `holdState: false` (default) auto-fires `State` on subscribe
* (synced immediately), which is the convenient mode for the reactivity/empty cases.
*/
function makeFake(opts?: { holdState?: boolean }) {
const quads: Quad[] = [];
let docCounter = 0;
const subs: SubRec[] = [];
const hold = opts?.holdState ?? false;
// Nuris whose barrier `State` has been released (auto-fire on future subscribe).
const released = new Set<string>();
// The shim ANCHOR (private-store-root) is ALWAYS loaded/synced on the real broker
// (the store repo is bootstrapped at connect), so its barrier `State` is always
// available. `resolveAccount`/`ensureAccount` now open it (the cold-start heal)
// before touching the shim — pre-release it here so `holdState` (which gates the
// per-ENTITY docs the tests control) never blocks the anchor open. This mirrors the
// real invariant the production heal relies on.
released.add(`did:ng:${SESSION.privateStoreId}`);
let releaseEverything = false;
const doc_create = mock(async () => `did:ng:o:doc${++docCounter}`);
const sparql_update = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[2] as string | undefined;
const gm = query.match(/GRAPH <([^>]+)>\s*\{([\s\S]*)\}/);
let g: string;
let body: string;
if (gm) {
g = gm[1]!;
body = gm[2]!;
} else {
if (!anchor) return undefined;
g = anchor;
body = query.replace(/^\s*INSERT DATA\s*\{/, "").replace(/\}\s*$/, "");
}
const sm = body.match(/<([^>]+)>/);
if (!sm) return undefined;
const s = sm[1]!;
const pairRe = /(?:a|<([^>]+)>)\s+(?:"((?:[^"\\]|\\.)*)"|<([^>]+)>)/g;
let m: RegExpExecArray | null;
const after = body.slice(body.indexOf(sm[0]) + sm[0].length);
while ((m = pairRe.exec(after)) !== null) {
const p = m[1] ?? "urn:ng-eventually:shim:Account";
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
quads.push({ g, s, p, o });
// The doc-shim (named by the write-once pointer triple) is INFRASTRUCTURE, like
// the store-root: `doc_create` bootstrapped it into the session, so its barrier
// `State` is immediately available. Pre-release it so `holdState` (which gates the
// per-ENTITY docs the tests control) never blocks the doc-shim open. The pointer is
// published BEFORE the doc-shim barrier open (resolveShimDoc first-login order).
if (p === "urn:ng-eventually:shim:shimDoc") {
released.add(o);
for (const sub of subs) if (sub.nuri === o) sub.cb({ V0: { State: {} } });
}
}
// A write to a subscribed doc fires a Patch push (reactivity signal).
for (const sub of subs) {
if (sub.nuri === g) sub.cb({ V0: { Patch: {} } });
}
return undefined;
});
const sparql_query = mock(async (...a: unknown[]) => {
const query = a[1] as string;
const anchor = a[3] as string | undefined;
// Pointer SELECT (store-root -> doc-shim).
if (query.includes("<urn:ng-eventually:shim:shimDoc>")) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:shimDoc")
.map((q) => ({ shimDoc: { value: q.o } }));
return { results: { bindings } };
}
if (query.includes("<urn:ng-eventually:shim:id>")) {
const subjM = query.match(
/<([^>]+)>\s+a\s+<urn:ng-eventually:shim:Account>/,
);
const onlySubject = subjM ? subjM[1]! : null;
const bySubject = new Map<string, Record<string, string>>();
for (const q of quads) {
if (q.g !== anchor) continue;
if (onlySubject !== null && q.s !== onlySubject) continue;
const rec = bySubject.get(q.s) ?? {};
if (q.p === "urn:ng-eventually:shim:id") rec.id = q.o;
if (q.p === "urn:ng-eventually:shim:docPublic") rec.docPublic = q.o;
if (q.p === "urn:ng-eventually:shim:docProtected") rec.docProtected = q.o;
if (q.p === "urn:ng-eventually:shim:docPrivate") rec.docPrivate = q.o;
bySubject.set(q.s, rec);
}
const bindings = [...bySubject.values()]
.filter((r) => r.id)
.map((r) => ({
id: { value: r.id! },
docPublic: { value: r.docPublic ?? "" },
docProtected: { value: r.docProtected ?? "" },
docPrivate: { value: r.docPrivate ?? "" },
}));
return { results: { bindings } };
}
if (query.includes("<urn:ng-eventually:shim:inboxCap>")) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:inboxCap")
.map((q) => ({ c: { value: q.o } }));
return { results: { bindings } };
}
if (query.includes("<urn:ng-eventually:shim:readCap>")) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:readCap")
.map((q) => ({ c: { value: q.o } }));
return { results: { bindings } };
}
if (query.includes("<urn:ng-eventually:shim:contains>")) {
const bindings = quads
.filter((q) => q.g === anchor && q.p === "urn:ng-eventually:shim:contains")
.map((q) => ({ e: { value: q.o } }));
return { results: { bindings } };
}
// Anchored per-doc read (readUnion `SELECT ?s ?p ?o`).
const bindings = quads
.filter((q) => q.g === anchor)
.map((q) => ({ s: { value: q.s }, p: { value: q.p }, o: { value: q.o } }));
return { results: { bindings } };
});
const doc_subscribe = mock(
async (nuri: string, _sid: string, cb: (r: unknown) => void) => {
subs.push({ nuri, cb });
// Platform pushes TabInfo FIRST (never the barrier).
setTimeout(() => cb({ V0: { TabInfo: {} } }), 0);
// Fire the barrier State if this fake auto-syncs, or if this nuri was already
// released (so a doc subscribed AFTER a release still crosses the barrier).
if (!hold || releaseEverything || released.has(nuri)) {
setTimeout(() => cb({ V0: { State: {} } }), 0);
}
return () => {};
},
);
/** Release the barrier for `nuri` (fire State now + auto-fire for future subs). */
function fireState(nuri: string): void {
released.add(nuri);
for (const sub of subs) if (sub.nuri === nuri) sub.cb({ V0: { State: {} } });
}
/** Release the barrier for EVERY doc, present and future. */
function releaseAll(): void {
releaseEverything = true;
for (const sub of subs) sub.cb({ V0: { State: {} } });
}
return {
doc_create,
sparql_update,
sparql_query,
doc_subscribe,
_quads: quads,
fireState,
releaseAll,
subs,
};
}
function inject(ng: ReturnType<typeof makeFake>) {
configure({ ng: ng as any, useShape: (() => {}) as any });
configureStoreRegistry({
getSession: async () => SESSION,
normalizeId: (u: string) => u.trim().replace(/^@+/, "").toLowerCase(),
});
resetRegistryCache();
resetOpenedRepos();
resetCaps();
}
// Insert a triple straight into a doc's graph in the fake store (no push).
function seed(ng: ReturnType<typeof makeFake>, doc: string, p: string, o: string): void {
ng._quads.push({ g: doc, s: doc, p, o });
}
const tick = () => new Promise((r) => setTimeout(r, 5));
// A minimal SHEX ShapeType pinning rdf:type to `${FP}Event`.
const EventShape = {
shape: `${FP}EventShape`,
schema: {
[`${FP}EventShape`]: {
iri: `${FP}EventShape`,
predicates: [{ iri: TYPE, dataTypes: [{ literals: [`${FP}Event`], valType: "iri" }] }],
},
},
};
afterEach(() => {
setCurrentUser(null);
});
afterAll(() => {
resetConfig();
resetStoreRegistry();
resetRegistryCache();
resetOpenedRepos();
// The cap registry is process-wide: leaving caps behind would put the possession
// gate in force for a suite that never declares any.
resetCaps();
});
describe("watchShape", () => {
it("(a) isPending at first, then isSuccess after the first State (barrier)", async () => {
const ng = makeFake({ holdState: true });
inject(ng);
setCurrentUser("alice");
// One protected entity doc for alice, carrying an Event triple.
const doc = await createEntityDoc("alice", "protected");
seed(ng, doc, TYPE, `${FP}Event`);
seed(ng, doc, `${FP}title`, "Alpha");
const obs = watchShape(EventShape, "protected");
let notes = 0;
const unsub = obs.subscribe(() => {
notes += 1;
});
// Before the barrier: pending, no data.
await tick();
expect(obs.getSnapshot().isPending).toBe(true);
expect(obs.getSnapshot().isSuccess).toBe(false);
expect(obs.getSnapshot().data).toEqual([]);
// Release the barrier for every opened doc (present + future) → synced.
ng.releaseAll();
await tick();
await tick();
await tick();
const snap = obs.getSnapshot();
expect(snap.isPending).toBe(false);
expect(snap.isSuccess).toBe(true);
expect(snap.isError).toBe(false);
expect(snap.data.length).toBe(1);
expect(snap.data[0]!.props[`${FP}title`]).toEqual(["Alpha"]);
expect(notes).toBeGreaterThan(0);
unsub();
});
it("(b) isSuccess + data:[] on a synced-but-EMPTY scope (the key distinction)", async () => {
const ng = makeFake(); // auto-fires State → synced immediately
inject(ng);
setCurrentUser("bob");
// bob has NO entity docs in this scope — the scope is genuinely empty.
const obs = watchShape(EventShape, "protected");
const unsub = obs.subscribe(() => {});
await tick();
await tick();
const snap = obs.getSnapshot();
expect(snap.isPending).toBe(false);
expect(snap.isSuccess).toBe(true); // synced, NOT stuck pending
expect(snap.isError).toBe(false);
expect(snap.data).toEqual([]); // empty — distinguishable from "still syncing"
unsub();
});
it("(c) a write then push updates data (reactivity, no polling)", async () => {
const ng = makeFake(); // synced immediately
inject(ng);
setCurrentUser("carol");
const doc = await createEntityDoc("carol", "protected");
seed(ng, doc, TYPE, `${FP}Event`);
seed(ng, doc, `${FP}title`, "One");
const obs = watchShape(EventShape, "protected");
const unsub = obs.subscribe(() => {});
await tick();
await tick();
expect(obs.getSnapshot().data.length).toBe(1);
// Write a SECOND event doc + fire the push via a write to the ALREADY-subscribed
// doc. Because a new doc must appear in the set, write into the scope-INDEX
// (createEntityDoc appends to it, and the index is subscribed → re-resolve).
const doc2 = await createEntityDoc("carol", "protected");
seed(ng, doc2, TYPE, `${FP}Event`);
seed(ng, doc2, `${FP}title`, "Two");
// createEntityDoc's index append fired a Patch on the index doc → re-resolve.
await tick();
await tick();
const titles = obs
.getSnapshot()
.data.flatMap((s) => s.props[`${FP}title`] ?? [])
.sort();
expect(titles).toEqual(["One", "Two"]);
// No setInterval anywhere — reactivity was push-driven.
unsub();
});
it("(d) timed-out → isSuccess (best-effort), NOT isError", async () => {
// A doc whose subscription NEVER pushes a `State`: open-repo's bounded fallback
// fires and marks the nuri "timed-out" (NOT "synced"). We shrink the fallback to
// a few ms so this is fast, and assert the barrier is genuinely reached via
// timed-out (getSyncState === "timed-out") and that the snapshot maps that to
// isSuccess, never isError.
const ng = makeFake({ holdState: true }); // State is never released
inject(ng);
setOpenTimeoutForTests(20); // fallback fires quickly instead of after 8s
setCurrentUser("dave");
const doc = await createEntityDoc("dave", "protected");
seed(ng, doc, TYPE, `${FP}Event`);
seed(ng, doc, `${FP}title`, "Timed");
const obs = watchShape(EventShape, "protected");
const unsub = obs.subscribe(() => {});
await tick();
// Before the fallback fires: still pending (subscribed, no State).
expect(obs.getSnapshot().isPending).toBe(true);
// Let the bounded fallback elapse → open-repo marks each opened doc timed-out.
await new Promise((r) => setTimeout(r, 60));
await tick();
await tick();
// The entity doc's barrier resolved via timed-out (never a State).
expect(getSyncState(doc)).toBe("timed-out");
const snap = obs.getSnapshot();
expect(snap.isError).toBe(false);
expect(snap.isSuccess).toBe(true); // timed-out is best-effort success
expect(snap.isPending).toBe(false);
// The data still read (best-effort): the doc's triples resolved.
expect(snap.data.length).toBe(1);
unsub();
});
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "../../tsconfig.base.json",
"include": ["src", "test"]
}