diff --git a/README.md b/README.md index e1c3ff5..a05de20 100644 --- a/README.md +++ b/README.md @@ -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) | | 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 | -| 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 diff --git a/docs/api-contract.md b/docs/api-contract.md index b9bfac8..7cff45b 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -289,12 +289,11 @@ export async function sparqlQuery( label = "sparqlQuery", ): Promise; // docs.ts:113 — machinery, see § 15 -export async function depositInto( - sessionId: string, - query: string, - targetInbox: Nuri, - label = "deposit", -): Promise; +// NOT published since 2026-08-07 — moved to `emulated-verifier/register-write.ts`. +// It skips the boundary by design ("the one write that legitimately crosses"), and a door +// that skips a guard must not be one an application can open: holding nothing but a public +// document's bare reference, one could rewrite the inbox address posted on it and divert +// every deposit meant for its owner. Go through `inbox.post` / `inbox.share`. ``` ### 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; ``` -`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: @@ -601,7 +600,7 @@ export type { NG } from "@ng-org/web"; Exported, but not SDK surface. Coding against these builds knowledge that migration deletes: -- **`docs.depositInto`** — the named boundary-crossing write `inbox.post` uses. It is exported only because `inbox.ts` lives in another module; a consumer must always go through `inbox.post` / `inbox.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. - **`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. @@ -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. - ~~**`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. -- **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 @@ -631,7 +630,7 @@ Exported, but not SDK surface. Coding against these builds knowledge that migrat ```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 -docs: depositInto, docCreate, sparqlQuery, sparqlUpdate +docs: docCreate, sparqlQuery, sparqlUpdate inbox: Deposit, PostOptions, materialize, post, postToDocument, processInbox, read, readForDocument, readSynced, share, watch storeRegistry: createEntityDoc, listMyEntityDocs, openDocumentInbox, resolveScopeGraph, resolveWriteGraph ``` diff --git a/docs/simulation.md b/docs/simulation.md index 1132b9b..cad6f27 100644 --- a/docs/simulation.md +++ b/docs/simulation.md @@ -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 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, 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. diff --git a/packages/sdk/src/emulated-verifier/branch-registers.ts b/packages/sdk/src/emulated-verifier/branch-registers.ts index 4144f6d..0e8f2ae 100644 --- a/packages/sdk/src/emulated-verifier/branch-registers.ts +++ b/packages/sdk/src/emulated-verifier/branch-registers.ts @@ -33,7 +33,8 @@ * 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 { escapeLiteral } from "../surface/sparql"; 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 - * 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 { const holder = getCurrentUser(); if (holder === null || accountKey(holder) !== accountKey(id)) return; @@ -156,13 +157,13 @@ export async function publishInboxAddress(doc: Nuri, inbox: Nuri): Promise // form verified against the real broker (see // `docs/decisions/sparql-delete-for-orm-objects.md`), whereas a `;`-joined update // is not exercised anywhere in this lib. - await sparqlUpdate( + await registerUpdate( s.sessionId, `DELETE WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> ?a }`, doc, "publishInboxAddress:clear", ); - await sparqlUpdate( + await registerUpdate( s.sessionId, `INSERT DATA { <${HEADER_BRANCH_SUBJECT}> <${P.inboxAddress}> "${escapeLiteral(inbox)}" }`, doc, @@ -363,7 +364,7 @@ export async function addLink(cap: ReadCap): Promise { if ((await readLinks()).includes(cap)) return; const s = await session(); try { - await sparqlUpdate( + await registerUpdate( s.sessionId, `INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.link}> "${escapeLiteral(cap)}" }`, store, @@ -490,7 +491,7 @@ export async function openDocumentInbox(doc: Nuri): Promise { getCaps().open(inbox, "private"); // its owner holds it, like any document of theirs if (store) { try { - await sparqlUpdate( + await registerUpdate( s.sessionId, `INSERT DATA { <${USER_BRANCH_SUBJECT}> <${P.inboxCap}> "${escapeLiteral(encodeInboxCap(doc, inbox))}" }`, store, diff --git a/packages/sdk/src/emulated-verifier/caps.ts b/packages/sdk/src/emulated-verifier/caps.ts index 8b67929..c19357d 100644 --- a/packages/sdk/src/emulated-verifier/caps.ts +++ b/packages/sdk/src/emulated-verifier/caps.ts @@ -47,11 +47,17 @@ * * ── What this module does NOT do ────────────────────────────────────────── * Enforce. The shape is right after P1a; the isolation is still fake. Per-document - * encryption and closing the read paths that bypass the guard (`docs.sparqlQuery`, - * the inbox, `store-registry`, `subscribe`, `open-repo`) are P1b. Nothing may be - * claimed "anonymous" or "private" until then. The write caps below are likewise - * decorative — the guard they feed (`ng-proxy`) is bypassed by every internal - * writer; they are left as-is and belong to P1b. + * encryption and closing the read paths that bypass the guard (an ANCHORLESS + * `docs.sparqlQuery`, the inbox, `store-registry`, `subscribe`, `open-repo`) are P1b. + * Nothing may be claimed "anonymous" or "private" until then. + * + * 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"; @@ -86,6 +92,21 @@ const ANONYMOUS = ""; export class CapRegistry { /** holder → the caps they hold, indexed by the cap-less NURI. */ private heldByHolder = new Map>(); + /** + * 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>(); /** * 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. @@ -96,17 +117,6 @@ export class CapRegistry { * been, the holder holds it like any other and this set records only how it got there. */ private inPublicStore = new Set(); - /** - * holder → the documents whose cap they hold ONLY because a public store served it - * (see {@link learnFromPublicStore}). - * - * PER HOLDER, unlike the set above, and the difference is the whole point: *"this - * document is in a public store"* is a fact about the document, whereas *"the only - * claim I have on it is that the network handed me its key"* is a fact about one - * holder. Kept global, the owner of a public document would be refused writes to it - * the moment any third party fetched its cap. - */ - private servedByHolder = new Map>(); /** doc NURI → principals holding its WRITE cap. Decorative until P1b. */ private writers = new Map>(); /** Fired whenever a holder gains a cap — a cap delivered asynchronously must @@ -153,11 +163,6 @@ export class CapRegistry { ); } const target = targetOf(cap); - // Filing is the STRONG claim — I created this document, or its cap was deposited - // for me. Either one supersedes "a public store served it to me", so the read-only - // mark goes. {@link learnFromPublicStore} re-adds it after calling here, and only - // when nothing was held before. - this.servedToHolder().delete(target); const ring = this.heldCaps(); if (ring.get(target) === cap) return false; ring.set(target, cap); @@ -173,9 +178,21 @@ export class CapRegistry { mint(nuri: Nuri): ReadCap { const cap = mintCap(nuri); 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; } + /** + * 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 * 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. */ learnFromPublicStore(cap: ReadCap): void { - const target = targetOf(cap); - const alreadyHeld = this.heldCaps().has(target); this.file(cap); - // Only when this is the ONLY reason I hold it — filing never downgrades a claim. - if (!alreadyHeld) this.servedToHolder().add(target); } - /** - * Is the ONLY reason the current holder holds this document's cap that a public store - * served it? Then it grants reading and nothing more — see {@link learnFromPublicStore}. - */ - isReadOnlyPublicCap(nuri: Nuri): boolean { - return this.servedToHolder().has(targetOf(nuri)); - } - - /** The current holder's public-store-served set, created on first use. */ - private servedToHolder(): Set { - const key = this.holder() ?? ANONYMOUS; - let s = this.servedByHolder.get(key); - if (!s) this.servedByHolder.set(key, (s = new Set())); - return s; - } /** * Do I hold the cap of `nuri`? Returns it, or `undefined` when I hold @@ -353,7 +351,7 @@ export class CapRegistry { * NOT what an identity change does (that switches heldByHolder, see the header). */ clear(): void { this.heldByHolder.clear(); - this.servedByHolder.clear(); + this.mintedByHolder.clear(); this.inPublicStore.clear(); this.writers.clear(); this.issued = false; diff --git a/packages/sdk/src/emulated-verifier/public-store.ts b/packages/sdk/src/emulated-verifier/public-store.ts index bf2226d..d267776 100644 --- a/packages/sdk/src/emulated-verifier/public-store.ts +++ b/packages/sdk/src/emulated-verifier/public-store.ts @@ -54,7 +54,7 @@ * 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 { getCaps } from "../shared-wallet/bootstrap"; import { escapeLiteral } from "../surface/sparql"; @@ -70,17 +70,26 @@ import { 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). + * Targets whose outer-overlay fetch has been attempted in this session, and WHAT it + * returned — the cap, or `null` for "not in a public store". + * + * ── 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 - * 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. + * cached `null` cannot go stale for a document that existed when it was taken. It CAN for + * one created afterwards in the same page — {@link resetPublicStoreFetches} is the way + * out, and a session or wallet reset calls it. */ -const attempted = new Map>(); +const attempted = new Map>(); /** Forget every outer-overlay fetch (tests / a switched session or wallet). */ export function resetPublicStoreFetches(): void { @@ -102,13 +111,13 @@ export async function exposeReadCap(doc: Nuri, cap: ReadCap): Promise { // 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( + await registerUpdate( s.sessionId, `DELETE WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.exposedReadCap}> ?c }`, doc, "exposeReadCap:clear", ); - await sparqlUpdate( + await registerUpdate( s.sessionId, `INSERT DATA { <${HEADER_BRANCH_SUBJECT}> <${P.exposedReadCap}> "${escapeLiteral(cap)}" }`, doc, @@ -142,11 +151,18 @@ export async function fetchReadCap(docLike: Nuri): Promise { pending = downloadReadCap(doc); 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. */ -async function downloadReadCap(doc: Nuri): Promise { +/** The fetch itself, through the machinery's door. Returns the cap, or `null` when the + * document is not in a public store — which is the normal case, not an error. */ +async function downloadReadCap(doc: Nuri): Promise { const s = await session(); try { // The repo has to be in the session before an anchored read resolves it — the @@ -165,24 +181,14 @@ async function downloadReadCap(doc: Nuri): Promise { // `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; - } + if (cap && hasReadCap(cap) && targetOf(cap) === doc) return cap; } } 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; + return null; } /** diff --git a/packages/sdk/src/emulated-verifier/reach.ts b/packages/sdk/src/emulated-verifier/reach.ts index 7100ec8..b58fb61 100644 --- a/packages/sdk/src/emulated-verifier/reach.ts +++ b/packages/sdk/src/emulated-verifier/reach.ts @@ -16,9 +16,13 @@ * 1. **You hold its cap.** Either because you created it (the store refiles the * cap) or because someone delivered it to you. This is the whole of the * access model, so it is the whole of the predicate. - * 2. **It is declared INFRASTRUCTURE.** A short, explicitly-registered list — - * never inferred from the shape of a NURI, because an inferred exemption is - * a hole. See {@link declareInfrastructure}. + * There is no second way, and there used to be a third door here: an explicit list of + * NURIs "declared infrastructure", exempt from the boundary. It was removed on + * 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 ───────────────────────────────── * > 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. * * The test an exemption must pass: *does removing it stop the virtual users from - * functioning, or does it merely stop users from seeing each other's content?* - * Only the first qualifies. The shim passes (remove it and no user is resolvable - * at all); a shared index of user content does not (remove it and every user still - * works — you simply have to be given links). + * functioning, or does it merely stop users from seeing each other's content?* Only the + * first qualifies — and the answer is that no exemption is needed at all: the one thing + * that qualifies (the shim) is reached through its own unguarded FUNCTIONS + * (`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 * 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 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(); - -/** - * Register `nuri` as scaffolding that the boundary does not apply to. Called by - * the store-registry as it resolves the store-root pointer and the doc-shim — - * the only two documents that qualify, because without them no virtual user can - * be resolved at all. - * - * Deliberately NOT exported from the package: nothing outside the library may - * widen the exemption list. - */ -export function declareInfrastructure(nuri: Nuri): void { - infrastructure.add(nuri); -} - -/** Is `nuri` registered scaffolding? */ -export function isInfrastructure(nuri: Nuri): boolean { - return infrastructure.has(nuri); -} - -/** Forget every declared exemption (tests / a fresh wallet). */ -export function resetInfrastructure(): void { - infrastructure.clear(); -} - /** * Do we POSSESS the cap of `nuri`? Not "does this string carry one" — a caller may * legitimately be holding the bare form and possess the cap elsewhere, which is the @@ -94,7 +65,7 @@ export function mayReach(nuri: Nuri): boolean { const caps = getCaps(); if (!caps.isEnforcing()) return true; const target = targetOf(nuri); - return isInfrastructure(target) || caps.capFor(target) !== undefined; + 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 - * because a public store served it. + * **Writing is OWNERSHIP, not possession of a read key.** * - * 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. + * Upstream the right to write is membership of the repo: `verify_permission` + * (`engine/repo/src/repo.rs:584`) is reachable only through `Commit::verify_perm` → + * `Commit::verify` (`engine/repo/src/commit.rs:780,897`), so it fires on commits and + * never on reads. Nothing about HOW a reader came by the read key bears on it — a public + * store hands its read cap to whoever asks (`PublicRepoLinkV0`, + * `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 — - * 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. + * ── What this replaced, and why ─────────────────────────────────────────── + * Until 2026-08-07 this asked *"was this cap served to me by a public store?"* and + * refused only then. That predicate was wrong in BOTH directions, and an adversarial + * 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 { - if (!getCaps().isReadOnlyPublicCap(targetOf(nuri))) return; +export async function assertMayWrite(nuri: Nuri, op: string): Promise { + 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( - `[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)}`, + `[ng-eventually] ${op}: refused — writing needs the WRITE cap, and reading a document ` + + "never grants it. A public store serves its read cap to anyone, and a cap deposited " + + `in your inbox is one someone gave you; neither makes you the document's owner. ${JSON.stringify(nuri)}`, ); } diff --git a/packages/sdk/src/emulated-verifier/read-filter.ts b/packages/sdk/src/emulated-verifier/read-filter.ts index 9a7f0a6..c0edbc4 100644 --- a/packages/sdk/src/emulated-verifier/read-filter.ts +++ b/packages/sdk/src/emulated-verifier/read-filter.ts @@ -62,32 +62,90 @@ export function filterReadable(items: Iterable, caps: CapRegistry): T[] { /** * A read-filtered VIEW over a reactive set (a `DeepSignalSet`, or any Set-like). - * Iteration / `size` / `forEach` yield only readable items; everything else - * (`add`, `delete`, `has`, `getById`, …) forwards to the target, so writes and - * the underlying reactivity are preserved. What the holder holds is consulted lazily, so the - * view reflects the holder in effect at read time. + * + * ── The rule this proxy must never break ────────────────────────────────── + * A filtered view may show LESS than the set holds. It may never show MORE. Until + * 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(set: S, caps: CapRegistry): S { 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) if (keep(item)) out.push(item); + return out; + }; return new Proxy(set, { get(target, prop, receiver) { - if (prop === Symbol.iterator) { - return function* () { - for (const item of target as Iterable) if (keep(item)) yield item; - }; - } - if (prop === "size") { - let n = 0; - for (const item of target as Iterable) if (keep(item)) n++; - return n; - } + if (prop === Symbol.iterator) return function* () { yield* kept(target); }; + if (prop === "size") return kept(target).length; + // 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 === "entries") return () => kept(target).map((i) => [i, i] as const)[Symbol.iterator](); if (prop === "forEach") { return (cb: (v: unknown, v2: unknown, s: unknown) => void) => { - for (const item of target as Iterable) 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).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); - 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; } diff --git a/packages/sdk/src/emulated-verifier/register-write.ts b/packages/sdk/src/emulated-verifier/register-write.ts new file mode 100644 index 0000000..73c3477 --- /dev/null +++ b/packages/sdk/src/emulated-verifier/register-write.ts @@ -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 { + 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 { + const { ng } = getConfig(); + logAccess("WRITE", targetInbox, label, " (cross-user deposit)"); + return ng.sparql_update(sessionId, query, targetInbox); +} diff --git a/packages/sdk/src/shared-wallet/account-registry.ts b/packages/sdk/src/shared-wallet/account-registry.ts index 0c80fa3..1cb014b 100644 --- a/packages/sdk/src/shared-wallet/account-registry.ts +++ b/packages/sdk/src/shared-wallet/account-registry.ts @@ -61,7 +61,8 @@ * `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 { getCaps, getCurrentUser, getStoreRegistryDeps } from "./bootstrap"; // 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 { const entityNuri = await createDoc(); const s = await session(); try { - await sparqlUpdate( + await registerUpdate( s.sessionId, // NO explicit `GRAPH <…>` wrapper: write the anchored DEFAULT graph (the // `indexDoc` anchor scopes it) — the CANONICAL, always-safe shape the @@ -940,7 +941,7 @@ export async function createEntityDoc(id: string, scope: Scope): Promise { // cap stores the pair. const cap = mintCap(entityNuri); try { - await sparqlUpdate( + await registerUpdate( s.sessionId, `INSERT DATA { <${STORE_BRANCH_SUBJECT}> <${P.readCap}> "${escapeLiteral(cap)}" }`, indexDoc, diff --git a/packages/sdk/src/surface/docs.ts b/packages/sdk/src/surface/docs.ts index 87164e0..269f878 100644 --- a/packages/sdk/src/surface/docs.ts +++ b/packages/sdk/src/surface/docs.ts @@ -91,47 +91,25 @@ export async function sparqlUpdate( ): Promise { const { ng } = getConfig(); const anchor = anchorLike === undefined ? undefined : toNuri(anchorLike, "docs.sparqlUpdate"); - // The boundary: a write may only touch what the connected virtual user reaches. + // 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: - // 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. + // Reaching is possession. Writing is OWNERSHIP — upstream the right to write is + // membership of the repo (`verify_permission`, reachable only from `Commit::verify`, + // so on commits and never on reads), and how you came by the READ key changes nothing + // about it. A public store hands its read cap to whoever asks; a cap deposited in your + // 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) { 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`. logAccess("WRITE", anchor ?? "(no anchor)", label); return ng.sparql_update(sessionId, query, anchor); } -/** - * Deposit into ANOTHER virtual user's inbox — the one write that legitimately - * crosses the boundary, and therefore the one that skips {@link assertMayReach}. - * - * Why this is a separate primitive rather than a flag: depositing is not "a write - * that happens to be allowed", it is a different act. You cannot read the inbox you - * deposit into, you hold no cap for it, and you get nothing back — upstream it is an - * anonymous sealed box. Naming the exception makes it greppable and keeps - * {@link sparqlUpdate} free of a bypass that would otherwise be reusable for - * anything. - * - * The recipient's ownership of the inbox is what bounds this: `inbox.post` is the - * only caller, and reading is guarded separately (`inbox.read`). - */ -export async function depositInto( - sessionId: string, - query: string, - targetInbox: Nuri, - label = "deposit", -): Promise { - 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. * diff --git a/packages/sdk/src/surface/inbox.ts b/packages/sdk/src/surface/inbox.ts index 3aab00a..05158ca 100644 --- a/packages/sdk/src/surface/inbox.ts +++ b/packages/sdk/src/surface/inbox.ts @@ -26,7 +26,8 @@ * 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 { ensureRepoOpen } from "../emulated-verifier/open-repo"; 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.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"); // 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 diff --git a/packages/sdk/test/caps.test.ts b/packages/sdk/test/caps.test.ts index 19e3797..14e77d5 100644 --- a/packages/sdk/test/caps.test.ts +++ b/packages/sdk/test/caps.test.ts @@ -104,32 +104,22 @@ test("markInPublicStore records where a document sits, and mints nothing", () => 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 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 + expect(caps.capFor(doc)).toBe(served); - // 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); + // No read-only mark, and that absence is the point. It existed until 2026-08-07 and + // fed the write guard, which was the wrong predicate in both directions — writing is + // 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. + become("carol"); + expect(caps.capFor(doc)).toBeUndefined(); }); test("open(): a public document is marked as sitting in a public store, a private one is not", () => { diff --git a/packages/sdk/test/cold-start-anchor.test.ts b/packages/sdk/test/cold-start-anchor.test.ts index d510456..51d9512 100644 --- a/packages/sdk/test/cold-start-anchor.test.ts +++ b/packages/sdk/test/cold-start-anchor.test.ts @@ -26,7 +26,6 @@ import { resetOpenedRepos } from "../src/emulated-verifier/open-repo"; import { configure } from "../src/index"; import { configureStoreRegistry, setCurrentUser } 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 ANCHOR = `did:ng:${SESSION.privateStoreId}`; @@ -37,7 +36,6 @@ afterAll(() => { resetRegistryCache(); resetOpenedRepos(); resetCaps(); - resetInfrastructure(); setCurrentUser(null); }); @@ -48,7 +46,6 @@ beforeEach(() => { resetRegistryCache(); resetOpenedRepos(); resetCaps(); - resetInfrastructure(); setCurrentUser(null); }); diff --git a/packages/sdk/test/cross-user-access.test.ts b/packages/sdk/test/cross-user-access.test.ts index e09638c..f3e61d8 100644 --- a/packages/sdk/test/cross-user-access.test.ts +++ b/packages/sdk/test/cross-user-access.test.ts @@ -416,6 +416,52 @@ test("connecting a user that does not exist provisions nothing", async () => { 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` and its // owner records the private half with `AddInboxCap` on the User branch — the same // branch as `AddLink`. So "which inboxes may I read" has one answer, and connecting diff --git a/packages/sdk/test/open-repo.test.ts b/packages/sdk/test/open-repo.test.ts index cef66e0..a96e397 100644 --- a/packages/sdk/test/open-repo.test.ts +++ b/packages/sdk/test/open-repo.test.ts @@ -23,7 +23,6 @@ import { readUnion } from "../src/surface/read-model"; import { configure } from "../src/index"; import { configureStoreRegistry, setCurrentUser } 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"; afterAll(() => { @@ -40,7 +39,6 @@ beforeEach(() => { resetOpenedRepos(); resetRegistryCache(); resetCaps(); - resetInfrastructure(); setCurrentUser(null); }); diff --git a/packages/sdk/test/public-store.test.ts b/packages/sdk/test/public-store.test.ts index f388088..25cf24d 100644 --- a/packages/sdk/test/public-store.test.ts +++ b/packages/sdk/test/public-store.test.ts @@ -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(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); }); @@ -111,6 +109,25 @@ test("inert while no cap has been issued at all — nothing to obtain, nothing a 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 () => { const { sparql_query } = inject(); setCurrentUser("alice"); diff --git a/packages/sdk/test/reach.test.ts b/packages/sdk/test/reach.test.ts index 0c56295..1423853 100644 --- a/packages/sdk/test/reach.test.ts +++ b/packages/sdk/test/reach.test.ts @@ -11,7 +11,8 @@ * how a link travels between users at all, and it gives the depositor nothing back. */ import { test, expect, mock, afterAll } from "bun:test"; -import { sparqlQuery, sparqlUpdate, depositInto } from "../src/surface/docs"; +import { 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 type { RegistrySession } from "../src/shared-wallet/account-registry"; import { configure } from "../src/index"; diff --git a/packages/sdk/test/read-filter.test.ts b/packages/sdk/test/read-filter.test.ts index 4e6d2aa..c5b5c37 100644 --- a/packages/sdk/test/read-filter.test.ts +++ b/packages/sdk/test/read-filter.test.ts @@ -74,6 +74,38 @@ test("makeReadFilteredView forwards mutations and membership to the target", () 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(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([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", () => { const set = new Set([MINE, LINKED]); const seen: string[] = [];