Align the cap emulation on NextGraph's model, and confine it to a virtual user
Two batches, verified against nextgraph-rs throughout. P1a — the capability surface. Reading was an ACL (Map<doc, Set<principal>>), the exact inversion of key possession. It is now possession: `capFor(nuri)` is the only question, there is no principal parameter anywhere, and nothing turns a bare reference into a cap. Sharing is `shareCap(cap, toInbox)`, a Link deposit; receiving needs no operation. `Nuri` and `ReadCap` are template literal types, so passing a bare reference where a cap belongs is a compile error, with runtime guards behind it for JavaScript callers. The virtual user boundary. Every access function is now confined to the connected user, through two rules on one criterion (possession), implemented in two places so a lapse in either is caught by the other: authorization at the passage points, and "do not even attempt" at the callers. The polyfill's own machinery moved to physical.ts — unguarded, never exported — which replaced an exemption list: the machinery no longer gets waved through the guard, it calls something the guard never saw. Removed, as emulating capabilities the target does not have: - discovery.ts and its global index. There is no discovery in NextGraph; you follow links. It also pooled user data across wallets. - the cross-account fan-out (listEntityDocs, resolveReadGraphs, allAccounts, loadShim), which was cross-user enumeration by construction. - resolveInboxAnchor, a single inbox common to every user. Caps are now stored where NextGraph stores them, and read back rather than recomputed: AddRepo on the store's Store branch for documents a user creates, AddLink on its User branch for caps received. Inboxes belong to someone — the user's own, plus one per document — and connecting a user drains them all; that is the library's job, not the app's. Corrections worth recording: a ReadCap is `r:`, not `:k:` (reported by NextGraph's developer, verified in BlockRef::readcap_nuri); received caps DO have a register (AddLink), contrary to what this repo's notes claimed; and "wallet" upstream means keyring — what owns three stores is a user, so the vocabulary follows. The cap value is the constant OK: the only question the emulation answers is whether a cap is held. P1b replaces that one constant with a real key. After this the shape is right and the isolation is still fake. Nothing here may be described as anonymous or private.
This commit is contained in:
@@ -29,11 +29,10 @@ Docs (this library's own engineering doctrine, under [`docs/`](./docs/)):
|
||||
behaviour on one shared wallet (shim, per-document ReadCaps, emulated inbox,
|
||||
write guard, the two axes, the double-proxy constraint).
|
||||
- [`docs/read-model.md`](./docs/read-model.md) — the read model the polyfill
|
||||
implements: events via the global index, everything else by following a shared
|
||||
graph; listing via a bounded set of per-doc anchored `sparql_query`s; reactivity
|
||||
via re-query on a change signal.
|
||||
implements: you follow links, you never enumerate; listing via a bounded set of
|
||||
per-doc anchored `sparql_query`s; reactivity via re-query on a change signal.
|
||||
- [`docs/decisions/`](./docs/decisions/) — current-SDK ADRs (private-store scope,
|
||||
SPARQL delete, shared-wallet identity, discovery mechanism).
|
||||
SPARQL delete, shared-wallet identity).
|
||||
- [`docs/fork-inbox-fallback.md`](./docs/fork-inbox-fallback.md) — the Rust-patch /
|
||||
self-host inbox path not taken (kept as a fallback).
|
||||
- [`docs/migration-guide.md`](./docs/migration-guide.md) — the checklist for when
|
||||
@@ -52,12 +51,12 @@ is needed), and how this lib emulates it today.
|
||||
|
||||
| Capability | What the consumer application does | Real NextGraph target | Current NextGraph status (why a workaround) | Current emulation |
|
||||
|---|---|---|---|---|
|
||||
| Multi-identity / per-identity wallet | Treats each identity id as its own wallet with its own documents | Each identity opens its own real wallet; native cross-wallet reads | Not-yet-implemented: the JS SDK exposes no cross-wallet read, so one session cannot read another identity's wallet | One shared wallet everyone opens; "identities" are virtual wallets — shim accounts keyed by an id, each mapped to its documents in `store-registry.ts` |
|
||||
| Multi-identity / per-identity wallet | Treats each identity id as its own wallet with its own documents | Each identity opens its own real wallet; native cross-wallet reads | Not-yet-implemented: the JS SDK exposes no cross-wallet read, so one session cannot read another identity's wallet | One shared wallet everyone opens; "identities" are virtual users — shim accounts keyed by an id, each mapped to its documents in `store-registry.ts` |
|
||||
| Three native stores per identity | Places entities by scope `public` / `protected` / `private` | The identity's three real native stores hold the entity documents | Not-yet-implemented: `doc_create`/ORM can target only the private (and protected) native store today; a `public`/arbitrary `StoreRepo` is not JS-constructible | Three emulated scope-index documents per account — each "store" is an index doc listing its entity-doc NURIs; all physically live in the one shared private store, and scope is a logical label |
|
||||
| Per-document read isolation | Declares a document's read policy via `getCaps().open(doc, scope, owner)`, then issues directed read grants (`grantRead(doc, granteeId)`) | The broker/verifier delivers only documents the wallet holds a ReadCap for; accessing a document without the cap yields an empty result in a union read (a targeted read of an unheld repo errors with `RepoNotFound`) | Bug/gap for emulation purposes: there is no cap-introspection API — a client cannot ask "may this identity read this doc?", so the polyfill cannot mirror the broker's decision from NextGraph itself | An emulated `CapRegistry` (`caps.ts`, per-document read/write caps) + a read filter (`read-filter.ts`, a defence-in-depth view) that keep only documents the current identity may read; `canRead`/`governsRead` are emulation-only, with no NextGraph API behind them |
|
||||
| Directed read sharing | Owns the relationship concept ("who is connected to whom") itself, and for each relationship issues directed read grants on the owner's protected documents | A native per-document ReadCap issued to a specific identity — but note this target is itself not-yet-built in nextgraph-rs today, not merely unexposed in JS: `AccessGrantV0{grantee}` is unpersisted scaffolding and cap-send is `unimplemented!()`, so directing a grant to another identity has no working platform primitive yet | Not-yet-implemented: sending a cap to another identity is `unimplemented!()`, and no relationship/mutuality primitive exists — relationship is an application concept, not a platform one | The app selects the owner's protected documents via `getCaps().protectedDocsOf(owner)` and calls `grantRead(doc, granteeId)` per grantee; the lib records the per-document grant |
|
||||
| Per-document read isolation | Nothing to declare: creating a document records its cap on its store, and its creator holds it. Reading is `capFor(doc)` — you hold the key or you do not read | The broker/verifier delivers only documents the wallet holds a ReadCap for; accessing a document without the cap yields an empty result in a union read (a targeted read of an unheld repo errors with `RepoNotFound`) | The model itself is the point: reading is key possession, and there is no read-ACL to introspect — a client cannot ask "may this identity read this doc?" because that question does not exist upstream | Caps recorded per identity: `AddRepo` on the store's emulated Store branch for documents it creates, `AddLink` on its User branch for caps received; `caps.ts` caches them for the session. A read filter (`read-filter.ts`) plus the boundary (`reach.ts`) keep only documents whose cap is held. The cap value is the stand-in `OK` — enforcement is P1b |
|
||||
| Directed read sharing | Owns the relationship concept ("who is connected to whom") itself, and on acceptance shares one document's cap to the other's inbox (`shareCap(cap, theirInbox)`) | The cap sealed to the recipient's inbox key (`ContactDetails.read_cap`), opened by their own verifier while processing the inbox | Not-yet-implemented — a **gap, not a disagreement**: the field exists but the message construction is `unimplemented!()`, its only caller passes "without read_cap", and the receiver discards the cap. The shape is right; the implementation is absent | `shareCap` deposits the cap into the recipient's inbox document; the recipient's existing `inbox.watch` absorbs it into what they hold. No "receive" operation, and no principal is ever named to the registry |
|
||||
| Inbox (registration notifications) | `inbox.post` / `read` / `watch` | A message is sealed to the recipient's key and queued in their inbox; the recipient's own verifier unseals and applies each queued message inline while processing the inbox | Not-yet-implemented: the sender-side seal-into-inbox call (`inbox_post_link`) is proposed/future, not exposed in the JS SDK | Deposits written as RDF into an inbox document via SPARQL; `read`/`watch` read the deposits back — an in-lib stand-in for the recipient's own inbox processing |
|
||||
| Discovery of all public events | `submitToIndex(ref)` / `readIndex()` | A real owned global document (owner undecided — a singleton-app path), fed via its native inbox, read as a materialized index | Not-yet-implemented / undecided: an identity's apps and services see only what it shares, so there is no global backend index yet | A global index document owned by a reserved special account (`@index`), fed via its inbox, read with dedup; a stable NURI every client resolves |
|
||||
| ~~Discovery of all public events~~ **REMOVED 2026-07-30** | Circulates the link itself — into inboxes, or into a document the reader already holds | **There is no discovery.** You cannot discover, you can only follow links: publishing = place the data in your public store **and** circulate the link, seen only by those who received it (a foundation of local-first) | Not a gap to be filled — a global index is not a NextGraph shape, and it would pool data across wallets | Nothing. `discovery.ts` and its global index were removed: they emulated a capability the target will never have. See [`docs/readcap-and-nuri-model.md`](docs/readcap-and-nuri-model.md) §4ter-bis |
|
||||
| Reads / listing | Lists the documents it needs, by scope, and reads them | Native per-wallet reads over the real per-identity stores | Bug/perf: an anchorless union query spans every named graph in the session store, which on a shared / accumulating wallet is O(wallet size) and stalls | A bounded, by-need set of per-doc anchored `sparql_query`s (each anchored to one repo's default graph), independent of wallet size |
|
||||
| Reactivity | Lists update on change | Native reactive reads | Not-yet-implemented: there is no reactive union query across graphs | Re-query the bounded per-doc anchored set on a lightweight change signal (`doc_subscribe` / ORM on an already-opened single store) |
|
||||
| Writes | Writes an entity to its scope | Writes land in the entity's real store via native primitives | Not-yet-implemented: `doc_create` can target only the private/protected store today (`StoreRepo` not JS-constructible) | Per-entity documents via direct SPARQL (`docs.sparqlUpdate` on the real injected `ng`) |
|
||||
@@ -102,7 +101,7 @@ away; the app code (SDK-shaped) is unchanged.
|
||||
Implemented. The polyfill mechanisms are wired against a real broker, not stubbed:
|
||||
|
||||
- Shared-wallet shim — `store-registry.ts` (`(account, scope) → document NURI`,
|
||||
`createEntityDoc` / `listEntityDocs` + per-scope index, cross-device via the RDF
|
||||
`createEntityDoc` / `listMyEntityDocs` + per-user stores, cross-device via the RDF
|
||||
shim anchored in the private store).
|
||||
- Document / SPARQL primitive — `docs.ts`, calling the real injected `ng` directly
|
||||
(avoids the `@ng-org` double-proxy `DataCloneError`).
|
||||
|
||||
@@ -35,7 +35,7 @@ This **prevents the shortcuts** the adversary pointed out (#4/#6: reading the pl
|
||||
|
||||
**This inventory IS the scope of P1b.** The only existing guard (`caps.canRead`) is moreover a **set-membership ACL** — the very inversion the vision forbids.
|
||||
|
||||
1. **Two distinct reference shapes**: cap-less (names/locates without reading — aligned with the NURI without `:k:`) vs cap-bearing (id + key/token). Absent today.
|
||||
1. **Two distinct reference shapes**: cap-less (names/locates without reading — aligned with the NURI without `:r:`) vs cap-bearing (id + key/token). Absent today.
|
||||
2. **Grant = delivering a cap-token to a recipient** (emulating the sealing: the recipient *receives* the token in their inbox; it is **possession** of the token that authorizes reading — not an ACL row checked per principal).
|
||||
3. **Enforcement by possession**: readers (`read-filter`, `use-shape`) only see what they **hold the token for**, not "what they are in the readers set for".
|
||||
4. **Resolving a cap-less** = naming / proving existence / counting, **without** exposing the content (support for anonymous presence).
|
||||
@@ -75,16 +75,16 @@ Erroneous content kept below as a record:
|
||||
## Open questions
|
||||
|
||||
- **SETTLED (PO directive, 2026-07-21)**: we **simulate the crypto** (per-doc encrypted data, cap = key). "Semantics only" (a token registry) is **discarded** — it turns back into an ACL and lets the plaintext be read. What remains to settle is the **level** of simulation (real lightweight encryption vs masked read-model projection), **before P1**.
|
||||
- NURI representation, cap-less vs cap-bearing, in the emulation (mirror `:k:`).
|
||||
- NURI representation, cap-less vs cap-bearing, in the emulation (mirror `:r:`).
|
||||
- ~~Should **keyless fetch** be allowed (resolving a cap-less into existence/count without the content)~~ — **SETTLED, and negatively (2026-07-27)**: not constructible. Addressing itself presupposes the cap, so there is nothing to expose. See the corrected Q1 verdict below. Kept struck through rather than deleted: the hypothesis is intuitive and will otherwise be re-formed.
|
||||
- API migration: `declareConnections`/`grantRead` → `seal(cap, recipient)` + `inbox → received caps`. Breaks consumers (the app-side `declareConnections` disappears).
|
||||
|
||||
## P0 — "keyless-resolve" spike (the blocker, BEFORE any P1)
|
||||
|
||||
**Load-bearing question**: can a holder of a **cap-less reference** (`did:ng:o:{id}:v:{overlay}`, without `:k:`), **without ever reading the content**:
|
||||
**Load-bearing question**: can a holder of a **cap-less reference** (`did:ng:o:{id}:v:{overlay}`, without `:r:`), **without ever reading the content**:
|
||||
- **Q1 — Existence / fetch**: prove/retrieve the presence of the (encrypted) blocks from the broker? Or does the broker require a ReadCap/membership in order to serve the blocks?
|
||||
- **Q2 — Deletion**: distinguish "exists" from "deleted"? *(The FRAGILE point: NextGraph is an append-only CRDT — a withdrawal = a **tombstone commit** that one would have to **read** in order to know about → potentially **the key is required**. And the **decrement on leave** depends on it.)*
|
||||
- **Q3 — Confidentiality**: does the key (`:k:`) remain **required** in order to decrypt (keyless never gives the content)?
|
||||
- **Q3 — Confidentiality**: does the key remain **required** in order to decrypt (keyless never gives the content)?
|
||||
|
||||
**Why this is the blocker**: the whole **anonymous counter** (counting/validating cap-less refs without reading) AND the **decrement on leave** depend on it. **If NO** → "anonymous counter via cap-less ref" is **not constructible in the target** → Festipod must **not** code that shape (guaranteed rewrite). **If YES** → P1 exposes `resolveCapLess(nuri) → {exists|deleted}` (never any content), and the emulation simulates it faithfully.
|
||||
|
||||
@@ -109,11 +109,11 @@ Erroneous content kept below as a record:
|
||||
- **Withdrawal has to be a message, not an observation.** On the consumer side: an explicit *nudge*. The polyfill has **nothing** to emulate for that — it just must not pretend otherwise.
|
||||
- **Settled by the Q1 correction**: the anonymous counter can**not** rest on an existence validation — that is not constructible. So it rests on something **declarative**, which is acceptable (outside the security scope) as long as the **exposed shape does not lie**: do not expose an existence primitive that the target will not offer.
|
||||
|
||||
## P1a — the surface
|
||||
## P1a — the surface — **DONE (2026-07-28)**
|
||||
|
||||
**Extracted into its own brief: [`2026-07-27-p1a-cap-surface.md`](2026-07-27-p1a-cap-surface.md).**
|
||||
**Extracted into its own brief: [`2026-07-27-p1a-cap-surface.md`](2026-07-27-p1a-cap-surface.md), which records what landed where.**
|
||||
|
||||
This batch is **specified and ready to implement**; it has its own note so that one can code from it without wading through the retracted material of this document.
|
||||
The ACL inversion — *the central defect this whole chantier exists to fix* — is gone: `caps.ts` is a keyring, sharing is a per-document delivery to an inbox, and a bare reference reads nothing. **P1b is now the blocker for any privacy claim**: the emulated key is derived (hence reproducible) and the bypass inventory below is untouched.
|
||||
|
||||
In two lines: a single new type (`ReadCap`), a keyring (`capFor`), a per-document share to an inbox (`shareCap`) — and nothing else. The branded types, `resolveCapLess`, `receivedCaps`, `refOf`, `parseNuri` and `PrincipalId` were **discarded** after a double adversarial review; the reasons are in that note.
|
||||
|
||||
@@ -121,8 +121,8 @@ This brief remains the **overall effort**: P0 verdicts, P1b scope, P2–P4 batch
|
||||
|
||||
## Phase sketch
|
||||
|
||||
- **P1a** — **the surface**: one new type (`ReadCap`), a keyring (`capFor`), per-document sharing to an inbox (`shareCap`). **The only batch that blocks Festipod.** Specified in its own note: [`2026-07-27-p1a-cap-surface.md`](2026-07-27-p1a-cap-surface.md). *(An earlier draft listed `DocRef`/`DocCap` branded types, `resolveCapLess` and a durable `sealCapTo` here — all three were **dropped** after adversarial review; the note says why.)*
|
||||
- **P1b** — **the enforcement**: per-doc encryption (cap = key) and closing out the inventory of bypasses. Without it the shape is right but the isolation remains false — so nothing "anonymous" can be claimed.
|
||||
- ~~**P1a** — **the surface**~~ **DONE 2026-07-28**: one new type (`ReadCap`), a keyring (`capFor`), per-document sharing to an inbox (`shareCap`). It was the only batch blocking Festipod, and it no longer does. See [`2026-07-27-p1a-cap-surface.md`](2026-07-27-p1a-cap-surface.md) for what landed where. *(An earlier draft listed `DocRef`/`DocCap` branded types, `resolveCapLess` and a durable `sealCapTo` here — all three were **dropped** after adversarial review; the note says why.)*
|
||||
- **P1b** — **the enforcement**: per-doc encryption (cap = key) and closing out the inventory of bypasses. Without it the shape is right but the isolation remains false — so nothing "anonymous" can be claimed. **Requalified 2026-07-30**: the bypass inventory below is really a **virtual user boundary** problem, and it is now specified on its own in [`2026-07-30-virtual-wallet-boundary.md`](2026-07-30-virtual-wallet-boundary.md). That lot precedes or absorbs this one — encrypting each document while any wallet can reach any document secures the windows with the door open.
|
||||
- **P2** — replace the ACL with a **token possession** model (grant = deliver to a recipient; enforcement = possession). *Requalified by the adversarial review: the real content of P2 is **durability + cap-less + re-sharing by the holder**, not "inverting the ACL" — without crypto, inverting produces no observable delta.*
|
||||
- **P3** — revocation by re-key (invalidation + re-delivery, non-retroactive).
|
||||
- ~~**PW** — WriteCap = membership~~ **DROPPED (2026-07-27)**. This batch rested on a notion of membership that **does not exist** in the model (everything is keys and URLs); see the struck-through section above. It was moreover justified by a need for **dedup via signature verification** that the consumer turns out not to have: its dedup rests on the overlay, which is store-scoped. *For the record, two facts verified along the way, not to be re-discovered*: author signature verification **is not called at runtime**; and the author digest is **not** keyed under the read secret — it is keyed by the **outer** overlay, which is public *(it is the commit's **content** that is encrypted, hence the fact that verifying still presupposes being able to read)*. Detail in `nextgraph-current-state.md`.
|
||||
|
||||
@@ -1,6 +1,195 @@
|
||||
# Brief — P1a: the capability surface
|
||||
|
||||
**Status: specified, ready to implement.** Extracted from `2026-07-20-caps-emulation-alignment.md` (which remains the wider chantier: P0 findings, P1b enforcement, P2–P4, and the adversarial reviews). This file is the actionable lot; read it alone to implement.
|
||||
**Status: IMPLEMENTED 2026-07-28, awaiting review.** Extracted from `2026-07-20-caps-emulation-alignment.md` (which remains the wider chantier: P0 findings, P1b enforcement, P2–P4, and the adversarial reviews). This file is the actionable lot; read it alone to implement.
|
||||
|
||||
The spec below is unchanged — read it first. Everything from here to *Why this lot exists* is the implementation report: what landed, the exact surface a consumer codes against, the decisions taken, and what is **not** verified.
|
||||
|
||||
---
|
||||
|
||||
# Implementation report (2026-07-28)
|
||||
|
||||
## What landed
|
||||
|
||||
| Spec | Where |
|
||||
|---|---|
|
||||
| `Nuri` / `ReadCap` (plain strings, `:r:` discriminant) | `packages/client/src/types.ts`, `src/nuri.ts` (internal parse/mint/derive) |
|
||||
| Keyring, one per identity — `capFor` | `src/caps.ts` (`CapRegistry`), surfaced as `capFor` in `src/polyfill.ts` |
|
||||
| Caps of my OWN documents (the emulated `AddRepo { read_cap }`) | `src/store-registry.ts` `fileOwnCaps`, called from `createEntityDoc` and `listMyEntityDocs` |
|
||||
| `shareCap(cap, toInbox)` + reception with no dedicated operation | `src/inbox.ts` (`shareCap`, and the inline absorption in `read`) |
|
||||
| `publishRepoLink` | `src/caps.ts`. *(The published-only guard it fed lived in `src/discovery.ts`, removed 2026-07-30 — see the boundary brief.)* |
|
||||
| Possession gate on reads | `src/read-model.ts` (`readUnion`), `src/read-filter.ts`, `src/use-shape.ts` |
|
||||
| Cap-mutation signal (a delivered cap re-triggers reads) | `CapRegistry.onChange` → `src/watch-shape.ts` |
|
||||
| Acceptance test (§8) | `test/cross-user-access.test.ts` (see below); isolation end-to-end in `test/isolation-active.test.ts`. *Originally `test/watch-shape.test.ts` (e), on the discovery fold — dropped 2026-07-30 with `discovery.ts`; the property it proved is covered on the model's own terms by the cross-user scenario.* |
|
||||
| Cross-user scenario (§5 non-recursiveness) | `test/cross-user-access.test.ts` — see below |
|
||||
|
||||
### The cross-user scenario, as the PO specified it (`test/cross-user-access.test.ts`)
|
||||
|
||||
Alice owns a **protected** document holding a secret, and a **public** document that carries a **reference** to it — a bare NURI. Then:
|
||||
|
||||
- **Bob** holds the public document's link. He reads it, finds the reference, and can NAME Alice's protected document while reading nothing of it. Publication is **not recursive**.
|
||||
- **Charlie** holds the same link, plus the protected document's cap — delivered by Alice to his inbox. Same document, same reference, same path: he reads through it.
|
||||
- **The only difference between them is what their keyring holds.** Nobody was named to the registry; Alice addressed an inbox.
|
||||
- **Dynamic**: Bob is refused, Alice delivers the cap to *his* inbox, his client processes it — and the read that was empty yields the content. Filing the cap fires `CapRegistry.onChange`, so a reader wired to that signal (which is what `watchShape` does internally) re-reads on its own.
|
||||
|
||||
One property this makes explicit and that is worth confirming: **the bare NURI of a PUBLIC document is not enough either** — its repo link is. See *Publication travels as a link* below.
|
||||
|
||||
## The exact surface a consumer codes against
|
||||
|
||||
From `@ng-eventually/client/polyfill`:
|
||||
|
||||
```ts
|
||||
capFor(nuri: Nuri): ReadCap | undefined // the keyring lookup
|
||||
shareCap(cap: ReadCap, toInbox: Nuri): Promise<void>
|
||||
getCaps(): CapRegistry
|
||||
resetCaps(): void // tests / fresh wallet ONLY — never on identity change
|
||||
setCurrentUser(id: PrincipalId | null): void // selects WHICH keyring is consulted
|
||||
```
|
||||
|
||||
On `CapRegistry` (reached via `getCaps()`):
|
||||
|
||||
```ts
|
||||
open(nuri: Nuri, scope: Scope): ReadCap // "this document is mine, in this scope"
|
||||
mint(nuri: Nuri): ReadCap // …its protected/private half
|
||||
publishRepoLink(nuri: Nuri): ReadCap // …its public half — returns the SHAREABLE LINK
|
||||
learn(cap: ReadCap): void // file a cap I was given (throws on a bare reference)
|
||||
capFor(nuri: Nuri): ReadCap | undefined
|
||||
isPublished(nuri: Nuri): boolean
|
||||
isEnforcing(): boolean // false until the first cap exists
|
||||
onChange(listener: () => void): () => void // keyring mutations
|
||||
grantWrite / canWrite / governsWrite / hasWritePolicy // unchanged, decorative, P1b
|
||||
clear(): void
|
||||
```
|
||||
|
||||
Plus the narrowing guards, from the SDK-identical entry:
|
||||
|
||||
```ts
|
||||
isNuri(s: string): s is Nuri // an untrusted string → a Nuri
|
||||
hasReadCap(s: string): s is ReadCap // …→ a ReadCap; the ONLY such narrowing
|
||||
```
|
||||
|
||||
Types: `Nuri`, `ReadCap`, `Scope`, `PrincipalId` are all exported from the SDK-identical entry (`export * from "./types"`). `Scope` is a literal union, so `open(doc, "protected")` is compiler-checked. `capFor` returns `ReadCap | undefined`, so under `strict` the consumer is forced to handle "I hold nothing".
|
||||
|
||||
Every call accepting a `Nuri` also accepts the cap-bearing form and normalizes it (`targetOf`), so passing a cap where a NURI is expected is never a silent mismatch — and it type-checks, because `ReadCap` is assignable to `Nuri`.
|
||||
|
||||
## Typing — template literal types, not `string`, not branded types
|
||||
|
||||
**Amended on the PO's instruction (2026-07-30), after the first pass shipped both as `type X = string`.** The types are now:
|
||||
|
||||
```ts
|
||||
type Nuri = `did:ng:${string}`
|
||||
type ReadCap = `did:ng:${string}:r:${string}`
|
||||
```
|
||||
|
||||
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`. §1's two documented objections to branded types do not apply: there is nothing to un-type at migration, and the cost at the ORM/SPARQL boundaries was **measured at zero** (see below). What the template buys is the single asymmetry that matters: a `ReadCap` is freely usable wherever a `Nuri` is expected (a cap IS a NURI with the key inside — upstream's one `NuriV0`), while a bare `Nuri` where a `ReadCap` is required is a **compile error**.
|
||||
|
||||
This is the one place the implementation departs from the letter of §7 (*"the discrimination lives in what you can obtain, not in what the compiler permits"*). It was an explicit PO decision: the consumer app benefits from the distinction, and possession is still what actually decides — the compiler only stops the app from writing a call the model has no meaning for.
|
||||
|
||||
**Cost, measured on the whole repo**: typing both aliases produced 16 errors, all of them at genuine boundaries, and every one resolved by narrowing rather than casting:
|
||||
|
||||
| Boundary | Resolution |
|
||||
|---|---|
|
||||
| Broker (`docs.docCreate`, whose `ng` is `any`) | Validates with `isNuri` and throws — the declared `Promise<Nuri>` was an unchecked promise every typed NURI downstream rested on |
|
||||
| SPARQL (`store-registry` `canonicalDoc`, `readScopeIndex`) | Narrow with `isNuri`; a stored value that is not a reference is now discarded instead of flowing through as a "document NURI" |
|
||||
| ORM (`read-filter` `docOf`, an untyped `@graph`) | Narrow with `isNuri` |
|
||||
| Inbox payload | Free — it already tested `hasReadCap`, which is now a **type guard** (`s is ReadCap`) |
|
||||
| `assertNuri` | Made generic (`<T extends string>(nuri: T): T`) so the caller's type flows through instead of widening to `string` |
|
||||
| `nuri.ts` `targetOf` | **The one cast in the library**, in the primitive that defines the contract, so no caller needs one |
|
||||
| Playwright bridge (e2e) | An `asNuri` helper that throws — arguments cross the bridge as plain strings |
|
||||
|
||||
`isNuri` and `hasReadCap` are **exported from the SDK-identical entry**, so a consumer narrows its own strings (storage, URL, JSON, a form) the same way instead of casting.
|
||||
|
||||
**The runtime guards stay, and are not redundant**: a JavaScript consumer never meets the compiler, and a cap read back from storage and *cast* rather than narrowed reaches the library just the same. `CapRegistry.file` — the single door into any keyring — refuses a reference with no `:r:`, and `inbox.shareCap` does likewise. That guard was added during implementation after the trap was demonstrated: `learn(someBareNuri)` filed the bare reference under its own name, `capFor` returned it, and the document read — "naming is not reading" silently becoming "naming is reading".
|
||||
|
||||
*(An earlier version of this section flagged a gap around what a consumer put into a discovery `ref`. Moot since 2026-07-30: `discovery.ts` was removed — there is no discovery. Circulating a link is now an explicit act, `shareCap(link, inbox)`, whose argument is typed `ReadCap` and checked at runtime.)*
|
||||
|
||||
## How this articulates with the virtual users
|
||||
|
||||
This is the part worth reviewing closely, because P1a puts a NextGraph concept (the keyring = the wallet) on top of an emulation that already fakes wallets.
|
||||
|
||||
**Upstream, the keyring IS the wallet.** Here there is ONE physical user that everybody opens, and an "identity" is a *virtual* wallet: a shim account in `store-registry`, mapped to three scope-index documents. So the registry holds **one keyring per virtual user** — `Map<accountKey, Map<Nuri, ReadCap>>` — and `setCurrentUser(id)` selects which one is consulted. Switching identity switches keyrings structurally; there is nothing to reset and nothing is wiped.
|
||||
|
||||
**Where a virtual user's caps come from, and what makes them survive a reload:**
|
||||
|
||||
- *Its own documents* — the scope-index document of the (account × scope) plays the role of the store branch that carries `AddRepo { read_cap }` upstream. `createEntityDoc` files the cap on creation; `listMyEntityDocs` refiles them on any later session. Nothing is persisted as a key store: the emulated key is derived from the NURI, so listing the documents is enough to hold them again. **This is why a fresh page reads its own documents with nothing re-declared.**
|
||||
- *Documents shared with it* — the cap lives in the recipient's **inbox document**, which is persistent in the shared wallet. It re-enters the keyring when the consumer processes that inbox.
|
||||
|
||||
**Today, caps received are refiled only when the inbox is read** — which means the consumer's startup sequence has to do it. **The PO has ruled that this is wrong** (see *Follow-up* below): inbox processing belongs to the polyfill, on connection, not to the app.
|
||||
|
||||
**A defect found while writing this up, and fixed.** The keyring was keyed on the **raw** `currentUser`, while the shim keys accounts through the consumer-injected `normalizeId`. So `setCurrentUser("@Alice")` and `setCurrentUser("alice")` — ONE shim account, one set of documents — produced **two keyrings**, and the second one was empty: the identity stopped reading its own documents. The keyring now keys the same way the shim does, so one virtual user has exactly one keyring however its id is spelled. Locked by `test/isolation-active.test.ts` *one keyring per virtual WALLET, not per spelling of its id*.
|
||||
|
||||
**Still per-process, and that is correct**: the keyring is in memory, so two tabs have two keyrings. Each rebuilds itself the same way (scope index + inbox), which is exactly how a real wallet behaves on two devices.
|
||||
|
||||
## Where NURIs and ReadCaps are actually stored
|
||||
|
||||
Worth stating plainly, because "the keyring" is in memory and that sounds fragile until you see what backs it.
|
||||
|
||||
**NURIs are persisted, in RDF, in the shared wallet** — they always were:
|
||||
|
||||
| What | Where it lives | Written by |
|
||||
|---|---|---|
|
||||
| account → its 3 scope-index documents | the **doc-shim**, itself named by a write-once pointer triple in the private store-root | `store-registry.writeRecord` |
|
||||
| scope index → the NURIs of that scope's entity documents | the per-(account × scope) **index document**, as `shim:contains` literals | `store-registry.createEntityDoc` |
|
||||
| a document's own content, including any reference to another document | that **document's** graph | the consumer's write path |
|
||||
| an inbox deposit (payload, ts, from) | the **inbox document's** graph | `inbox.post` |
|
||||
|
||||
**ReadCaps are NOT persisted as caps anywhere.** There is no key store, no trousseau document, nothing on disk that says "this identity holds these keys". The keyring is a plain in-memory `Map<accountKey, Map<Nuri, ReadCap>>` inside `CapRegistry`, rebuilt from scratch on every page load out of two persisted sources:
|
||||
|
||||
- **my own documents** → `listMyEntityDocs` reads the store's document list (persisted NURIs) and re-mints each cap, whose value is the constant `OK`. So *knowing which documents are mine is knowing their caps*. Upstream the key really is stored, on the Store branch (`AddRepo { read_cap }`); emulating that storage rather than re-minting is a separate lot.
|
||||
- **documents shared with me** → the cap sits **inside an inbox deposit's JSON payload**, which IS persisted (it is an ordinary triple in the inbox document's graph). Processing the inbox re-files it. So a shared cap survives a reload because the *delivery* is durable, not because we stored a key — which is exactly the upstream shape, where the seal sits in the inbox until the verifier applies it.
|
||||
|
||||
Consequence to keep in view: **a cap is only as durable as its delivery**. That is why the PO's follow-up below (the polyfill processing inboxes on connection) matters more than it looks — until it lands, the durability of a shared cap depends on the app remembering to read its inbox.
|
||||
|
||||
Second consequence, on the emulated key being derived: anyone can compute any document's cap from its NURI. That is the P1a/P1b line, stated once more — possession is a **shape** here, not a protection.
|
||||
|
||||
## Publication travels as a link — a choice to confirm
|
||||
|
||||
§5 says a public item is read by "whoever has the URL", and §8 says a harvested **bare** reference must yield nothing. Both hold only if what circulates for a public document is its **repo link** (`publishRepoLink` → `did:ng:o:…:r:…`), not its bare NURI. That is what was implemented, and the cross-user test pins it: Bob holding only the public document's bare NURI reads nothing; holding its link, he reads it.
|
||||
|
||||
The alternative — making a published document readable from its bare NURI — was rejected because the "published" fact would then live **only in the local registry**: another tab, another process, another user would have no way to know a document was published, and the emulation would stop being portable. Carrying the fact **in the data** (the link) is what makes it work across processes, and it matches `RepoLinkV0` upstream.
|
||||
|
||||
This is the point where the emulation is furthest from the eventual target, where the public store may not encrypt at all and a bare NURI would suffice. Per §5 that is fine — *"if the public store does not behave as this principle describes, this library adapts, not the consumer"* — but it is a deliberate divergence and the PO should confirm it.
|
||||
|
||||
## A debt this lot created — the unguarded inbox — **CLOSED 2026-07-30**
|
||||
|
||||
`inbox.read` had no guard and **absorbs caps into the reader's keyring**, so `inbox.read(someoneElsesInbox)` pocketed the caps addressed to them and directed sharing was defeatable by anyone who knew an inbox NURI. The inbox was never guarded before either, but before P1a it carried nothing that granted access.
|
||||
|
||||
Fixed in step 2 of [`2026-07-30-virtual-wallet-boundary.md`](2026-07-30-virtual-wallet-boundary.md): an inbox now BELONGS to a virtual user (`storeRegistry.walletInbox`), and only its owner may read it. Depositing into anyone's inbox stays open — that is the one legitimate cross-wallet act, and the only way a link crosses between wallets at all.
|
||||
|
||||
## Follow-up decided by the PO — to plan, NOT in this lot
|
||||
|
||||
**Processing inboxes is the polyfill's job, not the app's** (PO, 2026-07-30). As soon as the app connects a user/wallet, the polyfill must process that identity's inboxes itself — the consumer should never have to remember to do it for its shared documents to become readable.
|
||||
|
||||
And it is inboxes, plural, at **two levels**:
|
||||
|
||||
- the **user/wallet** inbox — where ReadCaps arrive;
|
||||
- the inbox of **every document the user owns** — a document is addressable and has its own inbox upstream, so deposits land per document too.
|
||||
|
||||
What exists today and is reusable: `inbox.readSynced` (the cold, barrier-gated read meant exactly for "process the inbox at (re)connection"), the inline cap absorption in `inbox.read`, and `CapRegistry.onChange` to re-trigger the reads a late cap unblocks. What is missing is the **orchestration**: a connection hook that enumerates the identity's inboxes (wallet-level + one per owned document, via the scope indexes) and processes them, idempotently and without polling.
|
||||
|
||||
Not started. It changes the consumer contract in the right direction (one less obligation), so it should land before the consumer re-architecture settles.
|
||||
|
||||
## Decisions taken while implementing, none contradicting the spec
|
||||
|
||||
- **`open(nuri, scope)` was kept** (it is in neither the §6 table nor the removals) as the single "this document is mine, in this scope" act — `mint` for protected/private, `publishRepoLink` for public. It no longer touches write caps: arming that guard would be enforcement this batch does not do.
|
||||
- **`grantWrite` / `canWrite` were left exactly as they were** (an authorization list, decorative, guard bypassed by every internal writer) and now have to be called explicitly — `open` used to set them as a side effect. They belong to P1b.
|
||||
- **`shareCap` is implemented in `inbox.ts`** and re-exported from `/polyfill`, so it is reachable both as `inbox.shareCap` (SDK-identical entry, via `export * as inbox`) and from the polyfill surface. Deliberate: sharing a cap **is** an inbox deposit upstream, and at migration this call becomes `inbox_post_link` — a real SDK method — so hiding it from the SDK entry would have been the less faithful choice. §7's boundary holds where it matters: the registry, `capFor` and `CapRegistry` stay on the polyfill side, and every signature is a plain string.
|
||||
- **The stand-in key is the constant `OK`** (`nuri.ts`; it was a derived FNV-1a digest until the PO simplified it on 2026-07-30). The only question the emulation answers is *do I hold this cap or not*, so the value says that and nothing more — a digest merely looked like a key. Possession is a shape here, not a protection; P1b replaces the constant with a real key.
|
||||
- **`resetCaps()` clears in place** rather than rebuilding the registry, so a `watchShape` subscribed to the change signal does not end up holding a listener on an orphaned instance.
|
||||
- **The scope-index feed is holder-scoped** (`fileOwnCaps` compares through the shim key): the cross-account fan-out `listEntityDocs` files nothing, because other accounts' caps are emphatically not ours to hold.
|
||||
|
||||
## Verification status
|
||||
|
||||
- **Unit suite green — 138 tests**, typecheck clean on `src`, `test` and the e2e harness.
|
||||
- The typing was verified from a **consumer's** point of view, not just the library's: a synthetic app compiled against the entry points shows the two real mistakes (`shareCap(bareNuri, …)` and passing a raw `string` from storage) as compile errors, while every correct path — `capFor(doc)` → `shareCap(cap, inbox)`, and narrowing with the exported guards — needs no cast.
|
||||
- The acceptance test was **mutation-checked**: reverting both gardes (the discovery fold and the `readUnion` possession gate) makes `watch-shape.test.ts` (e) fail with the bare-referenced document reappearing. The test has teeth.
|
||||
- **The e2e was updated but NOT run** — it needs a real broker. `capsReadFilter` was rewritten around possession and a new `capsShareCap` step exercises the full share→inbox→absorb path against the real broker; both await a run.
|
||||
- **The cap registry is process-wide and `bun test` shares modules across files**, so suites that read without declaring caps now reset explicitly (`read-model.test.ts`, `watch-shape.test.ts`). Worth knowing before adding a suite.
|
||||
|
||||
## Documentation state
|
||||
|
||||
The permanent documentation was updated in the same pass (root `README.md`, `packages/client/README.md`, `docs/simulation.md`, `docs/migration-guide.md` §1 + the assumed `declareConnections` break, `docs/read-model.md`, `docs/readcap-and-nuri-model.md` §5, `docs/nextgraph-current-state.md`, `packages/client/docs/sdk-reference.md`). **If the review changes the surface, those are the files to re-align** — they describe the code as it stands now, not a validated state.
|
||||
|
||||
---
|
||||
|
||||
Written 2026-07-27, after two adversarial reviews and three corrections from the PO. Background: `../vision.md` (why this library exists), `../readcap-and-nuri-model.md` (the target model, verified against `nextgraph-rs`).
|
||||
|
||||
@@ -31,11 +220,11 @@ Every invented name is **vocabulary debt**: the reader has to carry a translatio
|
||||
|
||||
### 1. Types — one new name
|
||||
|
||||
A NURI is **one object**, with or without the key inside — upstream, `NuriV0 { target, access }`, where a cap-less NURI simply has an empty `access`. `did:ng:` is the **URI scheme prefix**, present on inboxes, branches and overlays alike; it does not mean "without cap". The discriminant is the **`:k:` segment**.
|
||||
A NURI is **one object**, with or without the key inside — upstream, `NuriV0 { target, access }`, where a cap-less NURI simply has an empty `access`. `did:ng:` is the **URI scheme prefix**, present on inboxes, branches and overlays alike; it does not mean "without cap". The discriminant is the **`:r:` segment** *(the spec said `:k:`; corrected 2026-07-30 on a report from NextGraph's developer — `:k:` belongs to objects/files/commits, a ReadCap is `r:{base64url(serde_bare(ObjectRef))}`, `repo/types.rs:518`)*.
|
||||
|
||||
```ts
|
||||
type Nuri = string // did:ng:o:{doc}:v:{overlay} — names, does not read
|
||||
type ReadCap = string // …:k:{key} — names AND reads
|
||||
type ReadCap = string // …:r:{cap} — names AND reads
|
||||
```
|
||||
|
||||
`Nuri` **keeps its current meaning** in this package (~90 call sites, untouched): the cap-less form. `ReadCap` is the upstream name — do not invent another.
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# Brief — align on NextGraph's own model: users, stores, branches
|
||||
|
||||
> ## REFUTED by adversarial review, 2026-07-30 — do not implement as written
|
||||
>
|
||||
> Seven defects, four of them factual errors about NextGraph. The three that break the plan outright:
|
||||
>
|
||||
> 1. **D4 rests on a false premise.** There IS a register for received caps: `AddLink { read_cap }` on the **User branch** of the private store — *"so that a user can share with all its device a new Link they received… Only external repos are accepted"* (`engine/repo/src/types.rs:1934-1950`, verifier arm `commits/mod.rs:681`). It is wallet-resident and **cross-device** — the exact opposite of D4's per-browser localStorage. Corrected in [`../readcap-and-nuri-model.md`](../readcap-and-nuri-model.md) §4quinquies.
|
||||
> 2. **D2's rejection of the named graph is factually wrong.** A `GRAPH <…:v:…:b:…>` quad IS resolved to that branch and committed on **its own** topic (`engine/verifier/src/commits/transaction.rs:386-434`); the verifier does exactly this in `update_header`. An unknown branch id errors rather than silently landing on Main. And a branch **is** a valid SPARQL-update target: `TargetBranchV0::is_valid_for_sparql_update` returns true for `BranchId` (`engine/net/src/app_protocol.rs:77-82`) — the fact table's claim to the contrary was wrong twice over.
|
||||
> 3. **The Store branch holds no triples at all.** `BranchCrdt::None`, *"used by Overlay, Store and User BranchTypes"* (`engine/repo/src/types.rs:1420`). It is a stream of service commits (`AddRepo`/`RemoveRepo`), not a graph — so all three D2 candidates were RDF inventions dressed as fidelity.
|
||||
>
|
||||
> And four more, all confirmed:
|
||||
>
|
||||
> 4. **D4 would delete a working recovery path.** Inbox deposits are never removed (`packages/client/src/inbox.ts`), so a second device/tab recovers its caps by re-reading. localStorage-without-re-reading loses them permanently, and contradicts P1a's delivered doctrine that per-process rebuild "is correct".
|
||||
> 5. **D3 is false outside entity documents.** `capFor(scopeIndexDoc)` and `capFor(walletInbox)` are undefined before *and after* `listMyEntityDocs` — their caps can only ever be derived. Yet the boundary brief requires them reachable. Upstream that root comes from the wallet plus `AddSignerCap` on the User branch — a level the fact table omitted entirely.
|
||||
> 6. **`doc_create` writes four times, not two** (+ the class quad on the Header branch, + `AddSignerCap` on the User branch).
|
||||
> 7. **Ordering defect: D2 before the boundary guard opens cap harvesting.** Once caps are triples in `scopeIndexDoc(bob,…)`, and both `scopeIndexDoc` and `docs.sparqlQuery` are exported, `setCurrentUser("mallory")` reads Bob's caps. Today `mintCap` is unexported, so a NURI yields nothing. **The guard must land before the caps become triples.**
|
||||
>
|
||||
> Also flagged: "store" already means the *native* store in this codebase (`RegistrySession.privateStoreId`), so D1's `privateStore`/`storeDoc` collide head-on; "the keyring notion disappears" contradicts `readcap-and-nuri-model.md` §4quater, which calls the Store branch the owner's keyring; upstream `ldp#contains` takes an **IRI** object while the polyfill writes a **literal**, so D1 is not "nothing behavioural".
|
||||
>
|
||||
> Rewrite required. The verified facts are being folded back into `readcap-and-nuri-model.md` first; the plan is re-derived from there, not from this text.
|
||||
|
||||
**Status: REFUTED 2026-07-30 — superseded, kept as the record of what was wrong. Original header follows.**
|
||||
|
||||
**Status: plan, not started. 2026-07-30.** Companion to [`2026-07-30-virtual-wallet-boundary.md`](2026-07-30-virtual-wallet-boundary.md) (whose steps 3–4 are still pending) and to the caps chantier. This lot is about saying — and storing — what NextGraph says and stores, instead of a parallel vocabulary of our own.
|
||||
|
||||
## Why this lot exists
|
||||
|
||||
Two PO corrections, 2026-07-30:
|
||||
|
||||
> If NextGraph stores the key next to the document, then that is what we do. Without encryption we simply store a stand-in value. Stay as close as possible to how NextGraph works.
|
||||
|
||||
> I don't like the name "scope container". Let's keep NextGraph's names. We adapt for the polyfill when we need to, but there too we must stay as close as possible.
|
||||
|
||||
And the vocabulary correction underneath both:
|
||||
|
||||
> In the NextGraph code "wallet" is called "user", because a wallet is only a keyring. Virtual user → **virtual user**; physical user → **physical user**.
|
||||
|
||||
The library currently invents three things NextGraph does not have — a *keyring*, a *scope index*, a *virtual user* — and recomputes a key NextGraph stores. Each invention is a translation table a reader must carry, and each is a place where the consumer learns a model that will not exist.
|
||||
|
||||
## Verified facts this plan rests on
|
||||
|
||||
All read in `nextgraph-rs` (`git 213338f6`) on 2026-07-30, recorded in full in [`../readcap-and-nuri-model.md`](../readcap-and-nuri-model.md) §4quinquies. The load-bearing ones:
|
||||
|
||||
| Fact | Source |
|
||||
|---|---|
|
||||
| A wallet holds SEVERAL users: `SensitiveWalletV0.sites: HashMap<String, SiteV0>` | `engine/wallet/src/types.rs:434,457` |
|
||||
| A user (site) owns the three stores; `UserId = PubKey` | `engine/verifier/src/site.rs:23`; `engine/repo/src/types.rs:453` |
|
||||
| The wallet holds ONE root key per user — the private store's read cap | `site.rs:52` (`get_individual_site_private_store_read_cap`) |
|
||||
| `doc_create` writes TWICE: `AddRepo { read_cap }` on the **Store branch**, and `ldp:contains` on the **Main branch** | `engine/verifier/src/request_processor.rs:697-710`; `verifier.rs:2172-2199` |
|
||||
| Replaying the Store branch is what reloads the repos with their keys | `engine/verifier/src/commits/mod.rs:644-664` |
|
||||
| A branch is a NAMED GRAPH `did:ng:o:{repo}:v:{overlay}:b:{branch}`, with its own pub/sub topic and CRDT | `engine/net/src/app_protocol.rs:267-273`; `engine/repo/src/types.rs:1478-1501` |
|
||||
| Anchoring on a document targets its **Main** branch | `request_processor.rs:161-168` |
|
||||
| SPARQL cannot be anchored on a branch — every other target is `unimplemented!()` | `request_processor.rs:283` |
|
||||
| A `Store` branch exists only on a real store, created on a path `doc_create` does not take | `engine/repo/src/store.rs:425-440` |
|
||||
| A received cap has NO register: `ContactDetails.read_cap` is never read by the receiver | `engine/verifier/src/inbox_processor.rs:778-847` |
|
||||
| Durability of a received cap comes from OPENING the repo, which persists its `read_cap` in local user storage | `engine/verifier/src/user_storage/repo.rs:109,219,248,359`; `verifier.rs:542-544` |
|
||||
|
||||
**Not verified, and therefore not relied on anywhere below**: whether a `GRAPH <…:b:…>` write from the JS SDK round-trips through a real broker. It is assumed BROKEN and avoided.
|
||||
|
||||
## Decisions
|
||||
|
||||
### D1 — Vocabulary follows NextGraph
|
||||
|
||||
| Ours today | Becomes | Why |
|
||||
|---|---|---|
|
||||
| virtual user | **virtual user** | a wallet is a keyring; what owns three stores is a user (site) |
|
||||
| physical user | **physical user** | NextGraph sees exactly one user; our identities are virtual ones |
|
||||
| keyring (`CapRegistry`) | *(the notion disappears — see D3)* | there is no keyring object in NextGraph, and the wallet does NOT hold every key |
|
||||
| scope index / scope container (`scopeIndexDoc`, `readScopeIndex`, `indexDocOf`, `INDEX_SUBJECT`) | **store** (`storeDoc`, `readStore`, …) | the thing that lists a user's documents IS a store |
|
||||
| `shim:contains` | `ldp:contains` | NextGraph's own predicate for exactly this |
|
||||
|
||||
`docPublic` / `docProtected` / `docPrivate` on `AccountRecord` already read as stores; keep them, or rename to `publicStore` / `protectedStore` / `privateStore` for symmetry.
|
||||
|
||||
### D2 — Emulate the Store branch as a distinct SUBJECT, not a distinct graph or document
|
||||
|
||||
The store document gains, beside its `ldp:contains` list, the read cap of each document it lists — the emulation of `AddRepo { read_cap }` on the Store branch.
|
||||
|
||||
Three candidate shapes were considered:
|
||||
|
||||
- **A distinct named graph** (`GRAPH <…:b:store>`) — **rejected**. No branch would exist behind it; the content would be committed on the anchor's Main branch while claiming to live elsewhere. It misrepresents the structure, and its sync behaviour against a real broker is unverified (see above).
|
||||
- **A separate document per branch** — structurally closest (a document is what the JS SDK gives us that most resembles a branch: own topic, own sync, independently addressable), but it doubles the documents per store and adds an indirection to resolve them, to imitate a second pub/sub flow the polyfill will not use.
|
||||
- **A distinct subject in the same document** — **chosen**. It buys what actually matters: the key stored *next to* the document, separate from the list, read rather than recomputed. It does not buy a second event flow, which the polyfill cannot reproduce anyway.
|
||||
|
||||
*The honest cost of this choice*: our two "branches" share one commit stream and one topic, where NextGraph has two. Anything that comes to depend on them being separately subscribable will have to move to the separate-document shape.
|
||||
|
||||
### D3 — Deriving becomes minting, and `CapRegistry` stops being a keyring
|
||||
|
||||
Today `fileOwnCaps` **re-derives** each cap from its NURI, which only works because the emulated key is a function of the NURI. After D2 the cap is **read from the store document**. Derivation survives only inside `nuri.ts` as how a stand-in value is *minted at creation* — the single function P1b replaces with a real key.
|
||||
|
||||
The in-memory `CapRegistry` then stops being "the keyring" and becomes what it actually mirrors: **the verifier's local user storage** (fact table, last row) — the per-user cache of every opened repo and its read cap.
|
||||
|
||||
### D4 — Received caps: persist as local user storage, not as a document
|
||||
|
||||
Verified: there is no received-caps register upstream, and inventing one would expose a shape the target does not have. What upstream does is persist the `read_cap` of every **opened** repo in local user storage.
|
||||
|
||||
So the emulation is a **local, per-virtual-user store** — the same nature as `accounts.ts`'s existing `IdentityStore` (localStorage). This ends "re-read the inbox every session to recover caps", which the PO identified as the wrong model: an inbox is a queue you consume, not a store you re-read.
|
||||
|
||||
*Open*: whether to do D4 in this lot or after the boundary lot. It is the piece with the most design risk, and it is not needed for D1–D3 to be correct.
|
||||
|
||||
## Plan
|
||||
|
||||
1. **D1 vocabulary**, mechanically and in one pass — code, tests, docs. Nothing behavioural. Doing it first stops every later diff from being written in two vocabularies.
|
||||
2. **D2 + D3**: the store document carries each listed document's cap; `fileOwnCaps` reads it instead of re-deriving; `mintCap` keeps minting at creation only. Round-trip test: create → drop all in-memory state → re-list → the cap comes back **read, not recomputed** (assert by minting a *different* stand-in value in the test and checking the stored one wins).
|
||||
3. **D4** local per-user persistence of opened caps, replacing inbox re-reading.
|
||||
4. Then resume the boundary lot's steps 3–4 (guard at the four passage points; remove the cross-account fan-out), which are written in the new vocabulary.
|
||||
|
||||
## What this breaks
|
||||
|
||||
`storeRegistry`'s exported names change (`scopeIndexDoc`, `listEntityDocs`, `AccountRecord` fields). `shim:contains` becomes `ldp:contains`, so **existing dev wallets stop resolving their documents** — acceptable for dev data, and consistent with how the pointer/doc-shim migration was handled before, but it must be stated rather than discovered.
|
||||
|
||||
## Risks I want challenged
|
||||
|
||||
- D2's "distinct subject" may be too weak a reading of "stay close to NextGraph" — the separate-document shape is defensible and I may be under-weighting it.
|
||||
- D3 assumes reading the stored cap is always possible where deriving was — i.e. that every path reaching `fileOwnCaps` has the store document at hand.
|
||||
- D4 introduces browser-local state to a library that currently keeps everything in the shared wallet; that may be a bigger departure than it looks.
|
||||
- The vocabulary change touches the boundary brief and the P1a brief, which are mid-flight.
|
||||
@@ -0,0 +1,195 @@
|
||||
# Brief — the virtual user boundary
|
||||
|
||||
**Status: specified 2026-07-30; all four steps done.** Sits alongside `2026-07-20-caps-emulation-alignment.md` (the wider caps chantier) and `2026-07-27-p1a-cap-surface.md` (the surface, implemented). This lot is about something more fundamental than either: **what a virtual user is allowed to reach.**
|
||||
|
||||
## Why this lot exists
|
||||
|
||||
A virtual user must **simulate the boundary of the future single-user wallet**. Today it does not: it is a grouping fiction — a shim account listing three index documents — and nothing enforces it. Every access function reaches any document of any identity, given a session id and a NURI.
|
||||
|
||||
Stated by the PO on 2026-07-30, on discovering the state:
|
||||
|
||||
> A virtual user must simulate the boundary of the future mono-user wallet. So the access functions must all be restricted to the virtual user currently "connected" (`setCurrentUser`). No cross-wallet access may be permitted, otherwise we are building on a fundamentally wrong model.
|
||||
|
||||
This is the same failure mode the whole caps chantier exists to prevent, one level down. P1a fixed the *shape* of reading (possession, not an ACL). It left the *reach* unbounded — and a consumer coded against an unbounded reach is coded against a world that will never exist, exactly like one coded against an ACL.
|
||||
|
||||
## The rule
|
||||
|
||||
> **The only reads/writes not confined to a virtual user are those that make multi-wallet operation possible at all** (e.g. the index of virtual users). — PO, 2026-07-30
|
||||
|
||||
And its sharpened form, which decides the hard cases:
|
||||
|
||||
> **Nothing common — only indexing mechanisms to make the virtual users work.** — PO, 2026-07-30
|
||||
|
||||
So an exemption must be *plumbing*, never *pooled user data*. The test: **does removing it stop the virtual users from functioning, or does it merely stop users from seeing each other's content?** Only the first justifies living outside a wallet. The shim passes (remove it and no wallet is resolvable); a shared index of user announcements does not (remove it and every wallet still works — you simply have to be given links).
|
||||
|
||||
Everything else is confined. The exemption list is short, explicitly named, and each entry has to justify itself against those two sentences — an exemption that merely *helps* is not an exemption.
|
||||
|
||||
## The premise that collapsed: there is no discovery
|
||||
|
||||
Recorded here because it removes a whole module rather than guarding it (PO, 2026-07-30 — see [`../readcap-and-nuri-model.md`](../readcap-and-nuri-model.md) §4ter-bis, where the principle is documented in full):
|
||||
|
||||
> **You cannot discover. You can only follow links.** NextGraph is local-first: publishing is *place the data in your public store* **and** *circulate the link* — into inboxes, or into somewhere already reachable by the people concerned. It is seen only by those who received the link. Private distribution is the same act plus the ReadCap.
|
||||
|
||||
`discovery.ts` therefore fails on **both** counts: it emulates a global-list capability the target will never have, and it is pooled user data across wallets. It is not a boundary to guard, it is a module to remove — with `watchShape('public')`'s discovery fold, `INDEX_ACCOUNT`, and the `submitToIndex` guard along with it.
|
||||
|
||||
What replaces it is not a mechanism but the model itself: a link reaches you through an **inbox**, or through a document you already hold. Which makes the inbox the bootstrap of the entire reachability graph — the reason its guard (below) and its automatic processing matter more than they first appear.
|
||||
|
||||
*Consequence for P1a's acceptance test, resolved*: `test/watch-shape.test.ts` (e) proved "a harvested bare reference reads nothing, the repo link reads the document" **on the discovery fold**. The property is independent of discovery and survives — `test/cross-user-access.test.ts` already proves it on the model's own terms (Bob follows a reference found in a document he holds), so (e) was dropped rather than re-based.
|
||||
|
||||
## The good news: the boundary already exists
|
||||
|
||||
**The keyring is the boundary.** A document is legitimately reachable when `capFor(doc)` answers — either because this wallet created it (its scope index refiles the cap, the emulated `AddRepo { read_cap }`) or because someone delivered the cap to it. No new notion is needed; the guard is written. What is missing is applying it.
|
||||
|
||||
And the surface to guard is small. Everything in the library reaches NextGraph through **four functions in two modules**:
|
||||
|
||||
- `docs.docCreate`, `docs.sparqlUpdate`, `docs.sparqlQuery`
|
||||
- `subscribe`'s `ng.doc_subscribe`
|
||||
|
||||
Nothing else touches `ng`. (`open-repo` only tests whether `doc_subscribe` exists; `ng-proxy` is the app-facing proxy.)
|
||||
|
||||
## What is confined, and what is exempt
|
||||
|
||||
**Exempt — each one passes the rule:**
|
||||
|
||||
| Exemption | Why it makes multi-wallet operation possible |
|
||||
|---|---|
|
||||
| The store-root **pointer** + the **doc-shim**, to resolve THE CURRENT account | This is the index of virtual users. Without it no virtual user is resolvable at all. **Resolution only** — enumerating every account is not covered (see below). |
|
||||
| The **reserved accounts** (the inbox anchor; `@index` is gone with `discovery.ts`) | They host infrastructure documents; they are not anybody's wallet. |
|
||||
| **Depositing** into another wallet's inbox (write-only) | Without a cross-wallet write channel there is no sharing, hence no useful multi-wallet — and it carries no pooled data: a deposit is addressed to one wallet, not shared between them. This IS the NextGraph model: an inbox deposit is anonymous and sealed, and grants the depositor nothing in return. |
|
||||
|
||||
~~Reading a discovery index~~ — **withdrawn 2026-07-30**, on both counts: it emulates a capability that does not exist, and it is pooled user data. See *The premise that collapsed* above.
|
||||
|
||||
**Confined — none of these passes the rule:**
|
||||
|
||||
| Path | Today | Becomes |
|
||||
|---|---|---|
|
||||
| `docs.sparqlQuery` / `sparqlUpdate` (**exported from the SDK entry**) | any document, any wallet | guarded on the anchor: the cap must be held |
|
||||
| `inbox.read` / `readSynced` / `watch` | **any inbox, including someone else's** | only inboxes belonging to the current wallet |
|
||||
| `subscribeDoc` | any document | only documents whose cap is held |
|
||||
| `storeRegistry.listEntityDocs` / `resolveReadGraphs` | fan-out over every account | **removed** — cross-wallet enumeration, and its former justification (feeding discovery) is gone too |
|
||||
| `storeRegistry.allAccounts` / `loadShim` | enumerates every virtual user | **removed**, or reduced to the reserved-account resolution that infrastructure needs |
|
||||
| `storeRegistry.ensureAccount(id)` | any id | the current identity, plus the reserved accounts |
|
||||
| `readModel.readUnion` | ✅ already guarded (P1a) | unchanged |
|
||||
|
||||
## The breach P1a opened, and which this lot must close first
|
||||
|
||||
`inbox.read` has no guard, and since P1a it **absorbs caps into the reader's keyring**. So:
|
||||
|
||||
```ts
|
||||
setCurrentUser("mallory");
|
||||
await inbox.read(bobsInbox); // mallory pockets the caps addressed to Bob
|
||||
```
|
||||
|
||||
Directed sharing is therefore defeatable by anyone who knows an inbox NURI. Strictly speaking this is not a regression — the inbox was never guarded — but before P1a it carried nothing that granted access, and now it does. **This is the first thing to fix**, and it is arguably P1a's own debt rather than this lot's.
|
||||
|
||||
Closing it needs a notion that does not exist yet: **"my inbox"**. Today an inbox is an arbitrary NURI supplied by the caller. Which is the same brick as the PO's other instruction, so they should land together:
|
||||
|
||||
> Processing inboxes is the polyfill's job, not the app's — as soon as the app connects a user/wallet, at **two levels**: the wallet inbox (where ReadCaps arrive) and the inbox of **every document the user owns**.
|
||||
|
||||
## Design notes for the implementation
|
||||
|
||||
**Scaffolding in the keyring, not in the exemption list.** The current account's three scope-index documents belong to its wallet, so they should be *in its keyring* rather than exempted. That keeps the exemption list down to what genuinely serves multi-wallet operation (the shim, the reserved accounts). Only the pointer and the doc-shim stay outside.
|
||||
|
||||
**Exemptions are named, never inferred.** A NURI is exempt because it is *the* shim document or *a* reserved account's document, resolved as such — never because it "looks like infrastructure". An inferred exemption is a hole.
|
||||
|
||||
**Write-only really means write-only.** Depositing into another wallet's inbox must not make that inbox readable, subscribable, or listable as a side effect. This is the one asymmetric permission in the model and it needs its own test.
|
||||
|
||||
**The guard belongs to a function, not to a position.** One named predicate ("may the current wallet reach this document?"), called at each of the four passage points — not four inline checks that drift apart.
|
||||
|
||||
## Hardening is the polyfill's responsibility, not a negotiation with the consumer
|
||||
|
||||
Stated by the PO on 2026-07-30, closing the question "what does the app actually use?":
|
||||
|
||||
> We do not need to know what the app uses: the polyfill must harden **everything it exposes**. That is its responsibility. **Nothing may allow its own mechanisms to be bypassed** — the virtual user in particular.
|
||||
|
||||
This settles how the lot proceeds, and it generalizes past it. A surface that lets a caller go around the wallet boundary does not merely risk misuse: it **teaches a model that will not exist**, which is the one thing this library exists to prevent. So an exposed function that can bypass a mechanism the polyfill provides is a defect *whether or not anyone calls it that way*, and "the consumer might depend on it" is not an argument for keeping it — if the consumer depends on it, the consumer depends on something the target will refuse.
|
||||
|
||||
Applies to every exported surface, including ones added later: **if it is exposed, it is guarded**.
|
||||
|
||||
## What this breaks
|
||||
|
||||
`docs.*` is exported from the SDK-identical entry and can reach any document; the cross-account fan-out enumerates every wallet; `discovery.*` disappears entirely. The consumer will have to change where it relied on any of them. That is the point, not a side effect: each one is the API starting to tell the truth about a boundary that will exist. Update `../migration-guide.md` accordingly.
|
||||
|
||||
## Order of work
|
||||
|
||||
1. ~~**Remove `discovery.***~~ — **DONE 2026-07-30.** `src/discovery.ts` and `test/discovery.test.ts` deleted; `INDEX_ACCOUNT`, `watchShape`'s public-scope fold and its discovery-index container subscription, `nurisFromRef`, the `submitToIndex` guard, and the e2e discovery block all removed. P1a's acceptance test did not need re-basing: `test/cross-user-access.test.ts` already proves the same property (a bare reference reads nothing, the link reads the document) on the model's own terms — following a link — so `watch-shape.test.ts` (e), which proved it on the discovery fold, was dropped. Docs realigned: the ADR is marked superseded, `read-model.md` now describes ONE regime (follow, never enumerate), and the root README's capability row records the removal.
|
||||
2. ~~**"My inbox" + the inbox read guard**~~ — **DONE 2026-07-30.** `storeRegistry.walletInbox(id)` gives every virtual user its own inbox document, created on first sight and recorded in the doc-shim under its own predicate (`shim:docInbox`), read by its OWN query so an account record written before this existed still resolves — the fixed account SELECT did not grow a fourth required field. `isOwnInbox(nuri)` is the predicate; `inbox.read` / `readSynced` (hence `watch`, which reads through it) refuse an inbox that is not the connected wallet's, and refuse outright when no identity is set. **Depositing stays open** — `post` / `shareCap` are untouched, because that is the one legitimate cross-wallet act. The shared `resolveInboxAnchor` (a reserved account's document, an inbox COMMON to every wallet) was removed: it was unused by the library and violated *nothing common*. Locked by `test/isolation-active.test.ts` *an inbox may be DEPOSITED into by anyone, and READ only by its owner*, which walks the exact breach — Alice deposits, cannot read back; Mallory knowing the NURI absorbs nothing; anonymous is refused; Bob reads his own and only then does the cap land.
|
||||
|
||||
*Not done, and deliberately*: per-DOCUMENT inboxes. Upstream every document has one; here only the wallet does. **The PO has ruled they must come** (2026-07-30) — *"it can come in a second step, but it must come"* — so this is a commitment, not an option. The guard predicate (`isOwnInbox`) is where they plug in: it answers "is this inbox mine?", which extends to "…one of my documents' inboxes" without changing a single caller.
|
||||
|
||||
### Two defects this step surfaced, both open
|
||||
|
||||
**`walletInbox(id)` is a directory, and directories do not exist.** It resolves ANY wallet's inbox from its identity id, and it is exported (`storeRegistry.*` is re-exported from the SDK entry). But you cannot look someone up in NextGraph — you cannot discover, you can only follow links. Their inbox NURI reaches you because *they gave it to you*, not because you resolved it from a name. Resolving **my own** inbox is legitimate plumbing; resolving **anyone's** is the same shape as the discovery index just removed. Fix: the public surface becomes "my inbox" (no argument), and reaching someone else's requires a NURI you were given. Resolution-by-id stays internal, for the shim and the tests.
|
||||
|
||||
**The keyring is not stored anywhere, and the shape is wrong — fix it now, not at P1b.** It is an in-memory `Map<accountKey, Map<Nuri, ReadCap>>`, rebuilt from scratch each session. Nothing persists a cap *as a cap*. PO directive, 2026-07-30:
|
||||
|
||||
> If NextGraph stores the key next to the document, then that is what we do. Without encryption we simply store a stand-in value. Stay as close as possible to how NextGraph works.
|
||||
|
||||
So this is not a P1b concern, it is a **shape** concern — the one thing this library exists to get right — and the stand-in key is stored exactly where the real one will be. Two storage sites, mirroring upstream:
|
||||
|
||||
- **My own documents** → the cap goes **beside the NURI in the scope container**, which is the emulation of `AddRepo { read_cap }` on a branch of the store. Today the container stores only `shim:contains <nuri>` and the cap is **re-derived** from that NURI; it gains a `shim:readCap` beside it. Deriving then stops being how a cap is *recovered* and becomes merely how the stand-in value is *minted* — the single function P1b replaces.
|
||||
- **Caps I received** → into the emulation of `AddLink { read_cap }` on the **User branch** of the private store. Verified 2026-07-30 (see [`../readcap-and-nuri-model.md`](../readcap-and-nuri-model.md) §4quinquies): that register exists, it is explicitly for **external repos**, and its stated purpose is to *"share with all its device a new Link they received"* — wallet-resident and cross-device. So a received cap belongs **inside the virtual user**, like everything else. *(Two earlier versions of this note were wrong and are recorded in [`2026-07-30-users-stores-branches.md`](2026-07-30-users-stores-branches.md): the first blamed key derivation and proposed a "keyring document"; the second concluded no register existed at all and proposed browser-local storage — which would have put library data OUTSIDE even the physical user. Both were refuted; `AddLink` is the answer.)*
|
||||
3. ~~**The guard at the four passage points**~~ — **DONE 2026-07-30.** `src/reach.ts` holds the boundary as **two rules on one criterion — possession — implemented in two places** (PO directive):
|
||||
|
||||
- **Rule 1, authorization**, at the passage points (`assertMayReach`, called from `docs.sparqlQuery` / `sparqlUpdate`): nothing reaches `ng` unless the connected user possesses the document's cap.
|
||||
- **Rule 2, do not even attempt**, at the callers (`mustNotAttempt`, applied in `read-model.readUnion`, which now filters BEFORE opening or reading): a reader that holds no cap does not issue the operation at all. Upstream you cannot even address a repo you have no cap for, so asking about one is not "a read that will be refused" — it is a read with no meaning.
|
||||
|
||||
The redundancy is the point, and a test pins it: a caller that forgets rule 2 is still refused by rule 1, so a bookkeeping lapse fails loudly instead of succeeding quietly.
|
||||
|
||||
**Possession decides, never the shape of the reference in hand.** A caller legitimately manipulates a bare NURI while holding its cap elsewhere — references travel bare through content and indexes, the cap sits in what the user holds. `targetOf` first, so both forms answer alike. (An earlier reading of the directive checked the string for `:r:` instead; corrected on the PO's clarification.)
|
||||
|
||||
Exemptions are **declared**, never inferred from a NURI's shape (`declareInfrastructure`, called by the store-registry for the store-root pointer and the doc-shim — the index of virtual users, the only thing that passes the "remove it and no user resolves at all" test). A user also reaches its own three stores and its own inbox, or the boundary would lock it out of itself.
|
||||
|
||||
Not done: `subscribeDoc` is not yet guarded — it interacts with `ensureRepoOpen`, which opens documents before their cap is known on some cold-start paths. Left for step 4 with the barrier tests in view.
|
||||
4. ~~**Remove the cross-account fan-out**~~ — **DONE 2026-07-30.** `listEntityDocs`, `resolveReadGraphs`, `allAccounts`, `loadShim` and the full-shim cache are gone. Nothing in the library used them any more once `discovery` was removed; only their own tests did. `subscribeDoc` is now guarded too (rule 1) — a subscription IS an access, since the push carries the document's state, so leaving it open would have been a door beside the gate.
|
||||
|
||||
### Machinery vs virtual user: two APIs, and only one is the app's
|
||||
|
||||
The PO's framing, which replaced the exemption list entirely:
|
||||
|
||||
> Clearly distinguish what is polyfill machinery (and therefore the PHYSICAL user) from what is a virtual user's operation. Use different functions, probably grouped in different namespaces — because one API is exposed to the app and the other must never be.
|
||||
|
||||
`src/physical.ts` now holds `physicalCreate` / `physicalQuery` / `physicalUpdate`, with `ensurePhysicalRepoOpen` and `subscribePhysicalDoc` as their open/subscribe counterparts. They are unguarded, and **never exported from the package** — a test asserts it, because a regression there is silent and total.
|
||||
|
||||
The dividing line:
|
||||
|
||||
> 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.
|
||||
|
||||
So the store-root pointer, the doc-shim and the account records go through the machinery; a virtual user's stores, its inbox and its documents go through the guarded `docs.*`, even though the library is what calls them on the user's behalf.
|
||||
|
||||
**This is strictly stronger than the exemption list it replaces.** `declareInfrastructure` is deleted. The machinery no longer calls the guarded primitive and gets waved through — it calls a different primitive that was never guarded. There is no list to widen, to get wrong, or to infer from a NURI's shape, and the boundary now has no `if` in it that could be talked into saying yes.
|
||||
|
||||
5. ~~**The Link, and inbox processing on connection**~~ — **DONE 2026-07-30**, after the four steps above.
|
||||
|
||||
**The Link.** Giving access is a `Link` deposited into the recipient's inbox — upstream's word at all three stages (`InboxMsgContent::Link` for the message, `AddLink { read_cap }` for the filing, `RemoveLink` for the withdrawal). `shareCap` deposits one; the deposit kind is `…:inbox:link`.
|
||||
|
||||
**Applying it durably.** `storeRegistry.addLink` / `readLinks` emulate `AddLink` on the **User branch of the private store** — a distinct subject (`shim:userBranch`) in the private store document, kept separate from the `ldp:contains` listing exactly as upstream keeps the User branch separate from Main. Idempotent, so re-processing costs nothing.
|
||||
|
||||
**The split that matters**: `inbox.read` KEEPS a Link (in the session's keyring) but does not FILE it — reading a queue must not write to a user's store. `inbox.processInbox` *applies*: it reads, then files. That is what an inbox is upstream — **a queue you consume, not a store you re-read**.
|
||||
|
||||
**On connection.** `setCurrentUser` fires `connect.connectedUser()`: restore the already-applied Links from the User branch, then drain the inbox. Restore-first means a reconnecting user reads its shared documents immediately, without waiting on the queue. Fire-and-forget, because the setter is synchronous and every consumer calls it from synchronous code — the work announces itself through `CapRegistry.onChange`, which `watchShape` already listens to. `connectedUser()` is exported for a caller that needs to await it.
|
||||
|
||||
**Two things it deliberately does NOT do.** It does not **provision**: connecting an identity that does not exist creates nothing (`resolveAccount`, not `ensureAccount`) — otherwise connecting would mint a user's stores and caps as a background side effect, arming the whole emulation at a moment nothing controls. And it does not drain **per-document** inboxes, which do not exist yet.
|
||||
|
||||
Proven by `test/cross-user-access.test.ts`: a cap shared to Bob survives **with his inbox emptied** and every in-memory cap dropped — restored from the User branch, not from the queue.
|
||||
|
||||
*Cost noted*: `setCurrentUser` now has observable asynchronous effects (it reads, and it logs). Three log-assertion tests had to await `connectedUser()` before counting lines. That is the honest price of moving the obligation off the app, and it is worth naming rather than discovering.
|
||||
|
||||
6. ~~**The Store branch**~~ — **DONE 2026-08-03.** A document's cap is now STORED when it is created — `shim:readCap` on a `storeBranch` subject of the store document, the emulated `AddRepo { read_cap }` — and READ back by `listMyEntityDocs`, never recomputed. That closes the asymmetry left by the Link work, where received caps were filed durably while created ones were re-minted.
|
||||
|
||||
Two things this pinned down, both of which would have cost more later:
|
||||
|
||||
- **The listing and the keys stay separate**, as Main and Store branches are upstream: `contains` on one subject, `readCap` on another, written as two statements because upstream they are two commits.
|
||||
- **Creation mints the cap exactly once.** It used to mint twice — once to write, once to hold — which agreed only because the stand-in value is a constant. With P1b's real key those would be two different keys, and a creator would hold one that does not open its own document. A test pins it, and another proves the cap is read rather than recomputed by corrupting the stored value and checking the corruption wins.
|
||||
|
||||
Honest about the emulation: upstream the Store branch carries **no triples at all** (`BranchCrdt::None`). Representing it as RDF is ours; what is faithful is the storage beside the document and the separation from the listing.
|
||||
|
||||
7. ~~**Per-document inboxes**~~ — **DONE 2026-08-03.** Upstream a repo carries `inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`): an inbox is a keypair on the document whose PRIVATE half the owner holds, recorded with `AddInboxCap { repo_id, overlay, priv_key }` on the **User branch** — the same branch as `AddLink`, and with the same stated purpose (*"so that a user can share with all its device"*). So "which inboxes may I read" has exactly one answer, and it is the one place to look.
|
||||
|
||||
`storeRegistry.documentInbox(doc)` resolves — creating on first ask — the inbox of a document this user owns, recording the pair on its User branch. **Lazy**: minting an inbox document for every entity up front would double every `createEntityDoc` for inboxes most documents never receive anything in. `myInboxes()` enumerates both levels, `isOwnInbox` answers from the same record, and `connect.connectedUser` drains them all in one call.
|
||||
|
||||
The asymmetry holds at both levels, and a test walks it: **anyone deposits** into a document's inbox (that is how a third party reaches its owner at all), **only the owner reads** it.
|
||||
|
||||
## Relation to P1b
|
||||
|
||||
P1b (per-document encryption, closing the read paths that bypass the guard) largely **becomes** this lot, better framed. Encrypting each document while leaving the wallet boundary open would be securing the windows with the door open — and conversely, once every access is confined to the connected wallet, "the emulated key is derivable" stops being the load-bearing weakness. This lot should therefore precede P1b, or absorb it.
|
||||
@@ -1,6 +1,23 @@
|
||||
# ADR — Discovery mechanism (inbox-fed index, fan-out)
|
||||
|
||||
**Date:** 2026-06-16 · **Status:** mechanism accepted; target owner undecided.
|
||||
> ## SUPERSEDED — 2026-07-30. The premise does not hold.
|
||||
>
|
||||
> **There is no discovery in NextGraph. You cannot discover; you can only follow links** (PO, 2026-07-30 — the principle is documented in [`../readcap-and-nuri-model.md`](../readcap-and-nuri-model.md) §4ter-bis). Publishing is two acts: place the data in your public store, **and** circulate the link — into inboxes, or into somewhere already reachable by the people concerned. It is seen only by those who received the link. This is a foundation of local-first, not a gap to be filled.
|
||||
>
|
||||
> A global index therefore fails on **two independent counts**:
|
||||
>
|
||||
> 1. it emulates a capability the target will never have — teaching consumers a model that does not exist, which is the one failure mode this library exists to prevent;
|
||||
> 2. it is **data common to several users/wallets**, and nothing may be common — only indexing mechanisms that make the virtual users work (the shim qualifies; a shared index of user announcements does not).
|
||||
>
|
||||
> This ADR already recorded the first half of that verdict — *"a dedicated service with its own wallet sharing a freely-readable index is not a NextGraph shape"*, resting on a singleton-app path *"not implemented, uncertain"*. That reservation is now the conclusion.
|
||||
>
|
||||
> **`discovery.ts` and its tests were removed on 2026-07-30**, along with `watchShape`'s public-scope fold and `INDEX_ACCOUNT`. See [`../briefs/2026-07-30-virtual-wallet-boundary.md`](../briefs/2026-07-30-virtual-wallet-boundary.md).
|
||||
>
|
||||
> What survives, and is worth keeping from the text below: the **3-stage frame** (`discovery → synchronization → query`) is still exactly right, with stage 1 re-read as *"a link reached you"* rather than *"you consulted an index"*. You still cannot query what you have not synchronized, and you still do not synchronize what nobody gave you. The **inbox** is what feeds stage 1 — which makes it the bootstrap of the whole reachability graph, not a side feature.
|
||||
>
|
||||
> Kept in full below as a record of what was built and why, and of the reasoning that has to be re-read through the correction above.
|
||||
|
||||
**Date:** 2026-06-16 · **Status:** SUPERSEDED 2026-07-30 (see the block above). *Originally: mechanism accepted; target owner undecided.*
|
||||
Ported here for the discovery mechanism it defines — the piece this lib
|
||||
realizes (`inbox.ts` post/materialize/watch; `store-registry.ts` fan-out). The
|
||||
product intent (what a consumer application *should* surface) is the consumer
|
||||
|
||||
+43
-9
@@ -14,13 +14,33 @@ has no clear target image, that is a drift signal (see
|
||||
## Checklist
|
||||
|
||||
### 1. Emulated ReadCaps → real capabilities
|
||||
Translate the per-document `CapRegistry` (`caps.ts`) into real NextGraph caps: the
|
||||
broker/verifier enforces them, and `useShape` already returns only authorized
|
||||
documents. The directed `grantRead(doc, granteeId)` maps to a native per-document
|
||||
ReadCap issued to that identity. The read filter (`read-filter.ts`) and the write
|
||||
guard (`ng-proxy.ts` `sparql_update` override) are then dead code — remove them. The
|
||||
access unit is already the document (`@graph`), matching the native per-repo cap
|
||||
model, so this is a data step, not a reshape.
|
||||
The shape is already the target's (P1a): a `ReadCap` is the document's key, a
|
||||
each identity holds a set of caps, and there is no read-ACL anywhere. So
|
||||
this step swaps the *emulated* key for the real one, not the model:
|
||||
|
||||
- `caps.ts`'s per-identity record becomes the verifier's own local user storage —
|
||||
it was always the cache, not the register. The two durable registers we emulate
|
||||
(`readCap` on the store's Store branch, `link` on its User branch) become the real
|
||||
`AddRepo` / `AddLink` commits. Remove the emulation; the wallet and the branches
|
||||
already hold them.
|
||||
- `nuri.ts`'s stand-in cap value — the constant `OK` — becomes the real
|
||||
`r:{base64url(serde_bare(ObjectRef))}`. It is **one function** (`mintCap`), because
|
||||
every path now READS a stored cap instead of recomputing one. `hasReadCap` /
|
||||
`targetOf` stay meaningful: the `r:` discriminant is upstream grammar, not ours.
|
||||
- `shareCap(cap, toInbox)` becomes the native sealed delivery (`inbox_post_link`
|
||||
and `ContactDetails.read_cap`), and `inbox.read`'s inline absorption becomes the
|
||||
recipient's own verifier applying queued messages. **The consumer's call does not
|
||||
change.**
|
||||
- `publishRepoLink` becomes `RepoLinkV0`.
|
||||
- The read filter (`read-filter.ts`) and the possession gate in
|
||||
`read-model.readUnion` are then dead code — the broker only delivers documents
|
||||
whose cap the wallet holds. Remove them.
|
||||
- The write guard (`ng-proxy.ts` `sparql_update` override) is a separate axis and
|
||||
is decorative today (every internal writer bypasses the proxy); it belongs to the
|
||||
P1b batch, not here.
|
||||
|
||||
The access unit is already the document (`@graph`), matching the native per-repo cap
|
||||
model, so this is a key-material step, not a reshape.
|
||||
|
||||
### 2. Place documents in real native stores
|
||||
Today `docCreate(..., undefined)` writes every document into the shared wallet's
|
||||
@@ -39,7 +59,7 @@ in the shim (see the two-axes section in [`simulation.md`](./simulation.md)).
|
||||
- At that point `store-registry.ts` maps `(account, scope)` to the user's real
|
||||
store NURI instead of a document in the shared wallet; the per-scope index
|
||||
document (the store-container emulation) is replaced by the store itself. The
|
||||
surface facing the consumer application (`createEntityDoc`, `listEntityDocs`,
|
||||
surface facing the consumer application (`createEntityDoc`, `listMyEntityDocs`,
|
||||
resolvers) is designed to survive that swap unchanged.
|
||||
|
||||
### 3. Drop the resolver / shim
|
||||
@@ -80,9 +100,23 @@ resolve to the real SDK — the `ng`/`useShape`/`inbox` surface is SDK-identical
|
||||
no consumer code changes. The one non-SDK call — `configure(...)` /
|
||||
`@ng-eventually/client/polyfill` — is deleted. The lib itself disappears.
|
||||
|
||||
## The one break already taken: `declareConnections`
|
||||
|
||||
P1a broke the consumer once, deliberately and early, so that migration would not.
|
||||
The old surface was an ACL held in memory, which forced the consumer to re-declare
|
||||
every grant on every session (`declareConnections`). That call **disappears**: with
|
||||
delivered caps the grant moves to the moment a connection is *accepted* — one
|
||||
`shareCap(capFor(doc), theirInbox)` per document shared — and it persists, because
|
||||
the delivery lives in the recipient's inbox rather than in a map that empties at
|
||||
reload. There is no analogue of `protectedDocsOf` + the re-derivation loop.
|
||||
|
||||
This is a consumer **re-architecture**, not an API swap, and it is the price of
|
||||
being coded against a model that will exist. Nothing else about the migration below
|
||||
touches consumer code.
|
||||
|
||||
## What does not change
|
||||
|
||||
The consumer application's code. Shapes, screens, the *acts* of granting
|
||||
The consumer application's code. Shapes, screens, the *acts* of sharing
|
||||
access, entity→scope mapping, the relationship graph — all injected, all untouched.
|
||||
Migration is entirely inside this library plus removing the alias + the bootstrap
|
||||
call. That asymmetry — a mature SDK face outward, all compensation inward — is the
|
||||
|
||||
@@ -83,8 +83,10 @@ users/quorum (write/permissions), **not** read-cap possession. (Repos of a
|
||||
> entity. Read isolation is cryptographic in the target: with no cap for a repo, a
|
||||
> union / reactive read returns empty (the repo is never decrypted), while a
|
||||
> targeted read of an unheld repo returns `RepoNotFound`. There is no
|
||||
> cap-introspection API — the polyfill's `canRead` / `governsRead` are
|
||||
> emulation-only, with no NextGraph API behind them.
|
||||
> cap-introspection API, and there is nothing to introspect: reading is key
|
||||
> possession, so the polyfill asks the only question the model admits —
|
||||
> `capFor(doc)`, "do I hold it?". Its *implementation* is emulation-only; its shape
|
||||
> is the target's.
|
||||
|
||||
### Store ↔ document confusion (recurring)
|
||||
|
||||
@@ -662,7 +664,7 @@ anchored `sparql_query` against a document written in an earlier session comes b
|
||||
**0 rows and no error** — persisted documents read as empty. Observed on every anchored
|
||||
reader of the polyfill and healed identically in each (`ensureRepoOpen` before the read,
|
||||
`packages/client/src/open-repo.ts`): the discovery index (`discovery.ts` `readIndex`),
|
||||
the per-scope index (`store-registry.ts` `readScopeIndex`), the by-need doc batch
|
||||
the user's store (`store-registry.ts` `readUserStore`), the by-need doc batch
|
||||
(`read-model.ts` `readUnion`), and the store-root pointer read (`store-registry.ts`
|
||||
`resolvePointer`). The heal is `doc_subscribe(nuri)` → await the first `State` (the sync
|
||||
barrier) → THEN the anchored read, and it is verified to return the data.
|
||||
|
||||
+22
-22
@@ -37,31 +37,29 @@ The governing constraints (all verified in `nextgraph-rs`, cited there):
|
||||
- No reactive union query, and the reactive ORM hangs if handed a per-entity
|
||||
/ unsynced graph fan-out (`RepoNotFound` aborts `orm_start_graph`).
|
||||
|
||||
## Two read regimes — enumerate vs follow
|
||||
## One read regime — follow, never enumerate
|
||||
|
||||
There is **no cross-wallet read** in current NextGraph, so nothing is globally
|
||||
enumerable "for free". The polyfill splits every list into one of two regimes:
|
||||
There is **no cross-wallet read** in current NextGraph, and there is no discovery
|
||||
either: **you cannot discover, you can only follow links**
|
||||
([`readcap-and-nuri-model.md`](./readcap-and-nuri-model.md) §4ter-bis). Nothing is
|
||||
globally enumerable, and nothing is meant to be.
|
||||
|
||||
### Events (all public) = the global index — the one enumeration hack
|
||||
> An earlier version of this document described a second regime — "all public
|
||||
> events, enumerated through a global index" — presented as the one justified
|
||||
> "hack". It was removed on 2026-07-30 along with `discovery.ts`: a global index
|
||||
> emulates a capability the target will never have, and it pools data across
|
||||
> wallets. A public document is reached because someone circulated its link, never
|
||||
> because it was listed.
|
||||
|
||||
Public events are the only thing enumerated across accounts, via the emulated
|
||||
discovery index (`discovery.readIndex`, see
|
||||
[`simulation.md`](./simulation.md) § *Emulated discovery index*). This is the one
|
||||
"hack", and it is justified precisely because P2P has no cross-wallet read: without
|
||||
a shared index a client could never learn that another account's public event-doc
|
||||
exists. `readIndex` yields the event-doc NURIs to open/sync; those repos
|
||||
then enter the local union and become union-queryable.
|
||||
|
||||
### Everything else = follow a graph, never enumerate across accounts
|
||||
### Everything = follow a graph, never enumerate across accounts
|
||||
|
||||
My participations / my profile, protected data an owner has granted me, my
|
||||
notifications — none of these is enumerated across accounts. Each is reached by
|
||||
what is already reachable to me:
|
||||
|
||||
- my own docs (always in `self.repos`);
|
||||
- docs an owner has granted me via a directed per-document read grant
|
||||
(`grantRead(doc, granteeId)` — see the per-document ReadCap in
|
||||
[`simulation.md`](./simulation.md));
|
||||
- my own docs (always in `self.repos`, and whose caps what I hold holds);
|
||||
- docs whose cap an owner has delivered to my inbox (`shareCap` — see the
|
||||
per-document ReadCap in [`simulation.md`](./simulation.md));
|
||||
- my inbox (deposits addressed to me).
|
||||
|
||||
The rule of thumb: access is not discovery. You only union-query over graphs you
|
||||
@@ -71,14 +69,16 @@ Accessing a document without read rights yields an empty result: a reactive / un
|
||||
read never decrypts a repo you hold no cap for, so it simply returns nothing (this
|
||||
matches NextGraph's union read). A targeted read of a repo you do not hold diverges
|
||||
in one way — it raises `RepoNotFound` rather than returning empty — and the read
|
||||
path tolerates that per-doc (a doc that throws is skipped). The cap-introspection
|
||||
used here (`canRead` / `governsRead`) is emulation-only; there is no NextGraph API
|
||||
behind it, so it has no migration target.
|
||||
path tolerates that per-doc (a doc that throws is skipped). The held-caps lookup used
|
||||
here (`capFor`) is emulation-only in its *implementation*; its shape is the target's
|
||||
(possession), so what disappears at migration is the lookup, not the model. Note
|
||||
there is deliberately no "may identity X read doc D?" call: the real model cannot
|
||||
answer that either.
|
||||
|
||||
## Listing = a bounded set of per-doc anchored reads (never a union-scan, never the ORM fan-out)
|
||||
|
||||
To produce a list, take the bounded, by-need set of doc NURIs (the index-yielded
|
||||
event NURIs, my own docs, the NURIs an owner has granted me) and read each one with its
|
||||
To produce a list, take the bounded, by-need set of doc NURIs (my own docs, and the
|
||||
NURIs whose cap someone delivered to me) and read each one with its
|
||||
own anchored `sparql_query` (`SELECT ?s ?p ?o WHERE { ?s ?p ?o }`, anchor = that
|
||||
doc NURI, in parallel and tolerant per-doc). The anchor restricts the query to that
|
||||
one repo's graph, so each read is O(1) in the doc's own size and independent of how
|
||||
|
||||
+174
-22
@@ -4,6 +4,20 @@
|
||||
|
||||
Purpose: to give the ground truth of NextGraph's access-rights model, in order to align the polyfill's `caps.ts` emulation (today an ACL — the inverse of the real model). This is the basis for the item "align ReadCap/WriteCap with NextGraph".
|
||||
|
||||
> ## How to use this document — verify, never infer
|
||||
>
|
||||
> **NextGraph works very differently from what general knowledge of distributed systems suggests.** Assert nothing about it that is not, at minimum, in this repository's docs — and preferably read in `nextgraph-rs` itself, with a `file:line`. Reasoning by analogy with git, with ACL systems, with pub/sub brokers, or with "how this normally works" produces confident, wrong statements. Every correction recorded below started that way.
|
||||
>
|
||||
> **Write down everything you learn about NextGraph, as you learn it** (PO, 2026-07-30) — at least everything that helps move forward or that corrects a direction. Not at the end of an investigation, not only in the brief that happened to need it: a fact read in `nextgraph-rs` and left in a conversation is a fact the next agent will re-derive, and will get wrong.
|
||||
>
|
||||
> It does not all have to land in this file. This is where the **access model** accumulates (caps, NURIs, stores, branches, who can read what); platform behaviour and SDK gaps belong in [`nextgraph-current-state.md`](./nextgraph-current-state.md), and how the polyfill fakes something belongs in [`simulation.md`](./simulation.md). What matters is that it is written down somewhere durable and findable, with a `file:line` — not which file.
|
||||
>
|
||||
> Three traps in particular, all of which have already caught an agent more than once:
|
||||
>
|
||||
> - **A comment describing the CURRENT state is not the intent.** §3's DIRECTION block exists because `RepoLinkV0`'s comment was read as the target model. It is not.
|
||||
> - **A word you recognise probably does not mean what you think.** `branch` is not git's. `wallet` is only a keyring — what we call a virtual user is a **user** (a *site*). Check the type before using the word.
|
||||
> - **"I looked and it is not there" is not a finding.** §4quinquies once stated that no register existed for received caps, after checking one code path. `AddLink` had been sitting next to `AddRepo` in the same file the whole time. Absence needs at least as much evidence as presence — and an implementation *cache* (like local user storage) is never the model: it is what the model fills.
|
||||
|
||||
---
|
||||
|
||||
## 1. A ReadCap = possession of a key, NOT a per-identity ACL
|
||||
@@ -46,11 +60,11 @@ A delivered key is not "taken back". To revoke = **re-encrypt** with a new key a
|
||||
|
||||
So access is **not lost**, it is **deferred** until the next connection — consistent with local-first. Shape consequences: **no subscription obligation** to expose to the consumer; a re-delivery takes **the same channel** as the initial delivery, so the sharing mechanism covers both with no special case. **Revocation** remains "stop re-delivering", non-retroactive.
|
||||
|
||||
## 4. NURI grammar: cap-less vs cap-bearing (the `:k:` segment)
|
||||
## 4. NURI grammar: cap-less vs cap-bearing (the `r:` segment)
|
||||
|
||||
**Clearing up the confusion first**: `did:ng:` is **not** a "cap-less" marker, it is the **URI scheme prefix** — present everywhere (inbox `did:ng:d:…`, branch `did:ng:b:…`, overlay `did:ng:v:…`, document `did:ng:o:…`). A NURI **is** a `did:ng:…`. So there is no "the did" on one side and "the NURI" on the other: it is **a single object**, with or without the key inside it — a single type upstream, `NuriV0 { target, access }`, where a cap-less NURI simply has an empty `access`.
|
||||
|
||||
The discriminant is the **`:k:{key}`** segment: present = cap-bearing; **absent = cap-less** (names/locates **without** granting the right to read). This is **first-class** in the type: `NuriV0.target` (ids) and `access`/`objects` (the cap) are **separate fields** — an id-only NURI parses with `access: vec![]` (`engine/net/src/app_protocol.rs:53-62, 99-118, 181-195, 659-677`).
|
||||
The discriminant is the **`r:` segment** (see the correction below — this document said `:k:` until 2026-07-30): present = cap-bearing; **absent = cap-less** (names/locates **without** granting the right to read). This is **first-class** in the type: `NuriV0.target` (ids) and `access`/`objects` (the cap) are **separate fields** — an id-only NURI parses with `access: vec![]` (`engine/net/src/app_protocol.rs:53-62, 99-118, 181-195, 659-677`).
|
||||
|
||||
**Cap-less** (id + optional overlay, no key) — formatters in `app_protocol.rs`, regexes in `net/types.rs`:
|
||||
- `did:ng:o:{repo_id}` (`:315`, `RE_REPO_O` types.rs:52)
|
||||
@@ -59,10 +73,28 @@ The discriminant is the **`:k:{key}`** segment: present = cap-bearing; **absent
|
||||
- `did:ng:o:{repo_id}:c:{commit_id}` (`:355`)
|
||||
- `did:ng:b:{branch}` / `h:{topic}` / `v:{overlay}` / `d:{inbox}` (`:327,323,319,359`)
|
||||
|
||||
**Cap-bearing** (embeds the key):
|
||||
- `did:ng:j:{id}:k:{key}` — object/file read cap (`repo/types.rs:511`, `RE_FILE_READ_CAP` types.rs:49)
|
||||
- `did:ng:o:{repo}:c:{commit}:k:{key}` (`RE_COMMIT` types.rs:73)
|
||||
- list `RE_OBJECTS` `…:[cj]:{id}:k:{key}…:l:{locator}` (types.rs:64)
|
||||
**Cap-bearing — and `:k:` is NOT the ReadCap segment.** CORRECTED 2026-07-30, on a report from NextGraph's developer, verified in the source. There are **two different encodings**, and confusing them was an error in this document:
|
||||
|
||||
| Segment | Shape | What it is |
|
||||
|---|---|---|
|
||||
| `:k:` | `{id}:k:{key}` — id and key as **two segments** | an **object / file / commit** ref: `j:{id}:k:{key}` (`repo/types.rs:510`), `c:{id}:k:{key}` (`:514`) |
|
||||
| `r:` | `r:{base64url(serde_bare(ObjectRef))}` — id and key **serialized together into one** | a **ReadCap** — `BlockRef::readcap_nuri()` (`repo/types.rs:518-521`) |
|
||||
|
||||
```rust
|
||||
pub fn readcap_nuri(&self) -> String {
|
||||
let ser = serde_bare::to_vec(self).unwrap();
|
||||
format!("r:{}", base64_url::encode(&ser))
|
||||
}
|
||||
```
|
||||
|
||||
Used to surface a branch's / root branch's read cap (`engine/verifier/src/verifier.rs:278,320`; `rocksdb_user_storage.rs:162,172`).
|
||||
|
||||
So a ReadCap is **not** "a NURI with `:k:{key}` appended". It is an opaque `r:` segment carrying the whole `ObjectRef { id, key }`. Note also that **no regex matches a cap-bearing repo NURI**: `RE_REPO_O` (`did:ng:o:{id}`) and `RE_REPO` (`…:v:{overlay}`) are both cap-less, and `RE_COMMIT`/`RE_FILE_READ_CAP` are about commits and files, not repos (`net/types.rs:48-73`).
|
||||
|
||||
The `:k:` forms, for completeness:
|
||||
- `did:ng:j:{id}:k:{key}` — object/file read cap (`RE_FILE_READ_CAP` types.rs:48)
|
||||
- `did:ng:o:{repo}:c:{commit}:k:{key}` (`RE_COMMIT` types.rs:72)
|
||||
- list `RE_OBJECTS` `…:[cj]:{id}:k:{key}…:l:{locator}` (types.rs:63)
|
||||
|
||||
The `:v:` segment is the **overlay**, which has its own section below — it is the point with the heaviest consequences for anonymous-presence models.
|
||||
|
||||
@@ -109,36 +141,156 @@ This is a **second mechanism**, alongside key possession (§1) — not a breach
|
||||
|
||||
*Implementation detail, NOT to be carried by the shape*: NextGraph is moving toward **not encrypting** the content of the public store (the data remaining **signed**). A surface must not depend on it. And if the public store does not behave the way this principle describes, it is **the polyfill** that adapts, not the consumer.
|
||||
|
||||
## 4quater. The keyring: where the owner gets the caps for THEIR OWN documents
|
||||
## 4ter-bis. THERE IS NO DISCOVERY — you only ever follow links
|
||||
|
||||
On every document creation, an `AddRepo { read_cap }` is committed to a **store branch** — the store being itself a repo, endowed with **typed** branches (the word "branch" has nothing to do with git: it is a compartment with a defined role). That branch lists **the store's documents, each with its read key**.
|
||||
**Stated by the PO, 2026-07-30, as one of NextGraph's foundations.** It bears on more design decisions than any other point in this document, and it is the easiest to violate without noticing, so it is stated before anything is built on top of it:
|
||||
|
||||
So it **is** the **owner's keyring**: the mechanism by which they find the caps of their own documents. Upstream of that, the keyring is the **wallet**.
|
||||
> **You cannot discover. You can only follow links.**
|
||||
|
||||
**This is NOT the sharing mechanism.** An easy and costly confusion: concluding "we share at the store level" is wrong — delivering a store cap would give access to **all** of its content, present and future. **The unit of sharing is the document** (§2). The keyring is a private index, not an act of sharing.
|
||||
NextGraph is **local-first**. There is no global index, no registry, no crawler, no "list everything public" — and nothing of the kind is planned. Nothing exists *to be found*; things exist *to be reached*, and reaching them means someone handed you the way in.
|
||||
|
||||
*(VERIFIED for the `AddRepo { read_cap }` mechanism; the **exact name** of the branches and the enumeration of their types have not been re-traced — to be confirmed if this point becomes load-bearing.)*
|
||||
So **publishing is two acts, never one**:
|
||||
|
||||
## 5. What the polyfill emulates (caps.ts) — and where it diverges
|
||||
1. **Place** the data in your public store — that makes it readable *by whoever reaches it*, not visible;
|
||||
2. **Circulate the link** — post it into inboxes, or put it somewhere already reachable by the people concerned (a document they already hold).
|
||||
|
||||
`packages/client/src/caps.ts` models `readers: Map<Nuri, Set<PrincipalId>>` + `grantRead(doc, grantee)` (`:29-30, 41-42`) — **a per-document ACL of principals, that is the exact INVERSION of the real model** (key). Divergences:
|
||||
And it is seen **only by those who received the information**, i.e. the link. There is no audience beyond the people you reached, and no way to enumerate one. Private distribution is the same act, plus the ReadCap: place, then circulate — the cap being what turns "reached" into "readable".
|
||||
|
||||
| | Real NextGraph | caps.ts emulation |
|
||||
**The consequences, which are not obvious:**
|
||||
|
||||
- **A "global list of everything public" is not constructible**, and a surface that offers one exposes a capability the target will never have — precisely the failure mode this whole chantier exists to prevent. Whatever such a surface is emulated on, it teaches the consumer a model that does not exist.
|
||||
- **Reachability is a graph, not a directory.** The only way in is a link somebody gave you: in an inbox, or inside a document you already hold. Which is why the inbox is not a side feature — it is *the* bootstrap of the whole graph, the only channel through which a link crosses from one wallet to another.
|
||||
- **This is what makes §4ter operational.** "Whoever has the URL reads the content" is not a weaker form of public: the URL *is* the access. Having it means someone gave it to you.
|
||||
- **An audience cannot be counted, only addressed.** No primitive answers "who can see this"; you know who you sent it to.
|
||||
|
||||
### And the second reason, which stands on its own: nothing is COMMON
|
||||
|
||||
Even setting discovery aside, a global index is **data shared between users/wallets**, and that is not acceptable in an emulation whose whole job is to simulate the boundary of a single-user wallet (PO, 2026-07-30):
|
||||
|
||||
> Nothing common — only **indexing mechanisms to make the virtual users work**.
|
||||
|
||||
The distinction is the operative one, and it is sharp:
|
||||
|
||||
| | Verdict | Why |
|
||||
|---|---|---|
|
||||
| Nature | possession of a **key** | **ACL** (set of principals) |
|
||||
| Grant | seal the key (crypto_box) to the inbox | add a principal to the set |
|
||||
| Durability | **durable** (key delivered once) | **ephemeral** (Map empty every session → re-declared) |
|
||||
| Revocation | coarse **re-key**, non-retroactive | removal from the set: **instantaneous and total** |
|
||||
| Granularity | repo / branch / commit / object | **one cap per doc-NURI** |
|
||||
| Ref. without rights | **cap-less NURI** (no `:k:`) | no such notion (the ACL says who may) |
|
||||
| The **shim** (pointer → doc-shim → account → its scope documents) | **acceptable** | pure plumbing: it holds no user data, only the table that makes a virtual user resolvable at all. Remove it and no wallet exists. |
|
||||
| A **discovery index** (announcements deposited by users, read by everyone) | **not acceptable** | it is application data pooled across wallets. Remove it and every wallet still works — you simply have to be given links, which is the model. |
|
||||
|
||||
**App-facing**: `declareConnections` (on the consumer side), which re-declares "my connections read my protected entities" **every session**, is an **artifact of this ephemeral ACL** — moot in the real model (there the seals are durable; one seals per-doc at share time, not per-session).
|
||||
The test to apply to anything shared: *does removing it stop the virtual users from functioning, or does it merely stop users from seeing each other's content?* Only the first justifies existing outside a wallet.
|
||||
|
||||
*Impact on this library, recorded 2026-07-30 and not yet resolved*: `discovery.ts` (a global index owned by a reserved `@index` account, `submitToIndex` / `readIndex` / `watchIndex`) emulates exactly the capability described above as non-existent, **and** holds pooled user data, and `watchShape('public')` folds it into its read set. The ADR that specified it ([`decisions/discovery-model.md`](decisions/discovery-model.md)) already recorded that a freely-readable global index "is not a NextGraph shape" and rested on a singleton-app path that is "not implemented, uncertain". That reservation is now a verdict on both counts. See [`briefs/2026-07-30-virtual-wallet-boundary.md`](briefs/2026-07-30-virtual-wallet-boundary.md).
|
||||
|
||||
## 4quater. Where an owner gets the caps for THEIR OWN documents — the Store branch
|
||||
|
||||
**There is no "keyring" object in NextGraph, and this section used to say there was.** It read *"the store branch **is** the owner's keyring… upstream of that, the keyring is the wallet"*, which is wrong twice: the wallet holds **one** key per user (the private store's read cap, §4quinquies level 1), not every key; and the caps of one's own documents live on a **Store branch**, per store, not in any single trousseau. An agent built a global in-memory "keyring" on that sentence. Corrected 2026-07-30 on the PO's instruction — *use the Store branch logic, not an invented keyring*.
|
||||
|
||||
What is actually true:
|
||||
|
||||
On every document creation, an `AddRepo { read_cap }` is committed to the store's **Store branch** — the store being itself a repo with **typed** branches (the word "branch" has nothing to do with git: it is a compartment with a defined role, its own pub/sub topic, and here `BranchCrdt::None` — service commits, not triples). That branch lists **the store's documents, each with its read cap**, and replaying it is what reloads them (`AddRepo::verify` → `load_repo_from_read_cap`, `engine/verifier/src/commits/mod.rs:644-664`).
|
||||
|
||||
So the answer to *"how does an owner find the cap of a document they created?"* is: **it is on the Store branch of the store that document lives in** — one such branch per store, reached from the root key the wallet does hold.
|
||||
|
||||
**This is NOT the sharing mechanism.** An easy and costly confusion: concluding "we share at the store level" is wrong — delivering a store's cap would give access to **all** of its content, present and future. **The unit of sharing is the document** (§2), and a cap received for someone else's document goes somewhere else entirely (`AddLink` on the User branch, §4quinquies).
|
||||
|
||||
*(VERIFIED for the `AddRepo { read_cap }` mechanism and for `BranchType::Store` / `BranchCrdt::None`; the full enumeration of branch types is in `engine/repo/src/types.rs:1536-1551`.)*
|
||||
|
||||
## 4quinquies. WHERE the caps actually live — three levels, and one of them does not exist yet
|
||||
|
||||
**VERIFIED 2026-07-30** by reading `nextgraph-rs` (`git 213338f6`), answering "where does a received cap get stored?".
|
||||
|
||||
### Nomenclature first — `wallet` in the source is NOT what we call a wallet
|
||||
|
||||
A **wallet is only a keyring**. What we have been calling a "virtual user" is, upstream, a **user** (a *site*): `SensitiveWalletV0.sites: HashMap<String, SiteV0>` (`engine/wallet/src/types.rs:434,457`) — one wallet holds SEVERAL sites. `SiteV0` (`engine/verifier/src/site.rs:23`) is what owns the three stores (`public`, `protected`, `private`), and `UserId = PubKey` (`engine/repo/src/types.rs:453`). **Our vocabulary must follow: virtual user → user.**
|
||||
|
||||
### The three levels
|
||||
|
||||
**1. The wallet (keyring) holds ONE root key per user.** `SiteV0.site_type = SiteType::Individual((priv_key, read_cap))`, read back by `get_individual_site_private_store_read_cap` (`site.rs:52`) — the read cap of the **private store**, and nothing else. Everything else is reached *from* it. Following links applied to your own data.
|
||||
|
||||
**2. The store's own branch carries `AddRepo { read_cap }` — one per document.** `doc_create` performs **four distinct writes**; the two that matter here (`engine/verifier/src/request_processor.rs:697-710`):
|
||||
|
||||
- `send_add_repo_to_store` → a commit `AddRepo { read_cap }` on the **Store branch** of the store (`verifier.rs:2172-2199`) — *the key*;
|
||||
- `INSERT DATA { <store> ldp:contains <doc> }` on the store's **main branch** — *the listing*.
|
||||
|
||||
*(The other two: the class quad on the **Header** branch, `request_processor.rs:719-728`; and `AddSignerCap` on the private store's **User** branch, `verifier.rs:3022-3040`.)*
|
||||
|
||||
**The key and the list are separate, deliberately.** Replaying the Store branch is what reloads the repos with their keys: `AddRepo::verify` calls `load_repo_from_read_cap` then `add_doc` (`engine/verifier/src/commits/mod.rs:644-664`). Our `shim:contains` emulates `ldp:contains` and `shim:readCap` (on a `storeBranch` subject) emulates `AddRepo` — so a created document's cap is stored beside it and read back, not recomputed.
|
||||
|
||||
> **The Store branch holds NO triples.** Its CRDT is `BranchCrdt::None` — *"used by Overlay, Store and User BranchTypes"* (`engine/repo/src/types.rs:1420`; `store.rs:426`). It is a stream of **service commits** (`AddRepo` / `RemoveRepo`), not a graph. Any RDF we use to emulate it is our invention, and should be labelled as such rather than presented as "the same thing".
|
||||
|
||||
**3. Local user storage persists the read cap of EVERY opened repo.** `user_storage/repo.rs` stores `READ_CAP` as a property per repo (`:109,:219,:248,:359`), and a persistent verifier reloads from it at startup (`verifier.rs:542-544`). This is a **local store (RocksDB / IndexedDB), not a NextGraph document** — the verifier's own cache, per user.
|
||||
|
||||
### Giving access is a **Link** — one word, three places, all already named
|
||||
|
||||
**VERIFIED 2026-07-30.** The delivery message, the register and the record all exist upstream under the same word, which is what a shape being real looks like:
|
||||
|
||||
| Step | Upstream | State |
|
||||
|---|---|---|
|
||||
| The message deposited in the recipient's inbox | `InboxMsgContent::Link` (`engine/net/src/types.rs:4249-4261`) | **declared, payload-less** — a variant with no fields, i.e. specified and not implemented |
|
||||
| Where the recipient files it on processing | `AddLink { read_cap }` on the **User branch** of the private store (`engine/repo/src/types.rs:1934-1950`) | implemented (verifier arm `commits/mod.rs:681`) |
|
||||
| Withdrawing it | `RemoveLink`, ORset (`engine/repo/src/types.rs:1952`) | implemented |
|
||||
| What circulates | `RepoLinkV0 { read_cap, … }` (`engine/net/src/types.rs:5062`) | implemented |
|
||||
|
||||
So: **deposit a Link into the recipient's inbox; on connection the recipient processes the inbox and files it with `AddLink` on their User branch.** That is the whole gesture, and every piece of it has a name.
|
||||
|
||||
Two consequences worth stating, because both are easy to get wrong:
|
||||
|
||||
- **What travels is a cap-BEARING reference.** A bare NURI in a Link grants nothing — it names a document the recipient still cannot open. `AddLink` carries a `read_cap`, not a `RepoId`.
|
||||
- **`ContactDetails` is a different gesture.** It shares a *profile* (with an optional `read_cap` on it), not an arbitrary document. Do not route document sharing through it.
|
||||
|
||||
### A cap received from someone else: the **User branch**, via `AddLink`
|
||||
|
||||
**CORRECTED 2026-07-30 after adversarial review — an earlier version of this section claimed there was no register at all. That was wrong, and it was the kind of wrong this document exists to prevent: concluding "it does not exist" from having looked in one place.**
|
||||
|
||||
There IS a register, and it is a fourth commit type next to `AddRepo`:
|
||||
|
||||
```rust
|
||||
/// Adds a link into the user branch, so that a user can share with all its device a new Link they received.
|
||||
/// The repo's `store` field should not match with any store of the user. Only external repos are accepted here.
|
||||
pub struct AddLinkV0 { pub read_cap: ReadCap, /* … */ }
|
||||
```
|
||||
|
||||
`engine/repo/src/types.rs:1934-1950`, with `RemoveLink` as its ORset counterpart (`:1952`) and a verifier arm at `engine/verifier/src/commits/mod.rs:681`. So:
|
||||
|
||||
- it lives on the **User branch** — created only on the **private store** (`engine/repo/src/store.rs:448-452`; the public/protected stores get an `Overlay` branch instead), which also carries `AddInboxCap { repo_id, overlay, priv_key }` — *"so that a user can share with all its device"* (`engine/repo/src/types.rs:1969-1981`). So the User branch answers two questions with one mechanism: **which caps I received**, and **which inboxes I may read**;
|
||||
- it is explicitly for **external repos** — someone else's documents, exactly the received-cap case;
|
||||
- and its stated purpose is to **share the link with all of the user's devices**. It is wallet-resident and cross-device, not a local cache.
|
||||
|
||||
**Level 3 (local user storage) is therefore a cache, not the register.** The register is level 2': `AddLink` on the User branch of the private store.
|
||||
|
||||
What remains true, and is a separate matter — the *delivery* path is unimplemented:
|
||||
|
||||
- `InboxMsgContent::ContactDetails` processing (`engine/verifier/src/inbox_processor.rs:778-847`) creates a contact document holding the profile, inbox, name and email — and **never reads `details.read_cap`**. Confirmed on sight: the receiver discards it. So the cap never reaches the User branch today — the register exists, the road to it does not.
|
||||
- `RepoLinkV0` states the intended flow (`engine/net/src/types.rs:5055-5061`): *"the link is shared and then the recipient opens it and subscribes soon afterward"*. **The key IS kept**: opening the repo persists its `read_cap` in local user storage, so the next session decrypts fine. What is not durable is the key's **validity** — a `RootCapRefresh` (§3) mints a new one, and receiving it depends on **the rotating party choosing to send it to you** (§3's DIRECTION block), not on any subscription state.
|
||||
|
||||
> **Do not write "only a subscriber receives the new key".** That reads the `RepoLinkV0` comment as intent, which §3 already forbids. **Subscribing is a purely LOCAL act** — automatic pull of changes — and the other party records nothing about it; there is no subscriber list to send to. Who gets a rotated key is the rotating party's decision, delivered to an inbox.
|
||||
- `PermaCap` — still a **TODO** (`engine/repo/src/types.rs:578`) — covers exactly the gap that leaves: a link *"stored on disk and kept there unopened for a long period"*, i.e. never loaded, therefore never subscribed, therefore missing every refresh.
|
||||
|
||||
> **So there are TWO registers, by origin**: `AddRepo` on the **Store** branch for the documents a user creates in that store, and `AddLink` on the **User** branch of the private store for caps received for someone else's documents. Local user storage caches both. Opening a repo persists its cap locally, but that is the cache filling — not the durable record.
|
||||
|
||||
*Consequence for this library*: **both durable registers are now emulated** (2026-07-30) — `AddRepo` as a `shim:readCap` record on a distinct subject of the store document (`storeBranch`), `AddLink` as `shim:link` on another (`userBranch`) — and the in-memory `CapRegistry` is what it always was, level 3: the cache. Caps are READ back from those records, never recomputed. What stays an invention is representing branches as RDF subjects at all: upstream both branches carry `BranchCrdt::None` and hold service commits, not triples. What is faithful is that the key sits beside the document, and that the listing (`contains`, the Main branch) is separate from the keys.
|
||||
|
||||
## 5. What the polyfill emulates (caps.ts) — and where it still diverges
|
||||
|
||||
**Realigned 2026-07-28 (batch P1a).** `packages/client/src/caps.ts` used to model `readers: Map<Nuri, Set<PrincipalId>>` + `grantRead(doc, grantee)` — a per-document **ACL of principals**, the exact INVERSION of the real model. It now records, **per identity**, the caps that identity holds (`Map<Nuri, ReadCap>`) — whose only question is `capFor(nuri)` — and `nuri.ts` carries the cap-less / cap-bearing distinction on the `r:` segment. The durable registers are emulated in `store-registry.ts` (`readCap` on the Store branch, `link` on the User branch); this in-memory record is their cache.
|
||||
|
||||
| | Real NextGraph | caps.ts emulation (post-P1a) |
|
||||
|---|---|---|
|
||||
| Nature | possession of a **key** | possession of a **key** — recorded per identity, indexed by the cap-less NURI |
|
||||
| Grant | seal the key (crypto_box) to the inbox | `shareCap(cap, toInbox)` → an inbox deposit, absorbed inline on read |
|
||||
| Durability | **durable** (key delivered once) | durable **in shape**: creation and re-listing refile own caps from the scope index (the emulated `AddRepo` branch); a delivered cap persists in the recipient's inbox document |
|
||||
| Revocation | coarse **re-key**, non-retroactive | **not emulated** (P3). Nothing pretends to revoke |
|
||||
| Granularity | repo / branch / commit / object | **one cap per doc-NURI** |
|
||||
| Ref. without rights | **cap-less NURI** (no `:k:`) | same — `Nuri` names, `ReadCap` names and reads |
|
||||
|
||||
**The divergence that REMAINS, and it is the load-bearing one**: the stand-in key is a constant (`OK`) rather than a secret, and several read paths consult no cap at all (`docs.sparqlQuery`/`sparqlUpdate`, the whole inbox, `store-registry`, `discovery.readIndex`, `subscribe`, `open-repo`). So P1a bought the **shape**, not the isolation — per-document encryption and closing that inventory are **P1b**. Nothing may be claimed "anonymous" or "private" before it.
|
||||
|
||||
**App-facing**: `declareConnections` (on the consumer side), which re-declared "my connections read my protected entities" **every session**, was an artifact of the ephemeral ACL — **it disappears**. The grant moves to the moment a connection is accepted (`shareCap` once, per document), which is a consumer **re-architecture**, not an API swap.
|
||||
|
||||
## 6. Implications for consumers (e.g. Festipod)
|
||||
|
||||
- "**protected scope = my network can read**" is **not** an ACL checked by the broker: it is "I have **sealed my read key** to each of my connections". The "scope = ACL" mental model is wrong at the NextGraph level.
|
||||
- **Anonymous references are possible**: putting a **cap-less NURI** in a third party's collection lets that third party **name/count** without **reading the identity**; the cap-bearing one is sealed separately to the authorized parties only. (Basis for a presence model of the form "self-owned participation + curated cap-less Set + cap sealed to the connections".)
|
||||
- **Alignment to do**: when the real cap operations become available, replace the emulated ACL with durable per-doc key sealing, and `declareConnections`-as-a-re-declared-ACL disappears.
|
||||
- **Alignment DONE for the surface (P1a, 2026-07-28)**: the emulated ACL is gone, replaced by per-identity cap possession + per-document delivery to an inbox; `declareConnections`-as-a-re-declared-ACL has disappeared. What remains for the real cap operations is swapping the stand-in key value (`OK`) for the real one and closing the bypasses (P1b) — a key-material step, not a reshape. See `migration-guide.md` §1.
|
||||
|
||||
## Caveats / gaps
|
||||
|
||||
|
||||
+276
-166
@@ -32,34 +32,45 @@ application fiction the lib maintains. On top of that one wallet the lib rebuild
|
||||
by emulation, the per-user stores + capabilities + inbox the consumer application
|
||||
codes against.
|
||||
|
||||
## Physical wallet vs virtual wallet — never enumerate the physical one
|
||||
## Physical user vs virtual user — never enumerate the physical one
|
||||
|
||||
**Nomenclature (aligned on NextGraph, 2026-07-30).** A **wallet** upstream is only a
|
||||
**keyring**; what owns three stores is a **user** (a *site*), and one wallet holds
|
||||
several of them (`SensitiveWalletV0.sites`, `engine/wallet/src/types.rs:434,457`).
|
||||
So this document says *user*, not *wallet*, for the thing an identity is — the two
|
||||
words meant the opposite of each other here until this was corrected.
|
||||
|
||||
Because the emulation runs on ONE shared wallet, distinguish two levels:
|
||||
|
||||
- **Physical wallet** — the real NextGraph wallet everyone opens. Its local store
|
||||
holds every account's documents plus the lib's own internals (the shim index,
|
||||
the inbox docs, the discovery index) as named graphs. It accumulates without
|
||||
bound across sessions/runs. Listing or scanning "all documents" of the physical
|
||||
wallet is meaningless and O(size) — it mixes every user's data with lib internals,
|
||||
and it is exactly what a `sparql_query` with no anchor (`GRAPH ?g { … }`) does
|
||||
(it spans every synced graph). The physical wallet is a substrate,
|
||||
not something to enumerate.
|
||||
- **The physical user** — the single NextGraph user everybody's session opens. Its
|
||||
stores hold every account's documents plus the library's own internals (the
|
||||
pointer, the doc-shim, the inbox documents) as named graphs, accumulating without
|
||||
bound across sessions and runs. Listing or scanning "all documents" at this level
|
||||
is meaningless and O(size): it mixes every virtual user's data with library
|
||||
internals, and it is exactly what an anchorless `sparql_query` (`GRAPH ?g { … }`)
|
||||
does. The physical user is a substrate, not something to enumerate.
|
||||
|
||||
- **Virtual wallet** — the lib's emulation of one user's wallet: the set of
|
||||
documents the shim attributes to that account (its per-scope index in
|
||||
`store-registry.ts`). This is what "the user owns". Over a *virtual* wallet,
|
||||
"list my documents" is meaningful and bounded (only that account's docs).
|
||||
- **A virtual user** — the library's emulation of one identity: the documents the
|
||||
shim attributes to that account (its three store documents in
|
||||
`store-registry.ts`). This is what "the user owns", and over it "list my
|
||||
documents" is meaningful and bounded.
|
||||
|
||||
**Consequence for reads (see `read-model.md`):** to list a user's entities you
|
||||
enumerate the *virtual* wallet — the account's scope index (bounded, O(my docs)),
|
||||
not the physical union — then read those specific documents with a per-doc anchored
|
||||
`sparql_query`. A non-empty / bloated physical wallet then costs nothing, because the
|
||||
physical union is never scanned. Discovery (all public events) is the one bounded
|
||||
enumeration hack and goes through the discovery index, not a physical scan.
|
||||
**Which API you use follows that line, and it is enforced** (see `physical.ts`):
|
||||
machinery operating on the *index of virtual users* — the store-root pointer, the
|
||||
doc-shim, the account records — goes through unguarded primitives that are never
|
||||
exported from the package. Everything touching a virtual user's own content goes
|
||||
through the guarded `docs.*`, even when the library is what calls it. One API is the
|
||||
app's; the other must never be.
|
||||
|
||||
At migration each virtual wallet becomes a real per-user wallet; the physical/virtual
|
||||
distinction — and the "never enumerate the physical wallet" rule — dissolves into
|
||||
native per-wallet reads.
|
||||
**Consequence for reads (see `read-model.md`):** to list an identity's entities you
|
||||
enumerate the *virtual* user — that account's store document, bounded to its own
|
||||
documents — never the physical union. A bloated shared wallet then costs nothing,
|
||||
and nothing is enumerated across users at all: you read your own documents and the
|
||||
ones whose cap you were given.
|
||||
|
||||
At migration each virtual user becomes a real user with its own wallet; the
|
||||
physical/virtual distinction, the "never enumerate the physical one" rule, and
|
||||
`physical.ts` all dissolve into native per-user reads.
|
||||
|
||||
## Two axes, never conflate them (store ≠ document)
|
||||
|
||||
@@ -120,15 +131,14 @@ public/protected/private stores — on top of one shared wallet.
|
||||
is its own document/repo with a future inbox) and appends its NURI to the
|
||||
account's scope index document — the index doc plays the role of the future
|
||||
store-container (it lists the entity-document NURIs "in" that scope).
|
||||
`listEntityDocs(scope)` unions the contained NURIs across all accounts. This is a
|
||||
fallback / test-only path, not the read path: enumerating every account and
|
||||
handing the NURIs to `useShape({ graphs })` opens/syncs other accounts' possibly-
|
||||
unsynced docs and hangs (the ORM fan-out — see
|
||||
[`read-model.md`](./read-model.md)). The real read path is
|
||||
`listMyEntityDocs(id, scope)` reads back ONE user's documents — bounded to that
|
||||
user, and the only listing there is: the cross-account fan-out
|
||||
(`listEntityDocs` / `resolveReadGraphs` / `allAccounts` / `loadShim`) was
|
||||
**removed on 2026-07-30**, being cross-user enumeration by construction. The real read path is
|
||||
`readModel.readUnion(docs)`, which reads the by-need doc set with one per-doc
|
||||
anchored `sparql_query`, never an anchorless union-scan of the physical
|
||||
wallet (see [`read-model.md`](./read-model.md)). The consumer application resolves
|
||||
the by-need doc set from the discovery index (public events) and
|
||||
the by-need doc set from the current wallet's own scope index and
|
||||
`listMyEntityDocs(id, scope)` (its own account, bounded — no cross-account fan-out).
|
||||
- **Generic by construction.** The registry knows only the three native scopes,
|
||||
zero application entity kind. The consumer application maps its entities to a scope
|
||||
@@ -139,16 +149,16 @@ The `store≠document` two axes materialize here directly: the registry moves al
|
||||
axis B (more documents = more isolation), never axis A (it always writes into the
|
||||
one private store via `docCreate(..., undefined)`).
|
||||
|
||||
### A virtual wallet's structure — the three emulated stores
|
||||
### A virtual user's structure — the three emulated stores
|
||||
|
||||
A *virtual wallet* = one account in the shim, keyed by its virtual-wallet id
|
||||
(the technical identifier the consumer application sets when the physical wallet is
|
||||
opened; it identifies *which* virtual wallet, and is an id rather than a
|
||||
A *virtual user* = one account in the shim, keyed by its virtual-wallet id
|
||||
(the technical identifier the consumer application sets when the physical user is
|
||||
opened; it identifies *which* virtual user, and is an id rather than a
|
||||
human-friendly handle). Its structure mirrors the target "1 user = 1 wallet with 3
|
||||
native stores":
|
||||
|
||||
```
|
||||
Virtual wallet (id)
|
||||
Virtual user (id)
|
||||
├── public store = docPublic index → [ entity doc NURI, entity doc NURI, … ]
|
||||
├── protected store = docProtected index → [ record doc NURI, record doc NURI, … ]
|
||||
└── private store = docPrivate index → [ record doc NURI, … ]
|
||||
@@ -160,17 +170,17 @@ So the 3 native stores (public/protected/private) are present, but emulated: eac
|
||||
per-entity documents in that scope. It is not a physical native store.
|
||||
|
||||
Everything is physical in one place: the 3 index documents, every per-entity
|
||||
document, and the shim anchor itself all live in the shared physical wallet's
|
||||
document, and the shim anchor itself all live in the shared physical user's
|
||||
private store (`docCreate(..., undefined)`). The 3-store structure is the per-account
|
||||
logical layer the lib maintains on top.
|
||||
|
||||
```
|
||||
Physical wallet (shared, one) → private_store (physical) holds everything:
|
||||
Physical user (shared, one) → private_store (physical) holds everything:
|
||||
• the shim anchor: virtual-wallet-id → { docPublic, docProtected, docPrivate }
|
||||
• every account's 3 scope-index docs + all per-entity docs + inbox + discovery index
|
||||
• every account's 3 scope-index docs + all per-entity docs + inboxes
|
||||
```
|
||||
|
||||
At migration each virtual wallet's 3 index documents become the user's 3 **real**
|
||||
At migration each virtual user's 3 index documents become the user's 3 **real**
|
||||
native stores, the entity documents move into them physically, and the
|
||||
virtual/physical distinction dissolves (see [`migration-guide.md`](./migration-guide.md)).
|
||||
|
||||
@@ -190,16 +200,16 @@ store-id:
|
||||
blocker, [`migration-guide.md`](./migration-guide.md)). At migration each scope
|
||||
resolves to the user's real per-scope store — the change is in this function,
|
||||
and the consumer application is unchanged.
|
||||
- **`resolveInboxAnchor()`** — the anchor where emulated inbox deposits land: a
|
||||
dedicated inbox document (a reserved account's public scope document, from
|
||||
`docCreate` — a real repo NURI, stable across clients), not the shared
|
||||
wallet's private-store root. Why dedicated: the shim (the account→document trust
|
||||
root) lives in the private-store graph and is scanned on every `loadShim`;
|
||||
routing every inbox deposit into that same graph bloats it without bound
|
||||
(thousands of deposit triples across sessions), turning `loadShim` into a
|
||||
multi-second full-graph scan. A separate inbox document keeps the shim graph
|
||||
small and the deposits isolated. At migration it becomes the host's native
|
||||
inbox NURI.
|
||||
- **`walletInbox(id)` / `documentInbox(doc)`** — an inbox BELONGS to someone. The
|
||||
first is a virtual user's own inbox (where Links arrive), the second the inbox of
|
||||
one of its documents, created on first ask. Both are dedicated documents (real
|
||||
repo NURIs from `docCreate`), never the private-store root: routing deposits into
|
||||
the shim graph would bloat the account→document trust root without bound.
|
||||
`myInboxes()` enumerates both levels — what `connect.ts` drains at connection —
|
||||
and `isOwnInbox` answers from the same record. *(The former `resolveInboxAnchor`,
|
||||
a single inbox COMMON to every user, was removed on 2026-07-30: nothing may be
|
||||
common but the mechanisms that make the virtual users work.)* At migration these
|
||||
become native per-document inboxes.
|
||||
|
||||
Both resolve the native store ids from the injected session
|
||||
(`RegistrySession.protectedStoreId` / `publicStoreId`, alongside the existing
|
||||
@@ -255,77 +265,142 @@ In the target the broker only delivers documents the wallet holds a ReadCap
|
||||
for, so `useShape` already returns an authorized subset. Here (single shared
|
||||
wallet, everything readable) the lib reproduces that with a read-filtered view:
|
||||
|
||||
- **`CapRegistry` (`caps.ts`)** models ReadCaps as faithfully as a data layer
|
||||
can. The access unit is the document = repo NURI (an item's `@graph`),
|
||||
never the item — because in `nextgraph-rs` a store is just a container repo
|
||||
and holding its cap does not grant the repos it references (no store-level read
|
||||
inheritance; verified). So the registry is purely per-document:
|
||||
`grantRead(doc, granteeId)` issues a directed read grant to one identity,
|
||||
alongside `grantWrite` / `makePublic` / `open(doc, scope, owner)` /
|
||||
`canRead` / `canWrite` / `governsRead` / `hasReadPolicy`, plus the read-only
|
||||
accessor `protectedDocsOf(owner)` the consumer application uses to pick which
|
||||
protected docs to grant. The consumer application performs the *acts* of granting
|
||||
(create-public, grant a specific doc to a specific identity…) exactly as it
|
||||
will in the target; the lib injects no policy.
|
||||
- **`CapRegistry` (`caps.ts`)** models a ReadCap as what it is: **the document's
|
||||
key**. The access unit is the document = repo NURI (an item's `@graph`), never
|
||||
the item — because in `nextgraph-rs` a store is just a container repo and holding
|
||||
its cap does not grant the repos it references (no store-level read inheritance;
|
||||
verified). The registry records, **per identity**, the caps it holds — `Map<Nuri, ReadCap>`
|
||||
— and answers exactly one question: `capFor(nuri)`, *do I hold this document's
|
||||
cap?* There is deliberately **no** "may principal P read document D": that is an
|
||||
ACL question, and the real model cannot answer it either.
|
||||
- **`nuri.ts`** carries the cap-less / cap-bearing distinction, which upstream is
|
||||
one object (`NuriV0 { target, access }`) discriminated by the `:r:{cap}` segment.
|
||||
`Nuri` names, `ReadCap` names *and* reads. Both are plain strings — the real SDK
|
||||
takes `nuri: String` and enforces at runtime through cryptography, so a branded
|
||||
type would be a concept NextGraph does not have. The stand-in key value is the
|
||||
constant `OK` (see the module header): the only question the emulation answers is
|
||||
*do I hold this cap or not*, so the value says exactly that and pretends nothing
|
||||
more. P1b, not P1a, is the batch that turns the shape into a protection.
|
||||
- **`read-filter.ts`** — `makeReadFilteredView` wraps the reactive set in a
|
||||
`Proxy`: iteration / `size` / `forEach` are filtered by
|
||||
`caps.canRead(item['@graph'], user)`; everything else (`add`, `delete`, `has`,
|
||||
`getById`…) forwards to the target, preserving writes and reactivity. An item
|
||||
with no `@graph`, or in a document under no cap policy, is kept (the filter only
|
||||
restricts documents that *declare* a cap — no regression on ungoverned data).
|
||||
`filterReadable` is the pure variant.
|
||||
- **`useShape` (`use-shape.ts`)** applies the view only if
|
||||
`caps.hasReadPolicy()` — otherwise it passes the real set through unchanged
|
||||
(no regression when the consumer application declares no caps).
|
||||
`Proxy`: iteration / `size` / `forEach` keep only items whose `@graph` the
|
||||
current holder holds; everything else (`add`, `delete`, `has`, `getById`…) forwards to
|
||||
the target, preserving writes and reactivity. An item with no `@graph` is kept (it
|
||||
names no document, so there is no cap to hold). `filterReadable` is the pure
|
||||
variant. Note the absence of a `user` parameter — that absence *is* the model.
|
||||
- **`useShape` (`use-shape.ts`)** applies the view only once a cap exists at all
|
||||
(`caps.isEnforcing()`) — before that it passes the real set through unchanged (no
|
||||
regression for a consumer that never touches caps). Once ANY cap is issued the
|
||||
regime is possession for **every** holder, including one who holds nothing:
|
||||
that is the isolation.
|
||||
|
||||
In a mono-store layout (every item in one repo) this is all-or-nothing on that
|
||||
document — exactly the native behaviour, and why fine-grained isolation requires
|
||||
one document per entity (axis B).
|
||||
|
||||
### Making the ReadCap active — current identity + directed grants
|
||||
### Where caps come from — stored, never derived
|
||||
|
||||
The filter only discriminates once the consumer application (a) tells the SDK who is
|
||||
reading and (b) declares the access policy on the documents. Both are plain SDK
|
||||
calls; the consumer application never touches the registry internals:
|
||||
`doc_create` returns a **cap-less** NURI, so "no function ever goes from a bare
|
||||
reference to a cap" cannot be the whole rule — it would lock a document's own creator
|
||||
out of it. The real mechanism: creating a document commits `AddRepo { read_cap }` to
|
||||
the store's **Store branch**, separately from the `ldp:contains` listing on its Main
|
||||
branch. That is where an owner finds the caps of what it created; a cap RECEIVED for
|
||||
someone else's document goes elsewhere, on the **User branch** (`AddLink`). The wallet
|
||||
itself holds one key per user — the private store's read cap — from which the rest 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.**
|
||||
|
||||
Three ways a cap arrives, and there are no others:
|
||||
|
||||
- **Creation.** `createEntityDoc(id, scope)` writes the cap on the store's emulated
|
||||
Store branch (`shim:readCap`) and the creator holds it. The consumer declares
|
||||
nothing, and the cap is minted exactly ONCE — the stored value is the held value,
|
||||
which is what keeps this correct when P1b makes the key real.
|
||||
- **Re-listing.** `listMyEntityDocs(id, scope)` READS those records back. It does not
|
||||
recompute anything: that is the whole reason for storing them, and it is what lets
|
||||
a **fresh session** read its own documents again with nothing re-declared — the
|
||||
durability the old in-memory ACL faked and lost every reload.
|
||||
- **Delivery.** `shareCap(cap, toInbox)` deposits one document's cap into one
|
||||
recipient's inbox; `inbox.read` applies it inline, exactly as the recipient's own
|
||||
verifier applies queued messages upstream. **Receiving needs no 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.
|
||||
|
||||
**The caps a holder holds are not the sharing mechanism.** Handing over a *store* cap would give
|
||||
away everything the store contains, present and future. The unit of sharing is the
|
||||
document; the Store branch is a private index.
|
||||
|
||||
Switching identity **switches** records — it never wipes one. If it wiped,
|
||||
durability would be a lie and per-session re-declaration would come back under
|
||||
another name.
|
||||
|
||||
### Sharing, publication, and the recipient
|
||||
|
||||
- **`setCurrentUser(id)` (`polyfill.ts`)** — the SDK's "current identity" call.
|
||||
`useShape`'s filtered view reads it lazily, so the delivered subset always
|
||||
reflects the identity in effect at read time. Until it is set, the filter has no
|
||||
principal and (per `canRead(doc, null)`) only public documents pass — which is
|
||||
why isolation stays dormant until the consumer application makes this call.
|
||||
- **`getCaps().open(doc, scope, owner)`** — declares a document's policy when the
|
||||
consumer application creates it: `public` → world-readable; `protected`/`private`
|
||||
→ owner reads, owner holds the write cap. `open` also remembers `(scope, owner)`
|
||||
per document so `protectedDocsOf(owner)` can later enumerate the protected ones.
|
||||
- **`grantRead(doc, granteeId)` (`caps.ts`, exposed via `getCaps()`)** — the one
|
||||
relationship-shaped sharing act the lib exposes: a directed per-document read
|
||||
grant issued to a specific identity. Public docs stay world-readable; private
|
||||
docs stay owner-only; a protected doc becomes readable by `granteeId` once the
|
||||
owner grants it. The consumer application passes a document NURI and a grantee id
|
||||
— no store id.
|
||||
It selects *whose* caps are consulted, lazily, so the delivered subset always
|
||||
reflects the identity in effect at read time.
|
||||
- **`shareCap(cap, toInbox)`** — the one sharing act the lib exposes. Recipients
|
||||
are addressed as **inboxes**, which `inbox.post(targetInbox)` already does here;
|
||||
there is no `PrincipalId` in this surface, because that notion exists nowhere
|
||||
upstream. Reaching several recipients means calling it once per inbox, which is
|
||||
what the real model does too (each delivery is sealed to one recipient).
|
||||
- **`getCaps().publishRepoLink(doc)`** — upstream `RepoLinkV0`: a shareable link
|
||||
**whoever receives it** can open. Put the *link* in what you make discoverable, not
|
||||
the bare NURI, or no reader can open it. Publication is **not recursive**: a public
|
||||
document may reference private ones, and the reference grants nothing on what it
|
||||
references — which is what lets a public object point at a private identity without
|
||||
disclosing it.
|
||||
|
||||
The relationship concept — who is "connected" to whom, and therefore which of
|
||||
their protected docs to grant — is owned by the consumer application, not the lib.
|
||||
A connection or friendship is not a NextGraph primitive; the only platform-mappable
|
||||
primitive is the directed per-document read grant above. So the consumer application
|
||||
decides a relationship exists and, for each protected doc it wants to share, calls
|
||||
`grantRead(doc, granteeId)` — typically iterating `protectedDocsOf(owner)` to pick
|
||||
the owner's protected docs. The intended target of such a directed grant is a native
|
||||
per-document ReadCap issued to that identity — but that target is itself
|
||||
scaffolding-only in nextgraph-rs today, not merely unexposed in JS: `AccessGrantV0
|
||||
{grantee}` is unpersisted and cap-send is `unimplemented!()`, so directing a grant
|
||||
to another identity is not-yet-built at the platform level. There is no bilateral
|
||||
capability exchange to mirror, only (eventually) individual directed grants.
|
||||
Upstream, directed delivery is a **gap, not a disagreement**: `ContactDetails.read_cap`
|
||||
exists, but the message construction is `unimplemented!()`, its only caller passes
|
||||
"without read_cap", and the receiver discards the cap. The shape is right; the
|
||||
implementation is absent, so this lib emulates it meanwhile.
|
||||
|
||||
The result is the target's discrimination reproduced end-to-end: private →
|
||||
owner; protected → owner + whoever the owner has directly granted; public → all.
|
||||
Proven in `test/isolation-active.test.ts`: an unconnected principal is denied a
|
||||
protected document, granted it after the owner issues a directed `grantRead`, and
|
||||
reads the public document throughout.
|
||||
**Key rotation needs nothing on this surface.** A rotated key is re-sent to the
|
||||
inbox of whoever keeps access, and that inbox is processed automatically at the next
|
||||
connection — so access is not lost, it is *deferred*, consistent with local-first.
|
||||
Same channel as the initial delivery, so there is **no subscription obligation** to
|
||||
expose and no special case to write. Revocation stays what it is: stop re-delivering,
|
||||
non-retroactive.
|
||||
|
||||
This discrimination is only observable because each entity is its own document
|
||||
(the consumer application creates per-entity docs via `createEntityDoc` and `open`s
|
||||
each) — in a mono-store layout the per-document ReadCap is all-or-nothing.
|
||||
The relationship concept — who is "connected" to whom, and therefore whose documents
|
||||
to share — is owned by the consumer application, not the lib. A connection or
|
||||
friendship is not a NextGraph primitive; the only platform-mappable primitive is the
|
||||
per-document cap delivery above.
|
||||
|
||||
The result is the target's discrimination reproduced end-to-end: you read the
|
||||
documents whose caps you hold, and nothing else. Proven in
|
||||
`test/isolation-active.test.ts` (a document nobody shared is unreadable; a share to
|
||||
one inbox reveals it there and only there; a bare reference reads nothing while the
|
||||
repo link opens the published document; a returning identity keeps its caps) and in
|
||||
`test/watch-shape.test.ts` (e), the acceptance test below.
|
||||
|
||||
This discrimination is only observable because each entity is its own document (the
|
||||
consumer application creates per-entity docs via `createEntityDoc`) — in a mono-store
|
||||
layout the per-document ReadCap is all-or-nothing.
|
||||
|
||||
### The acceptance test — no cryptography required
|
||||
|
||||
Alice owns a protected document holding a secret and a public one that carries a
|
||||
**reference** to it. Bob, holding the public document's link, reads it, finds the
|
||||
reference, and can NAME the protected document while reading nothing of it —
|
||||
publication is **not recursive**. Charlie, holding the same link plus the protected
|
||||
document's cap (delivered to his inbox), reads through the very same reference. The
|
||||
only difference between them is what what they hold holds; nobody was named to any
|
||||
registry. And dynamically: the cap lands in Bob's inbox, his client processes it, and
|
||||
the read that was empty yields the content — the held-caps signal re-running it.
|
||||
|
||||
That is what real NextGraph does, and it holds **without a line of encryption** —
|
||||
which is what makes the P1a (shape) / P1b (enforcement) split honest rather than
|
||||
cosmetic. Proven in `test/cross-user-access.test.ts`.
|
||||
|
||||
> **After P1a the shape is right and the isolation is still fake.** The stand-in key
|
||||
> is a constant, and several read paths (`docs.sparqlQuery`/`sparqlUpdate`, the whole
|
||||
> inbox, `store-registry`, `subscribe`, `open-repo`) consult no cap at all — worse,
|
||||
> any wallet can reach any document. That is the subject of
|
||||
> [`briefs/2026-07-30-virtual-wallet-boundary.md`](./briefs/2026-07-30-virtual-wallet-boundary.md).
|
||||
> Nothing may be claimed "anonymous" or "private" until it lands.
|
||||
|
||||
### Write-guard coverage (honest scope)
|
||||
|
||||
@@ -342,16 +417,16 @@ natively at migration); the read side is what makes isolation observably active.
|
||||
### The per-document ReadCap is the isolation path (item-level filter retired)
|
||||
|
||||
Isolation is enforced by the per-document ReadCap (`caps.ts` + `read-filter.ts`)
|
||||
alone: the access unit is the document (`@graph` = repo), and grants are explicit
|
||||
(`open` / `grantRead` / `makePublic`) — for `protected`, the owner issues a directed
|
||||
`grantRead(doc, granteeId)` per identity it wants to share with. Because the consumer
|
||||
application now writes one document per entity (`createEntityDoc` + `open` per entity),
|
||||
the per-document cap discriminates at entity granularity — the target's behaviour.
|
||||
alone: the access unit is the document (`@graph` = repo), and the only acts are
|
||||
possession-shaped (`createEntityDoc` files a cap, `shareCap` delivers one,
|
||||
`publishRepoLink` emits an openable link). Because the consumer application writes
|
||||
one document per entity, the per-document cap discriminates at entity granularity —
|
||||
the target's behaviour.
|
||||
|
||||
The old item-level application-visibility filter (`isolation.ts`
|
||||
`applyIsolation`, a `Set`-of-records filter keyed on owner+scope) is retired
|
||||
from the consumer path: the application carries no access logic — it declares its
|
||||
identity and issues directed grants, and trusts the SDK. Its matrix functions are
|
||||
identity and shares caps, and trusts the SDK. Its matrix functions are
|
||||
dead scaffolding kept for reference and removed at migration. There is no longer a
|
||||
second, coexisting app-layer filter to reconcile — the single axis is the
|
||||
per-document cap, exactly as in the target.
|
||||
@@ -388,69 +463,104 @@ emulates the inbox on the shared wallet:
|
||||
polls `read` and fires when the deposit count changes (the polyfill has no
|
||||
reactive inbox subscription). Fires once immediately; returns an unsubscribe.
|
||||
|
||||
### An inbox BELONGS to a virtual user (2026-07-30)
|
||||
|
||||
`storeRegistry.walletInbox(id)` resolves — creating on first sight — the inbox
|
||||
document of one virtual user, recorded in the doc-shim under `shim:docInbox` and
|
||||
read by its own query (so an account written before this existed still resolves).
|
||||
The asymmetry that matters:
|
||||
|
||||
- **Depositing into anyone's inbox is open.** It is the ONLY way a link crosses
|
||||
from one wallet to another, and since you cannot discover, it is the bootstrap of
|
||||
the whole reachability graph. A deposit grants the depositor nothing in return —
|
||||
upstream it is an anonymous sealed box.
|
||||
- **Reading an inbox is confined to its owner** (`isOwnInbox`, enforced in `read` /
|
||||
`readSynced`, hence in `watch`). Since P1a routes ReadCaps through deposits, an
|
||||
unguarded read let anyone who knew an inbox NURI collect the caps addressed to its
|
||||
owner — defeating directed sharing. Anonymous owns no inbox and reads none.
|
||||
|
||||
At migration this guard disappears into cryptography: an inbox is sealed to its
|
||||
owner's key.
|
||||
|
||||
The module knows no domain — the consumer application supplies the inbox document
|
||||
NURI and interprets `payload`. At migration `post` becomes the native
|
||||
`inbox_post_link` (proposed/future) and the read side is served by the recipient's
|
||||
own verifier unsealing queued messages inline (see the deferred global-index note in
|
||||
the top-level README and [`decisions/discovery-model.md`](./decisions/discovery-model.md)).
|
||||
own verifier unsealing queued messages inline.
|
||||
The inbox + watcher is the one deposit/read mechanism a consumer reuses for its own
|
||||
purposes — e.g. a registration/deposit in one consumer app and submission to a
|
||||
discovery index — same `post` API, same watcher.
|
||||
purposes — a registration/deposit, a cap delivery (`shareCap`), a link handed to
|
||||
someone — same `post` API, same watcher.
|
||||
|
||||
## Emulated discovery index + special account (`discovery.ts`)
|
||||
## The virtual user boundary (`reach.ts` + `physical.ts`)
|
||||
|
||||
Discovery is a surface on top of the inbox, not a new primitive. Access is not the
|
||||
same as discovery: a public entity is world-readable *with its NURI*; the discovery
|
||||
index is how a client learns that NURI exists without holding a relationship
|
||||
to its creator (see [`decisions/discovery-model.md`](./decisions/discovery-model.md)).
|
||||
The model is: one global index = an owned document (public read), fed via
|
||||
its inbox. Nobody writes the index directly — a creator deposits a reference into
|
||||
the index's inbox, and the index is built up from those deposits. That build-up
|
||||
step is the natural dedup / moderation point.
|
||||
Every access function is confined to the user currently connected: no cross-user
|
||||
access, so the consumer is coded against a reach that will actually exist.
|
||||
**Two rules, one criterion — possession — implemented in two places**, deliberately
|
||||
redundant so a lapse in either is caught by the other:
|
||||
|
||||
- **The special account (polyfill owner).** "Who owns the global index" is
|
||||
undecided in the target (NextGraph is mono-user with no global data — a
|
||||
singleton app is the only glimpsed path). So the polyfill parks ownership on a
|
||||
reserved special account in the shim — `INDEX_ACCOUNT = reservedAccount("index")`.
|
||||
This is NOT the key `"index"` / `"@index"`: `reservedAccount` mints a
|
||||
sentinel-prefixed key in the shim's reserved namespace (e.g. `" reserved:index"`)
|
||||
that `normalizeId` can never produce, so no user id — not even one typed as
|
||||
"index" or "@index", which normalizes to the disjoint key "index" — can collide
|
||||
with or hijack the index account (asserted in `discovery.test.ts`). It is a
|
||||
normal shim account (so its 3 scope documents are created on first sight like
|
||||
any other), but never a real user; it only hosts the index document. Its
|
||||
`public` scope document is the index document, and its inbox receives the
|
||||
deposits — a stable NURI: every client opening the same shared wallet
|
||||
resolves the same account, hence the same document, so all clients read/write one
|
||||
shared index.
|
||||
- **`submitToIndex(ref, opts?)`** — the SDK act "make this discoverable".
|
||||
Deposits `ref` into the index document's inbox via `inbox.post`. `from` follows
|
||||
the inbox convention (bound to the current identity; anonymous when `null`).
|
||||
`ref` is opaque here — the consumer application serializes whatever locates the
|
||||
entity (e.g. an entity document NURI + discovery metadata). Public-only guard: when
|
||||
`opts.doc` names the document being surfaced, a document under a non-public
|
||||
(protected/private) read policy is refused (`caps.governsRead(doc) &&
|
||||
!caps.canRead(doc, null)`) — the global index is world-readable, so admitting a
|
||||
governed doc's NURI would leak it past its scope. Proven in
|
||||
`test/discovery.test.ts` case (d).
|
||||
- **`readIndex()`** — the emulated read side. Reads every submission, dedups by
|
||||
serialized `ref` (the moderation point: a duplicate submission surfaces
|
||||
once), returns entries sorted by `ts`. `watchIndex(onEntries, opts?)` is the
|
||||
emulated watcher (polls `readIndex`).
|
||||
- **Rule 1, authorization** (`assertMayReach`, at the passage points `docs.sparqlQuery`
|
||||
/ `sparqlUpdate` / `subscribeDoc`): nothing reaches `ng` unless the connected user
|
||||
possesses that document's cap. It fires on a request that should never have been
|
||||
made, and makes it fail loudly rather than succeed quietly.
|
||||
- **Rule 2, do not even attempt** (`mustNotAttempt`, at the callers — `readUnion`
|
||||
filters before opening or reading, `ensureRepoOpen` returns): a reader holding no
|
||||
cap does not issue the operation at all. Upstream you cannot even *address* a repo
|
||||
you have no cap for, so asking is not "a read that will be refused" — it is a read
|
||||
with no meaning.
|
||||
|
||||
This replaces the cross-account fan-out (`store-registry.ts`
|
||||
`listEntityDocs('public')` / `resolveReadGraphs`) as the app-facing discovery
|
||||
path: the consumer application submits public entities to the index and reads the
|
||||
index, instead of fanning out over every account's public documents. The fan-out
|
||||
survives only as an internal lib fallback — kept for the per-scope listing it also
|
||||
powers (e.g. `resolveReadGraphs`), never the app's discovery route.
|
||||
**Possession decides, never the shape of the reference in hand.** A caller
|
||||
legitimately holds a bare NURI while possessing its cap elsewhere — references travel
|
||||
bare through content and stores, the cap sits in what the user holds.
|
||||
|
||||
`discovery.ts` knows no application domain — the consumer application defines the
|
||||
`ref` shape and its meaning. At migration the special account disappears:
|
||||
ownership moves to the decided global-index owner, `submitToIndex` becomes the
|
||||
native `inbox_post_link` (proposed/future) on the index's inbox, and `readIndex`
|
||||
queries the real index document. The consumer surface (`submitToIndex` / `readIndex`)
|
||||
is designed to survive that swap unchanged.
|
||||
The exception is **depositing** into another user's inbox (`docs.depositInto`): a
|
||||
named primitive rather than a flag, because it is a different act — you hold no cap,
|
||||
you cannot read back, and you get nothing in return. It is the only channel by which
|
||||
a link crosses between users, hence the bootstrap of the whole reachability graph.
|
||||
|
||||
The machinery lives in `physical.ts` (see *Physical user vs virtual user* above):
|
||||
unguarded primitives, never exported from the package, used only for the index of
|
||||
virtual users. Separating the FUNCTIONS is what replaced an earlier exemption list —
|
||||
the machinery does not get waved through the guard, it calls something the guard
|
||||
never saw.
|
||||
|
||||
## Connecting a user (`connect.ts`)
|
||||
|
||||
Processing inboxes is the **library's** job, not the app's: a consumer must never
|
||||
have to remember to drain a queue for documents shared with it to become readable —
|
||||
forgetting would look like "the share did not work" rather than "nobody consumed the
|
||||
queue". So `setCurrentUser` fires `connectedUser()`, which does two things in order:
|
||||
|
||||
1. **Restore** — read back the caps this user already applied (`readLinks`, the
|
||||
emulated `AddLink` records on its User branch) into what it holds. Durable state,
|
||||
one read, no inbox involved.
|
||||
2. **Drain** — process every inbox it may read (`myInboxes`: its own, plus one per
|
||||
document it opened an inbox on), filing any new Link durably.
|
||||
|
||||
Restore-first is what lets a reconnecting user read its shared documents immediately
|
||||
instead of waiting on a queue round-trip.
|
||||
|
||||
**Fire-and-forget, deliberately.** The setter is synchronous and every consumer calls
|
||||
it from synchronous code; making it async would push the wait back onto the app,
|
||||
which is the obligation this removes. The work announces itself through
|
||||
`CapRegistry.onChange` — which `watchShape` already listens to — so a view that was
|
||||
empty for want of a cap re-reads when the cap lands. `connectedUser()` is exported
|
||||
for a caller that needs to await it (tests, a deterministic startup).
|
||||
|
||||
**It does not provision.** Connecting an identity that does not exist creates
|
||||
nothing (`resolveAccount`, not `ensureAccount`): otherwise connecting would mint a
|
||||
user's stores and their caps as a background side effect, arming the whole emulation
|
||||
at a moment nothing controls.
|
||||
|
||||
*Cost worth knowing*: `setCurrentUser` therefore has observable asynchronous effects
|
||||
— it reads, and it logs. Tests asserting on log output must await `connectedUser()`
|
||||
first.
|
||||
|
||||
## ~~Emulated discovery index + special account~~ — REMOVED 2026-07-30
|
||||
|
||||
**There is no discovery in NextGraph. You cannot discover; you can only follow links** (see [`readcap-and-nuri-model.md`](./readcap-and-nuri-model.md) §4ter-bis). Publishing is two acts — place the data in your public store, **and** circulate its link (into an inbox, or into a document the reader already holds) — and it is seen only by those who received the link.
|
||||
|
||||
`discovery.ts` (a global index owned by a reserved `@index` account, `submitToIndex` / `readIndex` / `watchIndex`), its tests, and `watchShape`'s public-scope fold were **removed**. The module failed on two independent counts: it emulated a capability the target will never have — teaching consumers a model that does not exist — and it was **data common to several wallets**, where nothing may be common but the indexing mechanisms that make the virtual users work.
|
||||
|
||||
The ADR that specified it ([`decisions/discovery-model.md`](./decisions/discovery-model.md)) is marked superseded, and keeps the part that survives: the `discovery → synchronization → query` frame still holds, with stage 1 re-read as *"a link reached you"* rather than *"you consulted an index"*. Which makes the **inbox** the bootstrap of the whole reachability graph — see [`briefs/2026-07-30-virtual-wallet-boundary.md`](./briefs/2026-07-30-virtual-wallet-boundary.md).
|
||||
|
||||
## Emulated write guard (`ng-proxy.ts`)
|
||||
|
||||
|
||||
@@ -6,7 +6,14 @@ separate:
|
||||
| Import | Surface |
|
||||
|---|---|
|
||||
| `@ng-eventually/client` | The same signature as the SDK — `ng`, `useShape`, `inbox` (+ types). A drop-in for `@ng-org/web` / `@ng-org/orm`; as NextGraph matures it resolves to the real SDK (build alias removed) with no code change. |
|
||||
| `@ng-eventually/client/polyfill` | The only non-SDK surface — `configure`, `setCurrentUser`, and capability helpers (`getCaps`, `grantRead`, `canRead`/`canWrite`). It falls away as NextGraph matures. |
|
||||
| `@ng-eventually/client/polyfill` | The only non-SDK surface — `configure`, `setCurrentUser`, and the capability surface (`capFor`, `shareCap`, `getCaps`). It falls away as NextGraph matures. |
|
||||
|
||||
> **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 several read paths bypass the guard entirely. Nothing this
|
||||
> library does may be described as "anonymous" or "private" until per-document
|
||||
> encryption lands (P1b).
|
||||
|
||||
```ts
|
||||
// bootstrap (the only non-SDK call) — inject the real SDK
|
||||
@@ -36,13 +43,72 @@ What the polyfill adds on top of the real SDK (each emulated for now, native as
|
||||
NextGraph matures):
|
||||
- Shared-wallet identity (one wallet for everyone; the current identity id is
|
||||
relayed to the SDK).
|
||||
- Capability enforcement — a read filter + write guard over emulated grants
|
||||
attached to documents; the app declares a document's read policy and issues
|
||||
directed read grants.
|
||||
- Anticipated methods (inbox `post`, capability ops) with their future-SDK shapes,
|
||||
- Capability emulation — per-identity **cap possession** (`capFor`) and a read filter
|
||||
over it: you read the documents whose cap you hold. Creating a document files its
|
||||
cap; receiving one is an inbox deposit. There is no authorization list.
|
||||
- Anticipated methods (inbox `post`, `shareCap`) with their future-SDK shapes,
|
||||
emulated for now.
|
||||
|
||||
Generic: no application domain. The consumer application injects its shapes and
|
||||
performs the acts of granting access. The relationship concept ("who is connected
|
||||
to whom") is the consumer application's own — the client exposes only directed
|
||||
per-document read grants.
|
||||
performs the acts of sharing. The relationship concept ("who is connected to whom")
|
||||
is the consumer application's own — the client exposes only "share this one
|
||||
document's cap to that inbox".
|
||||
|
||||
### The cap surface in three calls
|
||||
|
||||
```ts
|
||||
import { capFor, shareCap, getCaps } from "@ng-eventually/client/polyfill";
|
||||
import { storeRegistry } from "@ng-eventually/client";
|
||||
|
||||
// Creating a document records its cap and you hold it — nothing to declare.
|
||||
const doc = await storeRegistry.createEntityDoc(myId, "protected");
|
||||
capFor(doc); // → `${doc}:r:…` — you hold it
|
||||
|
||||
// Share it with one recipient, addressed by their inbox. They need no "receive"
|
||||
// operation: their existing inbox.watch absorbs it.
|
||||
await shareCap(capFor(doc)!, theirInbox);
|
||||
|
||||
// Publishing is TWO acts: place the data in your public store, and circulate its
|
||||
// LINK. There is no discovery — you cannot be found, you can only be reached — so
|
||||
// the link has to travel: into an inbox, or into a document the reader already
|
||||
// holds. The bare NURI would name the document without opening it.
|
||||
const link = getCaps().publishRepoLink(publicDoc);
|
||||
await shareCap(link, theirInbox);
|
||||
```
|
||||
|
||||
The one invariant to keep in mind: **you never derive a cap from a bare reference.**
|
||||
You look it up in what you hold, or you were given it. A `did:ng:o:…` without `:r:`
|
||||
names a document and grants nothing.
|
||||
|
||||
### 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:
|
||||
|
||||
```ts
|
||||
await shareCap(doc, theirInbox); // ✗ Argument of type '`did:ng:${string}`' is not
|
||||
// assignable to '`did:ng:${string}:r:${string}`'
|
||||
```
|
||||
|
||||
A string that comes from outside your code — storage, a URL, JSON, a form — is a
|
||||
plain `string`. **Narrow it, do not cast it**: a cast re-opens exactly the confusion
|
||||
the types close.
|
||||
|
||||
```ts
|
||||
import { isNuri, hasReadCap } from "@ng-eventually/client";
|
||||
|
||||
const saved = localStorage.getItem("cap");
|
||||
if (saved && hasReadCap(saved)) await shareCap(saved, theirInbox); // ✓ narrowed
|
||||
```
|
||||
|
||||
The runtime guards remain regardless — a JavaScript caller never meets the compiler,
|
||||
and a cast bypasses it — so passing a bare reference where a cap belongs throws with
|
||||
a message that says so.
|
||||
|
||||
@@ -202,11 +202,18 @@ Data is isolated **per document (repo)**, and each document lives in a **scope**
|
||||
| Scope | Read | Write |
|
||||
|---|---|---|
|
||||
| **Private** | Owner only | Owner only |
|
||||
| **Protected** | Owner + explicit grant holders | Owner + permissioned collaborators |
|
||||
| **Public** | Everyone (no capability needed) | **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**
|
||||
@@ -263,10 +270,9 @@ from the reactive contract:
|
||||
for a **single already-opened document**; it is the per-entity **fan-out** that is
|
||||
unfit today.
|
||||
|
||||
2. **Inbox and discovery index use polling watchers.** The inbox is emulated
|
||||
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/inbox.ts`](../src/inbox.ts)) and
|
||||
`discovery.watchIndex` ([`../src/discovery.ts`](../src/discovery.ts)) **poll** via
|
||||
deposit), so `inbox.watch` ([`../src/inbox.ts`](../src/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 (`inbox_post_link`) lands.
|
||||
|
||||
+23
-36
@@ -251,33 +251,6 @@ async function main(): Promise<void> {
|
||||
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}`);
|
||||
});
|
||||
|
||||
// ── discovery index ─────────────────────────────────────────────────────
|
||||
console.log("\n── discovery index ──");
|
||||
await step("discovery submit → read", async () => {
|
||||
const ref = { doc: "did:ng:o:some-public-doc", title: "t" };
|
||||
const r = await sdk<any>(frame, "discoverySubmitRead", ref);
|
||||
const refs = (r.entries || []).map((e: any) => JSON.stringify(e.ref));
|
||||
check("submitToIndex then readIndex returns the entry", refs.includes(JSON.stringify(ref)), `entries=${r.entries.length}`);
|
||||
});
|
||||
await step("discovery watchIndex fires reactively", async () => {
|
||||
await sdk(frame, "discoveryWatchStart");
|
||||
await frame.waitForFunction(() => (window as any).__sdk.discoveryWatchState().fires >= 1, { timeout: 20000 });
|
||||
const base = await sdkGet<any>(frame, "discoveryWatchState");
|
||||
await sdk(frame, "discoverySubmit", { doc: "did:ng:o:doc2", title: "t2", n: Date.now() });
|
||||
await frame.waitForFunction(
|
||||
(b) => (window as any).__sdk.discoveryWatchState().fires > (b as number),
|
||||
base.fires,
|
||||
{ timeout: 20000 },
|
||||
);
|
||||
const after = await sdkGet<any>(frame, "discoveryWatchState");
|
||||
check("watchIndex fires on a new submission", after.fires > base.fires, `fires=${after.fires}`);
|
||||
await sdk(frame, "discoveryWatchStop");
|
||||
});
|
||||
await step("reserved @index account isolation", async () => {
|
||||
const r = await sdk<any>(frame, "discoveryIndexIsolation");
|
||||
check("user '@index' resolves disjoint from the reserved index owner", r.disjoint === true, `disjoint=${r.disjoint}`);
|
||||
});
|
||||
|
||||
// ── store-registry ──────────────────────────────────────────────────────
|
||||
console.log("\n── store-registry ──");
|
||||
await step("ensureAccount idempotent", async () => {
|
||||
@@ -337,17 +310,31 @@ async function main(): Promise<void> {
|
||||
|
||||
// ── caps / read-filter (in-memory cap model) ────────────────────────────
|
||||
console.log("\n── caps / read-filter (in-memory cap model) ──");
|
||||
await step("read-filter: protected hidden from stranger", async () => {
|
||||
await step("read-filter: you read what your keyring holds, nothing else", async () => {
|
||||
const r = await sdk<any>(frame, "capsReadFilter");
|
||||
const ownerSeesProt = r.ownerView.includes("protected-item");
|
||||
const strangerHiddenProt = !r.strangerView.includes("protected-item");
|
||||
const bothSeePublic = r.ownerView.includes("public-item") && r.strangerView.includes("public-item");
|
||||
const bothSeeUngoverned = r.ownerView.includes("ungoverned-item") && r.strangerView.includes("ungoverned-item");
|
||||
check("owner reads protected; stranger does not; public+ungoverned visible to both", ownerSeesProt && strangerHiddenProt && bothSeePublic && bothSeeUngoverned, `owner=${JSON.stringify(r.ownerView)} stranger=${JSON.stringify(r.strangerView)}`);
|
||||
// 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(
|
||||
"owner reads held docs only; stranger reads nothing; the repo link opens the published one",
|
||||
ownerReadsHeld && ownerMissesUnheld && strangerReadsNothing && linkOpensPublic,
|
||||
`owner=${JSON.stringify(r.ownerView)} stranger=${JSON.stringify(r.strangerView)} withLink=${JSON.stringify(r.strangerWithLinkView)}`,
|
||||
);
|
||||
});
|
||||
await step("read-filter: directed grant reveals the doc", async () => {
|
||||
const r = await sdk<any>(frame, "capsDirectedGrant");
|
||||
check("grantRead reveals the protected doc to the grantee", r.before === 0 && r.after === 1, `before=${r.before} after=${r.after}`);
|
||||
await step("shareCap: a cap delivered to an inbox reveals the doc", async () => {
|
||||
const r = await sdk<any>(frame, "capsShareCap");
|
||||
check(
|
||||
"shareCap → 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) ────────────────────────────────────────────
|
||||
|
||||
@@ -15,23 +15,48 @@
|
||||
*/
|
||||
|
||||
import { ng as realNg, init as realInit } from "@ng-org/web";
|
||||
import { configure, configureStoreRegistry, setCurrentUser, getCaps, resetCaps } from "@ng-eventually/client/polyfill";
|
||||
import {
|
||||
configure,
|
||||
configureStoreRegistry,
|
||||
setCurrentUser,
|
||||
capFor,
|
||||
getCaps,
|
||||
resetCaps,
|
||||
shareCap,
|
||||
} from "@ng-eventually/client/polyfill";
|
||||
import {
|
||||
docs,
|
||||
subscribeDoc,
|
||||
subscribeDocs,
|
||||
readModel,
|
||||
inbox,
|
||||
discovery,
|
||||
storeRegistry,
|
||||
useShape as libUseShape,
|
||||
watchShape,
|
||||
accounts,
|
||||
} from "@ng-eventually/client";
|
||||
import type { ShapeObservable, ShapeQuery } from "@ng-eventually/client";
|
||||
import { isNuri } from "@ng-eventually/client";
|
||||
import type { Nuri, ShapeObservable, ShapeQuery } from "@ng-eventually/client";
|
||||
|
||||
const { IdentityStore } = accounts;
|
||||
|
||||
/**
|
||||
* The Playwright boundary. Every NURI reaching this harness crosses the bridge as
|
||||
* a plain `string` (Playwright serializes arguments), so it arrives untyped even
|
||||
* though the library's `Nuri` is a template literal type. Narrow it here, loudly:
|
||||
* a test that passes something which is not a NextGraph reference should fail with
|
||||
* that message, not with a confusing downstream error. Never cast — a cast would
|
||||
* re-open exactly the confusion the types exist to close.
|
||||
*/
|
||||
function asNuri(s: string): Nuri {
|
||||
if (!isNuri(s)) throw new Error(`[e2e] not a NextGraph reference: ${JSON.stringify(s)}`);
|
||||
return s;
|
||||
}
|
||||
/** Same, for an optional anchor. */
|
||||
function asAnchor(s?: string): Nuri | undefined {
|
||||
return s === undefined ? undefined : asNuri(s);
|
||||
}
|
||||
|
||||
// ── The broker session, resolved once the iframe connects ──────────────────
|
||||
interface BrokerSession {
|
||||
session_id: string;
|
||||
@@ -69,7 +94,7 @@ configure({
|
||||
});
|
||||
|
||||
configureStoreRegistry({
|
||||
// The registry (+ subscribe/inbox/discovery/read-model) reach the session
|
||||
// The registry (+ subscribe/inbox/read-model) reach the session
|
||||
// through this. It resolves once the broker connects.
|
||||
getSession: async () => {
|
||||
// Read the CURRENT session (mutable): a fresh session (session_stop+session_start
|
||||
@@ -144,11 +169,11 @@ const identity = new IdentityStore(
|
||||
},
|
||||
async sparqlUpdate(query: string, anchor?: string) {
|
||||
const s = await sessionReady;
|
||||
return docs.sparqlUpdate(s.session_id, query, anchor);
|
||||
return docs.sparqlUpdate(s.session_id, query, asAnchor(anchor));
|
||||
},
|
||||
async sparqlQuery(query: string, anchor?: string) {
|
||||
const s = await sessionReady;
|
||||
return docs.sparqlQuery(s.session_id, query, undefined, anchor);
|
||||
return docs.sparqlQuery(s.session_id, query, undefined, asAnchor(anchor));
|
||||
},
|
||||
/**
|
||||
* The load-bearing graph-behavior characterization against the REAL broker.
|
||||
@@ -252,7 +277,7 @@ const identity = new IdentityStore(
|
||||
*/
|
||||
async readUnionOverDocs(n: number, includeBad: boolean) {
|
||||
const s = await sessionReady;
|
||||
const docNuris: string[] = [];
|
||||
const docNuris: Nuri[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const d = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
|
||||
await docs.sparqlUpdate(
|
||||
@@ -262,20 +287,22 @@ const identity = new IdentityStore(
|
||||
);
|
||||
docNuris.push(d);
|
||||
}
|
||||
const toRead = includeBad ? [...docNuris, "did:ng:o:definitely-not-a-real-doc-xyz"] : docNuris;
|
||||
const toRead: Nuri[] = includeBad ? [...docNuris, "did:ng:o:definitely-not-a-real-doc-xyz"] : docNuris;
|
||||
const subjects = await readModel.readUnion(toRead);
|
||||
return { docNuris, subjectCount: subjects.length, subjects };
|
||||
},
|
||||
/**
|
||||
* readUnion cap gate: create a doc, mark it protected for owner O, set the
|
||||
* current user to a DIFFERENT identity, and readUnion → the doc is dropped.
|
||||
* readUnion possession gate: create a doc as owner O (whose keyring gets its
|
||||
* cap), then read it as a DIFFERENT identity, which holds nothing → dropped.
|
||||
* The stranger has the document's NURI in hand throughout: naming is not reading.
|
||||
*/
|
||||
async readUnionCapGate() {
|
||||
const s = await sessionReady;
|
||||
resetCaps();
|
||||
const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
|
||||
await docs.sparqlUpdate(s.session_id, `INSERT DATA { <urn:e2e:cg> <urn:e2e:p> "x" }`, doc);
|
||||
getCaps().open(doc, "protected", "owner-O");
|
||||
setCurrentUser("owner-O");
|
||||
getCaps().open(doc, "protected");
|
||||
setCurrentUser("someone-else");
|
||||
const asStranger = await readModel.readUnion([doc]);
|
||||
setCurrentUser("owner-O");
|
||||
@@ -308,7 +335,7 @@ const identity = new IdentityStore(
|
||||
await docs.sparqlUpdate(
|
||||
s.session_id,
|
||||
`INSERT DATA { <urn:e2e:sub:${marker}> <urn:e2e:m> "${marker}" }`,
|
||||
doc,
|
||||
asNuri(doc),
|
||||
);
|
||||
},
|
||||
subscribeStop(handle: string) {
|
||||
@@ -396,47 +423,6 @@ const identity = new IdentityStore(
|
||||
return { spoofRejected: threw, selfOk, anonOk };
|
||||
},
|
||||
|
||||
// ── discovery index ──────────────────────────────────────────────────────
|
||||
async discoverySubmitRead(ref: unknown) {
|
||||
setCurrentUser("publisher");
|
||||
await discovery.submitToIndex(ref);
|
||||
setCurrentUser(null);
|
||||
const entries = await discovery.readIndex();
|
||||
return { entries };
|
||||
},
|
||||
_discWatch: { fires: 0, lastLen: -1, unsub: () => {} },
|
||||
discoveryWatchStart() {
|
||||
const rec = { fires: 0, lastLen: -1, unsub: () => {} };
|
||||
(window as any).__sdk._discWatch = rec;
|
||||
rec.unsub = discovery.watchIndex((entries) => {
|
||||
rec.fires += 1;
|
||||
rec.lastLen = entries.length;
|
||||
});
|
||||
},
|
||||
async discoverySubmit(ref: unknown) {
|
||||
setCurrentUser("publisher2");
|
||||
await discovery.submitToIndex(ref);
|
||||
setCurrentUser(null);
|
||||
},
|
||||
discoveryWatchState() {
|
||||
const r = (window as any).__sdk._discWatch;
|
||||
return { fires: r.fires, lastLen: r.lastLen };
|
||||
},
|
||||
discoveryWatchStop() {
|
||||
(window as any).__sdk._discWatch.unsub();
|
||||
},
|
||||
// reserved @index account isolation: a real user named "index"/"@index" resolves
|
||||
// to a DIFFERENT account than the reserved index owner.
|
||||
async discoveryIndexIsolation() {
|
||||
const userIndex = await storeRegistry.ensureAccount("@index");
|
||||
const reserved = await storeRegistry.ensureAccount(discovery.INDEX_ACCOUNT);
|
||||
return {
|
||||
userIndexDoc: userIndex.docPublic,
|
||||
reservedDoc: reserved.docPublic,
|
||||
disjoint: userIndex.docPublic !== reserved.docPublic,
|
||||
};
|
||||
},
|
||||
|
||||
// ── store-registry ───────────────────────────────────────────────────────
|
||||
async ensureAccountIdempotent(id: string) {
|
||||
storeRegistry.resetRegistryCache();
|
||||
@@ -510,7 +496,7 @@ const identity = new IdentityStore(
|
||||
/**
|
||||
* RECONNECTION read (phase 2, run in a FRESH session over the SAME wallet). First a
|
||||
* DIAGNOSTIC raw anchored read with NO open (rawRowCount), then re-resolve the
|
||||
* account's entity docs of `scope` (listMyEntityDocs → readScopeIndex) and readUnion
|
||||
* account's entity docs of `scope` (listMyEntityDocs → readUserStore) and readUnion
|
||||
* them, purely from the persistent wallet — nothing from phase 1's session state
|
||||
* carries over. The SDK's open-before-read heal (open-repo.ts) opens each repo via
|
||||
* doc_subscribe before the anchored reads. NB: on the SDK/broker version tested here
|
||||
@@ -526,7 +512,7 @@ const identity = new IdentityStore(
|
||||
const s = session ?? (await sessionReady);
|
||||
let rawRowCount = -1;
|
||||
try {
|
||||
const raw: any = await docs.sparqlQuery(s.session_id, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", undefined, entityNuri);
|
||||
const raw: any = await docs.sparqlQuery(s.session_id, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", undefined, asNuri(entityNuri));
|
||||
rawRowCount = Array.isArray(raw) ? raw.length : (raw?.results?.bindings?.length ?? 0);
|
||||
} catch (e: any) {
|
||||
rawRowCount = -2; // threw (e.g. RepoNotFound / InvalidNuri)
|
||||
@@ -534,7 +520,7 @@ const identity = new IdentityStore(
|
||||
|
||||
storeRegistry.resetRegistryCache();
|
||||
const listed = await storeRegistry.listMyEntityDocs(id, scope);
|
||||
const subjects = await readModel.readUnion(listed.length ? listed : [entityNuri]);
|
||||
const subjects = await readModel.readUnion(listed.length ? listed : [asNuri(entityNuri)]);
|
||||
const markers: string[] = [];
|
||||
for (const subj of subjects) {
|
||||
for (const vals of Object.values(subj.props)) {
|
||||
@@ -545,7 +531,7 @@ const identity = new IdentityStore(
|
||||
rawRowCount,
|
||||
listed,
|
||||
listedCount: listed.length,
|
||||
foundEntity: listed.includes(entityNuri),
|
||||
foundEntity: listed.includes(asNuri(entityNuri)),
|
||||
subjectCount: subjects.length,
|
||||
markerPresent: markers.includes(marker),
|
||||
markers,
|
||||
@@ -588,7 +574,7 @@ const identity = new IdentityStore(
|
||||
anchor +
|
||||
"> { ?acc a <urn:ng-eventually:shim:Account> } }";
|
||||
try {
|
||||
const res: any = await docs.sparqlQuery(s.session_id, query, undefined, anchor);
|
||||
const res: any = await docs.sparqlQuery(s.session_id, query, undefined, asNuri(anchor));
|
||||
const rows = Array.isArray(res) ? res.length : (res?.results?.bindings?.length ?? 0);
|
||||
return { threw: false, error: null, rows, anchor };
|
||||
} catch (e: any) {
|
||||
@@ -754,39 +740,63 @@ const identity = new IdentityStore(
|
||||
// The read-filter over the injected useShape Set-like. Boundary note: the
|
||||
// caps/read-filter are EMULATED in-memory (CapRegistry) — the real broker does
|
||||
// NOT yet enforce per-doc read caps here (one shared wallet reads everything).
|
||||
// We test what the SDK enforces: the in-memory read-filtered VIEW.
|
||||
// We test what the SDK enforces: the in-memory read-filtered VIEW, which after
|
||||
// P1a is KEY POSSESSION — you read what your keyring holds, nothing else.
|
||||
capsReadFilter() {
|
||||
resetCaps();
|
||||
injectedSetItems = [
|
||||
{ "@graph": "did:ng:o:protdoc", "@id": "1", v: "protected-item" },
|
||||
{ "@graph": "did:ng:o:pubdoc", "@id": "2", v: "public-item" },
|
||||
{ "@graph": "did:ng:o:ungoverned", "@id": "3", v: "ungoverned-item" },
|
||||
{ "@graph": "did:ng:o:unheld", "@id": "3", v: "unheld-item" },
|
||||
];
|
||||
getCaps().open("did:ng:o:protdoc", "protected", "owner-O");
|
||||
getCaps().makePublic("did:ng:o:pubdoc");
|
||||
// as owner-O
|
||||
setCurrentUser("owner-O");
|
||||
getCaps().open("did:ng:o:protdoc", "protected");
|
||||
const link = getCaps().publishRepoLink("did:ng:o:pubdoc");
|
||||
const ownerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
|
||||
// as a stranger
|
||||
// A stranger holds nothing — including the PUBLISHED document, until the repo
|
||||
// link reaches them (§5: whoever has the URL reads the content).
|
||||
setCurrentUser("stranger");
|
||||
const strangerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
|
||||
getCaps().learn(link);
|
||||
const strangerWithLinkView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
|
||||
resetCaps();
|
||||
injectedSetItems = [];
|
||||
setCurrentUser(null);
|
||||
return { ownerView, strangerView };
|
||||
return { ownerView, strangerView, strangerWithLinkView };
|
||||
},
|
||||
capsDirectedGrant() {
|
||||
/**
|
||||
* Sharing a cap the way the model does it: the owner deposits it into the
|
||||
* recipient's INBOX, and the recipient processing that inbox absorbs it. No
|
||||
* "receive" operation exists, and no principal is ever named to the registry.
|
||||
* Runs against the REAL broker inbox document, so it exercises the whole path.
|
||||
*/
|
||||
async capsShareCap() {
|
||||
const s = await sessionReady;
|
||||
resetCaps();
|
||||
injectedSetItems = [{ "@graph": "did:ng:o:sharedoc", "@id": "1", v: "shared-item" }];
|
||||
getCaps().open("did:ng:o:sharedoc", "protected", "owner-O");
|
||||
const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
|
||||
const friendInbox = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
|
||||
injectedSetItems = [{ "@graph": doc, "@id": "1", v: "shared-item" }];
|
||||
|
||||
setCurrentUser("owner-O");
|
||||
getCaps().open(doc, "protected");
|
||||
const cap = capFor(doc)!;
|
||||
|
||||
setCurrentUser("friend");
|
||||
const before = [...(libUseShape(null, null) as Iterable<any>)].length;
|
||||
getCaps().grantRead("did:ng:o:sharedoc", "friend");
|
||||
|
||||
setCurrentUser("owner-O");
|
||||
await shareCap(cap, friendInbox);
|
||||
|
||||
setCurrentUser("friend");
|
||||
const absorbed = await inbox.read(friendInbox); // processing it applies the cap
|
||||
const after = [...(libUseShape(null, null) as Iterable<any>)].length;
|
||||
|
||||
resetCaps();
|
||||
injectedSetItems = [];
|
||||
setCurrentUser(null);
|
||||
return { before, after };
|
||||
// `absorbed` must be EMPTY: a cap delivery is infrastructure, never surfaced
|
||||
// to the consumer as a deposit.
|
||||
return { before, after, surfacedDeposits: absorbed.length };
|
||||
},
|
||||
|
||||
// ── accounts (IdentityStore) ─────────────────────────────────────────────
|
||||
@@ -849,7 +859,7 @@ const identity = new IdentityStore(
|
||||
unsub: () => {},
|
||||
};
|
||||
(window as any).__sdk._stateProbe = probe;
|
||||
probe.unsub = subscribeDoc(doc, (resp: any) => {
|
||||
probe.unsub = subscribeDoc(asNuri(doc), (resp: any) => {
|
||||
const elapsedMs = Date.now() - probe.startMs;
|
||||
// AppResponse shape: { V0: { State: … } } | { V0: { Patch: … } } | { V0: { TabInfo: … } } | …
|
||||
let typeKey = "unknown";
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* 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 wallet
|
||||
* 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.
|
||||
*
|
||||
@@ -54,7 +54,7 @@ export function enabled(): boolean {
|
||||
/**
|
||||
* 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 wallet id
|
||||
* 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,
|
||||
|
||||
+230
-101
@@ -1,117 +1,249 @@
|
||||
/**
|
||||
* Capability emulation — generic, with no domain rules. It models NextGraph
|
||||
* ReadCaps (and write caps) as a data layer can.
|
||||
* Capability emulation — key POSSESSION, not an authorization list.
|
||||
*
|
||||
* In NextGraph a ReadCap is possession of a document's (repo's) read key: the
|
||||
* broker only delivers documents the wallet holds a cap for. The access unit is
|
||||
* therefore the document = repo, identified here by its NURI — the `@graph` an
|
||||
* item lives in, rather than the item. (A store is just a container repo, and
|
||||
* holding a store's cap does not grant the repos it references — each document
|
||||
* carries its own cap — so this registry is purely per-document, with no
|
||||
* store-level inheritance.)
|
||||
* 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.
|
||||
*
|
||||
* Sharing here is DIRECTED: a grant issues one grantee the read cap of one
|
||||
* document (`grantRead(doc, granteeId)`). Whether two identities are "connected"
|
||||
* — and therefore whether such a grant should be issued — is an application
|
||||
* concept the consumer owns; this layer only records the resulting per-document
|
||||
* grants. At migration this whole layer disappears: the broker/verifier enforces
|
||||
* the real caps and `useShape` returns only authorized documents.
|
||||
* ── 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` (`fileOwnCaps` for created documents, `addLink` /
|
||||
* `readLinks` for received ones), and `connect.ts` restores from them.
|
||||
*
|
||||
* 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.shareCap(cap, toInbox)` — 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.
|
||||
*
|
||||
* ── 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 type { Nuri, PrincipalId, Scope } from "./types";
|
||||
import { hasReadCap, mintCap, targetOf } from "./nuri";
|
||||
import type { Nuri, PrincipalId, ReadCap, Scope } from "./types";
|
||||
|
||||
/** The map key of the anonymous holder (no identity established yet). */
|
||||
const ANONYMOUS = "";
|
||||
|
||||
/**
|
||||
* Who holds the read/write cap of each document. The consumer populates it via
|
||||
* cap operations (make-public, directed grant…) exactly as it will in the
|
||||
* target; this layer enforces possession generically, with no policy of its own.
|
||||
*/
|
||||
export class CapRegistry {
|
||||
/** doc NURI → principals holding its READ cap. */
|
||||
private readers = new Map<Nuri, Set<PrincipalId>>();
|
||||
/** doc NURI → principals holding its WRITE cap. */
|
||||
/** holder → the caps they hold, indexed by the cap-less NURI. */
|
||||
private heldByHolder = new Map<string, Map<Nuri, ReadCap>>();
|
||||
/**
|
||||
* Documents published as a shareable repo link (`RepoLinkV0`) — the emulated
|
||||
* public store. This is NOT a read grant: a published document is read by
|
||||
* whoever HOLDS the link, exactly like §5 of the brief says ("whoever has the
|
||||
* URL reads the content"), and holding it means having received it. The set
|
||||
* exists so the library can refuse to surface a document its holder never
|
||||
* published (see `discovery.submitToIndex`).
|
||||
*/
|
||||
private published = new Set<Nuri>();
|
||||
/** doc NURI → principals holding its WRITE cap. Decorative until P1b. */
|
||||
private writers = new Map<Nuri, Set<PrincipalId>>();
|
||||
/** doc NURIs readable by everyone (public_store repos — no cap needed). */
|
||||
private publicDocs = new Set<Nuri>();
|
||||
/** doc NURI → its declared (scope, owner), as recorded at {@link open}. Lets
|
||||
* the consumer re-derive which documents are `protected` and who owns them
|
||||
* (see {@link protectedDocsOf}) so it can issue directed grants, without
|
||||
* re-supplying that per-document — it already declared it at open. */
|
||||
private policy = new Map<Nuri, { scope: Scope; owner: PrincipalId }>();
|
||||
/** 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;
|
||||
|
||||
/** Grant `grantee` the READ cap of document `doc` — a directed grant. */
|
||||
grantRead(doc: Nuri, grantee: PrincipalId): void {
|
||||
add(this.readers, doc, grantee);
|
||||
/**
|
||||
* @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);
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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) -------------------------------------
|
||||
|
||||
/**
|
||||
* Publish `nuri` as a shareable repo link and return it — the upstream
|
||||
* `RepoLinkV0 { read_cap }`, which whoever receives it can open. The consumer
|
||||
* puts this link (not the bare NURI) in what it makes discoverable.
|
||||
*
|
||||
* NOT recursive: the published document may REFERENCE private documents, and the
|
||||
* reference grants nothing on what it references — that non-recursiveness is
|
||||
* what lets a public object point at a private identity without disclosing it.
|
||||
*/
|
||||
publishRepoLink(nuri: Nuri): ReadCap {
|
||||
const target = targetOf(nuri);
|
||||
this.published.add(target);
|
||||
return this.mint(target);
|
||||
}
|
||||
|
||||
/** Was `nuri` published as a repo link? (An emitter-side guard, not a right.) */
|
||||
isPublished(nuri: Nuri): boolean {
|
||||
return this.published.has(targetOf(nuri));
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a document the current holder owns in `scope`: its cap lands in their
|
||||
* what they hold, and a `public` one is additionally published as a repo link. Returns
|
||||
* the cap (the shareable link when public). Idempotent — the store-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 {
|
||||
return scope === "public" ? this.publishRepoLink(nuri) : this.mint(nuri);
|
||||
}
|
||||
|
||||
// --- 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 {
|
||||
add(this.writers, doc, principal);
|
||||
}
|
||||
|
||||
/** Mark `doc` public (readable without a cap — a public_store repo). */
|
||||
makePublic(doc: Nuri): void {
|
||||
this.publicDocs.add(doc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the caps a creator attaches to a fresh document, by scope. Public →
|
||||
* world-readable; protected/private → only the owner reads. The owner always
|
||||
* holds the write cap. Further sharing is a separate explicit grant.
|
||||
*/
|
||||
open(doc: Nuri, scope: Scope, owner: PrincipalId): void {
|
||||
if (scope === "public") this.makePublic(doc);
|
||||
else this.grantRead(doc, owner);
|
||||
this.grantWrite(doc, owner);
|
||||
this.policy.set(doc, { scope, owner });
|
||||
}
|
||||
|
||||
/**
|
||||
* The `protected` documents owned by `owner`, as recorded at {@link open}. The
|
||||
* consumer uses this to issue directed read grants: it decides who may read an
|
||||
* owner's protected documents (its own relationship concept) and calls
|
||||
* {@link grantRead} on each of these documents for each such reader. Public
|
||||
* documents are already world-readable and private documents stay owner-only,
|
||||
* so only the protected ones are surfaced here.
|
||||
*
|
||||
* This mirrors a native cap operation: in the target, sharing a protected repo
|
||||
* with another identity issues that identity the repo's ReadCap. Here the
|
||||
* consumer selects the documents via this accessor and grants the emulated read
|
||||
* cap on the same unit.
|
||||
*/
|
||||
protectedDocsOf(owner: PrincipalId): Nuri[] {
|
||||
const out: Nuri[] = [];
|
||||
for (const [doc, { scope, owner: o }] of this.policy) {
|
||||
if (scope === "protected" && o === owner) out.push(doc);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Is `doc` under any READ-cap policy? (Undeclared docs are not enforced.) */
|
||||
governsRead(doc: Nuri): boolean {
|
||||
return this.publicDocs.has(doc) || this.readers.has(doc);
|
||||
}
|
||||
|
||||
/** Does `principal` hold a READ cap for `doc` (or is `doc` public)? */
|
||||
canRead(doc: Nuri, principal: PrincipalId | null): boolean {
|
||||
if (this.publicDocs.has(doc)) return true;
|
||||
if (principal === null) return false;
|
||||
return this.readers.get(doc)?.has(principal) ?? false;
|
||||
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(doc);
|
||||
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(doc)?.has(principal) ?? false;
|
||||
}
|
||||
|
||||
/** No READ policy declared → the read filter stays inert (passthrough). */
|
||||
hasReadPolicy(): boolean {
|
||||
return this.readers.size > 0 || this.publicDocs.size > 0;
|
||||
return this.writers.get(targetOf(doc))?.has(principal) ?? false;
|
||||
}
|
||||
|
||||
/** No WRITE policy declared → the write guard stays inert (passthrough). */
|
||||
@@ -119,16 +251,13 @@ export class CapRegistry {
|
||||
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.readers.clear();
|
||||
this.heldByHolder.clear();
|
||||
this.published.clear();
|
||||
this.writers.clear();
|
||||
this.publicDocs.clear();
|
||||
this.policy.clear();
|
||||
this.issued = false;
|
||||
this.notify();
|
||||
}
|
||||
}
|
||||
|
||||
function add(m: Map<Nuri, Set<PrincipalId>>, doc: Nuri, principal: PrincipalId): void {
|
||||
let s = m.get(doc);
|
||||
if (!s) m.set(doc, (s = new Set()));
|
||||
s.add(principal);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* 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 "./polyfill";
|
||||
import { myInboxes, readLinks, resolveAccount } from "./store-registry";
|
||||
import { processInbox } from "./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();
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
/**
|
||||
* discovery — a GENERIC discovery-index surface, reusing the ONE deposit +
|
||||
* materialization mechanism (`inbox.ts`). GENERIC by construction: this module
|
||||
* knows no application domain (no event, no meeting-point). The consumer submits
|
||||
* an opaque reference and interprets the entries it reads back.
|
||||
*
|
||||
* ── The mechanism (see docs/decisions/discovery-model.md) ─────────────────
|
||||
* Access and discovery are separate concerns. A public entity is world-readable
|
||||
* with its NURI; the discovery index is how a client learns that NURI exists
|
||||
* without holding a grant to read its creator's other documents. There is one
|
||||
* global index — an owned document (public read), fed via its own inbox. A
|
||||
* creator deposits a reference into the index's inbox; reading the index folds
|
||||
* those deposits into entries, deduplicating identical references along the way.
|
||||
*
|
||||
* ── The special account (polyfill owner) ──────────────────────────────────
|
||||
* Ownership of a truly global index is undecided in the real platform, where an
|
||||
* identity's apps and services see only what that identity shares. The polyfill
|
||||
* therefore parks ownership on a reserved special account in the shim
|
||||
* ({@link INDEX_ACCOUNT}). Its `public` scope document is the index document;
|
||||
* deposits land in that document's inbox (a stable NURI: every client opening the
|
||||
* same shared wallet resolves the same account, so the same document). This is
|
||||
* the app-facing discovery path, in place of a cross-account fan-out
|
||||
* (`store-registry.ts` `listEntityDocs`), which survives only as an internal
|
||||
* fallback (see {@link readIndex}).
|
||||
*
|
||||
* ── Real target vs this emulation ─────────────────────────────────────────
|
||||
* The intended real shape is: `submitToIndex` seals a reference into the index
|
||||
* document's own inbox (a future `inbox_post_link`), and reading the index is a
|
||||
* query on the materialized index document. Here, everything runs in-lib on the
|
||||
* shared wallet (deposit via `inbox.post`, fold via `inbox.read`). Against real
|
||||
* NextGraph the special account gives way to the decided global-index owner and
|
||||
* `readIndex` points at that document; the consumer surface (`submitToIndex` /
|
||||
* `readIndex`) is designed to survive that change unchanged.
|
||||
*
|
||||
* All NextGraph I/O routes through `inbox.ts` (which routes through the `docs`
|
||||
* primitives, the real injected `ng`), so this module imports no `@ng-org`
|
||||
* package.
|
||||
*/
|
||||
|
||||
import * as inbox from "./inbox";
|
||||
import { subscribeDoc } from "./subscribe";
|
||||
import { ensureRepoOpen } from "./open-repo";
|
||||
import { ensureAccount, reservedAccount } from "./store-registry";
|
||||
import { getCaps } from "./polyfill";
|
||||
import type { Nuri, PrincipalId } from "./types";
|
||||
|
||||
/**
|
||||
* The reserved special account that owns the global discovery index in the
|
||||
* polyfill. It hosts the index document but is never a real identity. It lives in
|
||||
* the registry's reserved namespace ({@link reservedAccount}), whose key
|
||||
* `normalizeId` can never produce, so an id of "index"/"@index" cannot hijack it
|
||||
* (it normalizes to "index", a disjoint key). Removed against real NextGraph
|
||||
* (see file header).
|
||||
*/
|
||||
export const INDEX_ACCOUNT = reservedAccount("index");
|
||||
|
||||
/** One entry as materialized from the discovery index. */
|
||||
export interface IndexEntry {
|
||||
/** The reference submitted by a creator (opaque — the consumer interprets it). */
|
||||
ref: unknown;
|
||||
/** The submitter, if identified; `null` when the submission was anonymous. */
|
||||
from: PrincipalId | null;
|
||||
/** Submission timestamp (ms epoch). */
|
||||
ts: number;
|
||||
}
|
||||
|
||||
/** Options for {@link submitToIndex}. */
|
||||
export interface SubmitOptions {
|
||||
/**
|
||||
* Who is submitting. Omit for the current identity, or pass `null` for an
|
||||
* anonymous submission. `from` is bound to the current identity by the inbox
|
||||
* (naming another identity is rejected as a spoof — see {@link inbox.post}).
|
||||
*/
|
||||
from?: PrincipalId | null;
|
||||
/**
|
||||
* The NURI of the document being made discoverable. When given, the index
|
||||
* admits only a public document: one under a non-public (protected/private)
|
||||
* read policy is refused, so the world-readable index never exposes a governed
|
||||
* document's NURI. Omit it only for a ref with no addressable document (rare);
|
||||
* a governed document passes it so the guard can fire.
|
||||
*/
|
||||
doc?: Nuri;
|
||||
/** Optional deposit timestamp (ms epoch). Omitted → `Date.now()`. Passing it
|
||||
* keeps tests deterministic. */
|
||||
ts?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the NURI of the index document — the stable inbox where discovery
|
||||
* submissions land. The special account owns this document (its `public` scope
|
||||
* document, a real repo NURI from `docCreate`); deposits go into that document's
|
||||
* inbox exactly as host-registration deposits go into a host inbox. Because the
|
||||
* special account lives in the shim (persisted in the shared wallet's private
|
||||
* store), EVERY client opening the same wallet resolves the same account → the
|
||||
* same document NURI → ONE shared index for all clients. Distinct from
|
||||
* host-registration inboxes because it is a distinct document NURI.
|
||||
*/
|
||||
async function indexInboxNuri(): Promise<Nuri> {
|
||||
// Ensure the special account exists (idempotent) so its scope documents are
|
||||
// created and stably resolvable across clients.
|
||||
const record = await ensureAccount(INDEX_ACCOUNT);
|
||||
return record.docPublic;
|
||||
}
|
||||
|
||||
/**
|
||||
* The NURI of the global discovery-index document (the inbox where submissions
|
||||
* land). Exposed so a reactive reader ({@link watchShape}) that folds discovery
|
||||
* into the public read-set can SUBSCRIBE to this document and re-resolve when a
|
||||
* new public entity is announced. This is exactly {@link watchIndex}'s subscribe
|
||||
* anchor. Removed against real NextGraph along with the special account.
|
||||
*/
|
||||
export async function indexDocNuri(): Promise<Nuri> {
|
||||
return indexInboxNuri();
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit a reference to the global discovery index — the SDK act "make this
|
||||
* discoverable". Deposits `ref` into the index document's inbox via
|
||||
* {@link inbox.post}; reading the index ({@link readIndex}) folds it into an
|
||||
* entry. `ref` is opaque here (the consumer serializes whatever a client needs to
|
||||
* later locate the entity — e.g. an entity document NURI plus discovery metadata).
|
||||
* `from` follows the inbox convention (anonymous when `null`).
|
||||
*
|
||||
* When `opts.doc` names the document being surfaced, a document under a
|
||||
* non-public read policy (protected/private) is refused: the global index is
|
||||
* world-readable, so admitting a governed document's NURI would expose it past
|
||||
* its scope.
|
||||
*/
|
||||
export async function submitToIndex(ref: unknown, opts?: SubmitOptions): Promise<void> {
|
||||
const doc = opts?.doc;
|
||||
if (doc !== undefined) {
|
||||
const caps = getCaps();
|
||||
// A governed doc is submittable ONLY if it is public (anonymous may read it).
|
||||
if (caps.governsRead(doc) && !caps.canRead(doc, null)) {
|
||||
throw new Error(
|
||||
"[ng-eventually] submitToIndex: only PUBLIC documents may be submitted to " +
|
||||
"the discovery index — a protected/private document must not be surfaced.",
|
||||
);
|
||||
}
|
||||
}
|
||||
const target = await indexInboxNuri();
|
||||
await inbox.post(target, {
|
||||
payload: ref,
|
||||
...(opts && "from" in opts ? { from: opts.from } : {}),
|
||||
...(opts?.ts !== undefined ? { ts: opts.ts } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the global discovery index. Reads every submission from the index inbox,
|
||||
* deduplicates by serialized `ref` (a duplicate submission surfaces once — the
|
||||
* discovery model's moderation point), and returns the entries sorted by `ts`
|
||||
* ascending. Against real NextGraph this becomes a query on the materialized
|
||||
* index document.
|
||||
*/
|
||||
export async function readIndex(): Promise<IndexEntry[]> {
|
||||
const target = await indexInboxNuri();
|
||||
// COLD-START heal (polyfill-era): on a FRESH session over a persistent wallet the
|
||||
// discovery-index inbox repo is not yet in the verifier's `self.repos`, so the
|
||||
// anchored `inbox.read` below would resolve an unopened repo and silently return 0
|
||||
// deposits — the same self-inflicted cold-read gap `readScopeIndex`/`readUnion`
|
||||
// heal. This is what made the PUBLIC read's discovery fold come back empty on a
|
||||
// reconnect, so a fresh page's home stayed empty for tens of seconds while the doc
|
||||
// slowly synced by other means. Open/subscribe the index repo ONCE and await its
|
||||
// first `State` (the sync barrier) before the anchored read. Idempotent per session;
|
||||
// no-op with the unit fake ng (no `doc_subscribe`). This is done HERE (a cold direct
|
||||
// reader) rather than inside `inbox.read`, because `inbox.watch` already holds the
|
||||
// repo open via its own subscription and must not spawn a second bootstrap open. See
|
||||
// open-repo.ts.
|
||||
await ensureRepoOpen(target);
|
||||
const deposits = await inbox.read(target);
|
||||
const seen = new Set<string>();
|
||||
const entries: IndexEntry[] = [];
|
||||
for (const d of deposits) {
|
||||
// Dedup on the serialized reference — the materialization moderation point.
|
||||
const key = JSON.stringify(d.payload ?? null);
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
entries.push({ ref: d.payload, from: d.from, ts: d.ts });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch the discovery index — **event-driven, not polled**. Subscribes to the
|
||||
* index document via {@link subscribeDoc} (the platform's `doc_subscribe` push):
|
||||
* `onEntries` fires once on the initial state push and again on every subsequent
|
||||
* change to the index document — a local submission OR a broker-synced remote one.
|
||||
* Returns an unsubscribe. (Deduplication is applied on each read.)
|
||||
*
|
||||
* The `intervalMs` option is accepted for signature compatibility but IGNORED:
|
||||
* there is no polling. The index is a single document, so this is immune to the
|
||||
* ORM fan-out hang (see {@link subscribeDoc}).
|
||||
*/
|
||||
export function watchIndex(
|
||||
onEntries: (entries: IndexEntry[]) => void,
|
||||
_opts?: { intervalMs?: number },
|
||||
): () => void {
|
||||
let stopped = false;
|
||||
let lastCount = -1;
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
|
||||
const refresh = async (): Promise<void> => {
|
||||
if (stopped) return;
|
||||
try {
|
||||
const entries = await readIndex();
|
||||
if (!stopped && entries.length !== lastCount) {
|
||||
lastCount = entries.length;
|
||||
onEntries(entries);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[discovery] watchIndex read failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// The index document NURI is resolved async (ensureAccount); subscribe once it
|
||||
// is known. The initial State push fires the first read (onEntries fires once),
|
||||
// each later Patch a re-read.
|
||||
void (async () => {
|
||||
try {
|
||||
const anchor = await indexInboxNuri();
|
||||
if (stopped) return;
|
||||
unsubscribe = subscribeDoc(anchor, () => void refresh());
|
||||
} catch (error) {
|
||||
console.error("[discovery] watchIndex subscribe failed:", error);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
if (unsubscribe) {
|
||||
unsubscribe();
|
||||
unsubscribe = null;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -15,6 +15,8 @@
|
||||
|
||||
import { getConfig } from "./polyfill";
|
||||
import { logAccess, enabled as accessLogEnabled } from "./access-log";
|
||||
import { isNuri } from "./nuri";
|
||||
import { assertMayReach } from "./reach";
|
||||
import type { Nuri } from "./types";
|
||||
|
||||
// The low common point for ALL document access: every read in the SDK routes
|
||||
@@ -50,6 +52,15 @@ export async function docCreate(
|
||||
): 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)}`,
|
||||
);
|
||||
}
|
||||
// A container creation is a WRITE; the NURI only exists after the call.
|
||||
logAccess("WRITE", nuri, "docCreate");
|
||||
return nuri;
|
||||
@@ -68,11 +79,38 @@ export async function sparqlUpdate(
|
||||
label = "sparqlUpdate",
|
||||
): Promise<void> {
|
||||
const { ng } = getConfig();
|
||||
// The boundary: a write may only touch what the connected virtual user reaches.
|
||||
if (anchor !== undefined) assertMayReach(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.
|
||||
*
|
||||
@@ -87,6 +125,10 @@ export async function sparqlQuery(
|
||||
label = "sparqlQuery",
|
||||
): Promise<unknown> {
|
||||
const { ng } = getConfig();
|
||||
// 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.
|
||||
if (anchor !== undefined) 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
|
||||
|
||||
@@ -24,11 +24,13 @@
|
||||
* never `makeNg`), so this module imports no `@ng-org` package.
|
||||
*/
|
||||
|
||||
import { sparqlUpdate, sparqlQuery } from "./docs";
|
||||
import { depositInto, sparqlQuery } from "./docs";
|
||||
import { subscribeDoc } from "./subscribe";
|
||||
import { ensureRepoOpen } from "./open-repo";
|
||||
import { getCurrentUser, getStoreRegistryDeps } from "./polyfill";
|
||||
import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./polyfill";
|
||||
import { addLink, isOwnInbox } from "./store-registry";
|
||||
import { escapeLiteral } from "./sparql";
|
||||
import { hasReadCap } from "./nuri";
|
||||
import {
|
||||
accessLogPrefix,
|
||||
enabled as accessLogEnabled,
|
||||
@@ -36,7 +38,7 @@ import {
|
||||
logStage,
|
||||
shortNuri,
|
||||
} from "./access-log";
|
||||
import type { Nuri, PrincipalId } from "./types";
|
||||
import type { Nuri, PrincipalId, ReadCap } from "./types";
|
||||
|
||||
// --- deposit model --------------------------------------------------------
|
||||
|
||||
@@ -172,7 +174,8 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void>
|
||||
<${P.payload}> "${payloadLiteral}" ;
|
||||
<${P.ts}> "${ts}"${fromTriple} .
|
||||
}`;
|
||||
await sparqlUpdate(sid, update, targetInbox, "deposit");
|
||||
// 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.
|
||||
@@ -186,6 +189,93 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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's read cap with ONE recipient, addressed by their inbox.
|
||||
*
|
||||
* 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: the field exists
|
||||
* (`ContactDetails.read_cap`) but its message construction is `unimplemented!()`
|
||||
* and the receiver discards the cap. The shape is right; the implementation is
|
||||
* absent, so we emulate it meanwhile.
|
||||
*/
|
||||
export async function shareCap(cap: ReadCap, toInbox: Nuri): Promise<void> {
|
||||
if (!hasReadCap(cap)) {
|
||||
throw new Error(
|
||||
"[ng-eventually] inbox.shareCap: expected a ReadCap (a NURI carrying `:r:`), " +
|
||||
`got a bare reference — naming is not reading: ${JSON.stringify(cap)}`,
|
||||
);
|
||||
}
|
||||
await post(toInbox, { payload: { kind: LINK_KIND, cap } });
|
||||
}
|
||||
|
||||
// --- 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 shareCap});
|
||||
* 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/shareCap) 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 --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -194,8 +284,15 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void>
|
||||
* 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 shareCap}) 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(targetInbox: Nuri): Promise<Deposit[]> {
|
||||
await assertOwnInbox(targetInbox, "read");
|
||||
const sid = await sessionId();
|
||||
// NOTE: cold-start repo opening is done by the COLD DIRECT readers that need it
|
||||
// (e.g. `discovery.readIndex` → `ensureInboxRepoOpen`), NOT here — `inbox.watch`
|
||||
@@ -230,6 +327,23 @@ export async function read(targetInbox: Nuri): Promise<Deposit[]> {
|
||||
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
|
||||
@@ -239,9 +353,12 @@ export async function read(targetInbox: Nuri): Promise<Deposit[]> {
|
||||
"READ",
|
||||
targetInbox,
|
||||
"inbox materialize",
|
||||
" → " + deposits.length + " message(s)",
|
||||
" → " + delivered.length + " message(s)" +
|
||||
(deposits.length !== delivered.length
|
||||
? " (+" + (deposits.length - delivered.length) + " cap deliver(y/ies) absorbed)"
|
||||
: ""),
|
||||
);
|
||||
for (const d of deposits) {
|
||||
for (const d of delivered) {
|
||||
logAccess(
|
||||
"READ",
|
||||
targetInbox,
|
||||
@@ -250,7 +367,7 @@ export async function read(targetInbox: Nuri): Promise<Deposit[]> {
|
||||
);
|
||||
}
|
||||
}
|
||||
return deposits;
|
||||
return delivered;
|
||||
}
|
||||
|
||||
/** Alias for {@link read} — the name that reads as "process the inbox now". */
|
||||
@@ -282,10 +399,35 @@ export async function readSynced(targetInbox: Nuri): Promise<Deposit[]> {
|
||||
// (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 shareCap} 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(targetInbox: Nuri): Promise<Deposit[]> {
|
||||
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):
|
||||
@@ -341,6 +483,8 @@ export function watch(
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -17,8 +17,6 @@ export { watchShape } from "./watch-shape";
|
||||
export type { ShapeQuery, ShapeObservable } from "./watch-shape";
|
||||
export { init, initNg } from "./lifecycle";
|
||||
export * as inbox from "./inbox";
|
||||
export * as discovery from "./discovery";
|
||||
export type { IndexEntry, SubmitOptions } from "./discovery";
|
||||
export * as docs from "./docs";
|
||||
export { subscribeDoc, subscribeDocs, docChangeType } from "./subscribe";
|
||||
export type { DocChange, DocChangeType, Unsubscribe } from "./subscribe";
|
||||
@@ -35,6 +33,15 @@ export type { AccountStorage } from "./accounts";
|
||||
// validate trusted-shaped NURIs before embedding them in an IRI.
|
||||
export { escapeLiteral, escapeIri, assertNuri } from "./sparql";
|
||||
|
||||
// NURI type guards — the doors through which an app's own `string` (read back
|
||||
// from storage, a URL, JSON, a form) becomes a typed `Nuri` or `ReadCap`. `Nuri`
|
||||
// and `ReadCap` are template literal types, so an app that narrows with these
|
||||
// gets the same compile-time distinction the library uses internally — in
|
||||
// particular, it cannot pass a bare reference where a cap is required. Narrow
|
||||
// with these rather than casting: a cast re-opens exactly the confusion the
|
||||
// types exist to close.
|
||||
export { isNuri, hasReadCap } from "./nuri";
|
||||
|
||||
// SDK type re-exports — so the app imports these from @ng-eventually/client 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).
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 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. */
|
||||
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 stand-in cap value. A CONSTANT, on purpose.
|
||||
*
|
||||
* Upstream this segment carries `base64url(serde_bare(ObjectRef))` — the block id
|
||||
* and its key serialized together. Here it carries `OK`.
|
||||
*
|
||||
* The only question this library can answer today is **do I hold this document's
|
||||
* cap, or not** — a boolean. An earlier version derived a per-document digest,
|
||||
* which looked like a key and was not one: it invited the reader to believe
|
||||
* something was protected, and it made "the key is reproducible" a subtlety to
|
||||
* explain rather than a fact you can see. `OK` says what it is — a presence
|
||||
* marker. The document a cap opens is already identified by the NURI it is
|
||||
* attached to, so the value carries no information anyway.
|
||||
*
|
||||
* P1b replaces this single constant with a real key. Nothing else has to change:
|
||||
* every path already reads a cap rather than recomputing one.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* This is INTERNAL: nothing on the library's surface turns a bare reference into a
|
||||
* cap, because that is not how the model works — you look a cap up in what you
|
||||
* hold, or you were given it (see `caps.ts`).
|
||||
*
|
||||
* No cast needed on the way out: the compiler derives `` `did:ng:…:r:…` `` from the
|
||||
* template itself, which is exactly the {@link ReadCap} shape.
|
||||
*/
|
||||
export function mintCap(nuri: Nuri): ReadCap {
|
||||
return `${targetOf(nuri)}${CAP_SEGMENT}${STAND_IN_CAP}`;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
*
|
||||
* ── The cold-start defect this heals ──────────────────────────────────────
|
||||
* The anchored read path (`read-model.ts` `readDoc`, `store-registry.ts`
|
||||
* `readScopeIndex`) assumes the target repo is already in the verifier's
|
||||
* `readUserStore`) assumes the target repo is already in the verifier's
|
||||
* `self.repos` — 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). On that fresh session nothing has opened
|
||||
@@ -13,7 +13,7 @@
|
||||
*
|
||||
* 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 (`readScopeIndex`) is itself an anchored read of a not-yet-open
|
||||
* 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.
|
||||
@@ -59,8 +59,9 @@
|
||||
* resolves a same-session repo directly. Polyfill-era, removed with the shim.
|
||||
*/
|
||||
|
||||
import { mustNotAttempt } from "./reach";
|
||||
import { getConfig, getStoreRegistryDeps } from "./polyfill";
|
||||
import { subscribeDoc, type Unsubscribe } from "./subscribe";
|
||||
import { subscribePhysicalDoc, type Unsubscribe } from "./subscribe";
|
||||
import { logStage, shortNuri } from "./access-log";
|
||||
import type { Nuri } from "./types";
|
||||
|
||||
@@ -165,6 +166,27 @@ async function syncSession(): Promise<void> {
|
||||
*/
|
||||
export async function ensureRepoOpen(nuri: Nuri): Promise<void> {
|
||||
if (!nuri) return;
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a repo as the PHYSICAL user — the shim's own documents (store-root,
|
||||
* doc-shim). The machinery's counterpart to {@link ensureRepoOpen}: resolving
|
||||
* WHICH documents a virtual user owns cannot itself be confined to that user.
|
||||
* See `physical.ts` for why this is a separate function and not an exemption.
|
||||
*
|
||||
* Never exported from the package.
|
||||
*/
|
||||
export async function ensurePhysicalRepoOpen(nuri: Nuri): Promise<void> {
|
||||
if (!nuri) return;
|
||||
return openRepoUnguarded(nuri);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -214,7 +236,10 @@ export async function ensureRepoOpen(nuri: Nuri): Promise<void> {
|
||||
// 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.
|
||||
const unsub = subscribeDoc(nuri, (_r, type) => {
|
||||
// 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 = subscribePhysicalDoc(nuri, (_r, type) => {
|
||||
if (type === "State") onState();
|
||||
});
|
||||
held.set(nuri, unsub);
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* 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 "./polyfill";
|
||||
import { logAccess } from "./access-log";
|
||||
import { isNuri } from "./nuri";
|
||||
import type { Nuri } from "./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);
|
||||
}
|
||||
@@ -8,11 +8,12 @@
|
||||
* here is removed at migration.
|
||||
*/
|
||||
|
||||
import type { NgLike, UseShapeLike, PrincipalId } from "./types";
|
||||
import type { NgLike, UseShapeLike, Nuri, PrincipalId, ReadCap } from "./types";
|
||||
import type { RegistrySession } from "./store-registry";
|
||||
import { CapRegistry } from "./caps";
|
||||
import { setAccessLog } from "./access-log";
|
||||
import { inspectOutbox } from "./outbox-log";
|
||||
import { startConnect } from "./connect";
|
||||
|
||||
/**
|
||||
* Consumer-injected dependencies of the storeRegistry (polyfill-era). The
|
||||
@@ -70,9 +71,30 @@ type ResolvedRegistryDeps = Required<
|
||||
Pick<StoreRegistryDeps, "getSession" | "normalizeId" | "pointerGuard">
|
||||
>;
|
||||
let registryDeps: ResolvedRegistryDeps | null = null;
|
||||
/** The emulated ReadCap/WriteCap registry. Empty until the app declares caps;
|
||||
* while it has no read policy the read filter passes through (no regression). */
|
||||
let caps = new CapRegistry();
|
||||
/**
|
||||
* The 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;
|
||||
@@ -147,25 +169,61 @@ export function resetStoreRegistry(): void {
|
||||
* 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 — the app opens a document's read policy and issues
|
||||
* directed read grants on it (as it will via real cap operations in the target).
|
||||
* The read filter consults it. */
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/** Reset all emulated caps (mainly for tests / fresh sessions). */
|
||||
/**
|
||||
* Do I hold the cap of `nuri`? — the held-caps lookup, the ONLY way a cap is
|
||||
* obtained besides being given one. Returns `undefined` when what I hold has none;
|
||||
* that is the whole answer the model can give (there is no "may P read D?").
|
||||
*
|
||||
* Shorthand for `getCaps().capFor(nuri)`, exposed because it is the surface the
|
||||
* consumer actually uses.
|
||||
*/
|
||||
export function capFor(nuri: Nuri): ReadCap | undefined {
|
||||
return caps.capFor(nuri);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
caps = new CapRegistry();
|
||||
// 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();
|
||||
}
|
||||
|
||||
// Cap surface — polyfill-era (caps are emulated now; native at migration).
|
||||
// Re-exported here so the whole polyfill API lives under /polyfill.
|
||||
// Re-exported here so the whole polyfill API lives under /polyfill. `shareCap`
|
||||
// lives in `inbox.ts` because sharing IS an inbox deposit (upstream: a sealed
|
||||
// message carrying the cap), but it is surfaced here so the cap vocabulary stays
|
||||
// on the polyfill side of the boundary rather than in the SDK-identical entry.
|
||||
export { CapRegistry } from "./caps";
|
||||
export { shareCap } from "./inbox";
|
||||
export { connectedUser } from "./connect";
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 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 "./polyfill";
|
||||
import { targetOf } from "./nuri";
|
||||
import type { Nuri } from "./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)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* **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);
|
||||
}
|
||||
@@ -1,47 +1,52 @@
|
||||
/**
|
||||
* Read filter — the polyfill of capability-based read access.
|
||||
*
|
||||
* In the target, the broker only delivers documents the user holds a **ReadCap**
|
||||
* for, so `useShape` already returns an authorized subset. Here (single shared
|
||||
* 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) the current user may read, per the {@link CapRegistry}.
|
||||
* 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.
|
||||
*
|
||||
* 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 type { PrincipalId } from "./types";
|
||||
import { isNuri } from "./nuri";
|
||||
import type { Nuri } from "./types";
|
||||
|
||||
/** The document (repo NURI) an item lives in — its `@graph`. */
|
||||
function docOf(item: unknown): string | null {
|
||||
/** 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" ? g : null;
|
||||
return typeof g === "string" && isNuri(g) ? g : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* May `user` read this item? An item with no `@graph`, or in a document under no
|
||||
* cap policy, is KEPT (the filter only restricts documents that DECLARE a cap —
|
||||
* mirrors the prior behavior and keeps ungoverned data flowing).
|
||||
* 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, user: PrincipalId | null): boolean {
|
||||
function readable(item: unknown, caps: CapRegistry): boolean {
|
||||
const doc = docOf(item);
|
||||
if (doc === null) return true;
|
||||
if (!caps.governsRead(doc)) return true;
|
||||
return caps.canRead(doc, user);
|
||||
return caps.capFor(doc) !== undefined;
|
||||
}
|
||||
|
||||
/** Pure: keep only the items the user may read. */
|
||||
export function filterReadable<T>(
|
||||
items: Iterable<T>,
|
||||
caps: CapRegistry,
|
||||
user: PrincipalId | null,
|
||||
): T[] {
|
||||
/** 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, user)) out.push(item);
|
||||
for (const item of items) if (readable(item, caps)) out.push(item);
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -49,15 +54,11 @@ export function filterReadable<T>(
|
||||
* 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. The current user is read lazily (via
|
||||
* `getUser`) so the view reflects the user in effect at read time.
|
||||
* 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,
|
||||
getUser: () => PrincipalId | null,
|
||||
): S {
|
||||
const keep = (item: unknown): boolean => readable(item, caps, getUser());
|
||||
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) {
|
||||
|
||||
@@ -42,7 +42,8 @@
|
||||
*/
|
||||
|
||||
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
|
||||
import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./polyfill";
|
||||
import { getCaps, getStoreRegistryDeps } from "./polyfill";
|
||||
import { mustNotAttempt } from "./reach";
|
||||
import { ensureReposOpen } from "./open-repo";
|
||||
import { assertNuri } from "./sparql";
|
||||
import type { Nuri } from "./types";
|
||||
@@ -140,29 +141,33 @@ export async function readUnion(docs: Nuri[]): Promise<UnionSubject[]> {
|
||||
const unique = [...new Set(docs.filter(Boolean))];
|
||||
if (unique.length === 0) return [];
|
||||
|
||||
// RULE 2 — do not even attempt. Drop the documents whose cap this user does not
|
||||
// hold BEFORE opening or 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));
|
||||
|
||||
// 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.
|
||||
await ensureReposOpen(unique);
|
||||
await ensureReposOpen(reachable);
|
||||
|
||||
// One anchored query per doc, in parallel, tolerant (a bad doc yields []).
|
||||
const perDoc = await Promise.all(
|
||||
unique.map(async (d) => ({ doc: assertNuri(d), rows: await readDoc(sid, d) })),
|
||||
reachable.map(async (d) => ({ doc: assertNuri(d), rows: await readDoc(sid, d) })),
|
||||
);
|
||||
|
||||
// Cap gate (defence-in-depth). A doc whose read policy the current user may not
|
||||
// satisfy is dropped. Isolation holds both by construction (the app only resolves
|
||||
// docs it is entitled to) and by filter here. Generic: the lib owns the cap
|
||||
// registry; a doc under no policy (`!governsRead`) flows through unchanged. In this
|
||||
// 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 user = getCurrentUser();
|
||||
|
||||
const bySubject = new Map<string, UnionSubject>();
|
||||
for (const { doc, rows } of perDoc) {
|
||||
if (caps.governsRead(doc) && !caps.canRead(doc, user)) continue;
|
||||
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.
|
||||
|
||||
@@ -86,8 +86,13 @@ export function escapeIri(value: string): string {
|
||||
* 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(nuri: string): string {
|
||||
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)}`);
|
||||
}
|
||||
|
||||
@@ -59,15 +59,30 @@
|
||||
* `ng`), so this module imports **no** `@ng-org` package.
|
||||
*/
|
||||
|
||||
import { docCreate, sparqlUpdate, sparqlQuery } from "./docs";
|
||||
import { getStoreRegistryDeps } from "./polyfill";
|
||||
import { ensureRepoOpen } from "./open-repo";
|
||||
import { sparqlUpdate, sparqlQuery } from "./docs";
|
||||
import { physicalCreate, physicalQuery, physicalUpdate } from "./physical";
|
||||
import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./polyfill";
|
||||
import { ensureRepoOpen, ensurePhysicalRepoOpen } from "./open-repo";
|
||||
import { escapeLiteral, escapeIri, assertNuri } from "./sparql";
|
||||
import { hasReadCap, isNuri, mintCap } from "./nuri";
|
||||
import { accessLogPrefix, logStage, shortNuri } from "./access-log";
|
||||
import type { Nuri, Scope } from "./types";
|
||||
import type { Nuri, ReadCap, Scope } from "./types";
|
||||
|
||||
// --- sharedWalletShim model ----------------------------------------------
|
||||
|
||||
/**
|
||||
* A NURI as read back from the shim, where `""` means "absent or corrupt".
|
||||
*
|
||||
* The empty case is NOT new — `canonicalDoc` has always returned `""` for a missing
|
||||
* field, and callers have always had to test for it — but with {@link Nuri} typed it
|
||||
* stops hiding inside a `string`. It is kept confined to the shim-reading functions
|
||||
* below: `AccountRecord` still promises real NURIs, because a record with an empty
|
||||
* scope document is a corrupt record, not a valid state to spread through the API.
|
||||
* Tightening that (reject the record rather than let it flow) is a change of
|
||||
* behaviour and belongs to its own lot — see `recordFromRows`.
|
||||
*/
|
||||
type MaybeNuri = Nuri | "";
|
||||
|
||||
/** One account's three scope-document NURIs, as recorded in the shim. */
|
||||
export interface AccountRecord {
|
||||
id: string;
|
||||
@@ -84,11 +99,47 @@ const P = {
|
||||
docProtected: `${SHIM}:docProtected`,
|
||||
docPrivate: `${SHIM}:docPrivate`,
|
||||
contains: `${SHIM}:contains`, // scope-index → entity document NURI
|
||||
docInbox: `${SHIM}:docInbox`, // account → ITS OWN inbox document
|
||||
link: `${SHIM}:link`, // user branch → a ReadCap received for an EXTERNAL document
|
||||
readCap: `${SHIM}:readCap`, // store branch → the ReadCap of a document IN this store
|
||||
inboxCap: `${SHIM}:inboxCap`, // user branch → an inbox this user may READ
|
||||
} as const;
|
||||
// Fixed subject of the per-(account×scope) index document. The index doc plays
|
||||
// the role of the future store-container: it lists the NURIs of the entity
|
||||
// documents (one per entity) that live "in" that scope.
|
||||
const INDEX_SUBJECT = `${SHIM}:index`;
|
||||
const MAIN_BRANCH_SUBJECT = `${SHIM}:index`;
|
||||
/**
|
||||
* Fixed subject of the **User branch** emulation, inside a user's PRIVATE store
|
||||
* document. Upstream, `AddLink { read_cap }` is committed to the User branch of the
|
||||
* private store — *"so that a user can share with all its device a new Link they
|
||||
* received"*, and *"only external repos are accepted"* (`engine/repo/src/types.rs:1934-1950`).
|
||||
* That is where a cap received from someone else durably lives.
|
||||
*
|
||||
* We have no branches, so the compartment is a distinct SUBJECT in the same
|
||||
* document, kept separate from `MAIN_BRANCH_SUBJECT` (which emulates the store's Main
|
||||
* branch, the `ldp:contains` listing). Two compartments, two subjects — the
|
||||
* separation upstream makes with two branches.
|
||||
*/
|
||||
const USER_BRANCH_SUBJECT = `${SHIM}:userBranch`;
|
||||
/**
|
||||
* Fixed subject of the **Store branch** emulation, inside a user's store document.
|
||||
*
|
||||
* `doc_create` upstream writes TWICE (`engine/verifier/src/request_processor.rs:697-710`):
|
||||
* `ldp:contains` on the store's **Main** branch — the listing — and
|
||||
* `AddRepo { read_cap }` on its **Store** branch — the key. Two branches, two
|
||||
* purposes, deliberately separate; replaying the Store branch is what reloads a
|
||||
* store's documents WITH their caps (`AddRepo::verify` → `load_repo_from_read_cap`).
|
||||
*
|
||||
* We have no branches, so this is a distinct SUBJECT beside {@link MAIN_BRANCH_SUBJECT}
|
||||
* in the same document — the same shape already used for {@link USER_BRANCH_SUBJECT}.
|
||||
*
|
||||
* **Honest about the emulation**: upstream the Store branch carries NO triples at all
|
||||
* (`BranchCrdt::None`, `engine/repo/src/types.rs:1420`) — it is a stream of service
|
||||
* commits. Representing it as RDF is our invention; what is faithful is *that the cap
|
||||
* is stored beside the document rather than recomputed*, and that the listing and the
|
||||
* keys stay separate.
|
||||
*/
|
||||
const STORE_BRANCH_SUBJECT = `${SHIM}:storeBranch`;
|
||||
|
||||
// --- pointer (store-root → doc-shim indirection) --------------------------
|
||||
//
|
||||
@@ -178,10 +229,6 @@ async function rootNuri(): Promise<Nuri> {
|
||||
|
||||
// --- cache ----------------------------------------------------------------
|
||||
|
||||
// In-memory cache of the FULL shim (all accounts), keyed by account key. Set
|
||||
// only once loadShim() has read every account — used by the all-accounts paths.
|
||||
let cache: Map<string, AccountRecord> | null = null;
|
||||
|
||||
// Per-account cache, keyed by account key. Populated by the TARGETED resolver
|
||||
// (resolveAccount) and by loadShim(). Independent of `cache` so a single
|
||||
// targeted resolve never forces a full shim scan. Both are cleared together.
|
||||
@@ -197,8 +244,9 @@ let shimDocInFlight: Promise<Nuri> | null = null;
|
||||
|
||||
/** Reset cache (e.g. after switching the shared wallet). Mostly for tests. */
|
||||
export function resetRegistryCache(): void {
|
||||
cache = null;
|
||||
accountCache.clear();
|
||||
inboxCache.clear();
|
||||
inboxInFlight.clear();
|
||||
shimDocNuri = null;
|
||||
shimDocInFlight = null;
|
||||
}
|
||||
@@ -231,7 +279,7 @@ function bindingValue(row: Record<string, { value: string }>, key: string): stri
|
||||
* bindings (the cross-product of the duplicate values). Picking `rows[0]` is
|
||||
* NON-DETERMINISTIC (binding order is not stable across sessions), so the session
|
||||
* that WROTE an entity into one docPublic and a later fresh page that RESOLVED a
|
||||
* DIFFERENT docPublic would disagree → the anchored `readScopeIndex` returns 0 →
|
||||
* DIFFERENT docPublic would disagree → the anchored `readUserStore` returns 0 →
|
||||
* the home reads empty. When both happen to pick the same doc, it "works".
|
||||
*
|
||||
* The fix: for each scope field, collect EVERY distinct value across the bindings
|
||||
@@ -247,12 +295,15 @@ function bindingValue(row: Record<string, { value: string }>, key: string): stri
|
||||
* stay robust against the residue of PAST forks already persisted in a wallet, and
|
||||
* to reconcile a benign pointer fork the same content-addressed way.
|
||||
*/
|
||||
function canonicalDoc(rows: Array<Record<string, { value: string }>>, key: string): Nuri {
|
||||
let chosen = "";
|
||||
function canonicalDoc(rows: Array<Record<string, { value: string }>>, key: string): MaybeNuri {
|
||||
let chosen: MaybeNuri = "";
|
||||
const distinct = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const v = bindingValue(row, key);
|
||||
if (!v) continue;
|
||||
// The SPARQL boundary: a binding is an untrusted string. Narrowing here (rather
|
||||
// than casting) also discards a value that is not a NextGraph reference at all —
|
||||
// shim corruption that used to flow straight through as a "document NURI".
|
||||
if (!v || !isNuri(v)) continue;
|
||||
distinct.add(v);
|
||||
if (chosen === "" || v < chosen) chosen = v;
|
||||
}
|
||||
@@ -277,11 +328,17 @@ function recordFromRows(
|
||||
const v = bindingValue(row, "id");
|
||||
if (v) { id = v; break; }
|
||||
}
|
||||
// The ONE place the `""`-for-corrupt case is absorbed. `AccountRecord` promises
|
||||
// real NURIs; a shim missing a scope document yields `""` here, exactly as it
|
||||
// always has, and the cast records that this is a KNOWN gap rather than a proven
|
||||
// invariant. Callers already test for the empty value (e.g. `watchShape` skips a
|
||||
// falsy container). Rejecting such a record outright would be the right fix and is
|
||||
// a behaviour change — its own lot, not this one.
|
||||
return {
|
||||
id: id || fallbackId,
|
||||
docPublic: canonicalDoc(rows, "docPublic"),
|
||||
docProtected: canonicalDoc(rows, "docProtected"),
|
||||
docPrivate: canonicalDoc(rows, "docPrivate"),
|
||||
docPublic: canonicalDoc(rows, "docPublic") as Nuri,
|
||||
docProtected: canonicalDoc(rows, "docProtected") as Nuri,
|
||||
docPrivate: canonicalDoc(rows, "docPrivate") as Nuri,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -307,14 +364,14 @@ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms
|
||||
* canonical (lexicographically-smallest) doc-shim NURI — content-addressed and
|
||||
* stable, so every device converges on the SAME doc-shim.
|
||||
*/
|
||||
async function resolvePointer(): Promise<Nuri> {
|
||||
async function resolvePointer(): Promise<MaybeNuri> {
|
||||
const s = await session();
|
||||
const root = await rootNuri();
|
||||
// COLD-START heal: open the store-root repo before the anchored read, so a fresh
|
||||
// wallet whose store-root isn't yet in `self.repos` resolves instead of throwing
|
||||
// `RepoNotFound`. Idempotent; a no-op with the unit fake ng. The store-root has no
|
||||
// barrier, so this open cannot make the read authoritative — the guard below does.
|
||||
await ensureRepoOpen(root);
|
||||
await ensurePhysicalRepoOpen(root);
|
||||
const query = `
|
||||
SELECT ?shimDoc WHERE {
|
||||
GRAPH <${assertNuri(root)}> {
|
||||
@@ -335,7 +392,7 @@ async function resolvePointer(): Promise<Nuri> {
|
||||
let step = baseMs;
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
try {
|
||||
const result = await sparqlQuery(s.sessionId, query, undefined, root, "resolvePointer");
|
||||
const result = await physicalQuery(s.sessionId, query, undefined, root, "resolvePointer");
|
||||
const doc = canonicalDoc(readBindings(result), "shimDoc");
|
||||
if (doc) {
|
||||
logStage("resolvePointer → 1 target: " + shortNuri(doc));
|
||||
@@ -359,7 +416,7 @@ async function resolvePointer(): Promise<Nuri> {
|
||||
async function writePointer(doc: Nuri): Promise<void> {
|
||||
const s = await session();
|
||||
const root = await rootNuri();
|
||||
await ensureRepoOpen(root);
|
||||
await ensurePhysicalRepoOpen(root);
|
||||
const update = `
|
||||
INSERT DATA {
|
||||
GRAPH <${assertNuri(root)}> {
|
||||
@@ -367,7 +424,7 @@ async function writePointer(doc: Nuri): Promise<void> {
|
||||
}
|
||||
}`;
|
||||
try {
|
||||
await sparqlUpdate(s.sessionId, update, root, "writePointer");
|
||||
await physicalUpdate(s.sessionId, update, root, "writePointer");
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " writePointer failed:", error);
|
||||
}
|
||||
@@ -378,7 +435,7 @@ async function createDoc(): Promise<Nuri> {
|
||||
const s = await session();
|
||||
// crdt="Graph" (RDF/SPARQL/ORM), class="data:graph", destination="store",
|
||||
// store_repo=undefined → shared wallet's private store.
|
||||
return docCreate(s.sessionId, "Graph", "data:graph", "store", undefined);
|
||||
return physicalCreate(s.sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -406,7 +463,7 @@ async function resolveShimDoc(): Promise<Nuri> {
|
||||
if (existing) {
|
||||
// Open the doc-shim through its first-`State` barrier BEFORE any account read,
|
||||
// so a cold 0 on the doc-shim is authoritative (genuinely absent), not sync-lag.
|
||||
await ensureRepoOpen(existing);
|
||||
await ensurePhysicalRepoOpen(existing);
|
||||
shimDocNuri = existing;
|
||||
logStage("resolveShimDoc → " + shortNuri(existing));
|
||||
return existing;
|
||||
@@ -416,7 +473,7 @@ async function resolveShimDoc(): Promise<Nuri> {
|
||||
// the pointer, then open (no-op barrier for a just-created repo).
|
||||
const doc = await createDoc();
|
||||
await writePointer(doc);
|
||||
await ensureRepoOpen(doc);
|
||||
await ensurePhysicalRepoOpen(doc);
|
||||
shimDocNuri = doc;
|
||||
logStage("resolveShimDoc → " + shortNuri(doc));
|
||||
return doc;
|
||||
@@ -432,52 +489,6 @@ async function resolveShimDoc(): Promise<Nuri> {
|
||||
|
||||
// --- shim load / account bootstrap ----------------------------------------
|
||||
|
||||
/** Load all accounts from the shim (the doc-shim) into the cache. */
|
||||
export async function loadShim(): Promise<Map<string, AccountRecord>> {
|
||||
if (cache) return cache;
|
||||
const s = await session();
|
||||
const doc = await resolveShimDoc();
|
||||
const query = `
|
||||
SELECT ?id ?docPublic ?docProtected ?docPrivate WHERE {
|
||||
?acc a <${P.type}> ;
|
||||
<${P.id}> ?id ;
|
||||
<${P.docPublic}> ?docPublic ;
|
||||
<${P.docProtected}> ?docProtected ;
|
||||
<${P.docPrivate}> ?docPrivate .
|
||||
}`;
|
||||
const map = new Map<string, AccountRecord>();
|
||||
// The doc-shim is opened (first-`State` barrier) by resolveShimDoc, so this read is
|
||||
// authoritative.
|
||||
await ensureRepoOpen(doc);
|
||||
try {
|
||||
const result = await sparqlQuery(s.sessionId, query, undefined, doc, "loadShim");
|
||||
// Group ALL bindings by account key first, then pick the CANONICAL doc per
|
||||
// scope (see recordFromRows / canonicalDoc). A single account subject may carry
|
||||
// duplicate scope-doc values (fork residue) → several bindings; grouping +
|
||||
// canonical selection makes loadShim resolve the SAME doc the targeted
|
||||
// resolveAccount does, so full-scan and hot-path readers never disagree.
|
||||
const byKey = new Map<string, Array<Record<string, { value: string }>>>();
|
||||
for (const row of readBindings(result)) {
|
||||
const id = bindingValue(row, "id");
|
||||
if (!id) continue;
|
||||
const key = accountKey(id);
|
||||
const bucket = byKey.get(key) ?? [];
|
||||
bucket.push(row);
|
||||
byKey.set(key, bucket);
|
||||
}
|
||||
for (const [key, rows] of byKey) {
|
||||
const record = recordFromRows(rows, rows[0] ? bindingValue(rows[0], "id") : key);
|
||||
map.set(key, record);
|
||||
// Feed the per-account cache too, so a subsequent targeted resolve is free.
|
||||
accountCache.set(key, record);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " loadShim failed:", error);
|
||||
}
|
||||
cache = map;
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve ONE account by its shim key with a BOUNDED query — O(1), independent
|
||||
* of the number of accounts in the shim. This is the HOT-PATH lookup: it hits
|
||||
@@ -516,7 +527,7 @@ export async function resolveAccount(id: string): Promise<AccountRecord | null>
|
||||
<${P.docPrivate}> ?docPrivate .
|
||||
}`;
|
||||
try {
|
||||
const result = await sparqlQuery(s.sessionId, query, undefined, doc, "resolveAccount");
|
||||
const result = await physicalQuery(s.sessionId, query, undefined, doc, "resolveAccount");
|
||||
const rows = readBindings(result);
|
||||
if (rows.length === 0) {
|
||||
logStage("resolveAccount(" + key + ") → null");
|
||||
@@ -536,11 +547,6 @@ export async function resolveAccount(id: string): Promise<AccountRecord | null>
|
||||
}
|
||||
}
|
||||
|
||||
/** All known accounts (from the shim). */
|
||||
export async function allAccounts(): Promise<AccountRecord[]> {
|
||||
return [...(await loadShim()).values()];
|
||||
}
|
||||
|
||||
/** Persist one AccountRecord into the doc-shim (anchored default-graph write, the
|
||||
* canonical always-safe shape — same convention as createEntityDoc). */
|
||||
async function writeRecord(doc: Nuri, record: AccountRecord): Promise<void> {
|
||||
@@ -560,7 +566,7 @@ async function writeRecord(doc: Nuri, record: AccountRecord): Promise<void> {
|
||||
<${P.docPrivate}> "${escapeLiteral(record.docPrivate)}" .
|
||||
}`;
|
||||
try {
|
||||
await sparqlUpdate(s.sessionId, update, doc, "writeRecord");
|
||||
await physicalUpdate(s.sessionId, update, doc, "writeRecord");
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " writeRecord persist failed:", error);
|
||||
}
|
||||
@@ -594,7 +600,10 @@ export async function ensureAccount(id: string): Promise<AccountRecord> {
|
||||
const key = accountKey(id);
|
||||
// A completed provision/resolve is cached → no query, no fork risk.
|
||||
const cached = accountCache.get(key);
|
||||
if (cached) return cached;
|
||||
if (cached) {
|
||||
fileOwnStructure(id, cached);
|
||||
return cached;
|
||||
}
|
||||
// A concurrent provision for the SAME account is already running → await it,
|
||||
// instead of racing a second (forking) provision. This is the anti-fork guard.
|
||||
const pending = ensureInFlight.get(key);
|
||||
@@ -609,7 +618,10 @@ export async function ensureAccount(id: string): Promise<AccountRecord> {
|
||||
// absent — not sync-lag. No account-level retry: the store-root ambiguity that
|
||||
// forced the old provisionRetry loop is gone once the read moves behind the barrier.
|
||||
const existing = await resolveAccount(id);
|
||||
if (existing) return existing;
|
||||
if (existing) {
|
||||
fileOwnStructure(id, existing);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const doc = await resolveShimDoc();
|
||||
const [docPublic, docProtected, docPrivate] = await Promise.all([
|
||||
@@ -623,7 +635,7 @@ export async function ensureAccount(id: string): Promise<AccountRecord> {
|
||||
// Feed the per-account cache, and the full-shim cache if it is already loaded
|
||||
// (so allAccounts / the fan-out see the freshly-created account too).
|
||||
accountCache.set(key, record);
|
||||
cache?.set(key, record);
|
||||
fileOwnStructure(id, record);
|
||||
return record;
|
||||
})();
|
||||
|
||||
@@ -638,7 +650,7 @@ export async function ensureAccount(id: string): Promise<AccountRecord> {
|
||||
// --- resolvers ------------------------------------------------------------
|
||||
|
||||
/** The index document NURI of an account for a scope (the store-container). */
|
||||
function indexDocOf(record: AccountRecord, scope: Scope): Nuri {
|
||||
function storeOf(record: AccountRecord, scope: Scope): Nuri {
|
||||
return scope === "public"
|
||||
? record.docPublic
|
||||
: scope === "protected"
|
||||
@@ -653,13 +665,7 @@ function indexDocOf(record: AccountRecord, scope: Scope): Nuri {
|
||||
*/
|
||||
export async function resolveWriteGraph(id: string, scope: Scope): Promise<Nuri> {
|
||||
const record = await ensureAccount(id);
|
||||
return indexDocOf(record, scope);
|
||||
}
|
||||
|
||||
/** NURIs of every account's document for `scope` (read fan-out). */
|
||||
export async function resolveReadGraphs(scope: Scope): Promise<Nuri[]> {
|
||||
const accounts = await allAccounts();
|
||||
return accounts.map((a) => indexDocOf(a, scope));
|
||||
return storeOf(record, scope);
|
||||
}
|
||||
|
||||
// --- SDK-shaped scope resolvers (no store-id ever leaves the lib) ----------
|
||||
@@ -699,29 +705,170 @@ export async function resolveScopeGraph(scope: Scope): Promise<Nuri> {
|
||||
}
|
||||
|
||||
/**
|
||||
* The reserved account that OWNS the shared registration-inbox document. Like the
|
||||
* discovery index's special account, it lives in the reserved namespace (no user
|
||||
* can produce this key) and only HOSTS a document — its `public` scope document is
|
||||
* the inbox anchor. Disappears at migration (native per-document inboxes).
|
||||
* In-flight `walletInbox` resolutions, keyed by account key — so concurrent callers
|
||||
* for the SAME wallet share ONE resolve-or-create instead of racing two documents
|
||||
* into existence (mirrors {@link ensureInFlight}).
|
||||
*/
|
||||
const INBOX_ANCHOR_ACCOUNT = reservedAccount("inbox");
|
||||
const inboxInFlight = new Map<string, Promise<Nuri>>();
|
||||
/** Resolved wallet inboxes, keyed by account key. Cleared with the registry cache. */
|
||||
const inboxCache = new Map<string, Nuri>();
|
||||
|
||||
/**
|
||||
* The inbox anchor NURI for the current session (where emulated inbox deposits
|
||||
* physically land). SDK-shaped: the consumer never resolves a store itself.
|
||||
* The NURI of a virtual user's OWN inbox — where deposits addressed to that
|
||||
* identity land, ReadCaps among them.
|
||||
*
|
||||
* This is a DEDICATED inbox DOCUMENT (a reserved account's public scope document —
|
||||
* a real repo NURI from `docCreate`, stable across clients via the shim), NOT the
|
||||
* shared wallet's private-store root. Reason (perf + hygiene): the shim (the
|
||||
* account→document trust root) is scanned on every `loadShim`; routing every inbox
|
||||
* deposit into that SAME graph bloats it without bound (thousands of deposit triples
|
||||
* across sessions). A separate inbox document keeps the shim graph small and the
|
||||
* deposits isolated. At migration this becomes the host's native per-document inbox
|
||||
* and the resolution moves here.
|
||||
* ── Why a wallet owns an inbox, and why that is load-bearing ───────────────
|
||||
* You cannot discover in NextGraph; you can only follow links. So a link crosses
|
||||
* from one wallet to another through exactly one channel: a deposit into the
|
||||
* recipient's inbox. That makes the inbox the **bootstrap of the whole
|
||||
* reachability graph** rather than a side feature — and it is why an inbox has to
|
||||
* BELONG to someone. Before this existed, an inbox was any NURI a caller passed,
|
||||
* so "read the inbox" meant "read anyone's inbox", and since P1a routes caps
|
||||
* through it, reading someone else's collected the caps addressed to them.
|
||||
*
|
||||
* Created on first sight and stable thereafter. Recorded in the doc-shim under its
|
||||
* own predicate, read by its OWN query rather than added to the account SELECT: an
|
||||
* account record written before this existed must keep resolving, which it would
|
||||
* not if the fixed account pattern grew a fourth required field.
|
||||
*
|
||||
* Concurrency-safe (see {@link inboxInFlight}), and a fork is reconciled the same
|
||||
* content-addressed way as everything else ({@link canonicalDoc}).
|
||||
*
|
||||
* At migration this becomes the identity's native inbox and the resolution moves
|
||||
* here — the consumer-facing act (deposit to an inbox, process my own) is unchanged.
|
||||
*/
|
||||
export async function resolveInboxAnchor(): Promise<Nuri> {
|
||||
const record = await ensureAccount(INBOX_ANCHOR_ACCOUNT);
|
||||
return record.docPublic;
|
||||
export async function walletInbox(id: string): Promise<Nuri> {
|
||||
const key = accountKey(id);
|
||||
const cached = inboxCache.get(key);
|
||||
if (cached) {
|
||||
fileOwnInbox(id, cached);
|
||||
return cached;
|
||||
}
|
||||
const pending = inboxInFlight.get(key);
|
||||
if (pending) return pending;
|
||||
|
||||
const p = (async (): Promise<Nuri> => {
|
||||
const s = await session();
|
||||
const shimDoc = await resolveShimDoc();
|
||||
await ensureAccount(id); // the account must exist before it can own an inbox
|
||||
const subj = accountSubject(id);
|
||||
try {
|
||||
// The doc-shim is machinery: this reads WHICH inbox a virtual user owns,
|
||||
// which is exactly the kind of question that cannot be confined to that user.
|
||||
const res = await physicalQuery(
|
||||
s.sessionId,
|
||||
`SELECT ?d WHERE { <${subj}> <${P.docInbox}> ?d }`,
|
||||
undefined,
|
||||
shimDoc,
|
||||
"walletInbox",
|
||||
);
|
||||
const existing = canonicalDoc(readBindings(res), "d");
|
||||
if (existing) {
|
||||
inboxCache.set(key, existing);
|
||||
fileOwnInbox(id, existing);
|
||||
return existing;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " walletInbox read failed:", error);
|
||||
}
|
||||
|
||||
const doc = await createDoc();
|
||||
fileOwnInbox(id, doc);
|
||||
try {
|
||||
await physicalUpdate(
|
||||
s.sessionId,
|
||||
`INSERT DATA { <${subj}> <${P.docInbox}> "${escapeLiteral(doc)}" }`,
|
||||
shimDoc,
|
||||
"walletInbox",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " walletInbox persist failed:", error);
|
||||
}
|
||||
inboxCache.set(key, doc);
|
||||
logStage("walletInbox(" + key + ") → " + shortNuri(doc));
|
||||
return doc;
|
||||
})();
|
||||
|
||||
inboxInFlight.set(key, p);
|
||||
try {
|
||||
return await p;
|
||||
} finally {
|
||||
inboxInFlight.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
if ((await walletInbox(holder)) === 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.
|
||||
*/
|
||||
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);
|
||||
// Publication is a registry fact, not a stored one, so it is applied separately.
|
||||
if (scope === "public") caps.publishRepoLink(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.
|
||||
*/
|
||||
function fileOwnStructure(id: string, record: AccountRecord): 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`). */
|
||||
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 -------------------------------
|
||||
@@ -730,11 +877,14 @@ export async function resolveInboxAnchor(): Promise<Nuri> {
|
||||
* Create a dedicated document for ONE entity — mirrors the target, where each
|
||||
* such entity is its own document/repo (addressable, future inbox). The new
|
||||
* document's NURI is appended to the account's scope index document (the
|
||||
* store-container). Returns the entity document NURI (use it as `@graph`).
|
||||
* store-container). Returns the entity document NURI (use it as `@graph`) — a
|
||||
* CAP-LESS reference, exactly like `doc_create` upstream: it names the document,
|
||||
* it does not carry its key. The key goes to what the creator holds (see
|
||||
* {@link holdOwnCap}), which is where you look it up.
|
||||
*/
|
||||
export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
|
||||
const record = await ensureAccount(id);
|
||||
const indexDoc = indexDocOf(record, scope);
|
||||
const indexDoc = storeOf(record, scope);
|
||||
const entityNuri = await createDoc();
|
||||
const s = await session();
|
||||
try {
|
||||
@@ -742,24 +892,70 @@ export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
|
||||
s.sessionId,
|
||||
// NO explicit `GRAPH <…>` wrapper: write the anchored DEFAULT graph (the
|
||||
// `indexDoc` anchor scopes it) — the CANONICAL, always-safe shape the
|
||||
// anchored default-graph read queries (readScopeIndex below, same as
|
||||
// anchored default-graph read queries (readUserStore below, same as
|
||||
// read-model.ts). Not a round-trip necessity on the current broker: the e2e
|
||||
// harness (`packages/client/e2e/`) verified an anchored `GRAPH <plainNuri>`
|
||||
// write ALSO round-trips here (same repo graph, no phantom graph); no-GRAPH
|
||||
// is kept as a simplicity/safety convention. entityNuri is a NURI stored as
|
||||
// a literal → escapeLiteral.
|
||||
`INSERT DATA { <${INDEX_SUBJECT}> <${P.contains}> "${escapeLiteral(entityNuri)}" }`,
|
||||
`INSERT DATA { <${MAIN_BRANCH_SUBJECT}> <${P.contains}> "${escapeLiteral(entityNuri)}" }`,
|
||||
indexDoc,
|
||||
"createEntityDoc",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " createEntityDoc index append failed:", error);
|
||||
}
|
||||
// The second write: `AddRepo { read_cap }` on the Store branch. A separate
|
||||
// statement, not a second triple in the one above, because upstream these are two
|
||||
// commits on two branches — and because the cap must be recoverable even if the
|
||||
// listing write failed.
|
||||
//
|
||||
// One literal suffices: a ReadCap CARRIES its document (`targetOf`), so storing the
|
||||
// cap stores the pair.
|
||||
const cap = mintCap(entityNuri);
|
||||
try {
|
||||
await sparqlUpdate(
|
||||
s.sessionId,
|
||||
`INSERT DATA { <${STORE_BRANCH_SUBJECT}> <${P.readCap}> "${escapeLiteral(cap)}" }`,
|
||||
indexDoc,
|
||||
"createEntityDoc:addRepo",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " createEntityDoc cap append failed:", error);
|
||||
}
|
||||
// …and the creator holds THAT cap for this session.
|
||||
holdOwnCap(id, scope, entityNuri, cap);
|
||||
return entityNuri;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/** Read the entity-document NURIs contained in ONE scope index document. */
|
||||
async function readScopeIndex(indexDoc: Nuri): Promise<Nuri[]> {
|
||||
async function readUserStore(indexDoc: Nuri): Promise<Nuri[]> {
|
||||
const s = await session();
|
||||
const out: Nuri[] = [];
|
||||
// COLD-START heal (polyfill-era): on a fresh session over a persistent wallet the
|
||||
@@ -775,39 +971,21 @@ async function readScopeIndex(indexDoc: Nuri): Promise<Nuri[]> {
|
||||
s.sessionId,
|
||||
// NO explicit `GRAPH <…>` clause — read the anchored DEFAULT graph (see
|
||||
// the note in createEntityDoc). The `indexDoc` anchor scopes the query.
|
||||
`SELECT ?e WHERE { <${INDEX_SUBJECT}> <${P.contains}> ?e }`,
|
||||
`SELECT ?e WHERE { <${MAIN_BRANCH_SUBJECT}> <${P.contains}> ?e }`,
|
||||
undefined,
|
||||
indexDoc,
|
||||
"readScopeIndex",
|
||||
"readUserStore",
|
||||
);
|
||||
for (const row of readBindings(res)) {
|
||||
// SPARQL boundary again (see canonicalDoc): narrow, do not cast — a stored
|
||||
// value that is not a NextGraph reference is not an entity document.
|
||||
const v = bindingValue(row, "e");
|
||||
if (v) out.push(v);
|
||||
if (v && isNuri(v)) out.push(v);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " readScopeIndex failed:", error);
|
||||
}
|
||||
logStage("readScopeIndex(" + shortNuri(indexDoc) + ") → " + out.length + " entities");
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every entity document NURI of `scope`, across all accounts — the read
|
||||
* fan-out for per-entity scopes. Reads each account's scope index document and
|
||||
* unions the contained NURIs. Use as `useShape(shape, { graphs })`.
|
||||
*
|
||||
* NOTE (read-by-need): this ALL-ACCOUNTS fan-out contradicts the read-by-need
|
||||
* model (docs/read-model.md) — it opens/syncs other accounts' possibly-unsynced
|
||||
* docs, which HANGS. Prefer {@link listMyEntityDocs} (my own account's scope
|
||||
* docs) for "my entities", and the discovery index for "all public events".
|
||||
* Retained for callers that legitimately need every account (tests).
|
||||
*/
|
||||
export async function listEntityDocs(scope: Scope): Promise<Nuri[]> {
|
||||
const accounts = await allAccounts();
|
||||
const out: Nuri[] = [];
|
||||
for (const a of accounts) {
|
||||
out.push(...(await readScopeIndex(indexDocOf(a, scope))));
|
||||
console.error(accessLogPrefix() + " readUserStore failed:", error);
|
||||
}
|
||||
logStage("readUserStore(" + shortNuri(indexDoc) + ") → " + out.length + " entities");
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -820,9 +998,9 @@ export async function listEntityDocs(scope: Scope): Promise<Nuri[]> {
|
||||
* here). Idempotent via `ensureAccount`'s cache. At migration this becomes the
|
||||
* user's real per-scope store NURI (the container the store itself provides).
|
||||
*/
|
||||
export async function scopeIndexDoc(id: string, scope: Scope): Promise<Nuri> {
|
||||
export async function userStoreDoc(id: string, scope: Scope): Promise<Nuri> {
|
||||
const record = await ensureAccount(id);
|
||||
return indexDocOf(record, scope);
|
||||
return storeOf(record, scope);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -833,7 +1011,176 @@ export async function scopeIndexDoc(id: string, scope: Scope): Promise<Nuri> {
|
||||
* another account's unsynced docs. This is the helper a consumer application uses
|
||||
* for its own my-entities path, instead of the all-accounts `listEntityDocs`.
|
||||
*/
|
||||
/**
|
||||
* 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 document, 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:1969-1981`), the same branch that carries `AddLink`.
|
||||
* So "which inboxes may I read" is answered by the User branch, and that is what
|
||||
* this emulates.
|
||||
*
|
||||
* Lazy on purpose: 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.
|
||||
*
|
||||
* Only for documents this user holds — you cannot open an inbox on someone else's
|
||||
* document, you can only deposit into it.
|
||||
*/
|
||||
export async function documentInbox(doc: Nuri): Promise<Nuri> {
|
||||
const holder = getCurrentUser();
|
||||
if (holder === null) throw new Error("[ng-eventually] documentInbox: no identity is set");
|
||||
const known = (await readInboxCapsFor(doc)) ?? null;
|
||||
if (known) return known;
|
||||
|
||||
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(doc + " " + inbox)}" }`,
|
||||
store,
|
||||
"documentInbox",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " documentInbox persist failed:", error);
|
||||
}
|
||||
}
|
||||
return inbox;
|
||||
}
|
||||
|
||||
/** The `(document, inbox)` pairs recorded on this user's User branch. */
|
||||
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)) {
|
||||
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. */
|
||||
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[] = [];
|
||||
if ((await resolveAccount(holder)) !== null) out.push(await walletInbox(holder));
|
||||
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;
|
||||
}
|
||||
|
||||
export async function listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]> {
|
||||
const record = await ensureAccount(id);
|
||||
return readScopeIndex(indexDocOf(record, scope));
|
||||
const store = storeOf(record, scope);
|
||||
const docs = await readUserStore(store);
|
||||
// Recover the caps by READING the Store branch, never by recomputing them from
|
||||
// the NURIs — that is the whole point of storing them. A fresh session gets back
|
||||
// exactly what was recorded, and the day the stand-in value becomes a real key
|
||||
// (P1b) this path needs no change at all.
|
||||
//
|
||||
// Scoped to the current holder: another user's store caps are not ours to hold.
|
||||
const holder = getCurrentUser();
|
||||
if (holder !== null && accountKey(holder) === accountKey(id)) {
|
||||
const caps = getCaps();
|
||||
for (const cap of await readStoreCaps(store)) caps.learn(cap);
|
||||
// A `public` store's documents are also published links — the publication fact
|
||||
// lives in the registry, not in the store, so it is re-applied here.
|
||||
if (scope === "public") for (const d of docs) caps.publishRepoLink(d);
|
||||
}
|
||||
return docs;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
*/
|
||||
|
||||
import { getConfig, getStoreRegistryDeps } from "./polyfill";
|
||||
import { assertMayReach } from "./reach";
|
||||
import type { Nuri } from "./types";
|
||||
|
||||
/**
|
||||
@@ -103,6 +104,27 @@ async function sessionId(): Promise<string> {
|
||||
export function subscribeDoc(
|
||||
nuri: Nuri,
|
||||
onChange: (r: DocChange, type: DocChangeType) => void,
|
||||
): Unsubscribe {
|
||||
// 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe as the PHYSICAL user — the shim's own documents. The machinery's
|
||||
* counterpart to {@link subscribeDoc}; never exported from the package.
|
||||
*/
|
||||
export function subscribePhysicalDoc(
|
||||
nuri: Nuri,
|
||||
onChange: (r: DocChange, type: DocChangeType) => void,
|
||||
): Unsubscribe {
|
||||
return subscribeDocUnguarded(nuri, onChange);
|
||||
}
|
||||
|
||||
function subscribeDocUnguarded(
|
||||
nuri: Nuri,
|
||||
onChange: (r: DocChange, type: DocChangeType) => void,
|
||||
): Unsubscribe {
|
||||
const { ng } = getConfig();
|
||||
let stopped = false;
|
||||
|
||||
@@ -2,8 +2,36 @@
|
||||
* Generic, NextGraph-shaped types. ZERO application domain.
|
||||
*/
|
||||
|
||||
/** A NextGraph URI (document / store / inbox). */
|
||||
export type Nuri = string;
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* It constrains the consumer's code the same way, which is the point: an app that
|
||||
* reads a cap back from storage, a URL or JSON gets a `string` and must pass it
|
||||
* through {@link isNuri} / {@link hasReadCap} (exported from the SDK entry) to use
|
||||
* it — a validation it should be doing anyway. The runtime guards stay regardless:
|
||||
* a JavaScript consumer bypasses the compiler entirely.
|
||||
*/
|
||||
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. */
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
/**
|
||||
* Wrapped `useShape`: same signature as `@ng-org/orm`. When a read-cap policy is
|
||||
* declared, the returned set is a read-filtered VIEW (only items in documents the
|
||||
* current user holds a ReadCap for); otherwise it passes the real set through
|
||||
* unchanged. At migration the filtering disappears — the broker only delivers
|
||||
* authorized documents.
|
||||
* 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, getCurrentUser, getCaps } from "./polyfill";
|
||||
import { getConfig, getCaps } from "./polyfill";
|
||||
import { makeReadFilteredView } from "./read-filter";
|
||||
|
||||
export function useShape(shapeType: unknown, scope: unknown): unknown {
|
||||
const set = getConfig().useShape(shapeType, scope) as object;
|
||||
const caps = getCaps();
|
||||
if (!caps.hasReadPolicy()) return set; // no policy configured → passthrough
|
||||
return makeReadFilteredView(set, caps, getCurrentUser);
|
||||
if (!caps.isEnforcing()) return set; // no cap issued yet → passthrough
|
||||
return makeReadFilteredView(set, caps);
|
||||
}
|
||||
|
||||
@@ -15,11 +15,13 @@
|
||||
* 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 identity's per-entity
|
||||
* docs for that scope (`storeRegistry.listMyEntityDocs`), PLUS — for `public`
|
||||
* only — the discovery index folded in (`discovery.readIndex`), so the app
|
||||
* never orchestrates discovery to read. Faithful to the future
|
||||
* `useShape(shape, 'public')`.
|
||||
* 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
|
||||
* (`readModel.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
|
||||
@@ -31,12 +33,15 @@
|
||||
* ShapeType, not from any application concept.
|
||||
*
|
||||
* ── Reactivity WITHOUT polling (no `setInterval`) ──────────────────────────
|
||||
* Reactivity is push-only (rule no-broker-polling): `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; announcing a public entity appends
|
||||
* to the discovery index), so we ALSO subscribe to the scope-index document (and,
|
||||
* for `public`, the discovery-index document): a push there re-RESOLVES the scope
|
||||
* and re-keys the subscribed set. Subscriptions are idempotent — an already-followed
|
||||
* 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.
|
||||
*
|
||||
@@ -46,12 +51,11 @@
|
||||
* `isError` fires ONLY on a real thrown exception in the pipeline.
|
||||
*/
|
||||
|
||||
import { getCurrentUser } from "./polyfill";
|
||||
import { getCaps, getCurrentUser } from "./polyfill";
|
||||
import { ensureReposOpen, getSyncState } from "./open-repo";
|
||||
import { readUnion, type UnionSubject } from "./read-model";
|
||||
import { subscribeDoc, type Unsubscribe } from "./subscribe";
|
||||
import { listMyEntityDocs, scopeIndexDoc } from "./store-registry";
|
||||
import { readIndex, indexDocNuri } from "./discovery";
|
||||
import { listMyEntityDocs, userStoreDoc } from "./store-registry";
|
||||
import type { Nuri, Scope } from "./types";
|
||||
|
||||
/**
|
||||
@@ -183,6 +187,12 @@ export function watchShape<T = UnionSubject>(
|
||||
// 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;
|
||||
|
||||
@@ -201,64 +211,43 @@ export function watchShape<T = UnionSubject>(
|
||||
emit();
|
||||
}
|
||||
|
||||
/** Extract candidate document NURIs from an opaque discovery `ref` — every
|
||||
* string, recursively, that looks like a NextGraph doc NURI (`did:ng:`). Generic:
|
||||
* the app puts the entity doc NURI inside the ref it submits; we fold those docs
|
||||
* into the read-set so the app need not orchestrate discovery. Non-NURI refs
|
||||
* contribute nothing (and readUnion+shape-filter drop anything irrelevant). */
|
||||
function nurisFromRef(ref: unknown, out: Set<Nuri>): void {
|
||||
if (typeof ref === "string") {
|
||||
if (ref.startsWith("did:ng:")) out.add(ref);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(ref)) {
|
||||
for (const v of ref) nurisFromRef(v, out);
|
||||
return;
|
||||
}
|
||||
if (ref && typeof ref === "object") {
|
||||
for (const v of Object.values(ref)) nurisFromRef(v, out);
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the logical scope → the current doc set (my entity docs + discovery
|
||||
* fold for `public`). Tolerant: a resolution failure yields whatever resolved. */
|
||||
/** 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 (`readModel.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>();
|
||||
if (user) {
|
||||
try {
|
||||
for (const d of await listMyEntityDocs(user, scope)) set.add(d);
|
||||
} catch (error) {
|
||||
console.error("[watch-shape] listMyEntityDocs failed", error);
|
||||
}
|
||||
}
|
||||
if (scope === "public") {
|
||||
try {
|
||||
for (const e of await readIndex()) nurisFromRef(e.ref, set);
|
||||
} catch (error) {
|
||||
console.error("[watch-shape] discovery readIndex failed", error);
|
||||
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 documents (scope-index; discovery-index for public)
|
||||
* so a change to the doc SET re-resolves. Idempotent per NURI. */
|
||||
/** 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 scopeIndexDoc(user, scope));
|
||||
containers.push(await userStoreDoc(user, scope));
|
||||
} catch (error) {
|
||||
console.error("[watch-shape] scopeIndexDoc failed", error);
|
||||
}
|
||||
}
|
||||
if (scope === "public") {
|
||||
try {
|
||||
containers.push(await indexDocNuri());
|
||||
} catch (error) {
|
||||
console.error("[watch-shape] indexDocNuri failed", error);
|
||||
console.error("[watch-shape] userStoreDoc failed", error);
|
||||
}
|
||||
}
|
||||
for (const c of containers) {
|
||||
@@ -341,6 +330,15 @@ export function watchShape<T = UnionSubject>(
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -372,6 +370,10 @@ export function watchShape<T = UnionSubject>(
|
||||
}
|
||||
docSubs.clear();
|
||||
containerSubs.clear();
|
||||
if (capsUnsub) {
|
||||
capsUnsub();
|
||||
capsUnsub = null;
|
||||
}
|
||||
started = false;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
resetConfig,
|
||||
setCurrentUser,
|
||||
getCurrentUser,
|
||||
resetCaps,
|
||||
connectedUser,
|
||||
} from "../src/polyfill";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -97,6 +99,17 @@ afterAll(() => {
|
||||
// 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.
|
||||
@@ -147,8 +160,10 @@ describe("access-log: OFF by default", () => {
|
||||
});
|
||||
|
||||
describe("access-log: ON via configure({ debugAccessLog: true })", () => {
|
||||
beforeEach(() => {
|
||||
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 () => {
|
||||
@@ -262,9 +277,11 @@ describe("access-log: identity follows setCurrentUser", () => {
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
expect(lines.length).toBe(2);
|
||||
expect(lines[0]).toMatch(/^\[first-user\]\[polyfill\] /); // identity-first, glued [polyfill] prefix
|
||||
expect(lines[1]).toMatch(/^\[second-user\]\[polyfill\] /);
|
||||
// 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 () => {
|
||||
|
||||
@@ -23,7 +23,6 @@ import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
|
||||
import {
|
||||
ensureAccount,
|
||||
resolveAccount,
|
||||
loadShim,
|
||||
resetRegistryCache,
|
||||
} from "../src/store-registry";
|
||||
import type { RegistrySession } from "../src/store-registry";
|
||||
@@ -225,7 +224,8 @@ describe("deterministic resolution over a doc-shim corrupted by fork residue", (
|
||||
const r1 = await resolveAccount("dupuser");
|
||||
resetRegistryCache();
|
||||
const r2 = await resolveAccount("dupuser");
|
||||
const viaShim = (await loadShim()).get("dupuser");
|
||||
resetRegistryCache();
|
||||
const viaShim = await resolveAccount("dupuser");
|
||||
|
||||
// Canonical = lexicographically smallest → "did:ng:o:pub-a".
|
||||
expect(r1?.docPublic).toBe("did:ng:o:pub-a");
|
||||
|
||||
@@ -1,83 +1,165 @@
|
||||
/**
|
||||
* 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 } from "../src/caps";
|
||||
import { hasReadCap, targetOf } from "../src/nuri";
|
||||
import type { ReadCap } from "../src/types";
|
||||
|
||||
test("public documents are readable by anyone, even anonymous", () => {
|
||||
const caps = new CapRegistry();
|
||||
caps.open("did:ng:o:pub", "public", "alice");
|
||||
expect(caps.canRead("did:ng:o:pub", null)).toBe(true);
|
||||
expect(caps.canRead("did:ng:o:pub", "bob")).toBe(true);
|
||||
/** 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("protected documents: owner + explicitly granted principals only", () => {
|
||||
const caps = new CapRegistry();
|
||||
caps.open("did:ng:o:prot", "protected", "alice");
|
||||
expect(caps.canRead("did:ng:o:prot", "alice")).toBe(true);
|
||||
expect(caps.canRead("did:ng:o:prot", "bob")).toBe(false);
|
||||
caps.grantRead("did:ng:o:prot", "bob"); // a directed grant issues bob the read cap
|
||||
expect(caps.canRead("did:ng:o:prot", "bob")).toBe(true);
|
||||
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();
|
||||
});
|
||||
|
||||
test("private documents: owner only", () => {
|
||||
const caps = new CapRegistry();
|
||||
caps.open("did:ng:o:priv", "private", "alice");
|
||||
expect(caps.canRead("did:ng:o:priv", "alice")).toBe(true);
|
||||
expect(caps.canRead("did:ng:o:priv", "bob")).toBe(false);
|
||||
expect(caps.canRead("did:ng:o:priv", null)).toBe(false);
|
||||
// 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("protectedDocsOf surfaces an owner's protected documents for directed grants", () => {
|
||||
const caps = new CapRegistry();
|
||||
caps.open("did:ng:o:prot1", "protected", "alice");
|
||||
caps.open("did:ng:o:prot2", "protected", "alice");
|
||||
caps.open("did:ng:o:pub", "public", "alice"); // not protected → excluded
|
||||
caps.open("did:ng:o:priv", "private", "alice"); // not protected → excluded
|
||||
caps.open("did:ng:o:bob", "protected", "bob"); // other owner → excluded
|
||||
expect(caps.protectedDocsOf("alice").sort()).toEqual([
|
||||
"did:ng:o:prot1",
|
||||
"did:ng:o:prot2",
|
||||
]);
|
||||
expect(caps.protectedDocsOf("bob")).toEqual(["did:ng:o:bob"]);
|
||||
expect(caps.protectedDocsOf("carol")).toEqual([]);
|
||||
// A directed grant on one of them makes the reader read that doc only.
|
||||
caps.grantRead("did:ng:o:prot1", "carol");
|
||||
expect(caps.canRead("did:ng:o:prot1", "carol")).toBe(true);
|
||||
expect(caps.canRead("did:ng:o:prot2", "carol")).toBe(false);
|
||||
test("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("write is restricted to write-cap holders; the creator always holds it", () => {
|
||||
const caps = new CapRegistry();
|
||||
caps.open("did:ng:o:pub", "public", "alice");
|
||||
expect(caps.canWrite("did:ng:o:pub", "alice")).toBe(true);
|
||||
expect(caps.canWrite("did:ng:o:pub", "bob")).toBe(false);
|
||||
expect(caps.canWrite("did:ng:o:pub", null)).toBe(false);
|
||||
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("holding a document's cap does NOT grant another document (no inheritance)", () => {
|
||||
const caps = new CapRegistry();
|
||||
caps.grantRead("did:ng:o:doc1", "alice");
|
||||
expect(caps.canRead("did:ng:o:doc1", "alice")).toBe(true);
|
||||
expect(caps.canRead("did:ng:o:doc2", "alice")).toBe(false); // separate repo, separate cap
|
||||
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);
|
||||
});
|
||||
|
||||
test("governsRead / hasReadPolicy distinguish governed from ungoverned documents", () => {
|
||||
const caps = new CapRegistry();
|
||||
expect(caps.hasReadPolicy()).toBe(false);
|
||||
caps.grantRead("did:ng:o:doc1", "alice");
|
||||
expect(caps.hasReadPolicy()).toBe(true);
|
||||
expect(caps.governsRead("did:ng:o:doc1")).toBe(true);
|
||||
expect(caps.governsRead("did:ng:o:unknown")).toBe(false); // not declared → not enforced
|
||||
test("publishRepoLink returns a cap-bearing link; reading it still means HOLDING it", () => {
|
||||
const { caps, become } = registry("alice");
|
||||
const doc = "did:ng:o:public-doc";
|
||||
const link = caps.publishRepoLink(doc);
|
||||
|
||||
expect(hasReadCap(link)).toBe(true);
|
||||
expect(targetOf(link)).toBe(doc);
|
||||
expect(caps.isPublished(doc)).toBe(true);
|
||||
expect(caps.isPublished("did:ng:o:other")).toBe(false);
|
||||
|
||||
// Publication is not a world-wide read grant: whoever HAS the URL reads it.
|
||||
become("bob");
|
||||
expect(caps.capFor(doc)).toBeUndefined();
|
||||
caps.learn(link); // bob received the link (e.g. from the discovery index)
|
||||
expect(caps.capFor(doc)).toBe(link);
|
||||
});
|
||||
|
||||
test("governsWrite / hasWritePolicy distinguish governed from ungoverned documents", () => {
|
||||
const caps = new CapRegistry();
|
||||
test("open(): a public document is published as a link, 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.isPublished("did:ng:o:pub")).toBe(true);
|
||||
expect(caps.isPublished("did:ng:o:prot")).toBe(false);
|
||||
expect(caps.isPublished("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.open("did:ng:o:doc1", "private", "alice"); // owner gets the write cap
|
||||
caps.grantWrite("did:ng:o:doc", "alice");
|
||||
expect(caps.hasWritePolicy()).toBe(true);
|
||||
expect(caps.governsWrite("did:ng:o:doc1")).toBe(true);
|
||||
expect(caps.governsWrite("did:ng:o:doc")).toBe(true);
|
||||
expect(caps.governsWrite("did:ng:o:unknown")).toBe(false); // not declared → not enforced
|
||||
// A public doc grants read to all but its write cap is still owner-only.
|
||||
const pub = new CapRegistry();
|
||||
pub.open("did:ng:o:pub", "public", "alice");
|
||||
expect(pub.hasWritePolicy()).toBe(true);
|
||||
expect(pub.governsWrite("did:ng:o:pub")).toBe(true);
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*
|
||||
* 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 `readScopeIndex` already applies to its
|
||||
* 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.
|
||||
*
|
||||
@@ -28,7 +28,10 @@ import {
|
||||
configureStoreRegistry,
|
||||
resetStoreRegistry,
|
||||
resetConfig,
|
||||
resetCaps,
|
||||
setCurrentUser,
|
||||
} from "../src/polyfill";
|
||||
import { resetInfrastructure } from "../src/reach";
|
||||
|
||||
const SESSION = { sessionId: "sid-cold", privateStoreId: "PRIV-COLD" };
|
||||
const ANCHOR = `did:ng:${SESSION.privateStoreId}`;
|
||||
@@ -38,11 +41,20 @@ afterAll(() => {
|
||||
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 }
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* 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 what what they hold holds. There is
|
||||
* no authorization list anywhere, and nobody was named to the registry.
|
||||
*/
|
||||
import { test, expect, mock, afterAll } from "bun:test";
|
||||
import { createEntityDoc, documentInbox, resetRegistryCache, walletInbox } from "../src/store-registry";
|
||||
import type { RegistrySession } from "../src/store-registry";
|
||||
import {
|
||||
configure,
|
||||
configureStoreRegistry,
|
||||
resetStoreRegistry,
|
||||
resetConfig,
|
||||
capFor,
|
||||
getCaps,
|
||||
resetCaps,
|
||||
setCurrentUser,
|
||||
shareCap,
|
||||
connectedUser,
|
||||
} from "../src/polyfill";
|
||||
import { post, read as readInbox } from "../src/inbox";
|
||||
import { readUnion } from "../src/read-model";
|
||||
import { sparqlUpdate } from "../src/docs";
|
||||
import type { Nuri } from "../src/types";
|
||||
|
||||
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;
|
||||
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 } })) } };
|
||||
}
|
||||
// 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 } })) } };
|
||||
}
|
||||
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. Returns what each actor could plausibly come to hold.
|
||||
*/
|
||||
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);
|
||||
|
||||
const pubLink = capFor(pubDoc)!; // the shareable repo link of the public doc
|
||||
const protCap = capFor(protDoc)!; // the cap Alice may hand to whoever she chooses
|
||||
return { protDoc, pubDoc, pubLink, protCap };
|
||||
}
|
||||
|
||||
/** 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, pubLink } = await aliceSetsUpHerDocuments();
|
||||
|
||||
setCurrentUser("bob");
|
||||
// Bob was given the public document's link — "whoever has the URL reads it".
|
||||
getCaps().learn(pubLink);
|
||||
|
||||
// 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(capFor(ref)).toBeUndefined();
|
||||
expect(await readValues([ref], SECRET)).toEqual([]);
|
||||
});
|
||||
|
||||
test("Charlie: same public document, same reference — and he reads through it", async () => {
|
||||
inject();
|
||||
const { protDoc, pubDoc, pubLink, protCap } = await aliceSetsUpHerDocuments();
|
||||
const CHARLIE_INBOX = await walletInbox("charlie");
|
||||
|
||||
// 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 shareCap(protCap, CHARLIE_INBOX);
|
||||
|
||||
setCurrentUser("charlie");
|
||||
getCaps().learn(pubLink);
|
||||
await readInbox(CHARLIE_INBOX); // processing the inbox files the cap
|
||||
|
||||
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
|
||||
expect(ref).toBe(protDoc);
|
||||
expect(capFor(ref)).toBe(protCap);
|
||||
expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]);
|
||||
});
|
||||
|
||||
test("the ONLY difference between Bob and Charlie is what what they hold holds", async () => {
|
||||
inject();
|
||||
const { protDoc, pubLink, protCap } = await aliceSetsUpHerDocuments();
|
||||
const CHARLIE_INBOX = await walletInbox("charlie");
|
||||
|
||||
setCurrentUser("alice");
|
||||
await shareCap(protCap, CHARLIE_INBOX);
|
||||
|
||||
setCurrentUser("bob");
|
||||
getCaps().learn(pubLink);
|
||||
const bobSees = await readValues([protDoc], SECRET);
|
||||
|
||||
setCurrentUser("charlie");
|
||||
getCaps().learn(pubLink);
|
||||
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 { pubDoc, pubLink, protCap } = await aliceSetsUpHerDocuments();
|
||||
const BOB_INBOX = await walletInbox("bob");
|
||||
|
||||
setCurrentUser("bob");
|
||||
getCaps().learn(pubLink);
|
||||
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 shareCap(protCap, BOB_INBOX);
|
||||
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(capFor(ref)).toBe(protCap);
|
||||
expect(latest).toEqual(["the-protected-content"]);
|
||||
expect(await readValues([ref], SECRET)).toEqual(["the-protected-content"]);
|
||||
unsub();
|
||||
});
|
||||
|
||||
test("a bare reference to the PUBLIC document is not enough either — the link is", async () => {
|
||||
inject();
|
||||
const { pubDoc, pubLink } = await aliceSetsUpHerDocuments();
|
||||
|
||||
setCurrentUser("bob");
|
||||
// Bob knows the public document's NURI but was never given its link.
|
||||
expect(await readValues([pubDoc], REFERS_TO)).toEqual([]);
|
||||
|
||||
getCaps().learn(pubLink);
|
||||
expect((await readValues([pubDoc], REFERS_TO)).length).toBe(1);
|
||||
});
|
||||
|
||||
// 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, protCap } = await aliceSetsUpHerDocuments();
|
||||
const bobInbox = await walletInbox("bob");
|
||||
|
||||
setCurrentUser("alice");
|
||||
await shareCap(protCap, bobInbox);
|
||||
|
||||
// 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");
|
||||
expect(await readValues([protDoc], SECRET)).toEqual([]); // bob holds nothing yet
|
||||
|
||||
// Connecting restores it — from the User branch, since the inbox has nothing left.
|
||||
await connectedUser();
|
||||
expect(capFor(protDoc)).toBe(protCap);
|
||||
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 docInbox = await documentInbox(doc);
|
||||
expect(docInbox).not.toBe(await walletInbox("alice"));
|
||||
|
||||
// Bob deposits into the document's inbox — the cross-user act, open to all.
|
||||
setCurrentUser("bob");
|
||||
await post(docInbox, { payload: { joining: true }, ts: 1 });
|
||||
|
||||
// …and cannot read it back: depositing grants nothing.
|
||||
await expect(readInbox(docInbox)).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(docInbox);
|
||||
expect(deposits.map((d) => d.payload)).toEqual([{ joining: true }]);
|
||||
});
|
||||
|
||||
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 documentInbox(pubDoc);
|
||||
const aliceInbox = await walletInbox("alice");
|
||||
|
||||
// Two deposits, one at each level, both made by someone else.
|
||||
setCurrentUser("carol");
|
||||
const carolDoc = await createEntityDoc("carol", "protected");
|
||||
await shareCap(capFor(carolDoc)!, aliceInbox); // a Link, to alice herself
|
||||
await post(docInbox, { payload: { onTheDocument: true }, ts: 2 });
|
||||
|
||||
// Alice connects: one call, both queues.
|
||||
setCurrentUser("alice");
|
||||
await connectedUser();
|
||||
|
||||
expect(capFor(carolDoc)).toBeDefined(); // 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
|
||||
});
|
||||
@@ -1,333 +0,0 @@
|
||||
import { test, expect, mock, beforeEach, afterAll } from "bun:test";
|
||||
import { submitToIndex, readIndex, watchIndex, INDEX_ACCOUNT } from "../src/discovery";
|
||||
import type { IndexEntry } from "../src/discovery";
|
||||
import {
|
||||
configure,
|
||||
configureStoreRegistry,
|
||||
resetStoreRegistry,
|
||||
resetConfig,
|
||||
setCurrentUser,
|
||||
getCaps,
|
||||
resetCaps,
|
||||
} from "../src/polyfill";
|
||||
import { resetRegistryCache, ensureAccount } from "../src/store-registry";
|
||||
import type { RegistrySession } from "../src/store-registry";
|
||||
|
||||
// discovery.ts submits to / reads from a global index owned by a RESERVED
|
||||
// SPECIAL ACCOUNT (@index) in the shim. This suite injects one fake `ng` that
|
||||
// emulates BOTH the shim SPARQL (ensureAccount('@index') → doc_create ×3 +
|
||||
// shim INSERT/SELECT) AND the inbox SPARQL (deposit INSERT + read SELECT), over
|
||||
// a single in-memory quad store. Restore un-configured state at the end.
|
||||
afterAll(() => {
|
||||
resetConfig();
|
||||
resetStoreRegistry();
|
||||
setCurrentUser(null);
|
||||
resetCaps();
|
||||
});
|
||||
|
||||
test("throws a clear error when configureStoreRegistry() was not called", async () => {
|
||||
resetStoreRegistry();
|
||||
resetRegistryCache();
|
||||
await expect(submitToIndex({ ref: 1 })).rejects.toThrow(
|
||||
/configureStoreRegistry\(\) must be called before use/,
|
||||
);
|
||||
});
|
||||
|
||||
interface Quad { g: string; s: string; p: string; o: string }
|
||||
|
||||
const SHIM = "urn:ng-eventually:shim";
|
||||
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;
|
||||
}
|
||||
|
||||
// A stateful fake `ng` serving BOTH the shim and the inbox SPARQL.
|
||||
function makeFakeNg() {
|
||||
const quads: Quad[] = [];
|
||||
let docCounter = 0;
|
||||
|
||||
// Reactive subscriptions (see inbox.test.ts): doc_subscribe registers a
|
||||
// callback per anchor + fires an initial push; sparql_update pushes a Patch to
|
||||
// that anchor's subscribers, so discovery.watchIndex (now event-driven) works
|
||||
// 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 } } }));
|
||||
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:doc${++docCounter}`);
|
||||
|
||||
const sparql_update = mock(async (...a: unknown[]) => {
|
||||
const query = a[1] as string;
|
||||
const anchor = a[2] as string | undefined;
|
||||
// TWO shapes coexist: the shim account write STILL uses `GRAPH <${priv}>`
|
||||
// (the private-store repo's graph name equals the plain store NURI → it
|
||||
// round-trips; key by that GRAPH IRI). The inbox deposit write has NO
|
||||
// explicit GRAPH — the real broker keys it by the ANCHORED repo's default
|
||||
// graph (repo_graph_name(id, overlay)); key it by the ANCHOR arg (a[2]).
|
||||
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) {
|
||||
// `a` → an rdf:type marker; the two type IRIs the modules use differ, so
|
||||
// pick by which body we're in (deposit vs account) — harmless if wrong,
|
||||
// the SELECT filters by the real predicates below.
|
||||
const isDeposit = query.includes(`${INBOX}:Deposit`);
|
||||
const p = m[1] ?? (isDeposit ? `${INBOX}:Deposit` : `${SHIM}:Account`);
|
||||
const o = m[2] !== undefined ? unescapeLiteral(m[2]) : (m[3] ?? "");
|
||||
quads.push({ g, s, p, o });
|
||||
}
|
||||
pushTo(g); // local-push to the written graph's subscribers
|
||||
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(`<${SHIM}:shimDoc>`)) {
|
||||
const bindings = quads
|
||||
.filter((q) => q.g === anchor && q.p === `${SHIM}:shimDoc`)
|
||||
.map((q) => ({ shimDoc: { value: q.o } }));
|
||||
return { results: { bindings } };
|
||||
}
|
||||
// Shim account SELECT (anchored to the doc-shim, no GRAPH wrapper). Two shapes:
|
||||
// the full scan (`?acc a <Account>`) and the TARGETED bounded resolve (`<subj> a
|
||||
// <Account>`) — honour that subject filter so the bounded query is O(1)/exact.
|
||||
if (query.includes(`<${SHIM}:id>`)) {
|
||||
const subjM = query.match(new RegExp(`<([^>]+)>\\s+a\\s+<${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 (?payload ?ts ?from).
|
||||
if (query.includes(`<${INBOX}:payload>`)) {
|
||||
const bySubject = new Map<string, Record<string, string>>();
|
||||
for (const q of quads) {
|
||||
if (q.g !== anchor) continue;
|
||||
if (q.p === `${INBOX}:Deposit`) {
|
||||
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 } };
|
||||
}
|
||||
// Entity-index SELECT (shim contains) — unused here.
|
||||
return { results: { bindings: [] } };
|
||||
});
|
||||
|
||||
return { doc_create, doc_subscribe, 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();
|
||||
setCurrentUser(null);
|
||||
return ng;
|
||||
}
|
||||
|
||||
let fake: ReturnType<typeof makeFakeNg>;
|
||||
beforeEach(() => {
|
||||
fake = inject();
|
||||
});
|
||||
|
||||
test("submitToIndex creates the @index special account on first sight (3 docs)", async () => {
|
||||
await submitToIndex({ nuri: "did:ng:o:event1", title: "Concert" });
|
||||
// ensureAccount('@index') created its 3 scope docs + 1 doc-shim (first login).
|
||||
expect(fake.doc_create).toHaveBeenCalledTimes(4);
|
||||
// The deposit landed in the @index public document (its inbox).
|
||||
const depositCall = fake.sparql_update.mock.calls.find((c) =>
|
||||
(c[1] as string).includes(`${INBOX}:Deposit`),
|
||||
)!;
|
||||
expect(depositCall, "a deposit INSERT was issued").not.toBeUndefined();
|
||||
expect(depositCall[2]).toMatch(/^did:ng:o:doc/); // the index document NURI
|
||||
});
|
||||
|
||||
test("submit → read round-trips the reference as an index entry", async () => {
|
||||
setCurrentUser("alice"); // `from` is bound to the current identity
|
||||
const ref = { nuri: "did:ng:o:event1", title: "Concert au parc" };
|
||||
await submitToIndex(ref, { from: "alice", ts: 100 });
|
||||
const entries = await readIndex();
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]).toEqual({ ref, from: "alice", ts: 100 } as IndexEntry);
|
||||
});
|
||||
|
||||
test("a reference submitted by A is discovered by a NON-connected reader via the index", async () => {
|
||||
// A submits (identified). No connection is ever declared. A separate reader
|
||||
// materializes the SAME index (same special account → same document) and sees
|
||||
// the reference — discovery is via the index, not any direct fan-out/link.
|
||||
setCurrentUser("alice");
|
||||
const ref = { nuri: "did:ng:o:evA", title: "Public event by A" };
|
||||
await submitToIndex(ref, { ts: 100 });
|
||||
|
||||
// Reader B: a fresh cache, never connected to A, reads the index.
|
||||
resetRegistryCache();
|
||||
setCurrentUser("bob");
|
||||
const entries = await readIndex();
|
||||
const refs = entries.map((e) => e.ref);
|
||||
expect(refs).toContainEqual(ref);
|
||||
expect(entries.find((e) => JSON.stringify(e.ref) === JSON.stringify(ref))!.from).toBe("alice");
|
||||
});
|
||||
|
||||
test("readIndex deduplicates identical references (materialization moderation point)", async () => {
|
||||
const ref = { nuri: "did:ng:o:dup", title: "Twice" };
|
||||
// Anonymous submissions (dedup keys on the ref, not the submitter).
|
||||
await submitToIndex(ref, { from: null, ts: 100 });
|
||||
await submitToIndex(ref, { from: null, ts: 200 }); // duplicate reference
|
||||
const entries = await readIndex();
|
||||
expect(entries).toHaveLength(1); // surfaced once
|
||||
});
|
||||
|
||||
test("from: null makes an anonymous submission", async () => {
|
||||
await submitToIndex({ nuri: "did:ng:o:anon" }, { from: null, ts: 100 });
|
||||
const entries = await readIndex();
|
||||
expect(entries[0]!.from).toBeNull();
|
||||
});
|
||||
|
||||
// (d) PUBLIC-ONLY: a protected/private document must NOT be submittable to the
|
||||
// world-readable discovery index; a public (or ungoverned) document is fine.
|
||||
test("(d) submitToIndex refuses a PROTECTED/PRIVATE document (public-only)", async () => {
|
||||
resetCaps();
|
||||
// A PROTECTED and a PRIVATE governed document, and a PUBLIC one.
|
||||
getCaps().open("did:ng:o:prot", "protected", "alice");
|
||||
getCaps().open("did:ng:o:priv", "private", "alice");
|
||||
getCaps().open("did:ng:o:pub", "public", "alice");
|
||||
|
||||
// Submitting the protected doc's NURI is REJECTED.
|
||||
await expect(
|
||||
submitToIndex({ nuri: "did:ng:o:prot" }, { from: null, doc: "did:ng:o:prot" }),
|
||||
).rejects.toThrow(/PUBLIC|public-only|protected\/private/i);
|
||||
// Private too.
|
||||
await expect(
|
||||
submitToIndex({ nuri: "did:ng:o:priv" }, { from: null, doc: "did:ng:o:priv" }),
|
||||
).rejects.toThrow(/PUBLIC|public-only|protected\/private/i);
|
||||
// The PUBLIC document passes.
|
||||
await submitToIndex({ nuri: "did:ng:o:pub" }, { from: null, doc: "did:ng:o:pub", ts: 1 });
|
||||
const entries = await readIndex();
|
||||
expect(entries.map((e) => (e.ref as { nuri: string }).nuri)).toEqual(["did:ng:o:pub"]);
|
||||
resetCaps();
|
||||
});
|
||||
|
||||
test("INDEX_ACCOUNT lives in the reserved namespace (no typed id can equal it)", () => {
|
||||
// The index account occupies a key no consumer input can produce: it is prefixed
|
||||
// with a NUL control char, which a user cannot type into an id field and
|
||||
// which no `normalizeId` output (a typeable value) contains. So it is
|
||||
// disjoint from the keys "index" / "@index" a hostile user would submit.
|
||||
expect(INDEX_ACCOUNT.startsWith("\u0000")).toBe(true); // unreachable-by-typing sentinel
|
||||
expect(INDEX_ACCOUNT).not.toBe("index");
|
||||
expect(INDEX_ACCOUNT).not.toBe("@index");
|
||||
});
|
||||
|
||||
test("a user named 'index'/'@index' does NOT resolve to the index account's document", async () => {
|
||||
// The discovery index lives on INDEX_ACCOUNT. A hostile (or unlucky) user who
|
||||
// registers as "index" or "@index" normalizes to key "index" — which must be
|
||||
// a DISJOINT key from the reserved index account, so they get their own
|
||||
// documents and cannot hijack / read-write the global index document.
|
||||
const indexRecord = await ensureAccount(INDEX_ACCOUNT);
|
||||
|
||||
// A real user "index" — same normalized form as "@index".
|
||||
const userIndex = await ensureAccount("index");
|
||||
expect(userIndex.docPublic).not.toBe(indexRecord.docPublic);
|
||||
expect(userIndex.docProtected).not.toBe(indexRecord.docProtected);
|
||||
expect(userIndex.docPrivate).not.toBe(indexRecord.docPrivate);
|
||||
|
||||
// "@index" must land on the SAME account as "index" (both normalize to
|
||||
// "index") — and still NOT on the reserved index account.
|
||||
const userAtIndex = await ensureAccount("@index");
|
||||
expect(userAtIndex.docPublic).toBe(userIndex.docPublic);
|
||||
expect(userAtIndex.docPublic).not.toBe(indexRecord.docPublic);
|
||||
});
|
||||
|
||||
test("watchIndex fires immediately then when a submission arrives", async () => {
|
||||
const seen: IndexEntry[][] = [];
|
||||
const stop = watchIndex((e) => seen.push(e), { intervalMs: 5 });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(seen.length).toBeGreaterThanOrEqual(1);
|
||||
expect(seen[seen.length - 1]).toEqual([]);
|
||||
|
||||
await submitToIndex({ nuri: "did:ng:o:watched" }, { from: null, ts: 1 });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
const last = seen[seen.length - 1]!;
|
||||
expect(last.map((e) => (e.ref as any).nuri)).toContain("did:ng:o:watched");
|
||||
|
||||
stop();
|
||||
const countAfterStop = seen.length;
|
||||
await submitToIndex({ nuri: "did:ng:o:after" }, { from: null, ts: 2 });
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(seen.length).toBe(countAfterStop);
|
||||
});
|
||||
@@ -1,5 +1,12 @@
|
||||
import { test, expect, mock } from "bun:test";
|
||||
import { test, expect, mock, beforeEach } from "bun:test";
|
||||
import { docCreate, sparqlUpdate, sparqlQuery } from "../src/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/ng-proxy";
|
||||
|
||||
// NOTE ORDER: the "not configured → throw" case MUST run before any configure()
|
||||
@@ -18,7 +25,7 @@ test("throws a clear error when configure() was not called", async () => {
|
||||
});
|
||||
|
||||
// From here on, a fake real `ng` is injected via configure().
|
||||
import { configure } from "../src/polyfill";
|
||||
import { configure, resetCaps, setCurrentUser } from "../src/polyfill";
|
||||
|
||||
function fakeNg() {
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { test, expect, mock, beforeEach, afterAll } from "bun:test";
|
||||
import { post, read, materialize, watch } from "../src/inbox";
|
||||
import { walletInbox, resetRegistryCache } from "../src/store-registry";
|
||||
import type { Deposit } from "../src/inbox";
|
||||
import {
|
||||
configure,
|
||||
@@ -147,7 +148,8 @@ function makeFakeNg() {
|
||||
}
|
||||
|
||||
const SESSION: RegistrySession = { sessionId: "sid-1", privateStoreId: "PRIV" };
|
||||
const TARGET = "did:ng:o:host-inbox";
|
||||
/** 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();
|
||||
@@ -159,15 +161,20 @@ function inject() {
|
||||
}
|
||||
|
||||
let fake: ReturnType<typeof makeFakeNg>;
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
fake = inject();
|
||||
resetRegistryCache();
|
||||
setCurrentUser("alice");
|
||||
TARGET = await walletInbox("alice");
|
||||
});
|
||||
|
||||
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).toHaveBeenCalledTimes(1);
|
||||
const call = fake.sparql_update.mock.calls[0]!;
|
||||
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 <…>`
|
||||
|
||||
@@ -1,31 +1,36 @@
|
||||
/**
|
||||
* ReadCap ACTIVE — end-to-end proof that the emulated SDK enforces per-DOCUMENT
|
||||
* isolation, driven by per-entity documents + DIRECTED read grants.
|
||||
* isolation, driven by per-entity documents + KEY POSSESSION.
|
||||
*
|
||||
* Mirrors what the app does: create an entity document through the REAL registry
|
||||
* (`createEntityDoc`), declare its cap policy via `getCaps().open(doc, scope,
|
||||
* owner)`, set the current identity, and — when the app decides two identities
|
||||
* are related — issue a DIRECTED read grant on each of the owner's protected
|
||||
* documents (`getCaps().grantRead(doc, granteeId)`). Whether identities are
|
||||
* "connected" is the application's own concept: this test plays that role
|
||||
* directly. The read filter then discriminates:
|
||||
* (a) an ungranted principal is denied a PROTECTED doc; granted once the owner
|
||||
* issues a directed grant; PUBLIC readable throughout — via the ACTIVE
|
||||
* ReadCap.
|
||||
* (b) no grant → no protected read (a reader cannot grant itself).
|
||||
* (`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) a bare reference grants NOTHING (naming is not reading), while the repo
|
||||
* link of a published document opens it for whoever receives it;
|
||||
* (c) switching identity SWITCHES heldByHolder — it never wipes one.
|
||||
*/
|
||||
import { test, expect, mock, afterAll } from "bun:test";
|
||||
import { createEntityDoc, resetRegistryCache } from "../src/store-registry";
|
||||
import { createEntityDoc, resetRegistryCache, walletInbox, listMyEntityDocs } from "../src/store-registry";
|
||||
import type { RegistrySession } from "../src/store-registry";
|
||||
import type { ReadCap } from "../src/types";
|
||||
import {
|
||||
configure,
|
||||
configureStoreRegistry,
|
||||
resetStoreRegistry,
|
||||
resetConfig,
|
||||
capFor,
|
||||
getCaps,
|
||||
resetCaps,
|
||||
setCurrentUser,
|
||||
shareCap,
|
||||
} from "../src/polyfill";
|
||||
import { read as readInbox } from "../src/inbox";
|
||||
import { filterReadable } from "../src/read-filter";
|
||||
|
||||
afterAll(() => {
|
||||
@@ -36,93 +41,371 @@ afterAll(() => {
|
||||
});
|
||||
|
||||
const SESSION: RegistrySession = { sessionId: "sid", privateStoreId: "PRIV" };
|
||||
const SHIM = "urn:ng-eventually:shim";
|
||||
const INBOX = "urn:ng-eventually:inbox";
|
||||
|
||||
function inject() {
|
||||
let n = 0;
|
||||
const ng = {
|
||||
doc_create: mock(async () => `did:ng:o:doc${++n}`),
|
||||
sparql_update: mock(async () => undefined),
|
||||
sparql_query: mock(async () => ({ results: { bindings: [] } })),
|
||||
};
|
||||
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 });
|
||||
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
|
||||
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
|
||||
configureStoreRegistry({ getSession: async () => SESSION, normalizeId });
|
||||
resetRegistryCache();
|
||||
resetCaps();
|
||||
setCurrentUser(null);
|
||||
return ng;
|
||||
}
|
||||
|
||||
/** The app's relationship concept, played inline: grant `reader` the read cap of
|
||||
* every protected document owned by `owner`. */
|
||||
function grantOwnerProtectedTo(owner: string, reader: string) {
|
||||
for (const doc of getCaps().protectedDocsOf(owner)) getCaps().grantRead(doc, reader);
|
||||
}
|
||||
/** 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("ReadCap active: a private entity doc created via the real registry is hidden from another principal", async () => {
|
||||
|
||||
test("a created document is readable by its creator and by nobody else", async () => {
|
||||
inject();
|
||||
|
||||
setCurrentUser("alice");
|
||||
const aliceDoc = await createEntityDoc("alice", "private");
|
||||
getCaps().open(aliceDoc, "private", "alice");
|
||||
setCurrentUser("bob");
|
||||
const bobDoc = await createEntityDoc("bob", "private");
|
||||
|
||||
const bobDoc = await createEntityDoc("bob", "public");
|
||||
getCaps().open(bobDoc, "public", "bob");
|
||||
const items = [item(aliceDoc, "a1"), item(bobDoc, "b1")];
|
||||
|
||||
const items = [
|
||||
{ "@graph": aliceDoc, "@id": "a1", label: "alice-private" },
|
||||
{ "@graph": bobDoc, "@id": "b1", label: "bob-public" },
|
||||
];
|
||||
|
||||
expect(filterReadable(items, getCaps(), "bob").map((i) => i["@id"])).toEqual(["b1"]);
|
||||
expect(filterReadable(items, getCaps(), "alice").map((i) => i["@id"]).sort()).toEqual(["a1", "b1"]);
|
||||
expect(filterReadable(items, getCaps(), null).map((i) => i["@id"])).toEqual(["b1"]);
|
||||
expect(getCaps().hasReadPolicy()).toBe(true);
|
||||
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) protected hidden while ungranted → revealed after a DIRECTED grant; public
|
||||
// readable regardless — all through the ACTIVE ReadCap.
|
||||
test("(a) PROTECTED doc: hidden ungranted, revealed after a DIRECTED grant, PUBLIC always readable", async () => {
|
||||
// (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")];
|
||||
|
||||
const aliceProtected = await createEntityDoc("alice", "protected");
|
||||
getCaps().open(aliceProtected, "protected", "alice");
|
||||
const alicePublic = await createEntityDoc("alice", "public");
|
||||
getCaps().open(alicePublic, "public", "alice");
|
||||
// BEFORE the share: bob reads nothing of alice's.
|
||||
setCurrentUser("bob");
|
||||
expect(view(items)).toEqual([]);
|
||||
|
||||
const items = [
|
||||
{ "@graph": aliceProtected, "@id": "p1" },
|
||||
{ "@graph": alicePublic, "@id": "u1" },
|
||||
];
|
||||
const view = (u: string) => filterReadable(items, getCaps(), u).map((i) => i["@id"]).sort();
|
||||
// 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 walletInbox("bob");
|
||||
setCurrentUser("alice");
|
||||
await shareCap(capFor(shared)!, bobInbox);
|
||||
|
||||
// BEFORE any grant: bob sees only the public item.
|
||||
expect(view("bob")).toEqual(["u1"]);
|
||||
expect(view("alice")).toEqual(["p1", "u1"]);
|
||||
// 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`
|
||||
|
||||
// The app decides alice↔bob are related and grants bob the read cap of alice's
|
||||
// protected documents.
|
||||
grantOwnerProtectedTo("alice", "bob");
|
||||
|
||||
expect(view("bob")).toEqual(["p1", "u1"]);
|
||||
// A third, ungranted principal still sees only the public one.
|
||||
expect(view("carol")).toEqual(["u1"]);
|
||||
// carol, who was not shared with, still reads nothing.
|
||||
setCurrentUser("carol");
|
||||
await readInbox(await walletInbox("carol"));
|
||||
expect(view(items)).toEqual([]);
|
||||
});
|
||||
|
||||
// (b) An identity gets no protected read until the OWNER issues the grant — a
|
||||
// reader cannot grant itself.
|
||||
test("(b) no directed grant → no protected read", async () => {
|
||||
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 walletInbox("bob");
|
||||
await shareCap(capFor(doc)!, bobInbox);
|
||||
|
||||
const aliceProtected = await createEntityDoc("alice", "protected");
|
||||
getCaps().open(aliceProtected, "protected", "alice");
|
||||
const items = [{ "@graph": aliceProtected, "@id": "p1" }];
|
||||
const view = (u: string) => filterReadable(items, getCaps(), u).map((i) => i["@id"]);
|
||||
|
||||
// mallory holds no grant on alice's protected doc → denied.
|
||||
expect(view("mallory")).toEqual([]);
|
||||
|
||||
// Granting bob (a different, legitimate reader) leaves mallory denied.
|
||||
grantOwnerProtectedTo("alice", "bob");
|
||||
expect(view("mallory")).toEqual([]);
|
||||
expect(view("bob")).toEqual(["p1"]);
|
||||
setCurrentUser("bob");
|
||||
const deposits = await readInbox(bobInbox);
|
||||
expect(deposits).toEqual([]); // infrastructure, not consumer data
|
||||
expect(capFor(doc)).toBeDefined(); // …but it landed in bob's held caps
|
||||
});
|
||||
|
||||
// (b) A bare reference grants nothing; the repo link of a published document does.
|
||||
test("(b) a bare reference reads nothing; the repo link of a published document opens it", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const pub = await createEntityDoc("alice", "public");
|
||||
const items = [item(pub, "u1")];
|
||||
expect(getCaps().isPublished(pub)).toBe(true);
|
||||
const link = capFor(pub)!;
|
||||
|
||||
// bob HAS the document's bare NURI (it is right there in `items`) and reads nothing.
|
||||
setCurrentUser("bob");
|
||||
expect(view(items)).toEqual([]);
|
||||
|
||||
// Receiving the repo link — what a discovery entry actually carries — opens it.
|
||||
getCaps().learn(link);
|
||||
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");
|
||||
const cap = capFor(doc);
|
||||
expect(cap).toBeDefined();
|
||||
|
||||
setCurrentUser("bob");
|
||||
expect(capFor(doc)).toBeUndefined();
|
||||
|
||||
setCurrentUser("alice");
|
||||
expect(capFor(doc)).toBe(cap!); // 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");
|
||||
const cap = capFor(doc);
|
||||
expect(cap).toBeDefined();
|
||||
|
||||
// Same account, spelled differently — same shim account, so the same held caps.
|
||||
setCurrentUser("alice");
|
||||
expect(capFor(doc)).toBe(cap!);
|
||||
setCurrentUser(" ALICE ");
|
||||
expect(capFor(doc)).toBe(cap!);
|
||||
|
||||
// A genuinely different account still holds nothing.
|
||||
setCurrentUser("bob");
|
||||
expect(capFor(doc)).toBeUndefined();
|
||||
});
|
||||
|
||||
// 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 walletInbox("bob");
|
||||
|
||||
// Alice deposits into bob's inbox — allowed, and it grants her nothing back.
|
||||
await shareCap(capFor(secret)!, bobInbox);
|
||||
await expect(readInbox(bobInbox)).rejects.toThrow(/does not belong to the connected wallet/i);
|
||||
expect(capFor(secret)).toBeDefined(); // 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(capFor(secret)).toBeUndefined(); // 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(capFor(secret)).toBeDefined();
|
||||
});
|
||||
|
||||
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/store-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(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(capFor(doc)).toBe(stored.o as ReadCap); // same value, not two mints that agree by luck
|
||||
});
|
||||
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
setCurrentUser,
|
||||
} from "../src/polyfill";
|
||||
|
||||
// This suite injects a fake `ng` via configure() and declares write caps. Reset
|
||||
// both after each test so the docs.test.ts "not configured" guard still holds
|
||||
// and no cap policy leaks into another suite.
|
||||
// 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();
|
||||
@@ -40,7 +42,7 @@ test("write guard: passthrough when NO write policy is declared (no regression)"
|
||||
|
||||
test("write guard: passthrough for an UNGOVERNED doc even when a policy exists elsewhere", async () => {
|
||||
const ng = inject();
|
||||
getCaps().open("did:ng:o:other", "private", "alice"); // policy on another doc
|
||||
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
|
||||
@@ -49,7 +51,7 @@ test("write guard: passthrough for an UNGOVERNED doc even when a policy exists e
|
||||
|
||||
test("write guard: REJECTS when the doc is governed and the user lacks the write cap", async () => {
|
||||
const ng = inject();
|
||||
getCaps().open(DOC, "private", "alice"); // alice holds write cap
|
||||
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(
|
||||
@@ -60,7 +62,7 @@ test("write guard: REJECTS when the doc is governed and the user lacks the write
|
||||
|
||||
test("write guard: REJECTS an anonymous (null) user on a governed doc", async () => {
|
||||
const ng = inject();
|
||||
getCaps().open(DOC, "public", "alice");
|
||||
getCaps().grantWrite(DOC, "alice");
|
||||
setCurrentUser(null);
|
||||
const proxy = makeNg();
|
||||
await expect(proxy.sparql_update("sid", UPDATE, DOC)).rejects.toThrow(
|
||||
@@ -71,7 +73,7 @@ test("write guard: REJECTS an anonymous (null) user on a governed doc", async ()
|
||||
|
||||
test("write guard: ALLOWS the write-cap holder", async () => {
|
||||
const ng = inject();
|
||||
getCaps().open(DOC, "private", "alice");
|
||||
getCaps().grantWrite(DOC, "alice");
|
||||
setCurrentUser("alice"); // owner always holds the write cap
|
||||
const proxy = makeNg();
|
||||
await proxy.sparql_update("sid", UPDATE, DOC);
|
||||
@@ -80,7 +82,7 @@ test("write guard: ALLOWS the write-cap holder", async () => {
|
||||
|
||||
test("write guard: passthrough when anchor is omitted (cannot scope the guard)", async () => {
|
||||
const ng = inject();
|
||||
getCaps().open(DOC, "private", "alice");
|
||||
getCaps().grantWrite(DOC, "alice");
|
||||
setCurrentUser("bob");
|
||||
const proxy = makeNg();
|
||||
await proxy.sparql_update("sid", "INSERT DATA {}"); // no anchor → passthrough
|
||||
|
||||
@@ -20,7 +20,15 @@
|
||||
import { describe, it, expect, mock, beforeEach, afterAll } from "bun:test";
|
||||
import { ensureRepoOpen, ensureReposOpen, resetOpenedRepos } from "../src/open-repo";
|
||||
import { readUnion } from "../src/read-model";
|
||||
import { configure, configureStoreRegistry, resetStoreRegistry, resetConfig } from "../src/polyfill";
|
||||
import {
|
||||
configure,
|
||||
configureStoreRegistry,
|
||||
resetStoreRegistry,
|
||||
resetConfig,
|
||||
resetCaps,
|
||||
setCurrentUser,
|
||||
} from "../src/polyfill";
|
||||
import { resetInfrastructure } from "../src/reach";
|
||||
import { resetRegistryCache } from "../src/store-registry";
|
||||
|
||||
afterAll(() => {
|
||||
@@ -30,9 +38,15 @@ afterAll(() => {
|
||||
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" };
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 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/docs";
|
||||
import { createEntityDoc, resetRegistryCache, walletInbox } from "../src/store-registry";
|
||||
import type { RegistrySession } from "../src/store-registry";
|
||||
import {
|
||||
configure,
|
||||
configureStoreRegistry,
|
||||
resetStoreRegistry,
|
||||
resetConfig,
|
||||
resetCaps,
|
||||
setCurrentUser,
|
||||
} from "../src/polyfill";
|
||||
import { mayReach, mustNotAttempt } from "../src/reach";
|
||||
import { hasReadCap } from "../src/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 walletInbox("alice");
|
||||
|
||||
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 walletInbox("bob");
|
||||
|
||||
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");
|
||||
const polyfill: Record<string, unknown> = await import("../src/polyfill");
|
||||
|
||||
for (const surface of [entry, polyfill]) {
|
||||
for (const name of Object.keys(surface)) {
|
||||
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();
|
||||
expect(polyfill[forbidden]).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();
|
||||
}
|
||||
});
|
||||
@@ -3,57 +3,82 @@ import { filterReadable, makeReadFilteredView } from "../src/read-filter";
|
||||
import { CapRegistry } from "../src/caps";
|
||||
|
||||
// The access unit is the DOCUMENT (an item's `@graph` = the repo it lives in),
|
||||
// not the item. Items here carry `@graph`; caps are granted per document.
|
||||
// not the item. Items here carry `@graph`; each holder holds caps per document.
|
||||
interface Item { id: string; "@graph"?: string }
|
||||
|
||||
const PRIV: Item = { id: "a", "@graph": "did:ng:o:alice" }; // alice's doc
|
||||
const PUB: Item = { id: "p", "@graph": "did:ng:o:public" }; // public doc
|
||||
const UNGOV: Item = { id: "n", "@graph": "did:ng:o:other" }; // doc under no policy
|
||||
const NOGRAPH: Item = { id: "x" }; // no document → kept
|
||||
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
|
||||
|
||||
function caps(): CapRegistry {
|
||||
const c = new CapRegistry();
|
||||
c.grantRead("did:ng:o:alice", "alice");
|
||||
c.makePublic("did:ng:o:public");
|
||||
return c;
|
||||
/** 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.publishRepoLink("did:ng:o:public");
|
||||
holder = before;
|
||||
return { caps, link, become: (id: string | null) => (holder = id) };
|
||||
}
|
||||
|
||||
test("filterReadable keeps public, cap-held, ungoverned and graphless items", () => {
|
||||
const items = [PRIV, PUB, UNGOV, NOGRAPH];
|
||||
expect(filterReadable(items, caps(), "alice").map(i => (i as Item).id)).toEqual(["a", "p", "n", "x"]);
|
||||
expect(filterReadable(items, caps(), "bob").map(i => (i as Item).id)).toEqual(["p", "n", "x"]);
|
||||
expect(filterReadable(items, caps(), null).map(i => (i as Item).id)).toEqual(["p", "n", "x"]);
|
||||
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("makeReadFilteredView filters iteration/size, reflects the current user", () => {
|
||||
const set = new Set<Item>([PRIV, PUB, UNGOV, NOGRAPH]);
|
||||
let user: string | null = "bob";
|
||||
const view = makeReadFilteredView(set, caps(), () => user);
|
||||
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([]);
|
||||
});
|
||||
|
||||
expect([...view].map(i => i.id)).toEqual(["p", "n", "x"]);
|
||||
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);
|
||||
|
||||
user = "alice"; // read lazily → view updates without rewrapping
|
||||
expect([...view].map(i => i.id)).toEqual(["a", "p", "n", "x"]);
|
||||
expect(view.size).toBe(4);
|
||||
});
|
||||
|
||||
test("makeReadFilteredView forwards mutations and membership to the target", () => {
|
||||
const set = new Set<Item>([PUB]);
|
||||
const view = makeReadFilteredView(set, caps(), () => "bob");
|
||||
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"]);
|
||||
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>([PRIV, PUB]);
|
||||
const set = new Set<Item>([MINE, LINKED]);
|
||||
const seen: string[] = [];
|
||||
makeReadFilteredView(set, caps(), () => "bob").forEach((i) => seen.push((i as Item).id));
|
||||
expect(seen).toEqual(["p"]);
|
||||
const { caps, become } = setup("alice");
|
||||
become("bob");
|
||||
makeReadFilteredView(set, caps).forEach((i) => seen.push((i as Item).id));
|
||||
expect(seen).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { test, expect, mock } from "bun:test";
|
||||
import { test, expect, mock, afterAll } from "bun:test";
|
||||
import { readUnion } from "../src/read-model";
|
||||
import { configure, configureStoreRegistry } from "../src/polyfill";
|
||||
import type { Nuri } from "../src/types";
|
||||
import {
|
||||
configure,
|
||||
configureStoreRegistry,
|
||||
getCaps,
|
||||
resetCaps,
|
||||
setCurrentUser,
|
||||
} from "../src/polyfill";
|
||||
|
||||
// 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
|
||||
@@ -31,6 +46,8 @@ function fakeNgWith(triplesByDoc: Record<string, Array<[string, string]>>) {
|
||||
|
||||
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" }),
|
||||
@@ -103,3 +120,26 @@ test("a doc that fails to read is skipped, not aborting the batch", async () =>
|
||||
// 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([]);
|
||||
});
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
import { test, expect, mock, beforeEach, afterAll } from "bun:test";
|
||||
import {
|
||||
ensureAccount,
|
||||
allAccounts,
|
||||
loadShim,
|
||||
resolveWriteGraph,
|
||||
resolveReadGraphs,
|
||||
resolveAccount,
|
||||
listMyEntityDocs,
|
||||
resolveScopeGraph,
|
||||
resolveInboxAnchor,
|
||||
walletInbox,
|
||||
createEntityDoc,
|
||||
listEntityDocs,
|
||||
resetRegistryCache,
|
||||
} from "../src/store-registry";
|
||||
import type { RegistrySession } from "../src/store-registry";
|
||||
@@ -223,19 +221,10 @@ test("ensureAccount de-dupes CONCURRENT provisions (anti-fork): one account, 3 d
|
||||
for (const r of results) expect(r).toEqual(results[0]!);
|
||||
});
|
||||
|
||||
test("loadShim round-trips a persisted account across a cache reset", async () => {
|
||||
await ensureAccount("Bob");
|
||||
resetRegistryCache(); // force a re-read from the fake store
|
||||
const map = await loadShim();
|
||||
const rec = map.get("bob");
|
||||
expect(rec?.id).toBe("Bob");
|
||||
expect(rec?.docPublic).toMatch(/^did:ng:o:doc/);
|
||||
});
|
||||
|
||||
test("resolveWriteGraph returns the per-scope index doc; resolveReadGraphs fans out", async () => {
|
||||
test("resolveWriteGraph returns the per-scope index doc", async () => {
|
||||
const rec = await ensureAccount("Carol");
|
||||
expect(await resolveWriteGraph("carol", "protected")).toBe(rec.docProtected);
|
||||
expect(await resolveReadGraphs("public")).toEqual([rec.docPublic]);
|
||||
});
|
||||
|
||||
test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to the caller)", async () => {
|
||||
@@ -255,14 +244,15 @@ test("resolveScopeGraph maps scopes to native store NURIs (no store-id leaks to
|
||||
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
|
||||
// The inbox anchor is now a DEDICATED inbox DOCUMENT (a reserved account's
|
||||
// public scope doc, from docCreate) — NOT the private-store root — so inbox
|
||||
// deposits don't bloat the shim graph. It is a real repo NURI and STABLE
|
||||
// across calls (same reserved account → same document).
|
||||
const anchor = await resolveInboxAnchor();
|
||||
expect(anchor).toMatch(/^did:ng:o:doc/);
|
||||
expect(anchor).not.toBe("did:ng:PRIV");
|
||||
expect(await resolveInboxAnchor()).toBe(anchor); // stable
|
||||
// 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 walletInbox("@alice");
|
||||
expect(mine).toMatch(/^did:ng:o:doc/);
|
||||
expect(mine).not.toBe("did:ng:PRIV");
|
||||
expect(await walletInbox("@alice")).toBe(mine); // stable
|
||||
expect(await walletInbox("@bob")).not.toBe(mine); // another wallet, another inbox
|
||||
});
|
||||
|
||||
test("resolveScopeGraph falls back to the private store when no protected id is injected", async () => {
|
||||
@@ -272,34 +262,21 @@ test("resolveScopeGraph falls back to the private store when no protected id is
|
||||
expect(await resolveScopeGraph("public")).toBe("did:ng:PRIV");
|
||||
});
|
||||
|
||||
test("createEntityDoc + listEntityDocs round-trip via the per-scope index", async () => {
|
||||
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 listEntityDocs("public");
|
||||
const pub = await listMyEntityDocs("dave", "public");
|
||||
expect(pub.sort()).toEqual([e1, e2].sort());
|
||||
const prot = await listEntityDocs("protected");
|
||||
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/);
|
||||
});
|
||||
|
||||
test("listEntityDocs fans out across multiple accounts", async () => {
|
||||
await ensureAccount("Eve");
|
||||
await ensureAccount("Frank");
|
||||
const e = await createEntityDoc("eve", "public");
|
||||
const f = await createEntityDoc("frank", "public");
|
||||
expect((await listEntityDocs("public")).sort()).toEqual([e, f].sort());
|
||||
});
|
||||
|
||||
test("allAccounts reflects every ensured account", async () => {
|
||||
await ensureAccount("Gina");
|
||||
await ensureAccount("Hank");
|
||||
const names = (await allAccounts()).map((a) => a.id).sort();
|
||||
expect(names).toEqual(["Gina", "Hank"]);
|
||||
});
|
||||
|
||||
// --- SPARQL injection hardening (F1) --------------------------------------
|
||||
//
|
||||
@@ -384,12 +361,11 @@ test("injection: a malicious id still round-trips through the shim", async () =>
|
||||
const rec = await ensureAccount(evil);
|
||||
expect(rec.id).toBe(evil);
|
||||
resetRegistryCache();
|
||||
const map = await loadShim();
|
||||
// The stored id came back verbatim (escaping is lossless) under its
|
||||
// normalized key, and exactly ONE account exists (no injected extra subject).
|
||||
const key = evil.trim().replace(/^@+/, "").toLowerCase();
|
||||
expect(map.get(key)?.id).toBe(evil);
|
||||
expect(map.size).toBe(1);
|
||||
// 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 () => {
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
configureStoreRegistry,
|
||||
resetStoreRegistry,
|
||||
resetConfig,
|
||||
resetCaps,
|
||||
setCurrentUser,
|
||||
} from "../src/polyfill";
|
||||
import { resetRegistryCache, createEntityDoc } from "../src/store-registry";
|
||||
@@ -156,6 +157,18 @@ function makeFake(opts?: { holdState?: boolean }) {
|
||||
}));
|
||||
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")
|
||||
@@ -214,6 +227,7 @@ function inject(ng: ReturnType<typeof makeFake>) {
|
||||
});
|
||||
resetRegistryCache();
|
||||
resetOpenedRepos();
|
||||
resetCaps();
|
||||
}
|
||||
|
||||
// Insert a triple straight into a doc's graph in the fake store (no push).
|
||||
@@ -242,6 +256,9 @@ afterAll(() => {
|
||||
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", () => {
|
||||
@@ -369,4 +386,5 @@ describe("watchShape", () => {
|
||||
expect(snap.data.length).toBe(1);
|
||||
unsub();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user