feat: un document en store public sert son ReadCap, une référence nue suffit

Le modèle amont est explicite dans `PublicRepoLinkV0` : le lien ne porte AUCUN
`read_cap`, et son commentaire dit pourquoi — *"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). La clé n'est pas remise par un émetteur : elle est
donnée par le réseau à qui la demande, parce que le broker a épinglé l'overlay
externe (`expose_outer`).

La bibliothèque refusait jusqu'ici la forme sans cap quel que soit le store. Sûr
dans le bon sens, mais une application ne pouvait pas exprimer « fais circuler, la
référence suffit » — le seul acte que le modèle rend gratuit — et son unique
contournement était de distribuer la clé, ce qui détruit la confidentialité
composable.

`emulated-verifier/public-store.ts` émule le mécanisme SANS toucher à la garde. La
possession reste l'unique critère : un document public est lisible non par exception
mais parce que son cap est *obtenable*. Chaque porte de lecture demande d'abord
(`readUnion`, `docs.sparqlQuery`, `ensureRepoOpen`, `documentInboxAddress`), puis le
chemin ordinaire s'applique.

Lire n'est pas écrire. Ce que le store sert est un droit de LECTURE :
`learnFromPublicStore` le classe à part et `assertMayWrite` refuse l'écriture
dessus. Sans cela une référence nue achetait une écriture, ce qu'aucun store amont
n'accorde.

Autres conséquences :

- `recordInPublicStore` (marquer + frapper) devient `markInPublicStore` (marquer).
  Frapper un second cap à côté de celui qu'on vient de télécharger donnerait deux
  clés différentes le jour où la constante devient un secret.
- `hasCap` quitte la porte polyfill : il se lisait « ai-je le droit de lire ceci ? »
  et un document public y répondait `false` jusqu'à ce qu'on demande son cap. Aucun
  appelant hors des tests.
- Les tests cross-user ne font plus traverser de cap par une variable JS : Bob
  n'obtient que la référence nue, comme une vraie application.

Écarts documentés plutôt que masqués : le pari sur un modèle DÉCLARÉ (`expose_outer`
est câblé à `false` côté client et `ExtTopicSyncReq` est `unimplemented!()`), la
découverte limitée à ce qu'on sait déjà nommer, `useShape` qui n'a pas d'await à
dépenser, et l'absence de `locator`.

