diff --git a/docs/api-contract.md b/docs/api-contract.md index 47b1a5f..e794052 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -336,7 +336,7 @@ declare function doc_subscribe(repo_o: string, session_id: any, callback: Functi ## 9. Inbox — deposits, and cap delivery -### Today — `@ng-eventually/client` (namespace `inbox`; `shareCap` also re-exported from `/polyfill`) +### Today — `@ng-eventually/client` (namespace `inbox`; `share` also re-exported from `/polyfill`) ```ts // inbox.ts:48,58 @@ -355,7 +355,7 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise; // inbox.ts:215 export async function postToDocument(doc: Nuri, opts: PostOptions): Promise; // inbox.ts:282 -export async function shareCap(cap: ReadCap, toInbox: Nuri): Promise; +export async function share(doc: NuriLike, toUser: string): Promise; // inbox.ts:339 export async function read(targetInbox: Nuri): Promise; // inbox.ts:419 @@ -384,7 +384,7 @@ export function watch( Consequences per function: - `post` / `postToDocument` — the sender-side act exists in the model (the broker routes `InboxPost` natively, `engine/net/src/server_broker.rs`); its JS surface does not. **The future SDK's name and signature are unknown** — `docs/nextgraph-current-state.md:187` records that nothing is announced. `postToDocument`'s resolution step (find the document's inbox address) rides on a **deliberate divergence**: this lib PUBLISHES the address on the document (Header-branch emulation), whereas upstream an address is only ever TRANSMITTED (`ContactDetails` carries `ng:site_inbox`/`ng:protected_inbox`, `engine/verifier/src/inbox_processor.rs:778-830`; the verifier's `inboxes` table is session-local, rebuilt empty — `verifier.rs:520,2820`). Documented in `docs/briefs/2026-08-03-document-inbox-addressing.md`. -- `shareCap` — a **gap upstream, not a disagreement**, verified at both ends: `ContactDetails.read_cap: Option` exists (`engine/net/src/types.rs:4233`) but building a message with it is `unimplemented!()` (`types.rs:3786`), its only caller passes `with_readcap: false`, and the receiving arm never reads the field (`inbox_processor.rs:778-830`). `InboxMsgContent::Link` is a **unit variant carrying nothing** (`types.rs:4252`) — do not read it as the delivery channel. The recipient-side filing the lib emulates is real: `AddLink { read_cap }` on the User branch (`engine/repo/src/types.rs:1939-1948`). The consumer's *act* (share one document's cap to one inbox) is target-shaped; only the transport is emulated. +- `share` — a **gap upstream, not a disagreement**, verified at both ends: `ContactDetails.read_cap: Option` exists (`engine/net/src/types.rs:4233`) but building a message with it is `unimplemented!()` (`types.rs:3786`), its only caller passes `with_readcap: false`, and the receiving arm never reads the field (`inbox_processor.rs:778-830`). `InboxMsgContent::Link` is a **unit variant carrying nothing** (`types.rs:4252`) — do not read it as the delivery channel. The recipient-side filing the lib emulates is real: `AddLink { read_cap }` on the User branch (`engine/repo/src/types.rs:1939-1948`). The consumer's *act* (share one document's cap to one inbox) is target-shaped; only the transport is emulated. - `read` / `materialize` / `readSynced` / `processInbox` / `watch` — **stand-ins for the recipient's own verifier processing**, which has no consumer-facing JS surface upstream and may never have this list-of-deposits shape. A consumer should treat "my inbox gets processed when I connect, and applied caps just appear in what I hold" as the durable contract (that is what `connectedUser` automates, § 13); code that leans on enumerating raw deposits as a mailbox UI is coding against emulation detail it may have to unlearn. The consumer-payload case (`Deposit.payload` as app data) maps to `InboxMsgContent` variants upstream (`types.rs:4249-4260`), of which only `ContactDetails` and `SocialQuery` are more than unit variants today — arbitrary app payloads through the inbox are an **ASSUMPTION**, constrained by the model only in that messages are sealed, per-recipient, and applied by the recipient. - `watch`'s `_opts?: { intervalMs?: number }` is accepted and **ignored** (kept for signature compatibility with a removed polling watcher) — dead surface, see § 15. @@ -405,11 +405,10 @@ export function hasReadCap(s: string): s is ReadCap; export type Nuri = `did:ng:${string}`; export type ReadCap = `did:ng:${string}:r:${string}`; -// @ng-eventually/client/polyfill — polyfill.ts:205 -export function capFor(nuri: Nuri): ReadCap | undefined; -// polyfill.ts:193 — hands out the registry itself -export function getCaps(): CapRegistry; -// polyfill.ts:215 — tests / fresh wallet only +// @ng-eventually/client/polyfill +// tests / fresh wallet only — the registry itself is NOT published, and neither is any +// "do I hold this?" predicate (`hasCap`, removed 2026-08-06: it read like "may I read +// this?", and a document in a public store answers `false` until something asks for it). export function resetCaps(): void; // @ng-eventually/client/polyfill — caps.ts:59 (class CapRegistry) @@ -417,8 +416,10 @@ constructor(holder?: () => PrincipalId | null); mint(nuri: Nuri): ReadCap; learn(cap: ReadCap): void; capFor(nuri: Nuri): ReadCap | undefined; -publishRepoLink(nuri: Nuri): ReadCap; -isPublished(nuri: Nuri): boolean; +learnFromPublicStore(cap: ReadCap): void; // a cap the public store SERVED — read only +isReadOnlyPublicCap(nuri: Nuri): boolean; +markInPublicStore(nuri: Nuri): void; +isInPublicStore(nuri: Nuri): boolean; open(nuri: Nuri, scope: Scope): ReadCap; isEnforcing(): boolean; onChange(listener: () => void): () => void; @@ -439,7 +440,7 @@ clear(): void; `capFor(nuri)` asks the only question the model admits — "do I hold this document's key?" — and returning `undefined` is the whole possible answer. There is no "may principal P read D?" anywhere, and the future SDK cannot offer one without inventing an ACL the engine does not have. That absence is a **finding about the target's model**, not a missing feature: a consumer should never expect a cap-introspection API. -The `CapRegistry` class itself is machinery (the in-memory record of what the connected holder holds — upstream's local user storage). The consumer-facing surface is `capFor` + the acts (`shareCap`, creating a document, processing one's inbox); see § 15. +The `CapRegistry` class itself is machinery (the in-memory record of what the connected holder holds — upstream's local user storage). It is not published at all: the consumer surface is the ACTS (creating a document, `inbox.share`, processing one's inbox — and, for a document in a public store, simply reading it), never a lookup; see § 15. --- @@ -586,10 +587,10 @@ export type { NG } from "@ng-org/web"; Exported, but not SDK surface. Coding against these builds knowledge that migration deletes: -- **`docs.depositInto`** — the named boundary-crossing write `inbox.post` uses. It is exported only because `inbox.ts` lives in another module; a consumer must always go through `inbox.post` / `inbox.shareCap`. Upstream a deposit is a sealed message, not a SPARQL update — this function's very signature is emulation. +- **`docs.depositInto`** — the named boundary-crossing write `inbox.post` uses. It is exported only because `inbox.ts` lives in another module; a consumer must always go through `inbox.post` / `inbox.share`. Upstream a deposit is a sealed message, not a SPARQL update — this function's very signature is emulation. - **`getConfig` / `getStoreRegistryDeps`** — tagged `@internal` in source, exported for the lib's own wrappers. - **`resetConfig` / `resetStoreRegistry` / `resetCaps` / `storeRegistry.resetRegistryCache`** — test/reset machinery. In particular `resetCaps` wipes EVERY holder's caps, which no product flow should ever do. -- **`getCaps()` and the `CapRegistry` class** — the registry is the emulation's engine room. The consumer surface is `capFor` (possession lookup), `inbox.shareCap` (grant), and the acts that file caps implicitly (creating a document, processing one's inbox). `CapRegistry.grantWrite` / `governsWrite` / `canWrite` / `hasWritePolicy` are explicitly decorative until P1b — the guard they feed is bypassed by every internal writer. +- **`getCaps()` and the `CapRegistry` class** — the registry is the emulation's engine room. The consumer surface is the acts that file caps: creating a document, `inbox.share` (grant), processing one's inbox, and reading a document a public store serves. `CapRegistry.grantWrite` / `governsWrite` / `canWrite` / `hasWritePolicy` are explicitly decorative until P1b — the guard they feed is bypassed by every internal writer. - ~~**`storeRegistry.reservedAccount`, `resolveAccount`, `ensureAccount`, `VirtualUserRecord`, `RegistrySession`**~~ — **RESOLVED 2026-08-03**: no longer exported. Shim internals, now in `docs/internal-contract.md`. The consumer's legitimate touchpoint is `configureStoreRegistry` (bootstrap) plus the scope/entity resolvers. - ~~**`storeRegistry.addLink` / `readLinks`**~~ — **RESOLVED 2026-08-03**: no longer exported. Consumers receive caps by processing their inbox (automated at connection); calling these directly baked in a register the verifier owns upstream. - ~~**`virtualUsers.*` on the SDK entry**~~ — **RESOLVED 2026-08-03**: moved to `/polyfill`, where its disappearance at migration is visible at the import line. @@ -599,7 +600,7 @@ Exported, but not SDK surface. Coding against these builds knowledge that migrat ### Places the current surface teaches something to unlearn - ~~**The SDK entry is not as pure as its header claims.**~~ **FIXED 2026-08-03.** The header claimed the entry "exposes ONLY what `@ng-org/web` / `@ng-org/orm` expose" while also shipping `virtualUsers` and the whole `store-registry` module. Both are gone from it, and the header now states what the entry actually promises: *every symbol here has a target-SDK counterpart, verified or assumed, listed in this document*. It still exports `docs`, `readUnion`, `watchShape`, `subscribeDoc(s)`, the SPARQL helpers and the NURI guards — justified inventions, documented per subject above — so the promise is no longer "@ng-org surface only", which was never true, but "nothing here is machinery". -- **`shareCap` is importable from both entries** (`inbox.shareCap` on the SDK entry via `export * as inbox`, and a named re-export on `/polyfill`). The polyfill re-export exists "so the cap vocabulary stays on the polyfill side" — but the namespace export undoes that. Harmless functionally; blurs the same boundary. +- **`share` is importable from both entries** (`inbox.share` on the SDK entry via `export * as inbox`, and a named re-export on `/polyfill`). The polyfill re-export exists "so the cap vocabulary stays on the polyfill side" — but the namespace export undoes that. Harmless functionally; blurs the same boundary. - **`inbox.read`/`materialize` as a mailbox** — enumerating raw deposits is emulation detail (§ 9); the durable contract is deposit-and-it-gets-applied. An app building UI on the deposit list should expect that surface to change shape entirely. - **`watchShape`'s "planned `useShape` upgrade"** — stated in the module header with no provenance in this repo or the clone (§ 5). The load-state *distinction* is safe; the claim that NextGraph plans this exact hook shape is an assumption and must not be cited as an announced API. - **`UnionSubject` property bags** — polyfill read-model shape, not a target type; map them into app types at the boundary (which `watchShape`'s design already assumes). @@ -623,5 +624,5 @@ storeRegistry: createEntityDoc, listMyEntityDocs, openDocumentInbox, resolveScop ### `@ng-eventually/client/polyfill` — `src/polyfill.ts` ```text -direct: EventuallyConfig, RegistrySession, StoreRegistryDeps, VirtualUserRecord, configure, configureStoreRegistry, connectedUser, getConfig, getStoreRegistryDeps, hasCap, resetCaps, resetConfig, resetStoreRegistry, setCurrentUser, share +direct: EventuallyConfig, RegistrySession, StoreRegistryDeps, VirtualUserRecord, configure, configureStoreRegistry, connectedUser, getConfig, getStoreRegistryDeps, resetCaps, resetConfig, resetStoreRegistry, setCurrentUser, share ``` diff --git a/docs/document-links.md b/docs/document-links.md index 984f9af..8326537 100644 --- a/docs/document-links.md +++ b/docs/document-links.md @@ -50,11 +50,15 @@ What follows for a public document, and is easy to get wrong: **there is nothing ### The polyfill's public emulation, read against this -Verified in this repo: publication **mints a cap** (`publishRepoLink` → `mint`, `packages/client/src/emulated-verifier/caps.ts:192-196`), the `published` set is consulted by no read path (emitter-side guard only), and the possession filter gates public documents exactly like private ones. Against the target *under the emulation's own topology* (one broker, so join-reachability is trivially satisfied): upstream, whoever can name a public document reads it; the polyfill refuses the cap-less form for everyone. That is **over-strict, not inverted** — it under-grants and never over-grants, and "circulate the link" remains the valid currency at migration. But three deltas deserve to stay visible wherever the public emulation is documented: +**Rewritten 2026-08-06**, when the emulation changed. It used to refuse a cap-less reference for every scope, which was over-strict in the safe direction but left an application unable to express *"circulate widely, the reference is enough"* — the one act the model makes cheap. `emulated-verifier/public-store.ts` now emulates the declared mechanism: a document in a public store exposes its ReadCap, and any reader's first door fetches it. Possession still decides everything; what changed is that for a public document the cap is **obtainable** instead of having to be handed over. -1. A **cap-less reference to a public document** embedded in reachable content will resolve upstream (once public serving is wired) and does not resolve here. -2. **Per-reader semantics for public documents** must not be inferred from the emulation's cap-per-holder bookkeeping — upstream has none. -3. The `:r:` segment inside a link to a *public* document is emulation detail: upstream's public link carries no key material. Harmless as long as the value is opaque to the consumer — which is the contract to enforce. +**This aligns on a DECLARED model, not on current behaviour, and the difference is worth stating.** What is read in source: `PublicRepoLinkV0` carries no `read_cap` and its comment says *"The latest ReadCap of the branch will be downloaded from the outerOverlay, if the peer brokers listed below allow it […] the public site are served differently by brokers"* (`engine/net/src/types.rs:5098-5124`); the broker's `expose_outer` plumbing exists (`engine/broker/src/server_storage/core/overlay.rs:103-133`). What is *not* wired today, per the inventory above: the client hard-codes `expose_outer: false` in both `PinRepo` constructors, and `ExtTopicSyncReq` — the anonymous branch-sync such a link needs — is `unimplemented!()`. So the emulation follows the model the engine declares, in a place the engine does not yet serve. That is the intended posture for this library (an absent implementation says nothing about what the target will do), and it is a bet, named here as one. + +Three things that remain true and must stay visible wherever the public emulation is documented: + +1. **Per-reader semantics for public documents** must not be inferred from the cap-per-holder bookkeeping — upstream has none. No grant, no per-reader revocation, no audience list. +2. The `:r:` segment inside anything naming a *public* document is emulation detail: upstream's public link carries no key material, because the key is fetched. Harmless as long as the value stays opaque to the consumer — which is the contract to enforce. +3. **Reading is not writing.** The cap a public store serves grants reading only; `caps.learnFromPublicStore` files it apart and `docs.sparqlUpdate` refuses a write on it. Upstream a public store never makes a repo world-writable — writing needs the write cap, and `verify_permission` fires on WRITE only. The surface consequence: the *act* — obtain a link, circulate it — is the same for both scopes upstream (`NgLinkV0` is one enum over both), so one producing function covering both is target-shaped; what differs is the semantics attached to the **value** (durability, revocability, the absence of per-reader anything), and that belongs in documentation, not in a second function. diff --git a/docs/internal-contract.md b/docs/internal-contract.md index 384aabf..b0ad3b6 100644 --- a/docs/internal-contract.md +++ b/docs/internal-contract.md @@ -4,7 +4,7 @@ **Scope.** The complement of [`docs/api-contract.md`](./api-contract.md): every module export under `packages/client/src/` that is NOT reachable from the two published entry points (`package.json` maps exactly `.` → `src/index.ts` and `./polyfill` → `src/polyfill.ts`). A consumer never reads this document; a maintainer does. The internal code is held to the same standard as the surface — as close as possible to what NextGraph does or plans — so every subject below carries the same target-side analysis. Written 2026-08-04, verified against the `nextgraph-rs` clone (HEAD `213338f6`) and the installed `@ng-org/web@0.1.2-alpha.13` declarations (`node_modules/.bun/@ng-org+web@0.1.2-alpha.13/node_modules/@ng-org/web/dist/index.d.ts`, hereafter `index.d.ts`). -**How the boundary was computed — mechanically, from the `export` statements.** `index.ts` re-exports wholesale (`export *` / `export * as ns`) from `types.ts`, `inbox.ts`, `docs.ts`, `surface/read-model.ts`, and by name everything `surface/use-shape.ts`, `surface/watch-shape.ts`, `lifecycle.ts`, `sparql.ts` export, plus `isNuri`/`hasReadCap` from `nuri.ts` and `subscribeDoc`/`subscribeDocs`/`docChangeType` (+ types) from `subscribe.ts`; its `storeRegistry` namespace is the **`surface/placement.ts` slice only** (7 functions: `createEntityDoc`, `listMyEntityDocs`, `resolveScopeGraph`, `resolveWriteGraph`, `userInbox`, `openDocumentInbox`, `documentInboxAddress`). `polyfill.ts` re-exports `CapRegistry` from `emulated-verifier/caps.ts`, `shareCap` from `inbox.ts`, `connectedUser` from `emulated-verifier/connect.ts`, `* as accounts` from `shared-wallet/virtualUsers.ts`, and the types `VirtualUserStorage`, `VirtualUserRecord`, `RegistrySession`. Everything else that carries `export` in a `src/` module is internal and inventoried here. Eight modules are internal in their entirety: `shared-wallet/access-log.ts`, `emulated-verifier/machinery.ts`, `surface/ng-proxy.ts`, `emulated-verifier/open-repo.ts`, `shared-wallet/outbox-log.ts`, `shared-wallet/physical.ts`, `emulated-verifier/reach.ts`, `emulated-verifier/read-filter.ts`. Four are internal in part: `nuri.ts`, `emulated-verifier/connect.ts`, `subscribe.ts`, `shared-wallet/account-registry.ts`. +**How the boundary was computed — mechanically, from the `export` statements.** `index.ts` re-exports wholesale (`export *` / `export * as ns`) from `types.ts`, `inbox.ts`, `docs.ts`, `surface/read-model.ts`, and by name everything `surface/use-shape.ts`, `surface/watch-shape.ts`, `lifecycle.ts`, `sparql.ts` export, plus `isNuri`/`hasReadCap` from `nuri.ts` and `subscribeDoc`/`subscribeDocs`/`docChangeType` (+ types) from `subscribe.ts`; its `storeRegistry` namespace is the **`surface/placement.ts` slice only** (7 functions: `createEntityDoc`, `listMyEntityDocs`, `resolveScopeGraph`, `resolveWriteGraph`, `userInbox`, `openDocumentInbox`, `documentInboxAddress`). `polyfill.ts` re-exports `share` from `inbox.ts`, `connectedUser` from `emulated-verifier/connect.ts`, `* as accounts` from `shared-wallet/virtualUsers.ts`, and the types `VirtualUserStorage`, `VirtualUserRecord`, `RegistrySession`. Everything else that carries `export` in a `src/` module is internal and inventoried here. Eight modules are internal in their entirety: `shared-wallet/access-log.ts`, `emulated-verifier/machinery.ts`, `surface/ng-proxy.ts`, `emulated-verifier/open-repo.ts`, `shared-wallet/outbox-log.ts`, `shared-wallet/physical.ts`, `emulated-verifier/reach.ts`, `emulated-verifier/read-filter.ts`. Four are internal in part: `nuri.ts`, `emulated-verifier/connect.ts`, `subscribe.ts`, `shared-wallet/account-registry.ts`. **Labels** are those of `docs/api-contract.md`: **PASSTHROUGH (level 3/2, VERIFIED)**, **LEVEL-1 SHAPE (model VERIFIED, JS surface ASSUMED)**, **ASSUMPTION**, **NO COUNTERPART**. Level numbers per `README.md` § *The three references*: 3 = JS ORM, 2 = wasm binding (`@ng-org/web`), 1 = Rust engine. One label recurs here that the surface contract rarely needs: **NO COUNTERPART, shared-wallet machinery** — the code below the emulation's floor, which the target has no image of because the target has no shared wallet. Per the design principle, an absent implementation is never treated as evidence about the future. diff --git a/docs/migration-guide.md b/docs/migration-guide.md index 97a06e7..fdcc8fe 100644 --- a/docs/migration-guide.md +++ b/docs/migration-guide.md @@ -27,11 +27,12 @@ this step swaps the *emulated* key for the real one, not the model: `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 (whatever the SDK ends up naming it — see the note below +- `inbox.share(doc, toUser)` becomes the native sealed delivery (whatever the SDK ends up naming it — see the note below 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`. +- `caps.markInPublicStore` and the whole of `emulated-verifier/public-store.ts` disappear: which store a document sits in stops being a fact we record, and serving a public store's repos becomes the broker's job (`expose_outer`, the ReadCap downloaded from the outer overlay — `PublicRepoLinkV0`, `engine/net/src/types.rs:5098`). Nothing an application calls changes: it circulates bare references now, and will still. +- `assertMayWrite` goes with it — refusing a write on a cap the public store served is a stand-in for the write cap this emulation does not have. - The read filter (`emulated-verifier/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. @@ -103,7 +104,7 @@ 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 +`inbox.share(doc, toUser)` 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. diff --git a/docs/read-model.md b/docs/read-model.md index 915efc1..f1320b7 100644 --- a/docs/read-model.md +++ b/docs/read-model.md @@ -58,7 +58,7 @@ notifications — none of these is enumerated across virtualUsers. Each is reach what is already reachable to me: - my own docs (always in `self.repos`, and whose caps I hold); -- docs whose cap an owner has delivered to my inbox (`shareCap` — see the +- docs whose cap an owner has delivered to my inbox (`inbox.share` — see the per-document ReadCap in [`simulation.md`](./simulation.md)); - my inbox (deposits addressed to me). diff --git a/docs/readcap-and-nuri-model.md b/docs/readcap-and-nuri-model.md index 6d304dc..f037ffd 100644 --- a/docs/readcap-and-nuri-model.md +++ b/docs/readcap-and-nuri-model.md @@ -358,9 +358,19 @@ The consequence for anything this library exposes: **a call either hands over th - **Transmit the reference** — covered, with no dedicated call: every reference the surface returns is bare (`createEntityDoc`, `docCreate`, `listMyEntityDocs`, `UnionSubject.subject`/`.graph`). An application cites what it already holds. Faithful. - **Transmit the reference and the key** — `inbox.share(doc, toUser)`. Names the document and the person; the key is looked up and sealed into a deposit, and the recipient applies it by connecting, with nothing to call. Faithful in shape. One recorded divergence: the deposit always goes to the recipient's PROTECTED inbox, where upstream the choice follows the profile the person was reached by (`engine/verifier/src/inbox_processor.rs:787`). -**And one property of the model this library does NOT emulate**: a document in a public store is readable from a bare reference. Here `mayReach` requires a held cap whatever the scope, so a bare reference never suffices. The gap is in the safe direction — we UNDER-grant, we do not over-grant — but it means an application cannot express "circulate widely, the reference is enough", and the only way it could work around that is by handing out the key, which is exactly what breaks composable confidentiality (§ 0). Left as a known limit rather than patched, because making public-scope documents readable without a cap would rest the property on a scope THIS library assigns, where upstream it comes from the store and from how brokers serve it (`expose_outer`). +**And the property that makes the first act worth anything — a public store SERVES its documents' caps.** `PublicRepoLinkV0` (`engine/net/src/types.rs:5098-5124`) carries `repo`, `public_store` and `peers` and no `read_cap`, and its own comment says why: *"The latest ReadCap of the branch (or main branch) will be **downloaded from the outerOverlay**, if the peer brokers listed below allow it. […] This link is durable, because the public site are **served differently by brokers**."* The key is not something a sender hands over; it is something the network gives to whoever asks, because the broker pinned the outer overlay (`expose_outer`, `engine/broker/src/server_storage/core/overlay.rs:103-133`). -Two things a consumer must not conclude from the emulation: that placing a document in a public store is an act of KEY DISTRIBUTION (here it mints one, upstream none travels), and that a public document has anything per-reader — upstream there is no grant, no revoke and no audience on it, so there is nothing to build a UI around. +That is emulated, since 2026-08-06, in `emulated-verifier/public-store.ts` — and emulated **without touching the guard**. Possession remains the one criterion: a public document is readable not because `mayReach` makes an exception, but because its cap is *obtainable* — the library asks, files what it gets, and from there the ordinary path applies. Every read door asks first (`readUnion`, `docs.sparqlQuery`, `ensureRepoOpen`, `documentInboxAddress`). + +Where the emulation shows its seams, stated rather than hidden: + +- Upstream nothing is WRITTEN to make a repo public — the store is public and the broker serves it. Here one broker serves every virtual user identically, so the cap is recorded on the document's Header branch and read back through the machinery's unguarded door. Fetching, not enumerating: a reader asks the document it already names. +- A reader therefore learns a document is public by asking THAT document. One it has never heard of stays invisible, where upstream a broker would serve it just the same. That limits discovery, not access. +- The cap a public store serves is a READ grant, and this emulation says so: `caps.learnFromPublicStore` files it apart, and `docs.sparqlUpdate` refuses a write on it (`assertMayWrite`). Without that, a bare reference would buy a write, which upstream it never does — writing needs the write cap, and no store hands that out. +- `useShape` cannot ask (its signature is the real ORM's, with no await to spend), so a public document reached through it alone, read nowhere first, is filtered out. Recorded in `emulated-verifier/read-filter.ts`. +- **No `locator` anywhere**, and the emulation's topology is why it does not show. Upstream a reference must be complete enough for a stranger to resolve — without a `locator` there is no broker to ask, and nothing opens, key or no key (`NuriV0.locator`, `engine/net/src/app_protocol.rs:181-194`). Here every virtual user is on the same broker, so the question never arises and no reference this library produces carries one. An application must not conclude that a bare reference travels anywhere: **it travels between users of one deployment**. The day two deployments have to exchange one, the locator is what will be missing, and nothing in the emulation will have prepared it. + +One thing a consumer must not conclude from the emulation: that a public document has anything per-reader. Upstream there is no grant, no revoke and no audience on it — there is nothing to build a UI around. ## 5. What the polyfill emulates (caps.ts) — and where it still diverges @@ -369,15 +379,16 @@ Two things a consumer must not conclude from the emulation: that placing a docum | | 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 | +| Grant | seal the key (crypto_box) to the inbox | `inbox.share(doc, toUser)` → 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 `r:` segment) | same — `Nuri` names, `ReadCap` names and reads | +| Public store | the broker serves the outer overlay; the ReadCap is **downloaded** from it | `public-store.ts` — the cap is exposed on the document and fetched through the machinery's door, then held like any other. Filed apart (`learnFromPublicStore`) so it grants reading and **not** writing | **The divergence that REMAINS**: the stand-in cap value is the constant `OK` rather than a secret. The read paths that once consulted no cap at all are now confined to the connected virtual user (`emulated-verifier/reach.ts`, 2026-07-30) — `docs.sparqlQuery`/`sparqlUpdate` and `subscribeDoc` are guarded, the inbox is read only by its owner, and the shim's own machinery moved to unguarded primitives that are never exported. So what is left for **P1b** is per-document encryption: replacing one constant with a real key. Until then, nothing may be claimed "anonymous" or "private". -**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. +**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 (`inbox.share` once, per document), which is a consumer **re-architecture**, not an API swap. ## 6. Implications for consumers (e.g. Festipod) diff --git a/docs/simulation.md b/docs/simulation.md index 388fe96..8bc5cd9 100644 --- a/docs/simulation.md +++ b/docs/simulation.md @@ -345,7 +345,7 @@ Three ways a cap arrives, and there are no others: 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 +- **Delivery.** `inbox.share(doc, toUser)` 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 @@ -364,17 +364,19 @@ another name. - **`setCurrentUser(id)` (`polyfill.ts`)** — the SDK's "current identity" call. 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 +- **`inbox.share(doc, toUser)`** — 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. +- **A document created in the `public` scope** needs no sharing act at all. The store + serves its ReadCap to whoever asks (`emulated-verifier/public-store.ts`, emulating + *"the latest ReadCap will be downloaded from the outerOverlay"* — `PublicRepoLinkV0`, + `engine/net/src/types.rs:5098`), so what an application circulates is the **bare + reference**, exactly as it will after migration. Never 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. + And never a write right: what the store serves is a read cap. Upstream, directed delivery is a **gap, not a disagreement**: `ContactDetails.read_cap` exists, but the message construction is `unimplemented!()`, its only caller passes @@ -442,8 +444,8 @@ natively at migration); the read side is what makes isolation observably active. Isolation is enforced by the per-document ReadCap (`emulated-verifier/caps.ts` + `emulated-verifier/read-filter.ts`) 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 +possession-shaped (`createEntityDoc` files a cap, `inbox.share` delivers one, a public +store serves one to whoever asks). Because the consumer application writes one document per entity, the per-document cap discriminates at entity granularity — the target's behaviour. @@ -512,7 +514,7 @@ deposit — **whose JS name and signature are not known**, since none is exposed announced — and the read side is served by the recipient's own verifier unsealing queued messages inline. The inbox + watcher is the one deposit/read mechanism a consumer reuses for its own -purposes — a registration/deposit, a cap delivery (`shareCap`), a link handed to +purposes — a registration/deposit, a cap delivery (`inbox.share`), a link handed to someone — same `post` API, same watcher. ## The virtual user boundary (`emulated-verifier/reach.ts` + `shared-wallet/physical.ts`) diff --git a/packages/client/e2e/run.ts b/packages/client/e2e/run.ts index ea61356..938fc83 100644 --- a/packages/client/e2e/run.ts +++ b/packages/client/e2e/run.ts @@ -298,7 +298,7 @@ async function main(): Promise { const t = Date.now(); const r = await sdk(frame, "documentInboxDeposit", "@owner-" + t, "@depositor-" + t); check( - "the depositor RESOLVES the same inbox from the document, deposits into it, and the address stays out of the data", + "the depositor holds only the BARE reference, resolves the same inbox from it, deposits, and the address stays out of the data", r.sameInbox === true && r.openRefused === true && JSON.stringify(r.deposits) === JSON.stringify([{ viaPostToDocument: true }, { joining: true }]) && @@ -383,15 +383,15 @@ async function main(): Promise { 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", + "the read-filtered view decides on possession alone: owner sees what he holds, a stranger nothing, and a filed cap opens it", ownerReadsHeld && ownerMissesUnheld && strangerReadsNothing && linkOpensPublic, - `owner=${JSON.stringify(r.ownerView)} stranger=${JSON.stringify(r.strangerView)} withLink=${JSON.stringify(r.strangerWithLinkView)}`, + `owner=${JSON.stringify(r.ownerView)} stranger=${JSON.stringify(r.strangerView)} withCap=${JSON.stringify(r.strangerWithLinkView)}`, ); }); await step("shareCap: a cap delivered to an inbox reveals the doc", async () => { const r = await sdk(frame, "capsShareCap", "@friend-" + Date.now()); check( - "shareCap → inbox processed → the shared doc becomes readable, and the delivery is not surfaced", + "share → inbox processed → the shared doc becomes readable, and the delivery is not surfaced", r.before === 0 && r.after === 1 && r.surfacedDeposits === 0, `before=${r.before} after=${r.after} surfaced=${r.surfacedDeposits}`, ); diff --git a/packages/client/e2e/sdk-entry.ts b/packages/client/e2e/sdk-entry.ts index 7338a53..53628fb 100644 --- a/packages/client/e2e/sdk-entry.ts +++ b/packages/client/e2e/sdk-entry.ts @@ -837,10 +837,11 @@ const identity = new IdentityStore( ]; setCurrentUser("owner-O"); getCaps().open("did:ng:o:protdoc", "protected"); - const link = getCaps().recordInPublicStore("did:ng:o:pubdoc"); + const link = getCaps().open("did:ng:o:pubdoc", "public"); const ownerView = [...(libUseShape(null, null) as Iterable)].map((i) => i.v); - // A stranger holds nothing — including the PUBLISHED document, until the repo - // link reaches them (§5: whoever has the URL reads the content). + // A stranger holds nothing, and this VIEW asks nobody: it is pure possession, with + // no round-trip to spend (see `read-filter.ts`). That a public store would serve + // the cap is proven on the read paths, not here. setCurrentUser("stranger"); const strangerView = [...(libUseShape(null, null) as Iterable)].map((i) => i.v); getCaps().learn(link); @@ -861,20 +862,19 @@ const identity = new IdentityStore( * owner opens it, a third party RESOLVES its address from the document itself and * deposits, the owner reads it back. * - * The point of the step is the resolution: nothing hands `depositorId` the address. - * It gets the document's link (which is what circulates in this model) and must find - * where to deposit on its own — which is exactly what a consumer app has to do, and - * what a unit test passing the NURI through a variable cannot prove. + * The point of the step is the resolution: the depositor is handed the document's + * BARE reference — the only thing an application circulates — and must find where to + * deposit on its own. It reads the document at all because the document sits in a + * public store, which serves its read cap to whoever asks (`public-store.ts`); no key + * crosses the identity boundary, here or in any real application. */ async documentInboxDeposit(ownerId: string, depositorId: string) { registryInternals.resetRegistryCache(); setCurrentUser(ownerId); const doc = await storeRegistry.createEntityDoc(ownerId, "public"); const ownerInbox = await storeRegistry.openDocumentInbox(doc); - const link = getCaps().capFor(doc)!; // out-of-band: the harness plays 'the owner sent it' setCurrentUser(depositorId); - getCaps().learn(link); const resolved = await documentInboxAddress(doc); // The one-call form an app actually uses: it names the DOCUMENT, never an inbox. await inbox.postToDocument(doc, { payload: { viaPostToDocument: true }, ts: 900 }); diff --git a/packages/client/src/emulated-verifier/branch-registers.ts b/packages/client/src/emulated-verifier/branch-registers.ts index c74ac6f..4144f6d 100644 --- a/packages/client/src/emulated-verifier/branch-registers.ts +++ b/packages/client/src/emulated-verifier/branch-registers.ts @@ -38,6 +38,7 @@ import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap"; import { escapeLiteral } from "../surface/sparql"; import { hasReadCap, isNuri } from "../model/nuri"; import { mustNotAttempt } from "./reach"; +import { fetchReadCap } from "./public-store"; import { ensureRepoOpen } from "./open-repo"; import { accessLogPrefix } from "../shared-wallet/access-log"; import { @@ -106,8 +107,9 @@ export function holdOwnCap(id: string, scope: Scope, doc: Nuri, cap: ReadCap): v // 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.recordInPublicStore(doc); + // Which store the document sits in is a registry fact, applied separately — and a + // MARK only, for the same reason the cap above is learned rather than re-minted. + if (scope === "public") caps.markInPublicStore(doc); } /** @@ -217,6 +219,11 @@ export async function documentInboxAddress(doc: Nuri): Promise // deposit for a document I cannot read" is not a refused question, it is a question // with no referent. Answering `undefined` here keeps the caller's shape (an address // or none) instead of turning the boundary into an exception it must catch. + // …but ask the (emulated) network first: a document in a public store serves its cap + // to whoever asks (public-store.ts), and "where do I deposit for this public + // document" is exactly the question a third party arrives with, holding nothing but + // the reference. + await fetchReadCap(doc); if (mustNotAttempt(doc)) return undefined; const s = await session(); try { diff --git a/packages/client/src/emulated-verifier/caps.ts b/packages/client/src/emulated-verifier/caps.ts index 71fd691..8b67929 100644 --- a/packages/client/src/emulated-verifier/caps.ts +++ b/packages/client/src/emulated-verifier/caps.ts @@ -37,10 +37,14 @@ * * ── 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 + * sharing is `inbox.share(doc, toUser)` — a **Link** deposit — and receiving is * the recipient processing their inbox. Handing over a store's cap is NOT the * gesture: it would give away everything that store contains, present and future. * + * And for a document in a PUBLIC store there is no sharing act at all: the store hands + * its cap to whoever asks (`public-store.ts`), so what circulates is the bare + * reference. Filed apart (`learnFromPublicStore`) because it grants reading only. + * * ── What this module does NOT do ────────────────────────────────────────── * Enforce. The shape is right after P1a; the isolation is still fake. Per-document * encryption and closing the read paths that bypass the guard (`docs.sparqlQuery`, @@ -83,14 +87,26 @@ export class CapRegistry { /** holder → the caps they hold, indexed by the cap-less NURI. */ private heldByHolder = new Map>(); /** - * Documents in a PUBLIC store, as this emulation records it. This is NOT a read grant: - * such a 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 - * in a public store. *(This fed `discovery.submitToIndex`, removed 2026-07-30.)* + * Documents this session knows to sit in a PUBLIC store — a fact about each + * DOCUMENT, so global rather than per-holder, unlike everything else here. + * + * It is not itself a right. What being in a public store buys is that the document's + * cap can be DOWNLOADED by anyone who asks (`emulated-verifier/public-store.ts`, + * emulating `PublicRepoLinkV0`'s *"downloaded from the outerOverlay"*); once it has + * been, the holder holds it like any other and this set records only how it got there. */ private inPublicStore = new Set(); + /** + * holder → the documents whose cap they hold ONLY because a public store served it + * (see {@link learnFromPublicStore}). + * + * PER HOLDER, unlike the set above, and the difference is the whole point: *"this + * document is in a public store"* is a fact about the document, whereas *"the only + * claim I have on it is that the network handed me its key"* is a fact about one + * holder. Kept global, the owner of a public document would be refused writes to it + * the moment any third party fetched its cap. + */ + private servedByHolder = new Map>(); /** doc NURI → principals holding its WRITE cap. Decorative until P1b. */ private writers = new Map>(); /** Fired whenever a holder gains a cap — a cap delivered asynchronously must @@ -137,6 +153,11 @@ export class CapRegistry { ); } const target = targetOf(cap); + // Filing is the STRONG claim — I created this document, or its cap was deposited + // for me. Either one supersedes "a public store served it to me", so the read-only + // mark goes. {@link learnFromPublicStore} re-adds it after calling here, and only + // when nothing was held before. + this.servedToHolder().delete(target); const ring = this.heldCaps(); if (ring.get(target) === cap) return false; ring.set(target, cap); @@ -168,6 +189,46 @@ export class CapRegistry { this.file(cap); } + /** + * File a cap a PUBLIC STORE served me — `emulated-verifier/public-store.ts`, the + * emulated *"downloaded from the outerOverlay"*. Held like any other cap, so reading + * needs no special case anywhere; recorded apart because of what it is NOT. + * + * It is a READ grant and nothing else. Upstream a public store makes its repos + * world-readable, never world-writable — writing needs the write cap, and + * `verify_permission` fires on WRITE only. Here the write guard still consults the + * read cap (write caps are decorative until P1b, see the module header), so without + * this distinction a bare reference to a public document would buy a WRITE — a + * consumer would build on it, and have to unlearn it at migration. + * + * A stronger claim on the same document erases the mark: {@link mint} (I created it) + * and {@link learn} (it was deposited for me) both go through {@link file}, which + * clears it. So a public document of my own is never read-only to me. + */ + learnFromPublicStore(cap: ReadCap): void { + const target = targetOf(cap); + const alreadyHeld = this.heldCaps().has(target); + this.file(cap); + // Only when this is the ONLY reason I hold it — filing never downgrades a claim. + if (!alreadyHeld) this.servedToHolder().add(target); + } + + /** + * Is the ONLY reason the current holder holds this document's cap that a public store + * served it? Then it grants reading and nothing more — see {@link learnFromPublicStore}. + */ + isReadOnlyPublicCap(nuri: Nuri): boolean { + return this.servedToHolder().has(targetOf(nuri)); + } + + /** The current holder's public-store-served set, created on first use. */ + private servedToHolder(): Set { + const key = this.holder() ?? ANONYMOUS; + let s = this.servedByHolder.get(key); + if (!s) this.servedByHolder.set(key, (s = new Set())); + return s; + } + /** * Do I hold the cap of `nuri`? Returns it, or `undefined` when I hold * none — which is the whole answer the model can give. Absorbs the former @@ -181,48 +242,49 @@ export class CapRegistry { // --- publication (the public store) ------------------------------------- /** - * Record that `nuri` sits in a PUBLIC store, and mint its cap. + * Record that `nuri` sits in a PUBLIC store. A fact about the DOCUMENT, not a right + * of anyone — hence a global set rather than a per-holder one, and hence no minting + * here: what sitting in a public store buys is that the cap is **obtainable** by + * whoever asks (`emulated-verifier/public-store.ts`), which is a separate act from + * this one holding it. * - * **Named for what it does here, not for what it means upstream** — and the gap is the - * point. It was called `publishRepoLink`, and "publish" is banned in this repo - * (`docs/readcap-and-nuri-model.md`, the traps block) precisely because it blurs three - * acts: placing a document in a public store, making it findable, and handing out a - * key. This method does the first and, as an emulation artefact, the third. + * Marking and minting were one method (`recordInPublicStore`) until they were split: + * the fetch path files the cap it DOWNLOADED, and minting a second one beside it + * would produce a different key the day the stand-in constant becomes a real one — + * the same trap `holdOwnCap` already documents. * - * Upstream a document in a public store is readable because the STORE is public and - * brokers serve it accordingly (`expose_outer`); a `PublicRepoLinkV0` carries no - * `read_cap` at all (`engine/net/src/types.rs:5105-5127`). Here there is no broker that - * serves differently, so possession stands in for it — the emulation is OVER-strict, - * not inverted: it under-grants, and "circulate the reference" remains the right - * gesture at migration. See `readcap-and-nuri-model.md` §4sexies. + * Upstream nothing corresponds to this call: the store IS public, and the broker + * exposes its outer overlay (`expose_outer`, + * `engine/broker/src/server_storage/core/overlay.rs:103-133`). We record it because + * one broker here serves every virtual user identically. * * NOT recursive: a document in a public store may REFERENCE private ones, and the * reference grants nothing on what it references. That non-recursiveness is what lets * a public object point at private content without disclosing it. */ - recordInPublicStore(nuri: Nuri): ReadCap { - const target = targetOf(nuri); - this.inPublicStore.add(target); - return this.mint(target); + markInPublicStore(nuri: Nuri): void { + this.inPublicStore.add(targetOf(nuri)); } - /** Is `nuri` recorded as sitting in a public store? An emitter-side fact, not a right. */ + /** Is `nuri` recorded as sitting in a public store? A fact about the document. */ isInPublicStore(nuri: Nuri): boolean { return this.inPublicStore.has(targetOf(nuri)); } /** - * Record a document the current holder owns in `scope`: its cap lands 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. + * Record a document the current holder owns in `scope`: its cap lands among what + * they hold, and a `public` one is additionally marked as sitting in a public store. + * Returns the cap. Idempotent — the registry calls it both when creating a document + * and when listing the holder's own documents back, which is how a holder's caps are + * rebuilt on a fresh session. * * Deliberately does NOT touch write caps: those are decorative until P1b, and * arming their guard here would be enforcement this batch does not do. */ open(nuri: Nuri, scope: Scope): ReadCap { - return scope === "public" ? this.recordInPublicStore(nuri) : this.mint(nuri); + const cap = this.mint(nuri); + if (scope === "public") this.markInPublicStore(nuri); + return cap; } // --- enforcement gate --------------------------------------------------- @@ -291,6 +353,7 @@ export class CapRegistry { * NOT what an identity change does (that switches heldByHolder, see the header). */ clear(): void { this.heldByHolder.clear(); + this.servedByHolder.clear(); this.inPublicStore.clear(); this.writers.clear(); this.issued = false; diff --git a/packages/client/src/emulated-verifier/open-repo.ts b/packages/client/src/emulated-verifier/open-repo.ts index fc1641e..258d123 100644 --- a/packages/client/src/emulated-verifier/open-repo.ts +++ b/packages/client/src/emulated-verifier/open-repo.ts @@ -72,6 +72,7 @@ */ import { mustNotAttempt } from "./reach"; +import { fetchReadCap } from "./public-store"; import { getConfig, getStoreRegistryDeps } from "../shared-wallet/bootstrap"; import { subscribeDocUnguarded, type Unsubscribe } from "../surface/subscribe"; import { logStage, shortNuri } from "../shared-wallet/access-log"; @@ -178,6 +179,12 @@ async function syncSession(): Promise { */ export async function ensureRepoOpen(nuri: Nuri): Promise { if (!nuri) return; + // A repo in a PUBLIC store hands its cap to whoever asks — upstream by serving it on + // the outer overlay (`PublicRepoLinkV0`, `engine/net/src/types.rs:5098`). So ASK + // before deciding whether we may touch it, or the answer would be "no" purely for + // want of asking, and a bare reference to a public document would never suffice. + // Memoised and inert once the cap is held (see public-store.ts). + await fetchReadCap(nuri); // RULE 2 — do not even attempt. Opening a repo IS an access: it subscribes and // pulls its state. A user that holds no cap for it has no business asking. // (`ensurePhysicalRepoOpen` is the machinery's door — see physical.ts.) diff --git a/packages/client/src/emulated-verifier/public-store.ts b/packages/client/src/emulated-verifier/public-store.ts new file mode 100644 index 0000000..bf2226d --- /dev/null +++ b/packages/client/src/emulated-verifier/public-store.ts @@ -0,0 +1,196 @@ +/** + * public-store — a document in a PUBLIC store gives up its ReadCap to whoever asks. + * + * ── The upstream mechanism this emulates (VERIFIED) ─────────────────────── + * `PublicRepoLinkV0` (`engine/net/src/types.rs:5098-5124`) carries `repo`, + * `public_store` and `peers` — and **no `read_cap`**. Its own doc comment says why: + * + * > *"The latest ReadCap of the branch (or main branch) will be **downloaded from + * > the outerOverlay**, if the peer brokers listed below allow it. […] This link is + * > durable, because the public site are **served differently by brokers**."* + * + * So for a repo in a public store, the key is not something a sender hands over: it is + * something the **network gives to anyone who asks**. The broker decides, by pinning + * the outer overlay (`expose_outer`, `engine/broker/src/server_storage/core/overlay.rs:103-133`). + * That is the whole of the property — nothing about the reader, everything about where + * the document sits and how brokers serve it. + * + * ── What that means for the model, and why nothing is special-cased ─────── + * Possession stays the ONE criterion. A public document is readable not because the + * guard makes an exception for it, but because its cap is **obtainable**: you ask, you + * receive, you hold it, and from there the ordinary path applies. `reach.ts` is + * untouched, and "whoever has the reference AND the key reads" still describes + * everything — a public store simply hands the key to whoever has the reference. + * + * The consequence an application must be able to rely on: **a bare reference to a + * document in a public store is enough**, and that is why nothing in this library + * needs to put a key into a link (see `readcap-and-nuri-model.md` § 0 — a call that + * returns a key where a reference was asked for is the failure mode to watch for). + * + * Non-recursive, like everything else here: a public document may REFERENCE a + * protected one, and following that reference gets you a name, not a key. Only the + * document actually sitting in the public store exposes its cap. + * + * ── The two halves, and which door each uses ────────────────────────────── + * - {@link exposeReadCap} — the OWNER's side, at creation: the cap is written on the + * document's Header branch, the compartment meant for what any reader may see. It + * goes through the guarded surface, because the owner holds the document. + * - {@link fetchReadCap} — the NETWORK's side: read through the **physical** door + * (`shared-wallet/physical.ts`), unguarded, because that is precisely the point — + * the broker serving an outer overlay does not ask who is asking. Using the guarded + * read here would be circular: you would need the cap to obtain the cap. + * + * ── Where the emulation is honest about its shape ───────────────────────── + * Upstream nothing is *written* anywhere to make a repo public: the store is public, + * and the broker exposes its outer overlay. Here there is one broker serving every + * virtual user identically, so "which documents are in a public store" has to be + * recorded somewhere the machinery can read — and the document itself is the one place + * that needs no index and no enumeration. At migration this whole module goes: the + * scope stops being a fact we record and becomes the store the document lives in. + * + * The gap that leaves: a reader learns a document is public by ASKING that document, + * so a document it has never heard of stays invisible. Upstream the broker would serve + * it just the same. That limits discovery, not access — an application that holds the + * reference reads, which is the property this module exists to provide. + */ + +import { sparqlUpdate } from "../surface/docs"; +import { physicalQuery, ensurePhysicalRepoOpen } from "../shared-wallet/physical"; +import { getCaps } from "../shared-wallet/bootstrap"; +import { escapeLiteral } from "../surface/sparql"; +import { hasReadCap, targetOf } from "../model/nuri"; +import { accessLogPrefix } from "../shared-wallet/access-log"; +import { + P, + HEADER_BRANCH_SUBJECT, + readBindings, + bindingValue, + session, +} from "../shared-wallet/account-registry"; +import type { Nuri, ReadCap } from "../model/types"; + +/** + * Targets whose outer-overlay fetch has already been attempted in this session, with + * its outcome. Memoised in BOTH directions on purpose: a hit spares a physical read, + * and a miss spares repeating one for every read of a document this user cannot reach + * — which is the common case (a protected document someone merely named). + * + * A scope never changes here (a document is created in a store and stays there), so a + * cached miss cannot go stale for a document that existed when it was taken. It CAN + * for one created afterwards by another user in the same page — {@link resetPublicStoreFetches} + * is the way out, and it is what a session change / a wallet reset calls. + */ +const attempted = new Map>(); + +/** Forget every outer-overlay fetch (tests / a switched session or wallet). */ +export function resetPublicStoreFetches(): void { + attempted.clear(); +} + +/** + * Expose `cap` on `doc`'s Header branch — the emulated `expose_outer`. Called when a + * document is created in a PUBLIC store, and only then: this is what makes the cap + * obtainable by anyone, which for a public store is the intended property and for any + * other scope would be a disclosure. + * + * Replacement, not addition, like every Header-branch register: one document has one + * current cap, and two would leave a fetcher picking between them. + */ +export async function exposeReadCap(doc: Nuri, cap: ReadCap): Promise { + const s = await session(); + try { + // Two separate updates: `DELETE WHERE { … }` is the form verified against the real + // broker (`docs/decisions/sparql-delete-for-orm-objects.md`); a `;`-joined update + // is not exercised anywhere in this library. + await sparqlUpdate( + s.sessionId, + `DELETE WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.exposedReadCap}> ?c }`, + doc, + "exposeReadCap:clear", + ); + await sparqlUpdate( + s.sessionId, + `INSERT DATA { <${HEADER_BRANCH_SUBJECT}> <${P.exposedReadCap}> "${escapeLiteral(cap)}" }`, + doc, + "exposeReadCap", + ); + } catch (error) { + console.error(accessLogPrefix() + " exposeReadCap failed:", error); + } +} + +/** + * Ask the (emulated) network for `doc`'s ReadCap, and file it if it answers — the + * emulated *"downloaded from the outerOverlay"*. Returns whether a cap was obtained. + * + * Nothing is asked when the cap is already held: a document you can read needs no + * fetching, and skipping it keeps the ordinary path free of physical reads. + * + * Never throws — a document that is not in a public store simply answers nothing, which + * is not an error but the normal case. + */ +export async function fetchReadCap(docLike: Nuri): Promise { + const doc = targetOf(docLike); + const caps = getCaps(); + // Inert until the emulation is in force, like the guard it serves: before the first + // cap exists everything reads anyway, so there is nothing to obtain and asking would + // be a physical round-trip bought for nothing. + if (!caps.isEnforcing()) return false; + if (caps.capFor(doc) !== undefined) return true; + let pending = attempted.get(doc); + if (pending === undefined) { + pending = downloadReadCap(doc); + attempted.set(doc, pending); + } + return pending; +} + +/** The fetch itself, through the machinery's door. See the module header. */ +async function downloadReadCap(doc: Nuri): Promise { + const s = await session(); + try { + // The repo has to be in the session before an anchored read resolves it — the + // cold-start heal, through the PHYSICAL door: this is the emulated broker serving + // an outer overlay, and it does not ask who is asking (see `open-repo.ts`). + await ensurePhysicalRepoOpen(doc); + const res = await physicalQuery( + s.sessionId, + `SELECT ?c WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.exposedReadCap}> ?c }`, + undefined, + doc, + "fetchReadCap", + ); + for (const row of readBindings(res)) { + const cap = bindingValue(row, "c"); + // `targetOf` guards the one confusion that would matter: a cap exposed on + // document A must not file a cap for document B. A document only ever speaks + // for itself. + if (cap && hasReadCap(cap) && targetOf(cap) === doc) { + // File the cap that was DOWNLOADED — never a freshly minted one. They agree + // today only because the stand-in value is a constant; with a real key (P1b) + // a second mint would produce a different key and the document would not open. + // + // `learnFromPublicStore`, not `learn`: what the network hands out is a READ + // grant. A public store makes its repos world-readable, never world-writable. + getCaps().learnFromPublicStore(cap); + getCaps().markInPublicStore(doc); + return true; + } + } + } catch (error) { + // Not in a public store, not synced, or no such document — all of them mean the + // same thing to the caller: no cap was obtained. + console.error(accessLogPrefix() + " fetchReadCap failed:", error); + } + return false; +} + +/** + * Ask for a SET of documents' caps, in parallel — what a batch read does before it + * decides which documents it may touch. Each fetch is independent and tolerant. + */ +export async function fetchReadCaps(docs: Nuri[]): Promise { + const unique = [...new Set(docs.filter(Boolean))]; + if (unique.length === 0) return; + await Promise.all(unique.map((d) => fetchReadCap(d))); +} diff --git a/packages/client/src/emulated-verifier/reach.ts b/packages/client/src/emulated-verifier/reach.ts index 83b2d39..7100ec8 100644 --- a/packages/client/src/emulated-verifier/reach.ts +++ b/packages/client/src/emulated-verifier/reach.ts @@ -116,6 +116,31 @@ export function assertMayReach(nuri: Nuri, op: string): void { ); } +/** + * Reading is not writing — refuse a write on a document whose cap the holder has ONLY + * because a public store served it. + * + * Upstream a public store makes its repos world-readable and never world-writable: the + * outer overlay hands out the ReadCap (`PublicRepoLinkV0`, + * `engine/net/src/types.rs:5098`), writing needs the write cap, and `verify_permission` + * fires on WRITE only. This emulation's write guard otherwise consults the READ cap + * (write caps are decorative until P1b — `caps.ts` header), so without this the + * public-store fetch would turn every bare reference into a write right. + * + * Narrow on purpose: it closes the case this batch opened, not the pre-existing one — + * a cap RECEIVED in an inbox still passes the write guard here, and upstream would not. + * That conflation is P1b's, and widening this check to cover it would be enforcement + * this batch does not do. + */ +export function assertMayWrite(nuri: Nuri, op: string): void { + if (!getCaps().isReadOnlyPublicCap(targetOf(nuri))) return; + throw new Error( + `[ng-eventually] ${op}: refused — this document is in a public store, which serves ` + + "its READ cap to anyone. Reading it is not writing to it: a write needs the write " + + `cap, and no store hands that out. ${JSON.stringify(nuri)}`, + ); +} + /** * **Rule 2 — do not even attempt**, at the CALLERS (`read-model`, `open-repo`, * `subscribe`'s callers…). diff --git a/packages/client/src/emulated-verifier/read-filter.ts b/packages/client/src/emulated-verifier/read-filter.ts index fd5b930..9a7f0a6 100644 --- a/packages/client/src/emulated-verifier/read-filter.ts +++ b/packages/client/src/emulated-verifier/read-filter.ts @@ -13,6 +13,16 @@ * nothing on that document — which is exactly the native behavior, and why * fine-grained isolation requires one document per entity. Removed at migration. * + * ── What this filter cannot do, and where that shows ────────────────────── + * It is SYNCHRONOUS and decides from what the holder holds at that instant. A document + * in a PUBLIC store hands its cap to whoever asks (`public-store.ts`), but asking is a + * round-trip — so this view drops such a document until some read path has asked. + * Every path this library owns does ask (`readUnion`, `docs.sparqlQuery`, + * `ensureRepoOpen`, `documentInboxAddress`), which covers `watchShape`; what it does + * not cover is `useShape`, whose signature is the real ORM's and has no await to + * spend. An application reaching a public document through `useShape` alone, having + * read it nowhere first, sees nothing. A polyfill-era limit, removed with the module. + * * Note there is no `user` parameter anywhere below, and that is the point: reading * is key possession, so the only question askable is "do I hold this document's * cap?". "May principal P read document D?" is an ACL question the real model diff --git a/packages/client/src/polyfill.ts b/packages/client/src/polyfill.ts index 84d9a7d..dfc0d86 100644 --- a/packages/client/src/polyfill.ts +++ b/packages/client/src/polyfill.ts @@ -22,12 +22,11 @@ export { getStoreRegistryDeps, resetStoreRegistry, setCurrentUser, - hasCap, resetCaps, } from "./shared-wallet/bootstrap"; // Cap surface — polyfill-era (caps are emulated now; native at migration). -// Re-exported here so the whole polyfill API lives under /polyfill. `shareCap` +// Re-exported here so the whole polyfill API lives under /polyfill. `share` // 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. @@ -48,8 +47,17 @@ export { connectedUser } from "./emulated-verifier/connect"; // application's job upstream too. The gate persists what IT needs // (`shared-wallet/access-gate.ts`); nothing else has to be exposed. // +// And one more, removed 2026-08-06 with the public-store emulation: +// +// - `hasCap(doc)` — "do I hold this document's cap?". It read like "may I read this?", +// and once a public store serves its caps to whoever asks +// (`emulated-verifier/public-store.ts`) the two answers part company: a readable +// document answers `false` right up until something asks for it. Nor is the question +// one the target answers — upstream you open a document and find out. It had no +// caller outside the tests, which now use the internal registry directly. +// // What remains here is the whole polyfill-era surface: `configure`, `setCurrentUser`, -// `capFor`, `shareCap` and the test resets. Two of them are what an application calls. +// `share`, `connectedUser` and the test resets. Two of them are what an application calls. // --- identity persistence (polyfill-era, no SDK counterpart) ---------------- // diff --git a/packages/client/src/shared-wallet/account-registry.ts b/packages/client/src/shared-wallet/account-registry.ts index cf68dd2..4b22f8f 100644 --- a/packages/client/src/shared-wallet/account-registry.ts +++ b/packages/client/src/shared-wallet/account-registry.ts @@ -77,6 +77,7 @@ import { documentInboxAddress, } from "../emulated-verifier/branch-registers"; import { ensureRepoOpen } from "../emulated-verifier/open-repo"; +import { exposeReadCap } from "../emulated-verifier/public-store"; import { ensurePhysicalRepoOpen, subscribePhysicalDoc } from "./physical"; import { escapeLiteral, escapeIri, assertNuri } from "../surface/sparql"; import { hasReadCap, isNuri } from "../model/nuri"; @@ -121,6 +122,7 @@ export const P = { readCap: `${SHIM}:readCap`, // store branch → the ReadCap of a document IN this store inboxCap: `${SHIM}:inboxCap`, // user branch → an inbox this user may READ inboxAddress: `${SHIM}:inboxAddress`, // header branch → WHERE to deposit for this document + exposedReadCap: `${SHIM}:exposedReadCap`, // header branch → the cap a PUBLIC store serves to anyone } 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 @@ -949,6 +951,12 @@ export async function createEntityDoc(id: string, scope: Scope): Promise { } // …and the creator holds THAT cap for this session. holdOwnCap(id, scope, entityNuri, cap); + // A document in a PUBLIC store hands its cap to whoever asks — upstream because the + // broker exposes the outer overlay and the ReadCap is downloaded from it + // (`PublicRepoLinkV0`, `engine/net/src/types.rs:5098`), here because the cap is put + // where the machinery can fetch it. This is what makes a BARE reference enough for a + // public document, so nothing in this library ever has to put a key into a link. + if (scope === "public") await exposeReadCap(entityNuri, cap); // NO inbox here, and NOT the owner's own inbox published as this document's address. // Upstream an inbox belongs to exactly ONE repo: the verifier routes an incoming // message by `inboxes: PubKey → RepoId` (`engine/verifier/src/verifier.rs:1677,1928`) @@ -1036,9 +1044,11 @@ export async function listMyEntityDocs(id: string, scope: Scope): Promise { * 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}); + * is how a link reaches another wallet at all — see {@link post} / {@link share}); * READING one is not, and it is not symmetric with it. Since caps travel as * deposits, an unguarded read let anyone who knew an inbox NURI collect the caps * addressed to its owner, which defeats directed sharing entirely. @@ -354,7 +354,7 @@ async function assertOwnInbox(targetInbox: Nuri, op: string): Promise { 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.", + "session — call setCurrentUser() first. Depositing (post/share) stays open.", ); } if (!(await isOwnInbox(targetInbox))) { @@ -376,7 +376,7 @@ async function assertOwnInbox(targetInbox: Nuri, op: string): Promise { * sealed-inbox path is available. The consumer interprets each deposit's * `payload`. * - * Cap deliveries ({@link shareCap}) are applied inline and NOT returned: they land + * Cap deliveries ({@link share}) are applied inline and NOT returned: they land * in what the current holder holds, like the verifier applying a queued message. * That is why receiving a cap needs no dedicated operation — a consumer already * watching its inbox gets them, and the resulting change re-triggers the @@ -500,7 +500,7 @@ export async function readSynced(targetInboxLike: NuriLike): Promise /** * PROCESS an inbox: read it, and **apply** what it contains. * - * Applying a {@link shareCap} Link means filing it durably — `storeRegistry.addLink`, + * Applying a {@link share} Link means filing it durably — `storeRegistry.addLink`, * the emulated `AddLink { read_cap }` on the User branch of the private store — so * the cap survives the session. Upstream this is what a verifier does when it * processes queued messages: an inbox is a **queue you consume**, not a store you diff --git a/packages/client/src/surface/placement.ts b/packages/client/src/surface/placement.ts index f99b9aa..aa6dbd5 100644 --- a/packages/client/src/surface/placement.ts +++ b/packages/client/src/surface/placement.ts @@ -20,7 +20,7 @@ * * **No inbox ADDRESS is published here**, deliberately (`userInbox`, * `documentInboxAddress`, removed 2026-08-05). An application deposits with - * `inbox.postToDocument(doc, …)`, shares with `inbox.shareCap(cap, toUser)` and reads + * `inbox.postToDocument(doc, …)`, shares with `inbox.share(doc, toUser)` and reads * its own with `inbox.readForDocument(doc)` — always naming a document or a person, * never an address. Upstream an address is resolved from a profile and never handled by * a caller, so exposing one taught a step that has to be unlearned. The example diff --git a/packages/client/src/surface/read-model.ts b/packages/client/src/surface/read-model.ts index 800de57..4ffb68e 100644 --- a/packages/client/src/surface/read-model.ts +++ b/packages/client/src/surface/read-model.ts @@ -157,19 +157,24 @@ export async function readUnion(docsLike: NuriLike[]): Promise { const unique = [...new Set(docsLike.filter(Boolean))].map((d) => toNuri(d, "readUnion")); 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(reachable); + // + // Called on the WHOLE set, before the boundary is consulted, because opening is also + // where a document in a PUBLIC store hands over its cap (see public-store.ts): a + // document filtered out first would never get the chance to answer. `ensureRepoOpen` + // still refuses to open what this user may not touch — it asks, it does not enter. + await ensureReposOpen(unique); + + // RULE 2 — do not even attempt. Drop the documents whose cap this user does not + // hold before reading anything: upstream you cannot address a repo you have no cap + // for, so asking about one is not "a read that will be refused", it is a read that + // has no meaning. (The passage points enforce rule 1 regardless — see reach.ts — so + // a lapse here is caught, not exploited.) + const reachable = unique.filter((d) => !mustNotAttempt(d)); // One anchored query per doc, in parallel, tolerant (a bad doc yields []). const perDoc = await Promise.all( diff --git a/packages/client/test/access-log.test.ts b/packages/client/test/access-log.test.ts index dbc68ef..434c91a 100644 --- a/packages/client/test/access-log.test.ts +++ b/packages/client/test/access-log.test.ts @@ -160,6 +160,11 @@ describe("access-log: ON via configure({ debugAccessLog: true })", () => { it("sparqlQuery emits a READ line with identity, nuri, label, and row-count", async () => { injectFake(true); setCurrentUser("alice"); + // `setCurrentUser` FIRES the connection work; draining it here (and only then + // dropping the caps it filed) is what keeps this test about the log and not about + // whether a background connect happened to win the race. + await connectedUser(); + resetCaps(); const { lines, restore } = spyConsoleLog(); try { await sparqlQuery("sid-log", "SELECT * {}", undefined, "did:ng:o:q", "myLabel"); @@ -178,6 +183,11 @@ describe("access-log: ON via configure({ debugAccessLog: true })", () => { it("sparqlUpdate emits a WRITE line with identity, anchor nuri, and label", async () => { injectFake(true); setCurrentUser("alice"); + // `setCurrentUser` FIRES the connection work; draining it here (and only then + // dropping the caps it filed) is what keeps this test about the log and not about + // whether a background connect happened to win the race. + await connectedUser(); + resetCaps(); const { lines, restore } = spyConsoleLog(); try { await sparqlUpdate("sid-log", "INSERT DATA {}", "did:ng:o:w", "writeLabel"); @@ -194,6 +204,11 @@ describe("access-log: ON via configure({ debugAccessLog: true })", () => { it("docCreate emits a WRITE line with identity and the returned nuri", async () => { injectFake(true); setCurrentUser("alice"); + // `setCurrentUser` FIRES the connection work; draining it here (and only then + // dropping the caps it filed) is what keeps this test about the log and not about + // whether a background connect happened to win the race. + await connectedUser(); + resetCaps(); const { lines, restore } = spyConsoleLog(); try { await docCreate("sid-log", "Graph", "data:graph", "store"); diff --git a/packages/client/test/caps.test.ts b/packages/client/test/caps.test.ts index 0ed1023..19e3797 100644 --- a/packages/client/test/caps.test.ts +++ b/packages/client/test/caps.test.ts @@ -7,7 +7,7 @@ * function turns a bare reference into a cap. */ import { test, expect } from "bun:test"; -import { CapRegistry } from "../src/emulated-verifier/caps"; +import { CapRegistry, mintCap } from "../src/emulated-verifier/caps"; import { hasReadCap, targetOf } from "../src/model/nuri"; import type { ReadCap } from "../src/model/types"; @@ -87,24 +87,52 @@ test("a cap received (learn) reads, exactly like one minted", () => { expect(bob.caps.capFor(doc)).toBe(cap); }); -test("recordInPublicStore returns a cap-bearing link; reading it still means HOLDING it", () => { +// A public store SERVES its documents' caps (`emulated-verifier/public-store.ts`). +// This registry is one level below that: it records WHERE a document sits, and it +// files a served cap apart from one that was minted or deposited — because the two +// grant different things. +test("markInPublicStore records where a document sits, and mints nothing", () => { const { caps, become } = registry("alice"); const doc = "did:ng:o:public-doc"; - const link = caps.recordInPublicStore(doc); + caps.markInPublicStore(doc); - expect(hasReadCap(link)).toBe(true); - expect(targetOf(link)).toBe(doc); expect(caps.isInPublicStore(doc)).toBe(true); expect(caps.isInPublicStore("did:ng:o:other")).toBe(false); - - // Publication is not a world-wide read grant: whoever HAS the URL reads it. + // Marking is not holding: the fact is about the document, the cap is about a holder. + expect(caps.capFor(doc)).toBeUndefined(); become("bob"); expect(caps.capFor(doc)).toBeUndefined(); - caps.learn(link); // bob received the link (e.g. from the discovery index) - expect(caps.capFor(doc)).toBe(link); }); -test("open(): a public document is published as a link, a private one is not", () => { +test("a cap SERVED by a public store reads, and is refused a write", () => { + const { caps, become } = registry("alice"); + const doc = "did:ng:o:public-doc"; + const served = mintCap(doc); + + become("bob"); + caps.learnFromPublicStore(served); + expect(caps.capFor(doc)).toBe(served); // he reads it, like any held cap + expect(caps.isReadOnlyPublicCap(doc)).toBe(true); // …and only that + + // A stronger claim supersedes it: a cap DEPOSITED for me is not the network's copy. + caps.learn(served); + expect(caps.isReadOnlyPublicCap(doc)).toBe(false); +}); + +test("the owner of a public document is never read-only on it", () => { + const { caps, become } = registry("alice"); + const doc = "did:ng:o:mine"; + caps.open(doc, "public"); // alice created it + + // A third party fetching the same document must not affect her claim on it. + become("bob"); + caps.learnFromPublicStore(mintCap(doc)); + expect(caps.isReadOnlyPublicCap(doc)).toBe(true); + become("alice"); + expect(caps.isReadOnlyPublicCap(doc)).toBe(false); +}); + +test("open(): a public document is marked as sitting in a public store, a private one is not", () => { const { caps } = registry(); const pub = caps.open("did:ng:o:pub", "public"); const prot = caps.open("did:ng:o:prot", "protected"); diff --git a/packages/client/test/cross-user-access.test.ts b/packages/client/test/cross-user-access.test.ts index 484105c..c9feb57 100644 --- a/packages/client/test/cross-user-access.test.ts +++ b/packages/client/test/cross-user-access.test.ts @@ -26,12 +26,22 @@ import { } from "../src/shared-wallet/account-registry"; import { documentInboxAddress, openDocumentInbox } from "../src/emulated-verifier/branch-registers"; import type { RegistrySession } from "../src/shared-wallet/account-registry"; -import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,hasCap,resetCaps,setCurrentUser,share,connectedUser} from "../src/polyfill"; +import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,resetCaps,setCurrentUser,share,connectedUser} from "../src/polyfill"; import { post, postToDocument, read as readInbox } from "../src/surface/inbox"; import { readUnion } from "../src/surface/read-model"; import { sparqlUpdate } from "../src/surface/docs"; import type { Nuri } from "../src/model/types"; +/** + * Do I hold this document's cap? Possession, asked of the internal registry — the + * polyfill door stopped publishing this (see `polyfill.ts`), because as an app-facing + * question it reads like "may I read this?" and a public store's document answers + * `false` until something has asked for its cap. + */ +function hasCap(nuri: Nuri): boolean { + return getCaps().capFor(nuri) !== undefined; +} + afterAll(() => { resetConfig(); resetStoreRegistry(); @@ -168,6 +178,10 @@ function makeFakeNg() { if (query.includes(`<${SHIM}:link>`)) { return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:link`).map((q) => ({ c: { value: q.o } })) } }; } + // Header-branch `exposedReadCap` SELECT — what a PUBLIC store serves to anyone. + if (query.includes(`<${SHIM}:exposedReadCap>`)) { + return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:exposedReadCap`).map((q) => ({ c: { value: q.o } })) } }; + } if (query.includes(`<${SHIM}:contains>`)) { return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:contains`).map((q) => ({ e: { value: q.o } })) } }; } @@ -207,7 +221,13 @@ async function readValues(docs: Nuri[], p: string): Promise { /** * 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. + * REFERS to it by bare NURI. + * + * What crosses to the other actors is **the bare reference of the public document and + * nothing else** — no cap, no link with a key in it. That is the whole discipline of + * this file: an application circulates references, and if a test had to hand a key + * across an identity boundary through a JS variable, the feature it claims to prove + * would have no path in any real application. */ async function aliceSetsUpHerDocuments() { setCurrentUser("alice"); @@ -219,8 +239,7 @@ async function aliceSetsUpHerDocuments() { // grants nothing. This is the whole point of the scenario. await write(pubDoc, REFERS_TO, protDoc); - const pubLink = getCaps().capFor(pubDoc)!; // out-of-band: the test plays 'Alice sent Bob the link' // the shareable repo link of the public doc - return { protDoc, pubDoc, pubLink }; + return { protDoc, pubDoc }; } /** Follow the reference found in the public document — what a reader actually does. */ @@ -232,11 +251,11 @@ function referenceFoundIn(values: string[]): Nuri { test("Bob: reads the public document, sees the reference, and cannot read through it", async () => { inject(); - const { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments(); + const { protDoc, pubDoc } = await aliceSetsUpHerDocuments(); setCurrentUser("bob"); - // Bob was given the public document's link — "whoever has the URL reads it". - getCaps().learn(pubLink); + // Bob holds the BARE reference and nothing else. The document sits in a public + // store, so the store serves him its cap — he never received a key from anyone. // He reads the public document and finds the reference. const refs = await readValues([pubDoc], REFERS_TO); @@ -250,7 +269,7 @@ test("Bob: reads the public document, sees the reference, and cannot read throug test("Charlie: same public document, same reference — and he reads through it", async () => { inject(); - const { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments(); + const { protDoc, pubDoc } = await aliceSetsUpHerDocuments(); const CHARLIE_INBOX = await userInbox("charlie", "protected"); // Alice decides Charlie may read that ONE document, and delivers its cap to his @@ -259,7 +278,6 @@ test("Charlie: same public document, same reference — and he reads through it" await share(protDoc, "charlie"); setCurrentUser("charlie"); - getCaps().learn(pubLink); await readInbox(CHARLIE_INBOX); // processing the inbox files the cap const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO)); @@ -270,18 +288,16 @@ test("Charlie: same public document, same reference — and he reads through it" test("the ONLY difference between Bob and Charlie is each of them holds", async () => { inject(); - const { protDoc, pubLink } = await aliceSetsUpHerDocuments(); + const { protDoc } = await aliceSetsUpHerDocuments(); const CHARLIE_INBOX = await userInbox("charlie", "protected"); setCurrentUser("alice"); await share(protDoc, "charlie"); 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); @@ -293,11 +309,10 @@ test("the ONLY difference between Bob and Charlie is each of them holds", async // that was empty becomes full — with nothing re-declared and nobody re-authorized. test("dynamic: a cap delivered to Bob's inbox makes the refused document readable, and signals it", async () => { inject(); - const { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments(); + const { protDoc, pubDoc } = await aliceSetsUpHerDocuments(); const BOB_INBOX = await userInbox("bob", "protected"); setCurrentUser("bob"); - getCaps().learn(pubLink); const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO)); // Before: named, unreadable. @@ -331,16 +346,24 @@ test("dynamic: a cap delivered to Bob's inbox makes the refused document readabl unsub(); }); -test("a bare reference to the PUBLIC document is not enough either — the link is", async () => { +// The property this whole batch exists for, stated on its own: WHERE a document sits +// decides whether a bare reference is enough. Upstream a public store's repos are +// served on the outer overlay and their ReadCap is downloaded from it +// (`PublicRepoLinkV0`, `engine/net/src/types.rs:5098`) — so the same value transmitted +// (a bare reference) yields a different outcome depending on the store, and never +// because a key travelled. +test("a bare reference is enough for a PUBLIC document, and not for a protected one", async () => { inject(); - const { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments(); + const { protDoc, pubDoc } = 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); + // Bob has been given nothing but the two NURIs. expect((await readValues([pubDoc], REFERS_TO)).length).toBe(1); + expect(await readValues([protDoc], SECRET)).toEqual([]); + + // And what he obtained for the public one is a READ grant, not a write right: a + // public store serves its read cap, no store hands out the write cap. + await expect(write(pubDoc, SECRET, "bob-was-here")).rejects.toThrow(/public store/i); }); // THE POINT OF THE LINK: a cap survives because it was APPLIED, not because the @@ -369,7 +392,10 @@ test("a Link is APPLIED durably: the cap survives with the inbox emptied", async setCurrentUser("alice"); await createEntityDoc("alice", "private"); // re-arms: a cap exists again setCurrentUser("bob"); - expect(await readValues([protDoc], SECRET)).toEqual([]); // bob holds nothing yet + // Checked SYNCHRONOUSLY, before yielding: `setCurrentUser` fires the connection work + // itself, and that work is precisely what restores the cap. An awaited check here + // would be asserting who won a race, not what the library does. + expect(hasCap(protDoc)).toBe(false); // bob holds nothing yet // Connecting restores it — from the User branch, since the inbox has nothing left. await connectedUser(); @@ -396,13 +422,12 @@ test("a document has its own inbox: anyone deposits, only the owner reads", asyn const doc = await createEntityDoc("alice", "public"); const aliceInbox = await openDocumentInbox(doc); expect(aliceInbox).not.toBe(await userInbox("alice", "protected")); - const link = getCaps().capFor(doc)!; // the repo link alice circulates — links DO travel - // Bob RESOLVES the address himself, from the document. The only thing he is handed - // is the link, which is the one thing the model says circulates. The address is not - // passed to him — if it had to be, there would be no way for an app to get it. + // Bob RESOLVES the address himself, from the BARE reference — the only thing he is + // handed, and the only thing an application circulates. The document is in a public + // store, so the store serves him its read cap; the address is not passed to him, + // because if it had to be there would be no way for an app to get it. setCurrentUser("bob"); - getCaps().learn(link); const bobTarget = await documentInboxAddress(doc); expect(bobTarget).toBe(aliceInbox); // …and it is the SAME inbox alice reads await post(bobTarget!, { payload: { joining: true }, ts: 1 }); @@ -422,11 +447,8 @@ test("opening an inbox on someone else's document is refused, not silently forke const doc = await createEntityDoc("alice", "public"); const aliceInbox = await openDocumentInbox(doc); - const link = getCaps().capFor(doc)!; - - // Bob holds the document — that is a READ right, and it is not ownership. + // Bob can READ the document (it is in a public store) — and reading is not ownership. setCurrentUser("bob"); - getCaps().learn(link); await expect(openDocumentInbox(doc)).rejects.toThrow(/already has an inbox|you may only open an inbox/i); // The address he resolves is still alice's, so his deposits reach her. expect(await documentInboxAddress(doc)).toBe(aliceInbox); @@ -436,13 +458,11 @@ test("a fresh document has NO inbox — one belongs to one document, and only it inject(); setCurrentUser("alice"); const doc = await createEntityDoc("alice", "public"); - const link = getCaps().capFor(doc)!; // Not "the owner's inbox by default": upstream an inbox belongs to exactly ONE repo // (the verifier routes by `inboxes: PubKey → RepoId`), so pointing several documents // at one inbox is a relation the model cannot express. setCurrentUser("bob"); - getCaps().learn(link); expect(await documentInboxAddress(doc)).toBeUndefined(); // …and depositing THROWS rather than vanishing — a lost deposit is the bug this // whole path exists to close. @@ -456,9 +476,7 @@ test("opening an inbox publishes ONE address, and re-opening does not accumulate const dedicated = await openDocumentInbox(doc); expect(await openDocumentInbox(doc)).toBe(dedicated); // idempotent - const link = getCaps().capFor(doc)!; setCurrentUser("bob"); - getCaps().learn(link); expect(await documentInboxAddress(doc)).toBe(dedicated); // The deposit reaches the owner, addressed by the document alone. await postToDocument(doc, { payload: { signingUp: true } }); diff --git a/packages/client/test/isolation-active.test.ts b/packages/client/test/isolation-active.test.ts index 65573a1..f5937a2 100644 --- a/packages/client/test/isolation-active.test.ts +++ b/packages/client/test/isolation-active.test.ts @@ -11,16 +11,17 @@ * 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; + * (b) the read-filtered VIEW decides on possession alone — it is synchronous, so it + * asks no store anything (a public store WOULD serve its cap; that is proven on + * the read paths, in `cross-user-access.test.ts`); * (c) switching identity SWITCHES heldByHolder — it never wipes one. */ import { getCaps } from "../src/shared-wallet/bootstrap"; import { test, expect, mock, afterAll } from "bun:test"; import { createEntityDoc, resetRegistryCache, userInbox, listMyEntityDocs } from "../src/shared-wallet/account-registry"; import type { RegistrySession } from "../src/shared-wallet/account-registry"; -import type { ReadCap } from "../src/model/types"; -import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,hasCap,resetCaps,setCurrentUser,share} from "../src/polyfill"; +import type { Nuri, ReadCap } from "../src/model/types"; +import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,resetCaps,setCurrentUser,share} from "../src/polyfill"; import { read as readInbox } from "../src/surface/inbox"; import { filterReadable } from "../src/emulated-verifier/read-filter"; @@ -35,6 +36,12 @@ const SESSION: RegistrySession = { sessionId: "sid", privateStoreId: "PRIV" }; const SHIM = "urn:ng-eventually:shim"; const INBOX = "urn:ng-eventually:inbox"; +/** Possession, asked of the internal registry — see `polyfill.ts` on why the door + * stopped publishing it. */ +function hasCap(nuri: Nuri): boolean { + return getCaps().capFor(nuri) !== undefined; +} + interface Quad { g: string; s: string; p: string; o: string } /** Reverse of the lib's escapeLiteral: single left-to-right pass over `\x`. */ @@ -247,21 +254,30 @@ test("a cap deposit is absorbed, not surfaced as a consumer deposit", async () = expect(hasCap(doc)).toBe(true); // …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 () => { +// (b) The ORM read filter is PURE POSSESSION — it asks nothing of anyone. +// +// Note what this does NOT say: that a bare reference to a public document is +// unreadable. It is readable, through the read paths, because a public store serves +// its cap (`emulated-verifier/public-store.ts`, and `cross-user-access.test.ts` proves +// it). This filter sits below that: it is synchronous, it decides from what the holder +// holds AT THAT MOMENT, and a document whose cap was never obtained is filtered out +// whatever store it sits in. The library's own read paths ask first; the reactive ORM +// view has no door to ask through, and that limit is recorded in `read-filter.ts`. +test("(b) the read-filtered view decides on possession alone, with no lookup", async () => { inject(); setCurrentUser("alice"); const pub = await createEntityDoc("alice", "public"); const items = [item(pub, "u1")]; expect(getCaps().isInPublicStore(pub)).toBe(true); - const link = getCaps().capFor(pub)!; + const cap = getCaps().capFor(pub)!; - // bob HAS the document's bare NURI (it is right there in `items`) and reads nothing. + // bob HAS the document's bare NURI (it is right there in `items`), holds no cap for + // it, and the view drops it — no question asked of any store. setCurrentUser("bob"); expect(view(items)).toEqual([]); - // Receiving the repo link — what a discovery entry actually carries — opens it. - getCaps().learn(link); + // Once the cap IS among what he holds — however it got there — the same view yields it. + getCaps().learn(cap); expect(view(items)).toEqual(["u1"]); }); diff --git a/packages/client/test/public-store.test.ts b/packages/client/test/public-store.test.ts new file mode 100644 index 0000000..5b618f4 --- /dev/null +++ b/packages/client/test/public-store.test.ts @@ -0,0 +1,152 @@ +/** + * public-store.test.ts — the emulated *"downloaded from the outerOverlay"*, in isolation. + * + * `cross-user-access.test.ts` proves the consequence end to end (Bob reads Alice's + * public document from a bare reference). This file pins the primitive itself: what it + * asks, what it refuses, and when it says nothing at all. + */ +import { test, expect, mock, afterEach } from "bun:test"; +import { exposeReadCap, fetchReadCap, resetPublicStoreFetches } from "../src/emulated-verifier/public-store"; +import { mintCap } from "../src/emulated-verifier/caps"; +import { getCaps } from "../src/shared-wallet/bootstrap"; +import { + configure, + configureStoreRegistry, + resetConfig, + resetStoreRegistry, + resetCaps, + setCurrentUser, +} from "../src/polyfill"; +import type { Nuri } from "../src/model/types"; + +const SHIM = "urn:ng-eventually:shim"; +const SESSION = { sessionId: "sid-ps", privateStoreId: "PRIV-PS" }; + +interface Quad { g: string; s: string; p: string; o: string } + +/** A fake `ng` holding just enough to answer the Header-branch `exposedReadCap` query. */ +function inject() { + const quads: Quad[] = []; + const sparql_update = mock(async (...a: unknown[]) => { + const query = a[1] as string; + const anchor = a[2] as string; + if (/^\s*DELETE WHERE/.test(query)) { + for (let i = quads.length - 1; i >= 0; i--) if (quads[i]!.g === anchor) quads.splice(i, 1); + return undefined; + } + const m = query.match(/<([^>]+)>\s+<([^>]+)>\s+"([^"]*)"/); + if (m) quads.push({ g: anchor, s: m[1]!, p: m[2]!, o: m[3]! }); + return undefined; + }); + const sparql_query = mock(async (...a: unknown[]) => ({ + results: { + bindings: quads + .filter((q) => q.g === (a[3] as string) && q.p === `${SHIM}:exposedReadCap`) + .map((q) => ({ c: { value: q.o } })), + }, + })); + configure({ ng: { doc_create: mock(async () => "did:ng:o:x"), sparql_update, sparql_query } as any, useShape: (() => {}) as any }); + configureStoreRegistry({ getSession: async () => SESSION }); + resetCaps(); + resetPublicStoreFetches(); + setCurrentUser(null); + return { sparql_query, quads }; +} + +afterEach(() => { + resetConfig(); + resetStoreRegistry(); + resetCaps(); + setCurrentUser(null); +}); + +/** Arm the emulation without giving the current holder anything: some OTHER document. */ +function armEmulation(): void { + setCurrentUser("someone-else"); + getCaps().mint("did:ng:o:unrelated"); +} + +const PUB = "did:ng:o:pub" as Nuri; + +test("a cap exposed on a document is downloaded by a holder that has nothing", async () => { + inject(); + setCurrentUser("alice"); + await exposeReadCap(PUB, mintCap(PUB)); + + setCurrentUser("bob"); + armEmulation(); + setCurrentUser("bob"); + expect(getCaps().capFor(PUB)).toBeUndefined(); + + expect(await fetchReadCap(PUB)).toBe(true); + expect(getCaps().capFor(PUB)).toBe(mintCap(PUB)); + // …and what he got is a READ grant, recorded as such. + expect(getCaps().isReadOnlyPublicCap(PUB)).toBe(true); + expect(getCaps().isInPublicStore(PUB)).toBe(true); +}); + +test("a document that exposes nothing yields nothing — that is the normal case, not an error", async () => { + inject(); + armEmulation(); + setCurrentUser("bob"); + expect(await fetchReadCap("did:ng:o:protected" as Nuri)).toBe(false); + expect(getCaps().capFor("did:ng:o:protected" as Nuri)).toBeUndefined(); +}); + +// A document speaks for itself and for nothing else. Without this, whoever can write +// into one public document could file caps for every document they care to name. +test("a cap naming ANOTHER document is refused, not filed", async () => { + const { quads } = inject(); + setCurrentUser("alice"); + await exposeReadCap(PUB, mintCap(PUB)); + // Forge the exposed value so it names a different document. + quads[0]!.o = mintCap("did:ng:o:someone-elses" as Nuri); + + armEmulation(); + setCurrentUser("bob"); + expect(await fetchReadCap(PUB)).toBe(false); + expect(getCaps().capFor(PUB)).toBeUndefined(); + expect(getCaps().capFor("did:ng:o:someone-elses" as Nuri)).toBeUndefined(); +}); + +test("inert while no cap has been issued at all — nothing to obtain, nothing asked", async () => { + const { sparql_query } = inject(); + setCurrentUser("bob"); + expect(await fetchReadCap(PUB)).toBe(false); + expect(sparql_query).toHaveBeenCalledTimes(0); +}); + +test("asked once per document: the outcome is memoised, in both directions", async () => { + const { sparql_query } = inject(); + setCurrentUser("alice"); + await exposeReadCap(PUB, mintCap(PUB)); + armEmulation(); + setCurrentUser("bob"); + + await fetchReadCap(PUB); + const afterHit = sparql_query.mock.calls.length; + await fetchReadCap(PUB); // held now → not even the memo is consulted + expect(sparql_query.mock.calls.length).toBe(afterHit); + + const absent = "did:ng:o:nothing-here" as Nuri; + await fetchReadCap(absent); + const afterMiss = sparql_query.mock.calls.length; + await fetchReadCap(absent); // a miss is remembered too + expect(sparql_query.mock.calls.length).toBe(afterMiss); +}); + +test("resetting the caps forgets the memo — a stale yes would hand back what is no longer held", async () => { + const { sparql_query } = inject(); + setCurrentUser("alice"); + await exposeReadCap(PUB, mintCap(PUB)); + armEmulation(); + setCurrentUser("bob"); + await fetchReadCap(PUB); + + resetCaps(); // also calls resetPublicStoreFetches + armEmulation(); + setCurrentUser("bob"); + const before = sparql_query.mock.calls.length; + expect(await fetchReadCap(PUB)).toBe(true); + expect(sparql_query.mock.calls.length).toBeGreaterThan(before); // asked again +}); diff --git a/packages/client/test/read-filter.test.ts b/packages/client/test/read-filter.test.ts index eb2df10..4e6d2aa 100644 --- a/packages/client/test/read-filter.test.ts +++ b/packages/client/test/read-filter.test.ts @@ -18,7 +18,7 @@ function setup(initial: string | null = "alice") { const before = holder; holder = "alice"; caps.mint("did:ng:o:alice"); - const link = caps.recordInPublicStore("did:ng:o:public"); + const link = caps.open("did:ng:o:public", "public"); holder = before; return { caps, link, become: (id: string | null) => (holder = id) }; }