fix: écrire est une PROPRIÉTÉ, et trois portes qui n'auraient pas dû être ouvertes

Suite de la revue adverse. Quatre trous de frontière, tous hors du champ « l'isolation
est fausse jusqu'à P1b » — P1b parle de matériau de clé, ceux-ci sont des défauts de
FORME et resteraient des trous avec une vraie clé.

**La garde d'écriture reposait sur la mauvaise question.** Elle demandait « ce cap m'a-t-il
été servi par un store public ? ». Ce prédicat était faux dans les deux sens à la fois :
trop laxiste — une clé reçue dans une inbox donnait l'écriture, alors qu'en amont un Link
est « external repos only » et qu'écrire est l'appartenance au repo ; trop strict — la
propriétaire de son propre document public était refusée dès qu'elle l'ouvrait depuis sa
référence avant que son store ne soit listé. Un prédicat poussé dans deux sens est le
signe que c'était le mauvais prédicat.

Écrire dépend désormais de la PROPRIÉTÉ, lue sur la branche Store (l'`AddRepo` émulé),
plus la paternité de session pour les documents créés par la primitive brute qui n'a
aucun store où s'inscrire. Conséquence assumée et documentée : seul le propriétaire écrit,
ce qui est l'état amont d'un repo tant qu'aucun membre n'a été ajouté — mécanisme qu'on
n'émule pas.

**`docs.depositInto` quittait la frontière en la publiant.** Sa doc disait « `inbox.post`
est le seul appelant » : vrai dans la bibliothèque, faux dès qu'on le publie. Démontré :
avec la seule référence nue d'un document public, on réécrit l'adresse d'inbox posée
dessus et on détourne les dépôts destinés à son propriétaire. Une porte qui saute une
garde ne doit pas être ouvrable par une application — elle rejoint la machinerie.

**Le filtre de lecture n'interceptait que trois membres** et transmettait tout le reste
lié à la CIBLE : `.values()`, `.map()`, `.getById()` rendaient le contenu d'un autre
utilisateur — précisément les membres qu'une API de set réactif met en avant. Les membres
qui rendent des éléments sont désormais filtrés, les mutations passent (elles ne rendent
rien), et **tout membre inconnu lève** au lieu de transmettre : une transmission est une
fuite silencieuse, une levée est bruyante et greppable.

**Le mémo du store public était par document.** Le premier demandeur déclenchait le
téléchargement, le cap était classé chez LUI, et tout demandeur suivant recevait « oui »
en ne détenant rien. En amont un broker qui sert un overlay externe répond à TOUS. Le
mémo garde la valeur, l'appelant la classe pour qui est connecté.

Aussi : l'exemption `declareInfrastructure` supprimée — zéro appelant, ensemble toujours
vide, et une doc décrivant deux documents exemptés qui ne l'ont jamais été. Et les caps
d'écriture décrits comme « partiels » sont dits **inertes**, ce qu'ils sont : `grantWrite`
n'a aucun appelant de production.

**Ce que l'e2e a rattrapé.** Ma première version de la garde refusait au créateur
l'écriture sur un document fait par `docs.docCreate` — 7 étapes rouges contre le broker,
après une suite unitaire restée verte. La primitive brute n'inscrit la paternité nulle
part ; c'est ce que `mintedHere` couvre désormais.

185 tests unitaires (dont quatre régressions : la propriétaire écrit, le destinataire non,
le store public sert tout demandeur, aucun membre non filtré ne transmet), e2e 40/40 et
applicatif 10/10.
This commit is contained in:
Sylvain Duchesne
2026-08-07 13:59:13 +02:00
parent c5878c6126
commit 0b936d2119
19 changed files with 426 additions and 223 deletions
+1 -1
View File
@@ -61,7 +61,7 @@ is needed), and how this lib emulates it today.
| Reactivity | Lists update on change | Native reactive reads | Not-yet-implemented: there is no reactive union query across graphs | Re-query the bounded per-doc anchored set on a lightweight change signal (`doc_subscribe` / ORM on an already-opened single store) | | Reactivity | Lists update on change | Native reactive reads | Not-yet-implemented: there is no reactive union query across graphs | Re-query the bounded per-doc anchored set on a lightweight change signal (`doc_subscribe` / ORM on an already-opened single store) |
| Writes | Writes an entity to its scope | Writes land in the entity's real store via native primitives | Not-yet-implemented: `doc_create` can target only the private/protected store today (`StoreRepo` not JS-constructible) | Per-entity documents via direct SPARQL (`docs.sparqlUpdate` on the real injected `ng`) | | Writes | Writes an entity to its scope | Writes land in the entity's real store via native primitives | Not-yet-implemented: `doc_create` can target only the private/protected store today (`StoreRepo` not JS-constructible) | Per-entity documents via direct SPARQL (`docs.sparqlUpdate` on the real injected `ng`) |
| Current identity | Sets the current identity id (established at wallet import) via the SDK's current-identity call | Opening one's own wallet at the broker gate establishes the session identity | Not-yet-implemented for the shared-wallet case: everyone shares one wallet, so the broker cannot distinguish identities | A relayed id (`shared-wallet/virtualUsers.ts` `IdentityStore` persists it); the read filter and inbox `from` read it | | Current identity | Sets the current identity id (established at wallet import) via the SDK's current-identity call | Opening one's own wallet at the broker gate establishes the session identity | Not-yet-implemented for the shared-wallet case: everyone shares one wallet, so the broker cannot distinguish identities | A relayed id (`shared-wallet/virtualUsers.ts` `IdentityStore` persists it); the read filter and inbox `from` read it |
| Write-guard | Writes refused without the write cap | The broker/verifier enforces the write cap natively | Partial: the guard fires only on the public proxy, but the real write paths call the injected `ng` directly (the `DataCloneError` constraint), so it is best-effort today | A `sparql_update` override (`surface/ng-proxy.ts`) checking the emulated write cap | | Write-guard | Writes refused without the write cap | The broker/verifier enforces the write cap natively — writing is repo membership (`verify_permission`, reachable only from `Commit::verify`) | Not-yet-implemented for delegation: this library emulates no `AddMember`/`AddPermission`, so only a document's OWNER writes — a repo's upstream state until someone is added | An ownership check at the write door (`emulated-verifier/reach.ts` `assertMayWrite`, reading authorship from the Store branch). The older write-cap proxy (`surface/ng-proxy.ts`) is **inert**`grantWrite` has no production caller, so its policy set is always empty |
## Packages ## Packages
+9 -10
View File
@@ -289,12 +289,11 @@ export async function sparqlQuery(
label = "sparqlQuery", label = "sparqlQuery",
): Promise<unknown>; ): Promise<unknown>;
// docs.ts:113 — machinery, see § 15 // docs.ts:113 — machinery, see § 15
export async function depositInto( // NOT published since 2026-08-07 — moved to `emulated-verifier/register-write.ts`.
sessionId: string, // It skips the boundary by design ("the one write that legitimately crosses"), and a door
query: string, // that skips a guard must not be one an application can open: holding nothing but a public
targetInbox: Nuri, // document's bare reference, one could rewrite the inbox address posted on it and divert
label = "deposit", // every deposit meant for its owner. Go through `inbox.post` / `inbox.share`.
): Promise<void>;
``` ```
### Target ### Target
@@ -310,7 +309,7 @@ declare function sparql_update(session_id: any, sparql: string, nuri: any): Prom
declare function sparql_query(session_id: any, sparql: string, base: any, nuri: any): Promise<any>; declare function sparql_query(session_id: any, sparql: string, base: any, nuri: any): Promise<any>;
``` ```
`depositInto` has **NO COUNTERPART as a SPARQL write**: upstream a deposit is a sealed message, not an update into the recipient's graph (§ 9). It exists only because the emulated inbox is an RDF document. `depositInto` has **NO COUNTERPART as a SPARQL write**: upstream a deposit is a sealed message, not an update into the recipient's graph (§ 9). It exists only because the emulated inbox is an RDF document — and it is no longer published (see the block above).
**Store targeting — finer than "not JS-constructible".** *(The other docs were corrected on 2026-08-03 to match this entry; they used to state the blanket form.)* Verified in the clone: **Store targeting — finer than "not JS-constructible".** *(The other docs were corrected on 2026-08-03 to match this entry; they used to state the blanket form.)* Verified in the clone:
@@ -601,7 +600,7 @@ 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.share`. Upstream a deposit is a sealed message, not a SPARQL update — this function's very signature is emulation. - ~~**`docs.depositInto`**~~ **FIXED 2026-08-07.** It was published "only because `inbox.ts` lives in another module", with the note that a consumer must always go through `inbox.post`. That note is not a mechanism: an adversarial review drove through it — bare reference to a public document, rewrite its posted inbox address, divert its owner's deposits. It now lives in `emulated-verifier/register-write.ts`, which nothing exports.
- **`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 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. - **`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.
@@ -609,7 +608,7 @@ Exported, but not SDK surface. Coding against these builds knowledge that migrat
- ~~**`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.
- **`inbox.watch`'s `_opts?: { intervalMs?: number }`** — accepted and ignored (no polling exists). Dead compatibility surface; do not pass it. - **`inbox.watch`'s `_opts?: { intervalMs?: number }`** — accepted and ignored (no polling exists). Dead compatibility surface; do not pass it.
- **The `label` parameters** on `docs.sparqlUpdate` / `docs.sparqlQuery` / `docs.depositInto` — lib-internal access-log tags, never forwarded to `ng`. The real signatures have no such parameter. - **The `label` parameters** on `docs.sparqlUpdate` / `docs.sparqlQuery` — lib-internal access-log tags, never forwarded to `ng`. The real signatures have no such parameter.
### Places the current surface teaches something to unlearn ### Places the current surface teaches something to unlearn
@@ -631,7 +630,7 @@ Exported, but not SDK surface. Coding against these builds knowledge that migrat
```text ```text
direct: BaseType, DeepSignalSet, DocChange, DocChangeType, EventuallyConfig, InboxScope, NG, NgLike, Nuri, NuriLike, PrincipalId, ReadCap, RegistrySession, Schema, Scope, ShapeObservable, ShapeQuery, ShapeType, SharedWalletConfig, UnionSubject, Unsubscribe, UseShapeLike, configure, docChangeType, ensureIdentity, init, initNg, ng, readUnion, subscribeDoc, subscribeDocs, useShape, watchShape direct: BaseType, DeepSignalSet, DocChange, DocChangeType, EventuallyConfig, InboxScope, NG, NgLike, Nuri, NuriLike, PrincipalId, ReadCap, RegistrySession, Schema, Scope, ShapeObservable, ShapeQuery, ShapeType, SharedWalletConfig, UnionSubject, Unsubscribe, UseShapeLike, configure, docChangeType, ensureIdentity, init, initNg, ng, readUnion, subscribeDoc, subscribeDocs, useShape, watchShape
docs: depositInto, docCreate, sparqlQuery, sparqlUpdate docs: docCreate, sparqlQuery, sparqlUpdate
inbox: Deposit, PostOptions, materialize, post, postToDocument, processInbox, read, readForDocument, readSynced, share, watch inbox: Deposit, PostOptions, materialize, post, postToDocument, processInbox, read, readForDocument, readSynced, share, watch
storeRegistry: createEntityDoc, listMyEntityDocs, openDocumentInbox, resolveScopeGraph, resolveWriteGraph storeRegistry: createEntityDoc, listMyEntityDocs, openDocumentInbox, resolveScopeGraph, resolveWriteGraph
``` ```
+1 -1
View File
@@ -538,7 +538,7 @@ redundant so a lapse in either is caught by the other:
legitimately holds a bare NURI while possessing its cap elsewhere — references travel legitimately holds a bare NURI while possessing its cap elsewhere — references travel
bare through content and stores, the cap sits in what the user holds. bare through content and stores, the cap sits in what the user holds.
The exception is **depositing** into another user's inbox (`docs.depositInto`): a The exception is **depositing** into another user's inbox (`register-write.depositInto`, internal): a
named primitive rather than a flag, because it is a different act — you hold no cap, named primitive rather than a flag, because it is a different act — you hold no cap,
you cannot read back, and you get nothing in return. It is the only channel by which you cannot read back, and you get nothing in return. It is the only channel by which
a link crosses between users, hence the bootstrap of the whole reachability graph. a link crosses between users, hence the bootstrap of the whole reachability graph.
@@ -33,7 +33,8 @@
* use sits inside a function body, so the module cycle is inert at evaluation time. * use sits inside a function body, so the module cycle is inert at evaluation time.
*/ */
import { sparqlUpdate, sparqlQuery } from "../surface/docs"; import { sparqlQuery } from "../surface/docs";
import { registerUpdate } from "./register-write";
import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap"; 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";
@@ -131,7 +132,7 @@ export function fileOwnStructure(id: string, record: VirtualUserRecord): void {
} }
/** Same, for the user's own inbox it is its document, and it must be able to /** Same, for the user's own inbox it is its document, and it must be able to
* read it. Depositing into someone else's needs no cap (see `docs.depositInto`). */ * read it. Depositing into someone else's needs no cap (see `register-write.depositInto`). */
export function fileOwnInbox(id: string, inbox: Nuri): void { export function fileOwnInbox(id: string, inbox: Nuri): void {
const holder = getCurrentUser(); const holder = getCurrentUser();
if (holder === null || accountKey(holder) !== accountKey(id)) return; if (holder === null || accountKey(holder) !== accountKey(id)) return;
@@ -156,13 +157,13 @@ export async function publishInboxAddress(doc: Nuri, inbox: Nuri): Promise<void>
// form verified against the real broker (see // form verified against the real broker (see
// `docs/decisions/sparql-delete-for-orm-objects.md`), whereas a `;`-joined update // `docs/decisions/sparql-delete-for-orm-objects.md`), whereas a `;`-joined update
// is not exercised anywhere in this lib. // is not exercised anywhere in this lib.
await sparqlUpdate( await registerUpdate(
s.sessionId, s.sessionId,
`DELETE WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`, `DELETE WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`,
doc, doc,
"publishInboxAddress:clear", "publishInboxAddress:clear",
); );
await sparqlUpdate( await registerUpdate(
s.sessionId, s.sessionId,
`INSERT DATA { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> "${escapeLiteral(inbox)}" }`, `INSERT DATA { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> "${escapeLiteral(inbox)}" }`,
doc, doc,
@@ -363,7 +364,7 @@ export async function addLink(cap: ReadCap): Promise<void> {
if ((await readLinks()).includes(cap)) return; if ((await readLinks()).includes(cap)) return;
const s = await session(); const s = await session();
try { try {
await sparqlUpdate( await registerUpdate(
s.sessionId, s.sessionId,
`INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.link}> "${escapeLiteral(cap)}" }`, `INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.link}> "${escapeLiteral(cap)}" }`,
store, store,
@@ -490,7 +491,7 @@ export async function openDocumentInbox(doc: Nuri): Promise<Nuri> {
getCaps().open(inbox, "private"); // its owner holds it, like any document of theirs getCaps().open(inbox, "private"); // its owner holds it, like any document of theirs
if (store) { if (store) {
try { try {
await sparqlUpdate( await registerUpdate(
s.sessionId, s.sessionId,
`INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> "${escapeLiteral(encodeInboxCap(doc, inbox))}" }`, `INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> "${escapeLiteral(encodeInboxCap(doc, inbox))}" }`,
store, store,
+39 -41
View File
@@ -47,11 +47,17 @@
* *
* 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 (an ANCHORLESS
* the inbox, `store-registry`, `subscribe`, `open-repo`) are P1b. Nothing may be * `docs.sparqlQuery`, the inbox, `store-registry`, `subscribe`, `open-repo`) are P1b.
* claimed "anonymous" or "private" until then. The write caps below are likewise * Nothing may be claimed "anonymous" or "private" until then.
* decorative the guard they feed (`ng-proxy`) is bypassed by every internal *
* writer; they are left as-is and belong to P1b. * The write caps below (`grantWrite`, `governsWrite`, `canWrite`, `hasWritePolicy`) are
* **inert, not partial** a distinction the docs got wrong until 2026-08-07, when an
* adversarial review measured it. `grantWrite` has NO production caller, so
* `hasWritePolicy()` is permanently false and the `ng-proxy` guard they feed never fires
* at all. Writing is governed instead by OWNERSHIP, at the write door (`reach.ts`
* `assertMayWrite`) which is what upstream's `verify_permission` actually checks. These
* four are dead surface kept for P1b; do not read them as a working policy.
*/ */
import { CAP_SEGMENT, hasReadCap, targetOf } from "../model/nuri"; import { CAP_SEGMENT, hasReadCap, targetOf } from "../model/nuri";
@@ -86,6 +92,21 @@ const ANONYMOUS = "";
export class CapRegistry { 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>>();
/**
* holder the documents they CREATED in this session, through {@link mint}.
*
* Authorship, for the one path that records it nowhere else. `storeRegistry`'s
* documents are recorded durably on a Store branch (the emulated `AddRepo`, which is
* what upstream's `doc_create` commits), so `ownsDocument` finds them on a later
* session. The raw `docs.docCreate` has no store to record into so nothing about
* such a document survives its session, and an in-session note of who made it is
* exactly as durable as the thing it describes.
*
* Consulted by the write guard before it pays for a Store-branch read. Without it the
* guard refused a caller a write to a document it had just created caught by the
* live-broker e2e, seven steps red, after the unit suite stayed green.
*/
private mintedByHolder = new Map<string, Set<Nuri>>();
/** /**
* Documents this session knows to sit in a PUBLIC store a fact about each * Documents this session knows to sit in a PUBLIC store a fact about each
* DOCUMENT, so global rather than per-holder, unlike everything else here. * DOCUMENT, so global rather than per-holder, unlike everything else here.
@@ -96,17 +117,6 @@ export class CapRegistry {
* been, the holder holds it like any other and this set records only how it got there. * 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
@@ -153,11 +163,6 @@ 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);
@@ -173,9 +178,21 @@ export class CapRegistry {
mint(nuri: Nuri): ReadCap { mint(nuri: Nuri): ReadCap {
const cap = mintCap(nuri); const cap = mintCap(nuri);
this.file(cap); this.file(cap);
const key = this.holder() ?? ANONYMOUS;
let made = this.mintedByHolder.get(key);
if (!made) this.mintedByHolder.set(key, (made = new Set()));
made.add(targetOf(nuri));
return cap; return cap;
} }
/**
* Did the current holder CREATE this document in this session? Authorship, and
* therefore the right to write see {@link mintedByHolder}.
*/
mintedHere(nuri: Nuri): boolean {
return this.mintedByHolder.get(this.holder() ?? ANONYMOUS)?.has(targetOf(nuri)) ?? false;
}
/** /**
* File a cap I was GIVEN an inbox deposit of kind `cap`, or a repo link found * File a cap I was GIVEN an inbox deposit of kind `cap`, or a repo link found
* in world-readable content. This is the ONLY way a cap arrives from * in world-readable content. This is the ONLY way a cap arrives from
@@ -206,28 +223,9 @@ export class CapRegistry {
* clears it. So a public document of my own is never read-only to me. * clears it. So a public document of my own is never read-only to me.
*/ */
learnFromPublicStore(cap: ReadCap): void { learnFromPublicStore(cap: ReadCap): void {
const target = targetOf(cap);
const alreadyHeld = this.heldCaps().has(target);
this.file(cap); 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
@@ -353,7 +351,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.mintedByHolder.clear();
this.inPublicStore.clear(); this.inPublicStore.clear();
this.writers.clear(); this.writers.clear();
this.issued = false; this.issued = false;
@@ -54,7 +54,7 @@
* reference reads, which is the property this module exists to provide. * reference reads, which is the property this module exists to provide.
*/ */
import { sparqlUpdate } from "../surface/docs"; import { registerUpdate } from "./register-write";
import { physicalQuery, ensurePhysicalRepoOpen } from "../shared-wallet/physical"; import { physicalQuery, ensurePhysicalRepoOpen } from "../shared-wallet/physical";
import { getCaps } from "../shared-wallet/bootstrap"; import { getCaps } from "../shared-wallet/bootstrap";
import { escapeLiteral } from "../surface/sparql"; import { escapeLiteral } from "../surface/sparql";
@@ -70,17 +70,26 @@ import {
import type { Nuri, ReadCap } from "../model/types"; import type { Nuri, ReadCap } from "../model/types";
/** /**
* Targets whose outer-overlay fetch has already been attempted in this session, with * Targets whose outer-overlay fetch has been attempted in this session, and WHAT it
* its outcome. Memoised in BOTH directions on purpose: a hit spares a physical read, * returned the cap, or `null` for "not in a public store".
* 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). * The memo caches the answer, never the filing
* It cached a boolean until 2026-08-07, and that was a bug an adversarial review found:
* the first holder to ask triggered the download, the cap was filed for THEM, and every
* later holder in the same session hit the memo, got `true`, and held nothing. Their next
* read was refused. Upstream a broker serving a pinned outer overlay answers EVERY asker
* (`expose_outer`, `engine/broker/src/server_storage/core/overlay.rs:103-133`), so
* "served once, to whoever asked first" is a relation the model does not have.
*
* The round-trip is what is worth saving, not the filing. So the memo holds the value and
* the caller files it for whoever is connected, every time.
* *
* A scope never changes here (a document is created in a store and stays there), so a * 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 * cached `null` cannot go stale for a document that existed when it was taken. It CAN for
* for one created afterwards by another user in the same page {@link resetPublicStoreFetches} * one created afterwards in the same page {@link resetPublicStoreFetches} is the way
* is the way out, and it is what a session change / a wallet reset calls. * out, and a session or wallet reset calls it.
*/ */
const attempted = new Map<Nuri, Promise<boolean>>(); const attempted = new Map<Nuri, Promise<ReadCap | null>>();
/** Forget every outer-overlay fetch (tests / a switched session or wallet). */ /** Forget every outer-overlay fetch (tests / a switched session or wallet). */
export function resetPublicStoreFetches(): void { export function resetPublicStoreFetches(): void {
@@ -102,13 +111,13 @@ export async function exposeReadCap(doc: Nuri, cap: ReadCap): Promise<void> {
// Two separate updates: `DELETE WHERE { … }` is the form verified against the real // Two separate updates: `DELETE WHERE { … }` is the form verified against the real
// broker (`docs/decisions/sparql-delete-for-orm-objects.md`); a `;`-joined update // broker (`docs/decisions/sparql-delete-for-orm-objects.md`); a `;`-joined update
// is not exercised anywhere in this library. // is not exercised anywhere in this library.
await sparqlUpdate( await registerUpdate(
s.sessionId, s.sessionId,
`DELETE WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.exposedReadCap}> ?c }`, `DELETE WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.exposedReadCap}> ?c }`,
doc, doc,
"exposeReadCap:clear", "exposeReadCap:clear",
); );
await sparqlUpdate( await registerUpdate(
s.sessionId, s.sessionId,
`INSERT DATA { <${HEADER_BRANCH_SUBJECT}> <${P.exposedReadCap}> "${escapeLiteral(cap)}" }`, `INSERT DATA { <${HEADER_BRANCH_SUBJECT}> <${P.exposedReadCap}> "${escapeLiteral(cap)}" }`,
doc, doc,
@@ -142,11 +151,18 @@ export async function fetchReadCap(docLike: Nuri): Promise<boolean> {
pending = downloadReadCap(doc); pending = downloadReadCap(doc);
attempted.set(doc, pending); attempted.set(doc, pending);
} }
return pending; const cap = await pending;
if (cap === null) return false;
// Filed for whoever is connected NOW, on every call — the memo spares the round-trip,
// not the filing. See the note on {@link attempted}.
caps.learnFromPublicStore(cap);
caps.markInPublicStore(doc);
return true;
} }
/** The fetch itself, through the machinery's door. See the module header. */ /** The fetch itself, through the machinery's door. Returns the cap, or `null` when the
async function downloadReadCap(doc: Nuri): Promise<boolean> { * document is not in a public store which is the normal case, not an error. */
async function downloadReadCap(doc: Nuri): Promise<ReadCap | null> {
const s = await session(); const s = await session();
try { try {
// The repo has to be in the session before an anchored read resolves it — the // The repo has to be in the session before an anchored read resolves it — the
@@ -165,24 +181,14 @@ async function downloadReadCap(doc: Nuri): Promise<boolean> {
// `targetOf` guards the one confusion that would matter: a cap exposed on // `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 // document A must not file a cap for document B. A document only ever speaks
// for itself. // for itself.
if (cap && hasReadCap(cap) && targetOf(cap) === doc) { if (cap && hasReadCap(cap) && targetOf(cap) === doc) return cap;
// 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) { } catch (error) {
// Not in a public store, not synced, or no such document — all of them mean the // 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. // same thing to the caller: no cap was obtained.
console.error(accessLogPrefix() + " fetchReadCap failed:", error); console.error(accessLogPrefix() + " fetchReadCap failed:", error);
} }
return false; return null;
} }
/** /**
+61 -58
View File
@@ -16,9 +16,13 @@
* 1. **You hold its cap.** Either because you created it (the store refiles the * 1. **You hold its cap.** Either because you created it (the store refiles the
* cap) or because someone delivered it to you. This is the whole of the * cap) or because someone delivered it to you. This is the whole of the
* access model, so it is the whole of the predicate. * access model, so it is the whole of the predicate.
* 2. **It is declared INFRASTRUCTURE.** A short, explicitly-registered list * There is no second way, and there used to be a third door here: an explicit list of
* never inferred from the shape of a NURI, because an inferred exemption is * NURIs "declared infrastructure", exempt from the boundary. It was removed on
* a hole. See {@link declareInfrastructure}. * 2026-08-07 with **zero callers**, an always-empty set, and a header describing two
* exempted documents that were never registered dead scaffolding whose documentation
* claimed a hole existed where none did. The machinery reaches the shim through
* `shared-wallet/physical.ts`, which is a different FUNCTION rather than an exemption,
* and that is the stronger arrangement the module below already argues for.
* *
* What may be exempt, and why so little * What may be exempt, and why so little
* > The only reads/writes not confined to a virtual user are those that make * > The only reads/writes not confined to a virtual user are those that make
@@ -26,10 +30,10 @@
* > mechanisms that make the virtual users work. * > mechanisms that make the virtual users work.
* *
* The test an exemption must pass: *does removing it stop the virtual users from * The test an exemption must pass: *does removing it stop the virtual users from
* functioning, or does it merely stop users from seeing each other's content?* * functioning, or does it merely stop users from seeing each other's content?* Only the
* Only the first qualifies. The shim passes (remove it and no user is resolvable * first qualifies and the answer is that no exemption is needed at all: the one thing
* at all); a shared index of user content does not (remove it and every user still * that qualifies (the shim) is reached through its own unguarded FUNCTIONS
* works you simply have to be given links). * (`shared-wallet/physical.ts`), so nothing has to be waved through here.
* *
* Depositing into another user's inbox is NOT handled here: it is a write to a * Depositing into another user's inbox is NOT handled here: it is a write to a
* document you do not hold, and it is legitimate the only channel by which a * document you do not hold, and it is legitimate the only channel by which a
@@ -44,39 +48,6 @@ import { getCaps } from "../shared-wallet/bootstrap";
import { targetOf } from "../model/nuri"; import { targetOf } from "../model/nuri";
import type { Nuri } from "../model/types"; import type { Nuri } from "../model/types";
/**
* NURIs of the polyfill's own scaffolding, registered as they are resolved.
*
* Explicit registration rather than pattern-matching: the store-root and the
* doc-shim are exempt because they ARE the index of virtual users, not because
* they look a certain way. A NURI is in here because some code path put it here,
* knowing what it was.
*/
const infrastructure = new Set<Nuri>();
/**
* Register `nuri` as scaffolding that the boundary does not apply to. Called by
* the store-registry as it resolves the store-root pointer and the doc-shim
* the only two documents that qualify, because without them no virtual user can
* be resolved at all.
*
* Deliberately NOT exported from the package: nothing outside the library may
* widen the exemption list.
*/
export function declareInfrastructure(nuri: Nuri): void {
infrastructure.add(nuri);
}
/** Is `nuri` registered scaffolding? */
export function isInfrastructure(nuri: Nuri): boolean {
return infrastructure.has(nuri);
}
/** Forget every declared exemption (tests / a fresh wallet). */
export function resetInfrastructure(): void {
infrastructure.clear();
}
/** /**
* Do we POSSESS the cap of `nuri`? Not "does this string carry one" a caller may * Do we POSSESS the cap of `nuri`? Not "does this string carry one" a caller may
* legitimately be holding the bare form and possess the cap elsewhere, which is the * legitimately be holding the bare form and possess the cap elsewhere, which is the
@@ -94,7 +65,7 @@ export function mayReach(nuri: Nuri): boolean {
const caps = getCaps(); const caps = getCaps();
if (!caps.isEnforcing()) return true; if (!caps.isEnforcing()) return true;
const target = targetOf(nuri); const target = targetOf(nuri);
return isInfrastructure(target) || caps.capFor(target) !== undefined; return caps.capFor(target) !== undefined;
} }
/** /**
@@ -117,27 +88,59 @@ export function assertMayReach(nuri: Nuri, op: string): void {
} }
/** /**
* Reading is not writing refuse a write on a document whose cap the holder has ONLY * **Writing is OWNERSHIP, not possession of a read key.**
* because a public store served it.
* *
* Upstream a public store makes its repos world-readable and never world-writable: the * Upstream the right to write is membership of the repo: `verify_permission`
* outer overlay hands out the ReadCap (`PublicRepoLinkV0`, * (`engine/repo/src/repo.rs:584`) is reachable only through `Commit::verify_perm`
* `engine/net/src/types.rs:5098`), writing needs the write cap, and `verify_permission` * `Commit::verify` (`engine/repo/src/commit.rs:780,897`), so it fires on commits and
* fires on WRITE only. This emulation's write guard otherwise consults the READ cap * never on reads. Nothing about HOW a reader came by the read key bears on it a public
* (write caps are decorative until P1b `caps.ts` header), so without this the * store hands its read cap to whoever asks (`PublicRepoLinkV0`,
* public-store fetch would turn every bare reference into a write right. * `engine/net/src/types.rs:5098`), and a cap deposited in an inbox is a Link someone gave
* you (`AddLinkV0`, "external repos only", `engine/repo/src/types.rs:1939-1948`). Neither
* makes you a member.
* *
* Narrow on purpose: it closes the case this batch opened, not the pre-existing one * What this replaced, and why
* a cap RECEIVED in an inbox still passes the write guard here, and upstream would not. * Until 2026-08-07 this asked *"was this cap served to me by a public store?"* and
* That conflation is P1b's, and widening this check to cover it would be enforcement * refused only then. That predicate was wrong in BOTH directions, and an adversarial
* this batch does not do. * review found each end:
*
* - too lax a cap received in an inbox passed, so an application could write into a
* document it merely reads. Someone could ship collaborative editing on it and lose
* it at migration. It was labelled "P1b's", but P1b is key MATERIAL and this is a
* model relation;
* - too strict the owner of her own public document was refused, whenever she opened
* it from its reference before her store had been listed (a deep link, a fresh
* session). The comment beside the code asserted the opposite.
*
* One predicate pushed two ways is the signal that it was the wrong predicate. Ownership
* is the right one, it is durable (it is read from the Store branch, the emulated
* `AddRepo`, not from session memory), and it answers both.
*
* What it does NOT cover
* Delegated writing. Upstream a repo's owner may add members (`AddMember` /
* `AddPermission`); this library emulates none of that, so here only the owner writes
* which is a repo's state upstream until someone is added. A narrowing, in the safe
* direction, and one an application cannot build a habit on because the target's answer
* (be granted permission) has no surface here to build on.
*
* The library's own registers do not come through here at all: they go through
* `docs.registerUpdate`, because a store document is not OWNED in this sense it IS a
* store, and the verifier commits to it on its own behalf.
*/ */
export function assertMayWrite(nuri: Nuri, op: string): void { export async function assertMayWrite(nuri: Nuri, op: string): Promise<void> {
if (!getCaps().isReadOnlyPublicCap(targetOf(nuri))) return; const caps = getCaps();
if (!caps.isEnforcing()) return;
const target = targetOf(nuri);
// Created here — authorship, and the cheap answer. It is also the ONLY record for a
// document made through the raw `docs.docCreate`, which has no store to file into.
if (caps.mintedHere(target)) return;
// Otherwise ask the durable register: the Store branch, the emulated `AddRepo`.
const { ownsDocument } = await import("./branch-registers");
if (await ownsDocument(target)) return;
throw new Error( throw new Error(
`[ng-eventually] ${op}: refused — this document is in a public store, which serves ` + `[ng-eventually] ${op}: refused — writing needs the WRITE cap, and reading a document ` +
"its READ cap to anyone. Reading it is not writing to it: a write needs the write " + "never grants it. A public store serves its read cap to anyone, and a cap deposited " +
`cap, and no store hands that out. ${JSON.stringify(nuri)}`, `in your inbox is one someone gave you; neither makes you the document's owner. ${JSON.stringify(nuri)}`,
); );
} }
@@ -62,32 +62,90 @@ export function filterReadable<T>(items: Iterable<T>, caps: CapRegistry): T[] {
/** /**
* A read-filtered VIEW over a reactive set (a `DeepSignalSet`, or any Set-like). * A read-filtered VIEW over a reactive set (a `DeepSignalSet`, or any Set-like).
* Iteration / `size` / `forEach` yield only readable items; everything else *
* (`add`, `delete`, `has`, `getById`, ) forwards to the target, so writes and * The rule this proxy must never break
* the underlying reactivity are preserved. What the holder holds is consulted lazily, so the * A filtered view may show LESS than the set holds. It may never show MORE. Until
* view reflects the holder in effect at read time. * 2026-08-07 it intercepted three members `Symbol.iterator`, `size`, `forEach` and
* forwarded everything else through `Reflect.get` bound to the TARGET. So `.values()`,
* `.keys()`, `.entries()`, `.map()`, `.getById()` returned another virtual user's items.
* An adversarial review found it, and the damage was proportional: those are exactly the
* members a reactive-set API puts forward, so a consumer reaches for them first.
*
* Why a whitelist, and why the default is to THROW
* There is no generic way to filter an unknown method: a `.getById()` on a filtered copy
* loses the class it belongs to, and a wrapper that guesses would guess wrong. So the
* members that yield items are handled explicitly, and **any other function member
* throws** rather than forwarding.
*
* That is deliberate, and it is the safe direction. Forwarding is a silent leak nothing
* fails, the wrong items simply appear. Throwing is loud, greppable, and tells whoever
* hits it exactly what to do: add the member here, filtered. A boundary whose unknown
* cases leak is not a boundary.
*
* Everything that is not a function passes through untouched (`size` is handled above):
* a plain property carries no items.
*/ */
export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry): S { export function makeReadFilteredView<S extends object>(set: S, caps: CapRegistry): S {
const keep = (item: unknown): boolean => readable(item, caps); const keep = (item: unknown): boolean => readable(item, caps);
/** The readable items, as a plain array — what every handled member works from. */
const kept = (target: object): unknown[] => {
const out: unknown[] = [];
for (const item of target as Iterable<unknown>) if (keep(item)) out.push(item);
return out;
};
return new Proxy(set, { return new Proxy(set, {
get(target, prop, receiver) { get(target, prop, receiver) {
if (prop === Symbol.iterator) { if (prop === Symbol.iterator) return function* () { yield* kept(target); };
return function* () { if (prop === "size") return kept(target).length;
for (const item of target as Iterable<unknown>) if (keep(item)) yield item; // A Set yields the item for both halves of a `[key, value]` pair; `DeepSignalSet`
}; // follows the same shape, so `keys`/`values`/`entries` are the Set contract.
} if (prop === "values" || prop === "keys") return () => kept(target)[Symbol.iterator]();
if (prop === "size") { if (prop === "entries") return () => kept(target).map((i) => [i, i] as const)[Symbol.iterator]();
let n = 0;
for (const item of target as Iterable<unknown>) if (keep(item)) n++;
return n;
}
if (prop === "forEach") { if (prop === "forEach") {
return (cb: (v: unknown, v2: unknown, s: unknown) => void) => { return (cb: (v: unknown, v2: unknown, s: unknown) => void) => {
for (const item of target as Iterable<unknown>) if (keep(item)) cb(item, item, receiver); for (const item of kept(target)) cb(item, item, receiver);
}; };
} }
// `has(item)` is filtered, not forwarded: the caller already holds the item, so the
// answer discloses nothing new — but upstream an unreadable item is never delivered
// at all, so "yes it is in there" would be an answer the target cannot give.
if (prop === "has") return (item: unknown) => keep(item) && (target as Set<unknown>).has(item);
// The reactive-set extras: they iterate, so they must iterate the filtered items.
if (prop === "map") return (fn: (v: unknown, i: number) => unknown) => kept(target).map(fn);
if (prop === "filter") return (fn: (v: unknown, i: number) => boolean) => kept(target).filter(fn);
if (prop === "find") return (fn: (v: unknown, i: number) => boolean) => kept(target).find(fn);
if (prop === "some") return (fn: (v: unknown, i: number) => boolean) => kept(target).some(fn);
if (prop === "every") return (fn: (v: unknown, i: number) => boolean) => kept(target).every(fn);
if (prop === "getById" || prop === "getBy") {
const inner = Reflect.get(target, prop, target) as ((...a: unknown[]) => unknown) | undefined;
if (typeof inner !== "function") return inner;
return (...args: unknown[]) => {
const item = inner.apply(target, args);
return keep(item) ? item : undefined;
};
}
// MUTATIONS forward untouched. They take an item and return void or a boolean, so
// they yield nothing to leak — and the view must not break writes or the underlying
// reactivity. (Caught by `test/read-filter.test.ts` when the blanket refusal below
// was first written: refusing everything unknown also refused `add`.)
if (prop === "add" || prop === "delete" || prop === "clear") {
const fn = Reflect.get(target, prop, target);
return typeof fn === "function" ? fn.bind(target) : fn;
}
const v = Reflect.get(target, prop, target); const v = Reflect.get(target, prop, target);
return typeof v === "function" ? v.bind(target) : v; if (typeof v !== "function") return v;
// UNKNOWN function member: refuse rather than forward. See the header — forwarding
// is a silent leak, and this view's one job is that it cannot show more than the
// holder may read.
return () => {
throw new Error(
`[ng-eventually] read filter: \`${String(prop)}\` is not filtered, so calling it ` +
"would return items this identity may not read. Add it to " +
"`emulated-verifier/read-filter.ts`, filtered — do not bypass the view.",
);
};
}, },
}) as S; }) as S;
} }
@@ -0,0 +1,77 @@
/**
* The one door for the library's OWN register writes.
*
* Why it is a module of its own, and not a function in `surface/docs.ts`
* It lived there for about ten minutes on 2026-08-07, and the contract check caught it:
* `docs` is a PUBLISHED namespace, so any function in it reaches applications. A door
* whose whole point is to skip a guard must not be one an application can open. It sits
* here instead, in the emulated verifier, where nothing is exported from the package
* the same reasoning that put the unguarded READ door in `shared-wallet/physical.ts`.
*/
import { getConfig } from "../shared-wallet/bootstrap";
import { logAccess } from "../shared-wallet/access-log";
import { assertMayReach } from "./reach";
import type { Nuri } from "../model/types";
/**
* Write one of the library's OWN registers the emulation of the service commits the
* verifier makes on a repo's typed branches (`AddRepo` on a store's Store branch,
* `AddLink` / `AddInboxCap` on the User branch, the Header branch's addresses).
*
* **Why this is a separate door rather than a flag.** The write guard above asks
* *"do you own this document?"*, and a store document is owned by nobody in that sense:
* it is not CONTAINED in a store, it IS one. Routing the registers through the same
* guard would have refused the library its own bookkeeping which is how a guard that
* looks right locks out the very writes it exists to protect. Upstream these are not
* application writes at all: they are commits the verifier makes on its own behalf, on
* branches whose CRDT is `BranchCrdt::None`.
*
* Still subject to `assertMayReach`: the register of a virtual user is that user's, and
* the machinery writes it while connected as them. What this door skips is ownership,
* nothing else. Never exported from the package.
*/
export async function registerUpdate(
sessionId: string,
query: string,
anchor: Nuri,
label = "registerUpdate",
): Promise<void> {
const { ng } = getConfig();
assertMayReach(anchor, label);
logAccess("WRITE", anchor, label, " (register)");
return ng.sparql_update(sessionId, query, anchor);
}
/**
* Deposit into ANOTHER virtual user's inbox the one write that legitimately
* crosses the boundary, and therefore the one that skips {@link assertMayReach}.
*
* Moved out of the published `docs` namespace on 2026-08-07, and that is the whole
* point of it living here. Its own docstring said "`inbox.post` is the only caller"
* true inside the library, false the moment it is published. An adversarial review
* showed what publishing it bought: holding nothing but the bare reference of a public
* document, one rewrites the inbox address posted on it and diverts every deposit meant
* for its owner exactly the vector `openDocumentInbox`'s ownership guard exists to
* close. A door that skips a guard must not be one an application can open.
*
* Why this is a separate primitive rather than a flag: depositing is not "a write
* that happens to be allowed", it is a different act. You cannot read the inbox you
* deposit into, you hold no cap for it, and you get nothing back upstream it is an
* anonymous sealed box. Naming the exception makes it greppable and keeps
* {@link sparqlUpdate} free of a bypass that would otherwise be reusable for
* anything.
*
* The recipient's ownership of the inbox is what bounds this: `inbox.post` is the
* only caller, and reading is guarded separately (`inbox.read`).
*/
export async function depositInto(
sessionId: string,
query: string,
targetInbox: Nuri,
label = "deposit",
): Promise<void> {
const { ng } = getConfig();
logAccess("WRITE", targetInbox, label, " (cross-user deposit)");
return ng.sparql_update(sessionId, query, targetInbox);
}
@@ -61,7 +61,8 @@
* `ng`), so this module imports **no** `@ng-org` package. * `ng`), so this module imports **no** `@ng-org` package.
*/ */
import { sparqlUpdate, sparqlQuery } from "../surface/docs"; import { sparqlQuery } from "../surface/docs";
import { registerUpdate } from "../emulated-verifier/register-write";
import { physicalCreate, physicalQuery, physicalUpdate } from "./physical"; import { physicalCreate, physicalQuery, physicalUpdate } from "./physical";
import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./bootstrap"; import { getCaps, getCurrentUser, getStoreRegistryDeps } from "./bootstrap";
// Cross-fate edge, deliberate: resolving a user is also when its own structure gets // Cross-fate edge, deliberate: resolving a user is also when its own structure gets
@@ -914,7 +915,7 @@ export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
const entityNuri = await createDoc(); const entityNuri = await createDoc();
const s = await session(); const s = await session();
try { try {
await sparqlUpdate( await registerUpdate(
s.sessionId, s.sessionId,
// NO explicit `GRAPH <…>` wrapper: write the anchored DEFAULT graph (the // NO explicit `GRAPH <…>` wrapper: write the anchored DEFAULT graph (the
// `indexDoc` anchor scopes it) — the CANONICAL, always-safe shape the // `indexDoc` anchor scopes it) — the CANONICAL, always-safe shape the
@@ -940,7 +941,7 @@ export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
// cap stores the pair. // cap stores the pair.
const cap = mintCap(entityNuri); const cap = mintCap(entityNuri);
try { try {
await sparqlUpdate( await registerUpdate(
s.sessionId, s.sessionId,
`INSERT DATA { <${STORE_BRANCH_SUBJECT}> <${P.readCap}> "${escapeLiteral(cap)}" }`, `INSERT DATA { <${STORE_BRANCH_SUBJECT}> <${P.readCap}> "${escapeLiteral(cap)}" }`,
indexDoc, indexDoc,
+10 -32
View File
@@ -91,47 +91,25 @@ export async function sparqlUpdate(
): Promise<void> { ): Promise<void> {
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, in two questions that are NOT the same one.
// //
// NO public-store fetch here, unlike the read below, and that asymmetry is the point: // Reaching is possession. Writing is OWNERSHIP — upstream the right to write is
// a public store makes its repos world-READABLE. Writing needs the write cap, which // membership of the repo (`verify_permission`, reachable only from `Commit::verify`,
// it never serves. A cap this holder has only because the network handed it over is // so on commits and never on reads), and how you came by the READ key changes nothing
// therefore refused a write outright — otherwise a bare reference to a public // about it. A public store hands its read cap to whoever asks; a cap deposited in your
// document would buy one, and a consumer would build on something that fails upstream. // inbox is a Link someone gave you. Neither makes you a member.
//
// NO public-store fetch here, unlike the read below: asking the network for a read key
// has no bearing on a write.
if (anchor !== undefined) { if (anchor !== undefined) {
assertMayReach(anchor, "docs.sparqlUpdate"); assertMayReach(anchor, "docs.sparqlUpdate");
assertMayWrite(anchor, "docs.sparqlUpdate"); await 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);
} }
/**
* Deposit into ANOTHER virtual user's inbox the one write that legitimately
* crosses the boundary, and therefore the one that skips {@link assertMayReach}.
*
* Why this is a separate primitive rather than a flag: depositing is not "a write
* that happens to be allowed", it is a different act. You cannot read the inbox you
* deposit into, you hold no cap for it, and you get nothing back upstream it is an
* anonymous sealed box. Naming the exception makes it greppable and keeps
* {@link sparqlUpdate} free of a bypass that would otherwise be reusable for
* anything.
*
* The recipient's ownership of the inbox is what bounds this: `inbox.post` is the
* only caller, and reading is guarded separately (`inbox.read`).
*/
export async function depositInto(
sessionId: string,
query: string,
targetInbox: Nuri,
label = "deposit",
): Promise<void> {
const { ng } = getConfig();
logAccess("WRITE", targetInbox, label, " (cross-user deposit)");
return ng.sparql_update(sessionId, query, targetInbox);
}
/** /**
* Run a SPARQL SELECT/CONSTRUCT/ASK query the raw SDK result. * Run a SPARQL SELECT/CONSTRUCT/ASK query the raw SDK result.
* *
+3 -2
View File
@@ -26,7 +26,8 @@
* never `makeNg`), so this module imports no `@ng-org` package. * never `makeNg`), so this module imports no `@ng-org` package.
*/ */
import { depositInto, sparqlQuery } from "./docs"; import { sparqlQuery } from "./docs";
import { depositInto } from "../emulated-verifier/register-write";
import { subscribeDoc } from "./subscribe"; import { subscribeDoc } from "./subscribe";
import { ensureRepoOpen } from "../emulated-verifier/open-repo"; import { ensureRepoOpen } from "../emulated-verifier/open-repo";
import { getCaps, getCurrentUser, getStoreRegistryDeps } from "../shared-wallet/bootstrap"; import { getCaps, getCurrentUser, getStoreRegistryDeps } from "../shared-wallet/bootstrap";
@@ -178,7 +179,7 @@ export async function post(targetInboxLike: NuriLike, opts: PostOptions): Promis
<${P.payload}> "${payloadLiteral}" ; <${P.payload}> "${payloadLiteral}" ;
<${P.ts}> "${ts}"${fromTriple} . <${P.ts}> "${ts}"${fromTriple} .
}`; }`;
// A deposit crosses the boundary on purpose — see docs.depositInto. // A deposit crosses the boundary on purpose — see `register-write.depositInto`.
await depositInto(sid, update, targetInbox, "deposit"); await depositInto(sid, update, targetInbox, "deposit");
// Domain-level diagnostic (on top of docs.ts's generic access-path WRITE log): // Domain-level diagnostic (on top of docs.ts's generic access-path WRITE log):
// who deposited WHAT into which inbox — the decoded payload, not just the // who deposited WHAT into which inbox — the decoded payload, not just the
+9 -19
View File
@@ -104,32 +104,22 @@ test("markInPublicStore records where a document sits, and mints nothing", () =>
expect(caps.capFor(doc)).toBeUndefined(); expect(caps.capFor(doc)).toBeUndefined();
}); });
test("a cap SERVED by a public store reads, and is refused a write", () => { test("a cap SERVED by a public store is held like any other — possession is the read criterion", () => {
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 served = mintCap(doc); const served = mintCap(doc);
become("bob"); become("bob");
caps.learnFromPublicStore(served); caps.learnFromPublicStore(served);
expect(caps.capFor(doc)).toBe(served); // he reads it, like any held cap expect(caps.capFor(doc)).toBe(served);
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. // No read-only mark, and that absence is the point. It existed until 2026-08-07 and
caps.learn(served); // fed the write guard, which was the wrong predicate in both directions — writing is
expect(caps.isReadOnlyPublicCap(doc)).toBe(false); // OWNERSHIP, and how a read key arrived says nothing about it (see `reach.ts`).
}); // Carol, in the same registry, holds nothing until she asks in her turn: what a public
// store serves is per-asker, not once-for-everyone.
test("the owner of a public document is never read-only on it", () => { become("carol");
const { caps, become } = registry("alice"); expect(caps.capFor(doc)).toBeUndefined();
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", () => { test("open(): a public document is marked as sitting in a public store, a private one is not", () => {
@@ -26,7 +26,6 @@ import { resetOpenedRepos } from "../src/emulated-verifier/open-repo";
import { configure } from "../src/index"; import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap"; import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap"; import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import { resetInfrastructure } from "../src/emulated-verifier/reach";
const SESSION = { sessionId: "sid-cold", privateStoreId: "PRIV-COLD" }; const SESSION = { sessionId: "sid-cold", privateStoreId: "PRIV-COLD" };
const ANCHOR = `did:ng:${SESSION.privateStoreId}`; const ANCHOR = `did:ng:${SESSION.privateStoreId}`;
@@ -37,7 +36,6 @@ afterAll(() => {
resetRegistryCache(); resetRegistryCache();
resetOpenedRepos(); resetOpenedRepos();
resetCaps(); resetCaps();
resetInfrastructure();
setCurrentUser(null); setCurrentUser(null);
}); });
@@ -48,7 +46,6 @@ beforeEach(() => {
resetRegistryCache(); resetRegistryCache();
resetOpenedRepos(); resetOpenedRepos();
resetCaps(); resetCaps();
resetInfrastructure();
setCurrentUser(null); setCurrentUser(null);
}); });
@@ -416,6 +416,52 @@ test("connecting a user that does not exist provisions nothing", async () => {
expect(getCaps().isEnforcing()).toBe(false); expect(getCaps().isEnforcing()).toBe(false);
}); });
// WRITING IS OWNERSHIP — the two regressions that replaced the old write guard.
//
// It used to ask "was this cap served to me by a public store?", which was wrong in both
// directions at once. Both are pinned here, because one predicate pushed two ways is
// exactly how a fix trades one bug for a worse one.
// Direction 1 — TOO STRICT. The owner opening her own public note from its reference,
// before her store has been listed (a deep link, a fresh session), got the "served by a
// public store" mark on her own document and was refused a write to it.
test("the owner writes to her own public note, even after opening it from its reference", async () => {
inject();
setCurrentUser("alice");
const pubDoc = await createEntityDoc("alice", "public");
await write(pubDoc, SECRET, "v1");
// She arrives at it the way a deep link would: by reference, with nothing held.
resetCaps();
setCurrentUser("bob");
await createEntityDoc("bob", "private"); // re-arms the emulation
setCurrentUser("alice");
await readValues([pubDoc], SECRET); // this is what files the served cap
await write(pubDoc, SECRET, "v2"); // must not throw
expect((await readValues([pubDoc], SECRET)).includes("v2")).toBe(true);
});
// Direction 2 — TOO LAX. A cap received in an inbox let its recipient WRITE into the
// owner's document. Upstream impossible: writing is repo membership, and a Link is
// "external repos only". An application could have shipped collaborative editing on it.
test("a cap received in an inbox reads, and does NOT write", async () => {
inject();
setCurrentUser("alice");
const protDoc = await createEntityDoc("alice", "protected");
await write(protDoc, SECRET, "alice's own");
const BOB_INBOX = await userInbox("bob", "protected");
await share(protDoc, "bob");
setCurrentUser("bob");
await readInbox(BOB_INBOX);
expect(await readValues([protDoc], SECRET)).toEqual(["alice's own"]); // he reads it
await expect(write(protDoc, SECRET, "bob was here")).rejects.toThrow(/WRITE cap/i);
setCurrentUser("alice");
expect(await readValues([protDoc], SECRET)).toEqual(["alice's own"]); // untouched
});
// PER-DOCUMENT INBOXES. Upstream a repo carries `inbox: Option<PrivKey>` and its // PER-DOCUMENT INBOXES. Upstream a repo carries `inbox: Option<PrivKey>` and its
// owner records the private half with `AddInboxCap` on the User branch — the same // owner records the private half with `AddInboxCap` on the User branch — the same
// branch as `AddLink`. So "which inboxes may I read" has one answer, and connecting // branch as `AddLink`. So "which inboxes may I read" has one answer, and connecting
-2
View File
@@ -23,7 +23,6 @@ import { readUnion } from "../src/surface/read-model";
import { configure } from "../src/index"; import { configure } from "../src/index";
import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap"; import { configureStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap"; import { resetCaps, resetConfig, resetStoreRegistry } from "../src/shared-wallet/bootstrap";
import { resetInfrastructure } from "../src/emulated-verifier/reach";
import { resetRegistryCache } from "../src/shared-wallet/account-registry"; import { resetRegistryCache } from "../src/shared-wallet/account-registry";
afterAll(() => { afterAll(() => {
@@ -40,7 +39,6 @@ beforeEach(() => {
resetOpenedRepos(); resetOpenedRepos();
resetRegistryCache(); resetRegistryCache();
resetCaps(); resetCaps();
resetInfrastructure();
setCurrentUser(null); setCurrentUser(null);
}); });
+19 -2
View File
@@ -75,8 +75,6 @@ test("a cap exposed on a document is downloaded by a holder that has nothing", a
expect(await fetchReadCap(PUB)).toBe(true); expect(await fetchReadCap(PUB)).toBe(true);
expect(getCaps().capFor(PUB)).toBe(mintCap(PUB)); 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); expect(getCaps().isInPublicStore(PUB)).toBe(true);
}); });
@@ -111,6 +109,25 @@ test("inert while no cap has been issued at all — nothing to obtain, nothing a
expect(sparql_query).toHaveBeenCalledTimes(0); expect(sparql_query).toHaveBeenCalledTimes(0);
}); });
// REGRESSION (2026-08-07, found adversarially). The memo used to cache a BOOLEAN, so the
// first holder to ask triggered the download, the cap was filed for THEM, and every later
// holder got `true` while holding nothing — their next read was refused. Upstream a broker
// serving a pinned outer overlay answers EVERY asker.
test("a public store serves every asker, not only the first", async () => {
inject();
setCurrentUser("alice");
await exposeReadCap(PUB, mintCap(PUB));
armEmulation();
setCurrentUser("bob");
expect(await fetchReadCap(PUB)).toBe(true);
expect(getCaps().capFor(PUB)).toBe(mintCap(PUB));
setCurrentUser("carol");
expect(await fetchReadCap(PUB)).toBe(true);
expect(getCaps().capFor(PUB)).toBe(mintCap(PUB)); // …and she HOLDS it, not just "true"
});
test("asked once per document: the outcome is memoised, in both directions", async () => { test("asked once per document: the outcome is memoised, in both directions", async () => {
const { sparql_query } = inject(); const { sparql_query } = inject();
setCurrentUser("alice"); setCurrentUser("alice");
+2 -1
View File
@@ -11,7 +11,8 @@
* how a link travels between users at all, and it gives the depositor nothing back. * how a link travels between users at all, and it gives the depositor nothing back.
*/ */
import { test, expect, mock, afterAll } from "bun:test"; import { test, expect, mock, afterAll } from "bun:test";
import { sparqlQuery, sparqlUpdate, depositInto } from "../src/surface/docs"; import { sparqlQuery, sparqlUpdate } from "../src/surface/docs";
import { depositInto } from "../src/emulated-verifier/register-write";
import { createEntityDoc, resetRegistryCache, userInbox } from "../src/shared-wallet/account-registry"; import { createEntityDoc, resetRegistryCache, userInbox } 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 { configure } from "../src/index"; import { configure } from "../src/index";
+32
View File
@@ -74,6 +74,38 @@ test("makeReadFilteredView forwards mutations and membership to the target", ()
expect(set.has(C)).toBe(false); expect(set.has(C)).toBe(false);
}); });
// REGRESSION (2026-08-07, found adversarially). The view forwarded every member it did
// not name, bound to the TARGET — so `.values()`, `.map()`, `.getById()` returned another
// identity's items. Those are the members a reactive-set API puts forward, so a consumer
// reaches for them first. A filtered view may show LESS than the set holds; never more.
test("every item-yielding member is filtered, not just iteration", () => {
const items = [MINE, LINKED, FOREIGN, NOGRAPH];
const set = new Set<Item>(items);
const { caps, become } = setup("alice");
const view = makeReadFilteredView(set, caps) as any;
become("bob"); // holds nothing: only the graphless item may surface
expect([...view.values()].map((i: Item) => i.id)).toEqual(["x"]);
expect([...view.keys()].map((i: Item) => i.id)).toEqual(["x"]);
expect([...view.entries()].map(([i]: [Item]) => i.id)).toEqual(["x"]);
expect(view.map((i: Item) => i.id)).toEqual(["x"]);
expect(view.filter((i: Item) => true).map((i: Item) => i.id)).toEqual(["x"]);
expect(view.find((i: Item) => i.id === "a")).toBeUndefined();
expect(view.some((i: Item) => i.id === "a")).toBe(false);
expect(view.has(MINE)).toBe(false);
});
// An unknown member must REFUSE, not forward: forwarding is a silent leak, and this
// view's one job is that it cannot show more than the holder may read.
test("an unfiltered member throws rather than leaking", () => {
const set = new Set<Item>([MINE]) as any;
set.sample = () => [...set][0];
const { caps, become } = setup("alice");
const view = makeReadFilteredView(set, caps) as any;
become("bob");
expect(() => view.sample()).toThrow(/not filtered/i);
});
test("forEach is filtered too", () => { test("forEach is filtered too", () => {
const set = new Set<Item>([MINE, LINKED]); const set = new Set<Item>([MINE, LINKED]);
const seen: string[] = []; const seen: string[] = [];