179 tests unitaires, e2e 42/42 contre le broker en ligne.
This commit is contained in:
Sylvain Duchesne
2026-08-06 19:55:32 +02:00
parent 32ef756b0b
commit 0832338201
28 changed files with 764 additions and 162 deletions
+16 -15
View File
@@ -336,7 +336,7 @@ declare function doc_subscribe(repo_o: string, session_id: any, callback: Functi
## 9. Inbox — deposits, and cap delivery ## 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 ```ts
// inbox.ts:48,58 // inbox.ts:48,58
@@ -355,7 +355,7 @@ export async function post(targetInbox: Nuri, opts: PostOptions): Promise<void>;
// inbox.ts:215 // inbox.ts:215
export async function postToDocument(doc: Nuri, opts: PostOptions): Promise<void>; export async function postToDocument(doc: Nuri, opts: PostOptions): Promise<void>;
// inbox.ts:282 // inbox.ts:282
export async function shareCap(cap: ReadCap, toInbox: Nuri): Promise<void>; export async function share(doc: NuriLike, toUser: string): Promise<void>;
// inbox.ts:339 // inbox.ts:339
export async function read(targetInbox: Nuri): Promise<Deposit[]>; export async function read(targetInbox: Nuri): Promise<Deposit[]>;
// inbox.ts:419 // inbox.ts:419
@@ -384,7 +384,7 @@ export function watch(
Consequences per function: 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`. - `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<ReadCap>` 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<ReadCap>` 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. - `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. - `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 Nuri = `did:ng:${string}`;
export type ReadCap = `did:ng:${string}:r:${string}`; export type ReadCap = `did:ng:${string}:r:${string}`;
// @ng-eventually/client/polyfill — polyfill.ts:205 // @ng-eventually/client/polyfill
export function capFor(nuri: Nuri): ReadCap | undefined; // tests / fresh wallet only — the registry itself is NOT published, and neither is any
// polyfill.ts:193 — hands out the registry itself // "do I hold this?" predicate (`hasCap`, removed 2026-08-06: it read like "may I read
export function getCaps(): CapRegistry; // this?", and a document in a public store answers `false` until something asks for it).
// polyfill.ts:215 — tests / fresh wallet only
export function resetCaps(): void; export function resetCaps(): void;
// @ng-eventually/client/polyfill — caps.ts:59 (class CapRegistry) // @ng-eventually/client/polyfill — caps.ts:59 (class CapRegistry)
@@ -417,8 +416,10 @@ constructor(holder?: () => PrincipalId | null);
mint(nuri: Nuri): ReadCap; mint(nuri: Nuri): ReadCap;
learn(cap: ReadCap): void; learn(cap: ReadCap): void;
capFor(nuri: Nuri): ReadCap | undefined; capFor(nuri: Nuri): ReadCap | undefined;
publishRepoLink(nuri: Nuri): ReadCap; learnFromPublicStore(cap: ReadCap): void; // a cap the public store SERVED — read only
isPublished(nuri: Nuri): boolean; isReadOnlyPublicCap(nuri: Nuri): boolean;
markInPublicStore(nuri: Nuri): void;
isInPublicStore(nuri: Nuri): boolean;
open(nuri: Nuri, scope: Scope): ReadCap; open(nuri: Nuri, scope: Scope): ReadCap;
isEnforcing(): boolean; isEnforcing(): boolean;
onChange(listener: () => void): () => void; 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. `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: 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. - **`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. - **`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.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. - ~~**`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. - ~~**`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 ### 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". - ~~**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. - **`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. - **`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). - **`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` ### `@ng-eventually/client/polyfill` — `src/polyfill.ts`
```text ```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
``` ```
+8 -4
View File
@@ -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 ### 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. **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.
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. 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. 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.
+1 -1
View File
@@ -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`). **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. **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.
+4 -3
View File
@@ -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 `r:{base64url(serde_bare(ObjectRef))}`. It is **one function** (`mintCap`), because
every path now READS a stored cap instead of recomputing one. `hasReadCap` / every path now READS a stored cap instead of recomputing one. `hasReadCap` /
`targetOf` stay meaningful: the `r:` discriminant is upstream grammar, not ours. `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 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 recipient's own verifier applying queued messages. **The consumer's call does not
change.** 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 - 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 `read-model.readUnion` are then dead code — the broker only delivers documents
whose cap the wallet holds. Remove them. 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 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 every grant on every session (`declareConnections`). That call **disappears**: with
delivered caps the grant moves to the moment a connection is *accepted* — one 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 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. reload. There is no analogue of `protectedDocsOf` + the re-derivation loop.
+1 -1
View File
@@ -58,7 +58,7 @@ notifications — none of these is enumerated across virtualUsers. Each is reach
what is already reachable to me: what is already reachable to me:
- my own docs (always in `self.repos`, and whose caps I hold); - 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)); per-document ReadCap in [`simulation.md`](./simulation.md));
- my inbox (deposits addressed to me). - my inbox (deposits addressed to me).
+15 -4
View File
@@ -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** — 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`). - **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 ## 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) | | | 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 | | 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 | | 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 | | Revocation | coarse **re-key**, non-retroactive | **not emulated** (P3). Nothing pretends to revoke |
| Granularity | repo / branch / commit / object | **one cap per doc-NURI** | | 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 | | 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". **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) ## 6. Implications for consumers (e.g. Festipod)
+13 -11
View File
@@ -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 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 a **fresh session** read its own documents again with nothing re-declared — the
durability the old in-memory ACL faked and lost every reload. 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 recipient's inbox; `inbox.read` applies it inline, exactly as the recipient's own
verifier applies queued messages upstream. **Receiving needs no operation** — a verifier applies queued messages upstream. **Receiving needs no operation** — a
consumer already watching its inbox gets them, and the resulting change 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. - **`setCurrentUser(id)` (`polyfill.ts`)** — the SDK's "current identity" call.
It selects *whose* caps are consulted, lazily, so the delivered subset always It selects *whose* caps are consulted, lazily, so the delivered subset always
reflects the identity in effect at read time. 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; are addressed as **inboxes**, which `inbox.post(targetInbox)` already does here;
there is no `PrincipalId` in this surface, because that notion exists nowhere there is no `PrincipalId` in this surface, because that notion exists nowhere
upstream. Reaching several recipients means calling it once per inbox, which is 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). what the real model does too (each delivery is sealed to one recipient).
- **`getCaps().publishRepoLink(doc)`** — upstream `RepoLinkV0`: a shareable link - **A document created in the `public` scope** needs no sharing act at all. The store
**whoever receives it** can open. Put the *link* in what you make discoverable, not serves its ReadCap to whoever asks (`emulated-verifier/public-store.ts`, emulating
the bare NURI, or no reader can open it. Publication is **not recursive**: a public *"the latest ReadCap will be downloaded from the outerOverlay"* — `PublicRepoLinkV0`,
document may reference private ones, and the reference grants nothing on what it `engine/net/src/types.rs:5098`), so what an application circulates is the **bare
references — which is what lets a public object point at a private identity without reference**, exactly as it will after migration. Never recursive: a public document
disclosing it. 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` Upstream, directed delivery is a **gap, not a disagreement**: `ContactDetails.read_cap`
exists, but the message construction is `unimplemented!()`, its only caller passes 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`) 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 alone: the access unit is the document (`@graph` = repo), and the only acts are
possession-shaped (`createEntityDoc` files a cap, `shareCap` delivers one, possession-shaped (`createEntityDoc` files a cap, `inbox.share` delivers one, a public
`publishRepoLink` emits an openable link). Because the consumer application writes store serves one to whoever asks). Because the consumer application writes
one document per entity, the per-document cap discriminates at entity granularity — one document per entity, the per-document cap discriminates at entity granularity —
the target's behaviour. 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 announced — and the read side is served by the recipient's own verifier unsealing
queued messages inline. queued messages inline.
The inbox + watcher is the one deposit/read mechanism a consumer reuses for its own 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. someone — same `post` API, same watcher.
## The virtual user boundary (`emulated-verifier/reach.ts` + `shared-wallet/physical.ts`) ## The virtual user boundary (`emulated-verifier/reach.ts` + `shared-wallet/physical.ts`)
+4 -4
View File
@@ -298,7 +298,7 @@ async function main(): Promise<void> {
const t = Date.now(); const t = Date.now();
const r = await sdk<any>(frame, "documentInboxDeposit", "@owner-" + t, "@depositor-" + t); const r = await sdk<any>(frame, "documentInboxDeposit", "@owner-" + t, "@depositor-" + t);
check( 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.sameInbox === true &&
r.openRefused === true && r.openRefused === true &&
JSON.stringify(r.deposits) === JSON.stringify([{ viaPostToDocument: true }, { joining: true }]) && JSON.stringify(r.deposits) === JSON.stringify([{ viaPostToDocument: true }, { joining: true }]) &&
@@ -383,15 +383,15 @@ async function main(): Promise<void> {
const linkOpensPublic = const linkOpensPublic =
r.strangerWithLinkView.length === 1 && r.strangerWithLinkView.includes("public-item"); r.strangerWithLinkView.length === 1 && r.strangerWithLinkView.includes("public-item");
check( 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, 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 () => { await step("shareCap: a cap delivered to an inbox reveals the doc", async () => {
const r = await sdk<any>(frame, "capsShareCap", "@friend-" + Date.now()); const r = await sdk<any>(frame, "capsShareCap", "@friend-" + Date.now());
check( 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, r.before === 0 && r.after === 1 && r.surfacedDeposits === 0,
`before=${r.before} after=${r.after} surfaced=${r.surfacedDeposits}`, `before=${r.before} after=${r.after} surfaced=${r.surfacedDeposits}`,
); );
+9 -9
View File
@@ -837,10 +837,11 @@ const identity = new IdentityStore(
]; ];
setCurrentUser("owner-O"); setCurrentUser("owner-O");
getCaps().open("did:ng:o:protdoc", "protected"); 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<any>)].map((i) => i.v); const ownerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
// A stranger holds nothing — including the PUBLISHED document, until the repo // A stranger holds nothing, and this VIEW asks nobody: it is pure possession, with
// link reaches them (§5: whoever has the URL reads the content). // 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"); setCurrentUser("stranger");
const strangerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v); const strangerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
getCaps().learn(link); 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 * owner opens it, a third party RESOLVES its address from the document itself and
* deposits, the owner reads it back. * deposits, the owner reads it back.
* *
* The point of the step is the resolution: nothing hands `depositorId` the address. * The point of the step is the resolution: the depositor is handed the document's
* It gets the document's link (which is what circulates in this model) and must find * BARE reference — the only thing an application circulates — and must find where to
* where to deposit on its own — which is exactly what a consumer app has to do, and * deposit on its own. It reads the document at all because the document sits in a
* what a unit test passing the NURI through a variable cannot prove. * 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) { async documentInboxDeposit(ownerId: string, depositorId: string) {
registryInternals.resetRegistryCache(); registryInternals.resetRegistryCache();
setCurrentUser(ownerId); setCurrentUser(ownerId);
const doc = await storeRegistry.createEntityDoc(ownerId, "public"); const doc = await storeRegistry.createEntityDoc(ownerId, "public");
const ownerInbox = await storeRegistry.openDocumentInbox(doc); const ownerInbox = await storeRegistry.openDocumentInbox(doc);
const link = getCaps().capFor(doc)!; // out-of-band: the harness plays 'the owner sent it'
setCurrentUser(depositorId); setCurrentUser(depositorId);
getCaps().learn(link);
const resolved = await documentInboxAddress(doc); const resolved = await documentInboxAddress(doc);
// The one-call form an app actually uses: it names the DOCUMENT, never an inbox. // The one-call form an app actually uses: it names the DOCUMENT, never an inbox.
await inbox.postToDocument(doc, { payload: { viaPostToDocument: true }, ts: 900 }); await inbox.postToDocument(doc, { payload: { viaPostToDocument: true }, ts: 900 });
@@ -38,6 +38,7 @@ import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
import { escapeLiteral } from "../surface/sparql"; import { escapeLiteral } from "../surface/sparql";
import { hasReadCap, isNuri } from "../model/nuri"; import { hasReadCap, isNuri } from "../model/nuri";
import { mustNotAttempt } from "./reach"; import { mustNotAttempt } from "./reach";
import { fetchReadCap } from "./public-store";
import { ensureRepoOpen } from "./open-repo"; import { ensureRepoOpen } from "./open-repo";
import { accessLogPrefix } from "../shared-wallet/access-log"; import { accessLogPrefix } from "../shared-wallet/access-log";
import { 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 // 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. // by the very session that created it. Mint once, store it, hold that one.
caps.learn(cap); caps.learn(cap);
// Publication is a registry fact, not a stored one, so it is applied separately. // Which store the document sits in is a registry fact, applied separately — and a
if (scope === "public") caps.recordInPublicStore(doc); // 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<Nuri | undefined>
// deposit for a document I cannot read" is not a refused question, it is a question // 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 // 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. // 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; if (mustNotAttempt(doc)) return undefined;
const s = await session(); const s = await session();
try { try {
+93 -30
View File
@@ -37,10 +37,14 @@
* *
* ── Sharing ─────────────────────────────────────────────────────────────── * ── Sharing ───────────────────────────────────────────────────────────────
* Not here: the unit of sharing is the document and the recipient is an INBOX, so * 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 * 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. * 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 ────────────────────────────────────────── * ── What this module does NOT do ──────────────────────────────────────────
* Enforce. The shape is right after P1a; the isolation is still fake. Per-document * 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`, * 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. */ /** holder → the caps they hold, indexed by the cap-less NURI. */
private heldByHolder = new Map<string, Map<Nuri, ReadCap>>(); private heldByHolder = new Map<string, Map<Nuri, ReadCap>>();
/** /**
* Documents in a PUBLIC store, as this emulation records it. This is NOT a read grant: * Documents this session knows to sit in a PUBLIC store — a fact about each
* such a document is read by * DOCUMENT, so global rather than per-holder, unlike everything else here.
* 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 * It is not itself a right. What being in a public store buys is that the document's
* exists so the library can refuse to surface a document its holder never * cap can be DOWNLOADED by anyone who asks (`emulated-verifier/public-store.ts`,
* in a public store. *(This fed `discovery.submitToIndex`, removed 2026-07-30.)* * emulating `PublicRepoLinkV0`'s *"downloaded from the outerOverlay"*); once it has
* been, the holder holds it like any other and this set records only how it got there.
*/ */
private inPublicStore = new Set<Nuri>(); private inPublicStore = new Set<Nuri>();
/**
* holder → the documents whose cap they hold ONLY because a public store served it
* (see {@link learnFromPublicStore}).
*
* PER HOLDER, unlike the set above, and the difference is the whole point: *"this
* document is in a public store"* is a fact about the document, whereas *"the only
* claim I have on it is that the network handed me its key"* is a fact about one
* holder. Kept global, the owner of a public document would be refused writes to it
* the moment any third party fetched its cap.
*/
private servedByHolder = new Map<string, Set<Nuri>>();
/** doc NURI → principals holding its WRITE cap. Decorative until P1b. */ /** doc NURI → principals holding its WRITE cap. Decorative until P1b. */
private writers = new Map<Nuri, Set<PrincipalId>>(); private writers = new Map<Nuri, Set<PrincipalId>>();
/** Fired whenever a holder gains a cap — a cap delivered asynchronously must /** Fired whenever a holder gains a cap — a cap delivered asynchronously must
@@ -137,6 +153,11 @@ export class CapRegistry {
); );
} }
const target = targetOf(cap); const target = targetOf(cap);
// Filing is the STRONG claim — I created this document, or its cap was deposited
// for me. Either one supersedes "a public store served it to me", so the read-only
// mark goes. {@link learnFromPublicStore} re-adds it after calling here, and only
// when nothing was held before.
this.servedToHolder().delete(target);
const ring = this.heldCaps(); const ring = this.heldCaps();
if (ring.get(target) === cap) return false; if (ring.get(target) === cap) return false;
ring.set(target, cap); ring.set(target, cap);
@@ -168,6 +189,46 @@ export class CapRegistry {
this.file(cap); this.file(cap);
} }
/**
* File a cap a PUBLIC STORE served me — `emulated-verifier/public-store.ts`, the
* emulated *"downloaded from the outerOverlay"*. Held like any other cap, so reading
* needs no special case anywhere; recorded apart because of what it is NOT.
*
* It is a READ grant and nothing else. Upstream a public store makes its repos
* world-readable, never world-writable — writing needs the write cap, and
* `verify_permission` fires on WRITE only. Here the write guard still consults the
* read cap (write caps are decorative until P1b, see the module header), so without
* this distinction a bare reference to a public document would buy a WRITE — a
* consumer would build on it, and have to unlearn it at migration.
*
* A stronger claim on the same document erases the mark: {@link mint} (I created it)
* and {@link learn} (it was deposited for me) both go through {@link file}, which
* clears it. So a public document of my own is never read-only to me.
*/
learnFromPublicStore(cap: ReadCap): void {
const target = targetOf(cap);
const alreadyHeld = this.heldCaps().has(target);
this.file(cap);
// Only when this is the ONLY reason I hold it — filing never downgrades a claim.
if (!alreadyHeld) this.servedToHolder().add(target);
}
/**
* Is the ONLY reason the current holder holds this document's cap that a public store
* served it? Then it grants reading and nothing more — see {@link learnFromPublicStore}.
*/
isReadOnlyPublicCap(nuri: Nuri): boolean {
return this.servedToHolder().has(targetOf(nuri));
}
/** The current holder's public-store-served set, created on first use. */
private servedToHolder(): Set<Nuri> {
const key = this.holder() ?? ANONYMOUS;
let s = this.servedByHolder.get(key);
if (!s) this.servedByHolder.set(key, (s = new Set()));
return s;
}
/** /**
* Do I hold the cap of `nuri`? Returns it, or `undefined` when I hold * 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 * none — which is the whole answer the model can give. Absorbs the former
@@ -181,48 +242,49 @@ export class CapRegistry {
// --- publication (the public store) ------------------------------------- // --- 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 * Marking and minting were one method (`recordInPublicStore`) until they were split:
* point. It was called `publishRepoLink`, and "publish" is banned in this repo * the fetch path files the cap it DOWNLOADED, and minting a second one beside it
* (`docs/readcap-and-nuri-model.md`, the traps block) precisely because it blurs three * would produce a different key the day the stand-in constant becomes a real one —
* acts: placing a document in a public store, making it findable, and handing out a * the same trap `holdOwnCap` already documents.
* key. This method does the first and, as an emulation artefact, the third.
* *
* Upstream a document in a public store is readable because the STORE is public and * Upstream nothing corresponds to this call: the store IS public, and the broker
* brokers serve it accordingly (`expose_outer`); a `PublicRepoLinkV0` carries no * exposes its outer overlay (`expose_outer`,
* `read_cap` at all (`engine/net/src/types.rs:5105-5127`). Here there is no broker that * `engine/broker/src/server_storage/core/overlay.rs:103-133`). We record it because
* serves differently, so possession stands in for it — the emulation is OVER-strict, * one broker here serves every virtual user identically.
* not inverted: it under-grants, and "circulate the reference" remains the right
* gesture at migration. See `readcap-and-nuri-model.md` §4sexies.
* *
* NOT recursive: a document in a public store may REFERENCE private ones, and the * 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 * reference grants nothing on what it references. That non-recursiveness is what lets
* a public object point at private content without disclosing it. * a public object point at private content without disclosing it.
*/ */
recordInPublicStore(nuri: Nuri): ReadCap { markInPublicStore(nuri: Nuri): void {
const target = targetOf(nuri); this.inPublicStore.add(targetOf(nuri));
this.inPublicStore.add(target);
return this.mint(target);
} }
/** 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 { isInPublicStore(nuri: Nuri): boolean {
return this.inPublicStore.has(targetOf(nuri)); return this.inPublicStore.has(targetOf(nuri));
} }
/** /**
* Record a document the current holder owns in `scope`: its cap lands in their * Record a document the current holder owns in `scope`: its cap lands among what
* what they hold, and a `public` one is additionally published as a repo link. Returns * they hold, and a `public` one is additionally marked as sitting in a public store.
* the cap (the shareable link when public). Idempotent — the store-registry calls * Returns the cap. Idempotent — the registry calls it both when creating a document
* it both when creating a document and when listing the holder's own documents * and when listing the holder's own documents back, which is how a holder's caps are
* back, which is how a holder's caps are rebuilt on a fresh session. * rebuilt on a fresh session.
* *
* Deliberately does NOT touch write caps: those are decorative until P1b, and * Deliberately does NOT touch write caps: those are decorative until P1b, and
* arming their guard here would be enforcement this batch does not do. * arming their guard here would be enforcement this batch does not do.
*/ */
open(nuri: Nuri, scope: Scope): ReadCap { 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 --------------------------------------------------- // --- enforcement gate ---------------------------------------------------
@@ -291,6 +353,7 @@ export class CapRegistry {
* NOT what an identity change does (that switches heldByHolder, see the header). */ * NOT what an identity change does (that switches heldByHolder, see the header). */
clear(): void { clear(): void {
this.heldByHolder.clear(); this.heldByHolder.clear();
this.servedByHolder.clear();
this.inPublicStore.clear(); this.inPublicStore.clear();
this.writers.clear(); this.writers.clear();
this.issued = false; this.issued = false;
@@ -72,6 +72,7 @@
*/ */
import { mustNotAttempt } from "./reach"; import { mustNotAttempt } from "./reach";
import { fetchReadCap } from "./public-store";
import { getConfig, getStoreRegistryDeps } from "../shared-wallet/bootstrap"; import { getConfig, getStoreRegistryDeps } from "../shared-wallet/bootstrap";
import { subscribeDocUnguarded, type Unsubscribe } from "../surface/subscribe"; import { subscribeDocUnguarded, type Unsubscribe } from "../surface/subscribe";
import { logStage, shortNuri } from "../shared-wallet/access-log"; import { logStage, shortNuri } from "../shared-wallet/access-log";
@@ -178,6 +179,12 @@ async function syncSession(): Promise<void> {
*/ */
export async function ensureRepoOpen(nuri: Nuri): Promise<void> { export async function ensureRepoOpen(nuri: Nuri): Promise<void> {
if (!nuri) return; 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 // 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. // pulls its state. A user that holds no cap for it has no business asking.
// (`ensurePhysicalRepoOpen` is the machinery's door — see physical.ts.) // (`ensurePhysicalRepoOpen` is the machinery's door — see physical.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<Nuri, Promise<boolean>>();
/** Forget every outer-overlay fetch (tests / a switched session or wallet). */
export function resetPublicStoreFetches(): void {
attempted.clear();
}
/**
* Expose `cap` on `doc`'s Header branch — the emulated `expose_outer`. Called when a
* document is created in a PUBLIC store, and only then: this is what makes the cap
* obtainable by anyone, which for a public store is the intended property and for any
* other scope would be a disclosure.
*
* Replacement, not addition, like every Header-branch register: one document has one
* current cap, and two would leave a fetcher picking between them.
*/
export async function exposeReadCap(doc: Nuri, cap: ReadCap): Promise<void> {
const s = await session();
try {
// Two separate updates: `DELETE WHERE { … }` is the form verified against the real
// broker (`docs/decisions/sparql-delete-for-orm-objects.md`); a `;`-joined update
// is not exercised anywhere in this library.
await sparqlUpdate(
s.sessionId,
`DELETE WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.exposedReadCap}> ?c }`,
doc,
"exposeReadCap:clear",
);
await sparqlUpdate(
s.sessionId,
`INSERT DATA { <${HEADER_BRANCH_SUBJECT}> <${P.exposedReadCap}> "${escapeLiteral(cap)}" }`,
doc,
"exposeReadCap",
);
} catch (error) {
console.error(accessLogPrefix() + " exposeReadCap failed:", error);
}
}
/**
* Ask the (emulated) network for `doc`'s ReadCap, and file it if it answers — the
* emulated *"downloaded from the outerOverlay"*. Returns whether a cap was obtained.
*
* Nothing is asked when the cap is already held: a document you can read needs no
* fetching, and skipping it keeps the ordinary path free of physical reads.
*
* Never throws — a document that is not in a public store simply answers nothing, which
* is not an error but the normal case.
*/
export async function fetchReadCap(docLike: Nuri): Promise<boolean> {
const doc = targetOf(docLike);
const caps = getCaps();
// Inert until the emulation is in force, like the guard it serves: before the first
// cap exists everything reads anyway, so there is nothing to obtain and asking would
// be a physical round-trip bought for nothing.
if (!caps.isEnforcing()) return false;
if (caps.capFor(doc) !== undefined) return true;
let pending = attempted.get(doc);
if (pending === undefined) {
pending = downloadReadCap(doc);
attempted.set(doc, pending);
}
return pending;
}
/** The fetch itself, through the machinery's door. See the module header. */
async function downloadReadCap(doc: Nuri): Promise<boolean> {
const s = await session();
try {
// The repo has to be in the session before an anchored read resolves it — the
// cold-start heal, through the PHYSICAL door: this is the emulated broker serving
// an outer overlay, and it does not ask who is asking (see `open-repo.ts`).
await ensurePhysicalRepoOpen(doc);
const res = await physicalQuery(
s.sessionId,
`SELECT ?c WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.exposedReadCap}> ?c }`,
undefined,
doc,
"fetchReadCap",
);
for (const row of readBindings(res)) {
const cap = bindingValue(row, "c");
// `targetOf` guards the one confusion that would matter: a cap exposed on
// document A must not file a cap for document B. A document only ever speaks
// for itself.
if (cap && hasReadCap(cap) && targetOf(cap) === doc) {
// File the cap that was DOWNLOADED — never a freshly minted one. They agree
// today only because the stand-in value is a constant; with a real key (P1b)
// a second mint would produce a different key and the document would not open.
//
// `learnFromPublicStore`, not `learn`: what the network hands out is a READ
// grant. A public store makes its repos world-readable, never world-writable.
getCaps().learnFromPublicStore(cap);
getCaps().markInPublicStore(doc);
return true;
}
}
} catch (error) {
// Not in a public store, not synced, or no such document — all of them mean the
// same thing to the caller: no cap was obtained.
console.error(accessLogPrefix() + " fetchReadCap failed:", error);
}
return false;
}
/**
* Ask for a SET of documents' caps, in parallel — what a batch read does before it
* decides which documents it may touch. Each fetch is independent and tolerant.
*/
export async function fetchReadCaps(docs: Nuri[]): Promise<void> {
const unique = [...new Set(docs.filter(Boolean))];
if (unique.length === 0) return;
await Promise.all(unique.map((d) => fetchReadCap(d)));
}
@@ -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`, * **Rule 2 — do not even attempt**, at the CALLERS (`read-model`, `open-repo`,
* `subscribe`'s callers…). * `subscribe`'s callers…).
@@ -13,6 +13,16 @@
* nothing on that document — which is exactly the native behavior, and why * nothing on that document — which is exactly the native behavior, and why
* fine-grained isolation requires one document per entity. Removed at migration. * 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 * 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 * 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 * cap?". "May principal P read document D?" is an ACL question the real model
+11 -3
View File
@@ -22,12 +22,11 @@ export {
getStoreRegistryDeps, getStoreRegistryDeps,
resetStoreRegistry, resetStoreRegistry,
setCurrentUser, setCurrentUser,
hasCap,
resetCaps, resetCaps,
} from "./shared-wallet/bootstrap"; } from "./shared-wallet/bootstrap";
// Cap surface — polyfill-era (caps are emulated now; native at migration). // 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 // 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 // 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. // 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 // application's job upstream too. The gate persists what IT needs
// (`shared-wallet/access-gate.ts`); nothing else has to be exposed. // (`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`, // 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) ---------------- // --- identity persistence (polyfill-era, no SDK counterpart) ----------------
// //
@@ -77,6 +77,7 @@ import {
documentInboxAddress, documentInboxAddress,
} from "../emulated-verifier/branch-registers"; } from "../emulated-verifier/branch-registers";
import { ensureRepoOpen } from "../emulated-verifier/open-repo"; import { ensureRepoOpen } from "../emulated-verifier/open-repo";
import { exposeReadCap } from "../emulated-verifier/public-store";
import { ensurePhysicalRepoOpen, subscribePhysicalDoc } from "./physical"; import { ensurePhysicalRepoOpen, subscribePhysicalDoc } from "./physical";
import { escapeLiteral, escapeIri, assertNuri } from "../surface/sparql"; import { escapeLiteral, escapeIri, assertNuri } from "../surface/sparql";
import { hasReadCap, isNuri } from "../model/nuri"; 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 readCap: `${SHIM}:readCap`, // store branch → the ReadCap of a document IN this store
inboxCap: `${SHIM}:inboxCap`, // user branch → an inbox this user may READ inboxCap: `${SHIM}:inboxCap`, // user branch → an inbox this user may READ
inboxAddress: `${SHIM}:inboxAddress`, // header branch → WHERE to deposit for this document 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; } as const;
// Fixed subject of the per-(account×scope) index document. The index doc plays // 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 // 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<Nuri> {
} }
// …and the creator holds THAT cap for this session. // …and the creator holds THAT cap for this session.
holdOwnCap(id, scope, entityNuri, cap); 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. // 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 // 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`) // 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<Nuri[]
if (holder !== null && accountKey(holder) === accountKey(id)) { if (holder !== null && accountKey(holder) === accountKey(id)) {
const caps = getCaps(); const caps = getCaps();
for (const cap of await readStoreCaps(store)) caps.learn(cap); for (const cap of await readStoreCaps(store)) caps.learn(cap);
// A `public` store's documents are also published links — the publication fact // Which store a document sits in is a registry fact, not one recorded beside the
// lives in the registry, not in the store, so it is re-applied here. // caps, so it is re-applied here. Marking only — the caps just came from the Store
if (scope === "public") for (const d of docs) caps.recordInPublicStore(d); // branch above, and minting a second one beside them is the trap `holdOwnCap` warns
// about.
if (scope === "public") for (const d of docs) caps.markInPublicStore(d);
} }
return docs; return docs;
} }
@@ -20,6 +20,7 @@ import type { SharedWalletConfig } from "./access-gate";
import { toNuri } from "../model/nuri"; import { toNuri } from "../model/nuri";
import type { RegistrySession } from "./account-registry"; import type { RegistrySession } from "./account-registry";
import { CapRegistry } from "../emulated-verifier/caps"; import { CapRegistry } from "../emulated-verifier/caps";
import { resetPublicStoreFetches } from "../emulated-verifier/public-store";
import { setAccessLog } from "./access-log"; import { setAccessLog } from "./access-log";
import { inspectOutbox } from "./outbox-log"; import { inspectOutbox } from "./outbox-log";
import { startConnect } from "../emulated-verifier/connect"; import { startConnect } from "../emulated-verifier/connect";
@@ -215,7 +216,7 @@ export function getCaps(): CapRegistry {
* would have to invent an ACL the engine does not have (`docs/api-contract.md` § 10). * would have to invent an ACL the engine does not have (`docs/api-contract.md` § 10).
* *
* Returns a BOOLEAN, not the cap. It used to hand the value back, and the only consumer * Returns a BOOLEAN, not the cap. It used to hand the value back, and the only consumer
* that used it did so to pass it to `shareCap` — which now takes the document instead. * that used it did so to pass it to `share` — which now takes the document instead.
* Nothing an application does requires holding a key: upstream it never sees one, the * Nothing an application does requires holding a key: upstream it never sees one, the
* verifier fills `ContactDetails.read_cap` itself. So the surface answers the question * verifier fills `ContactDetails.read_cap` itself. So the surface answers the question
* and keeps the key. * and keeps the key.
@@ -235,5 +236,9 @@ export function resetCaps(): void {
// change signal (`watchShape`) stays subscribed to the live instance instead of // change signal (`watchShape`) stays subscribed to the live instance instead of
// silently holding a listener on an orphaned one. // silently holding a listener on an orphaned one.
caps.clear(); caps.clear();
// …and forget which documents were already asked about, or the emulated public-store
// fetch would answer from a memo taken before the wipe and hand back caps this
// registry no longer holds.
resetPublicStoreFetches();
} }
+21 -3
View File
@@ -16,7 +16,8 @@
import { getCaps, getConfig } from "../shared-wallet/bootstrap"; import { getCaps, getConfig } from "../shared-wallet/bootstrap";
import { logAccess, enabled as accessLogEnabled } from "../shared-wallet/access-log"; import { logAccess, enabled as accessLogEnabled } from "../shared-wallet/access-log";
import { isNuri, toNuri } from "../model/nuri"; import { isNuri, toNuri } from "../model/nuri";
import { assertMayReach } from "../emulated-verifier/reach"; import { assertMayReach, assertMayWrite } from "../emulated-verifier/reach";
import { fetchReadCap } from "../emulated-verifier/public-store";
import type { Nuri, NuriLike } from "../model/types"; import type { Nuri, NuriLike } from "../model/types";
// The low common point for ALL document access: every read in the SDK routes // The low common point for ALL document access: every read in the SDK routes
@@ -91,7 +92,16 @@ export async function sparqlUpdate(
const { ng } = getConfig(); const { ng } = getConfig();
const anchor = anchorLike === undefined ? undefined : toNuri(anchorLike, "docs.sparqlUpdate"); const anchor = anchorLike === undefined ? undefined : toNuri(anchorLike, "docs.sparqlUpdate");
// The boundary: a write may only touch what the connected virtual user reaches. // The boundary: a write may only touch what the connected virtual user reaches.
if (anchor !== undefined) assertMayReach(anchor, "docs.sparqlUpdate"); //
// NO public-store fetch here, unlike the read below, and that asymmetry is the point:
// a public store makes its repos world-READABLE. Writing needs the write cap, which
// it never serves. A cap this holder has only because the network handed it over is
// therefore refused a write outright — otherwise a bare reference to a public
// document would buy one, and a consumer would build on something that fails upstream.
if (anchor !== undefined) {
assertMayReach(anchor, "docs.sparqlUpdate");
assertMayWrite(anchor, "docs.sparqlUpdate");
}
// `label` is a lib-internal access-log tag, NOT forwarded to `ng`. // `label` is a lib-internal access-log tag, NOT forwarded to `ng`.
logAccess("WRITE", anchor ?? "(no anchor)", label); logAccess("WRITE", anchor ?? "(no anchor)", label);
return ng.sparql_update(sessionId, query, anchor); return ng.sparql_update(sessionId, query, anchor);
@@ -140,7 +150,15 @@ export async function sparqlQuery(
// The boundary: an ANCHORED read may only touch what the connected virtual user // The boundary: an ANCHORED read may only touch what the connected virtual user
// reaches. An anchorless query spans the local union — a different problem (it is // reaches. An anchorless query spans the local union — a different problem (it is
// O(wallet size), and the read path never uses it), not one this guard can bound. // O(wallet size), and the read path never uses it), not one this guard can bound.
if (anchor !== undefined) assertMayReach(anchor, "docs.sparqlQuery"); //
// Asking the (emulated) network first, as `ensureRepoOpen` does: a document in a
// public store gives its cap to whoever asks, and this is a door an application can
// reach with nothing but a bare reference. Inert once the cap is held, memoised
// otherwise — see public-store.ts.
if (anchor !== undefined) {
await fetchReadCap(anchor);
assertMayReach(anchor, "docs.sparqlQuery");
}
// `label` is a lib-internal access-log tag, NOT forwarded to `ng`. // `label` is a lib-internal access-log tag, NOT forwarded to `ng`.
const result = await ng.sparql_query(sessionId, query, base, anchor); const result = await ng.sparql_query(sessionId, query, base, anchor);
// Log AFTER the read so the row count (a strong leak signal: a doc rendering // Log AFTER the read so the row count (a strong leak signal: a doc rendering
+4 -4
View File
@@ -341,7 +341,7 @@ export async function readForDocument(docLike: NuriLike): Promise<Deposit[]> {
* Refuse to READ an inbox that is not the current wallet's. * 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 * 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 * 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 * deposits, an unguarded read let anyone who knew an inbox NURI collect the caps
* addressed to its owner, which defeats directed sharing entirely. * addressed to its owner, which defeats directed sharing entirely.
@@ -354,7 +354,7 @@ async function assertOwnInbox(targetInbox: Nuri, op: string): Promise<void> {
if (getCurrentUser() === null) { if (getCurrentUser() === null) {
throw new Error( throw new Error(
`[ng-eventually] inbox.${op}: no identity is set, so no inbox belongs to this ` + `[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))) { if (!(await isOwnInbox(targetInbox))) {
@@ -376,7 +376,7 @@ async function assertOwnInbox(targetInbox: Nuri, op: string): Promise<void> {
* sealed-inbox path is available. The consumer interprets each deposit's * sealed-inbox path is available. The consumer interprets each deposit's
* `payload`. * `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. * 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 * 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 * watching its inbox gets them, and the resulting change re-triggers the
@@ -500,7 +500,7 @@ export async function readSynced(targetInboxLike: NuriLike): Promise<Deposit[]>
/** /**
* PROCESS an inbox: read it, and **apply** what it contains. * 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 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 * 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 * processes queued messages: an inbox is a **queue you consume**, not a store you
+1 -1
View File
@@ -20,7 +20,7 @@
* *
* **No inbox ADDRESS is published here**, deliberately (`userInbox`, * **No inbox ADDRESS is published here**, deliberately (`userInbox`,
* `documentInboxAddress`, removed 2026-08-05). An application deposits with * `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, * 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 * 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 * a caller, so exposing one taught a step that has to be unlearned. The example
+13 -8
View File
@@ -157,19 +157,24 @@ export async function readUnion(docsLike: NuriLike[]): Promise<UnionSubject[]> {
const unique = [...new Set(docsLike.filter(Boolean))].map((d) => toNuri(d, "readUnion")); const unique = [...new Set(docsLike.filter(Boolean))].map((d) => toNuri(d, "readUnion"));
if (unique.length === 0) return []; 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 // 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 // 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 // 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 // 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. // 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 []). // One anchored query per doc, in parallel, tolerant (a bad doc yields []).
const perDoc = await Promise.all( const perDoc = await Promise.all(
+15
View File
@@ -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 () => { it("sparqlQuery emits a READ line with identity, nuri, label, and row-count", async () => {
injectFake(true); injectFake(true);
setCurrentUser("alice"); 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(); const { lines, restore } = spyConsoleLog();
try { try {
await sparqlQuery("sid-log", "SELECT * {}", undefined, "did:ng:o:q", "myLabel"); 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 () => { it("sparqlUpdate emits a WRITE line with identity, anchor nuri, and label", async () => {
injectFake(true); injectFake(true);
setCurrentUser("alice"); 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(); const { lines, restore } = spyConsoleLog();
try { try {
await sparqlUpdate("sid-log", "INSERT DATA {}", "did:ng:o:w", "writeLabel"); 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 () => { it("docCreate emits a WRITE line with identity and the returned nuri", async () => {
injectFake(true); injectFake(true);
setCurrentUser("alice"); 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(); const { lines, restore } = spyConsoleLog();
try { try {
await docCreate("sid-log", "Graph", "data:graph", "store"); await docCreate("sid-log", "Graph", "data:graph", "store");
+38 -10
View File
@@ -7,7 +7,7 @@
* function turns a bare reference into a cap. * function turns a bare reference into a cap.
*/ */
import { test, expect } from "bun:test"; 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 { hasReadCap, targetOf } from "../src/model/nuri";
import type { ReadCap } from "../src/model/types"; 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); 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 { caps, become } = registry("alice");
const doc = "did:ng:o:public-doc"; 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(doc)).toBe(true);
expect(caps.isInPublicStore("did:ng:o:other")).toBe(false); expect(caps.isInPublicStore("did:ng:o:other")).toBe(false);
// Marking is not holding: the fact is about the document, the cap is about a holder.
// Publication is not a world-wide read grant: whoever HAS the URL reads it. expect(caps.capFor(doc)).toBeUndefined();
become("bob"); become("bob");
expect(caps.capFor(doc)).toBeUndefined(); 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 { caps } = registry();
const pub = caps.open("did:ng:o:pub", "public"); const pub = caps.open("did:ng:o:pub", "public");
const prot = caps.open("did:ng:o:prot", "protected"); const prot = caps.open("did:ng:o:prot", "protected");
+52 -34
View File
@@ -26,12 +26,22 @@ import {
} from "../src/shared-wallet/account-registry"; } from "../src/shared-wallet/account-registry";
import { documentInboxAddress, openDocumentInbox } from "../src/emulated-verifier/branch-registers"; import { documentInboxAddress, openDocumentInbox } from "../src/emulated-verifier/branch-registers";
import type { RegistrySession } from "../src/shared-wallet/account-registry"; 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 { post, postToDocument, read as readInbox } from "../src/surface/inbox";
import { readUnion } from "../src/surface/read-model"; import { readUnion } from "../src/surface/read-model";
import { sparqlUpdate } from "../src/surface/docs"; import { sparqlUpdate } from "../src/surface/docs";
import type { Nuri } from "../src/model/types"; 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(() => { afterAll(() => {
resetConfig(); resetConfig();
resetStoreRegistry(); resetStoreRegistry();
@@ -168,6 +178,10 @@ function makeFakeNg() {
if (query.includes(`<${SHIM}:link>`)) { if (query.includes(`<${SHIM}:link>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:link`).map((q) => ({ c: { value: q.o } })) } }; 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>`)) { if (query.includes(`<${SHIM}:contains>`)) {
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:contains`).map((q) => ({ e: { value: q.o } })) } }; 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<string[]> {
/** /**
* Alice's world: a protected document holding a secret, and a public document that * 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() { async function aliceSetsUpHerDocuments() {
setCurrentUser("alice"); setCurrentUser("alice");
@@ -219,8 +239,7 @@ async function aliceSetsUpHerDocuments() {
// grants nothing. This is the whole point of the scenario. // grants nothing. This is the whole point of the scenario.
await write(pubDoc, REFERS_TO, protDoc); 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 };
return { protDoc, pubDoc, pubLink };
} }
/** Follow the reference found in the public document — what a reader actually does. */ /** 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 () => { test("Bob: reads the public document, sees the reference, and cannot read through it", async () => {
inject(); inject();
const { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments(); const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
setCurrentUser("bob"); setCurrentUser("bob");
// Bob was given the public document's link — "whoever has the URL reads it". // Bob holds the BARE reference and nothing else. The document sits in a public
getCaps().learn(pubLink); // store, so the store serves him its cap — he never received a key from anyone.
// He reads the public document and finds the reference. // He reads the public document and finds the reference.
const refs = await readValues([pubDoc], REFERS_TO); 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 () => { test("Charlie: same public document, same reference — and he reads through it", async () => {
inject(); inject();
const { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments(); const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
const CHARLIE_INBOX = await userInbox("charlie", "protected"); const CHARLIE_INBOX = await userInbox("charlie", "protected");
// Alice decides Charlie may read that ONE document, and delivers its cap to his // 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"); await share(protDoc, "charlie");
setCurrentUser("charlie"); setCurrentUser("charlie");
getCaps().learn(pubLink);
await readInbox(CHARLIE_INBOX); // processing the inbox files the cap await readInbox(CHARLIE_INBOX); // processing the inbox files the cap
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO)); 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 () => { test("the ONLY difference between Bob and Charlie is each of them holds", async () => {
inject(); inject();
const { protDoc, pubLink } = await aliceSetsUpHerDocuments(); const { protDoc } = await aliceSetsUpHerDocuments();
const CHARLIE_INBOX = await userInbox("charlie", "protected"); const CHARLIE_INBOX = await userInbox("charlie", "protected");
setCurrentUser("alice"); setCurrentUser("alice");
await share(protDoc, "charlie"); await share(protDoc, "charlie");
setCurrentUser("bob"); setCurrentUser("bob");
getCaps().learn(pubLink);
const bobSees = await readValues([protDoc], SECRET); const bobSees = await readValues([protDoc], SECRET);
setCurrentUser("charlie"); setCurrentUser("charlie");
getCaps().learn(pubLink);
await readInbox(CHARLIE_INBOX); await readInbox(CHARLIE_INBOX);
const charlieSees = await readValues([protDoc], SECRET); 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. // 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 () => { test("dynamic: a cap delivered to Bob's inbox makes the refused document readable, and signals it", async () => {
inject(); inject();
const { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments(); const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
const BOB_INBOX = await userInbox("bob", "protected"); const BOB_INBOX = await userInbox("bob", "protected");
setCurrentUser("bob"); setCurrentUser("bob");
getCaps().learn(pubLink);
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO)); const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
// Before: named, unreadable. // Before: named, unreadable.
@@ -331,16 +346,24 @@ test("dynamic: a cap delivered to Bob's inbox makes the refused document readabl
unsub(); 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(); inject();
const { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments(); const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
setCurrentUser("bob"); setCurrentUser("bob");
// Bob knows the public document's NURI but was never given its link. // Bob has been given nothing but the two NURIs.
expect(await readValues([pubDoc], REFERS_TO)).toEqual([]);
getCaps().learn(pubLink);
expect((await readValues([pubDoc], REFERS_TO)).length).toBe(1); 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 // 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"); setCurrentUser("alice");
await createEntityDoc("alice", "private"); // re-arms: a cap exists again await createEntityDoc("alice", "private"); // re-arms: a cap exists again
setCurrentUser("bob"); 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. // Connecting restores it — from the User branch, since the inbox has nothing left.
await connectedUser(); 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 doc = await createEntityDoc("alice", "public");
const aliceInbox = await openDocumentInbox(doc); const aliceInbox = await openDocumentInbox(doc);
expect(aliceInbox).not.toBe(await userInbox("alice", "protected")); 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 // Bob RESOLVES the address himself, from the BARE reference — the only thing he is
// is the link, which is the one thing the model says circulates. The address is not // handed, and the only thing an application circulates. The document is in a public
// passed to him — if it had to be, there would be no way for an app to get it. // 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"); setCurrentUser("bob");
getCaps().learn(link);
const bobTarget = await documentInboxAddress(doc); const bobTarget = await documentInboxAddress(doc);
expect(bobTarget).toBe(aliceInbox); // …and it is the SAME inbox alice reads expect(bobTarget).toBe(aliceInbox); // …and it is the SAME inbox alice reads
await post(bobTarget!, { payload: { joining: true }, ts: 1 }); 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 doc = await createEntityDoc("alice", "public");
const aliceInbox = await openDocumentInbox(doc); const aliceInbox = await openDocumentInbox(doc);
const link = getCaps().capFor(doc)!; // Bob can READ the document (it is in a public store) — and reading is not ownership.
// Bob holds the document — that is a READ right, and it is not ownership.
setCurrentUser("bob"); setCurrentUser("bob");
getCaps().learn(link);
await expect(openDocumentInbox(doc)).rejects.toThrow(/already has an inbox|you may only open an inbox/i); 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. // The address he resolves is still alice's, so his deposits reach her.
expect(await documentInboxAddress(doc)).toBe(aliceInbox); 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(); inject();
setCurrentUser("alice"); setCurrentUser("alice");
const doc = await createEntityDoc("alice", "public"); 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 // 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 // (the verifier routes by `inboxes: PubKey → RepoId`), so pointing several documents
// at one inbox is a relation the model cannot express. // at one inbox is a relation the model cannot express.
setCurrentUser("bob"); setCurrentUser("bob");
getCaps().learn(link);
expect(await documentInboxAddress(doc)).toBeUndefined(); expect(await documentInboxAddress(doc)).toBeUndefined();
// …and depositing THROWS rather than vanishing — a lost deposit is the bug this // …and depositing THROWS rather than vanishing — a lost deposit is the bug this
// whole path exists to close. // 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); const dedicated = await openDocumentInbox(doc);
expect(await openDocumentInbox(doc)).toBe(dedicated); // idempotent expect(await openDocumentInbox(doc)).toBe(dedicated); // idempotent
const link = getCaps().capFor(doc)!;
setCurrentUser("bob"); setCurrentUser("bob");
getCaps().learn(link);
expect(await documentInboxAddress(doc)).toBe(dedicated); expect(await documentInboxAddress(doc)).toBe(dedicated);
// The deposit reaches the owner, addressed by the document alone. // The deposit reaches the owner, addressed by the document alone.
await postToDocument(doc, { payload: { signingUp: true } }); await postToDocument(doc, { payload: { signingUp: true } });
+26 -10
View File
@@ -11,16 +11,17 @@
* What the read filter then shows: * What the read filter then shows:
* (a) a document nobody shared is unreadable, and stays unreadable for a third * (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; * 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 * (b) the read-filtered VIEW decides on possession alone it is synchronous, so it
* link of a published document opens it for whoever receives 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. * (c) switching identity SWITCHES heldByHolder it never wipes one.
*/ */
import { getCaps } from "../src/shared-wallet/bootstrap"; import { getCaps } from "../src/shared-wallet/bootstrap";
import { test, expect, mock, afterAll } from "bun:test"; import { test, expect, mock, afterAll } from "bun:test";
import { createEntityDoc, resetRegistryCache, userInbox, listMyEntityDocs } from "../src/shared-wallet/account-registry"; import { createEntityDoc, resetRegistryCache, userInbox, listMyEntityDocs } from "../src/shared-wallet/account-registry";
import type { RegistrySession } from "../src/shared-wallet/account-registry"; import type { RegistrySession } from "../src/shared-wallet/account-registry";
import type { ReadCap } from "../src/model/types"; import type { Nuri, ReadCap } from "../src/model/types";
import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,hasCap,resetCaps,setCurrentUser,share} from "../src/polyfill"; import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,resetCaps,setCurrentUser,share} from "../src/polyfill";
import { read as readInbox } from "../src/surface/inbox"; import { read as readInbox } from "../src/surface/inbox";
import { filterReadable } from "../src/emulated-verifier/read-filter"; 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 SHIM = "urn:ng-eventually:shim";
const INBOX = "urn:ng-eventually:inbox"; 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 } interface Quad { g: string; s: string; p: string; o: string }
/** Reverse of the lib's escapeLiteral: single left-to-right pass over `\x`. */ /** 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 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. // (b) The ORM read filter is PURE POSSESSION — it asks nothing of anyone.
test("(b) a bare reference reads nothing; the repo link of a published document opens it", async () => { //
// 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(); inject();
setCurrentUser("alice"); setCurrentUser("alice");
const pub = await createEntityDoc("alice", "public"); const pub = await createEntityDoc("alice", "public");
const items = [item(pub, "u1")]; const items = [item(pub, "u1")];
expect(getCaps().isInPublicStore(pub)).toBe(true); 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"); setCurrentUser("bob");
expect(view(items)).toEqual([]); expect(view(items)).toEqual([]);
// Receiving the repo link — what a discovery entry actually carries — opens it. // Once the cap IS among what he holds — however it got there — the same view yields it.
getCaps().learn(link); getCaps().learn(cap);
expect(view(items)).toEqual(["u1"]); expect(view(items)).toEqual(["u1"]);
}); });
+152
View File
@@ -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
});
+1 -1
View File
@@ -18,7 +18,7 @@ function setup(initial: string | null = "alice") {
const before = holder; const before = holder;
holder = "alice"; holder = "alice";
caps.mint("did:ng:o: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; holder = before;
return { caps, link, become: (id: string | null) => (holder = id) }; return { caps, link, become: (id: string | null) => (holder = id) };
} }