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:
@@ -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<void>
|
||||
// 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<void> {
|
||||
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<Nuri> {
|
||||
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,
|
||||
|
||||
@@ -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<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
|
||||
* 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<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. */
|
||||
private writers = new Map<Nuri, Set<PrincipalId>>();
|
||||
/** 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<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
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Nuri, Promise<boolean>>();
|
||||
const attempted = new Map<Nuri, Promise<ReadCap | null>>();
|
||||
|
||||
/** 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<void> {
|
||||
// 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<boolean> {
|
||||
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<boolean> {
|
||||
/** 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<ReadCap | null> {
|
||||
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<boolean> {
|
||||
// `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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<Nuri>();
|
||||
|
||||
/**
|
||||
* Register `nuri` as scaffolding that the boundary does not apply to. Called by
|
||||
* the store-registry as it resolves the store-root pointer and the doc-shim —
|
||||
* the only two documents that qualify, because without them no virtual user can
|
||||
* be resolved at all.
|
||||
*
|
||||
* Deliberately NOT exported from the package: nothing outside the library may
|
||||
* widen the exemption list.
|
||||
*/
|
||||
export function declareInfrastructure(nuri: Nuri): void {
|
||||
infrastructure.add(nuri);
|
||||
}
|
||||
|
||||
/** Is `nuri` registered scaffolding? */
|
||||
export function isInfrastructure(nuri: Nuri): boolean {
|
||||
return infrastructure.has(nuri);
|
||||
}
|
||||
|
||||
/** Forget every declared exemption (tests / a fresh wallet). */
|
||||
export function resetInfrastructure(): void {
|
||||
infrastructure.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Do we POSSESS the cap of `nuri`? Not "does this string carry one" — a caller may
|
||||
* legitimately be holding the bare form and possess the cap elsewhere, which is the
|
||||
@@ -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<void> {
|
||||
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)}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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).
|
||||
* 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<S extends object>(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<unknown>) 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<unknown>) if (keep(item)) yield item;
|
||||
};
|
||||
}
|
||||
if (prop === "size") {
|
||||
let n = 0;
|
||||
for (const item of target as Iterable<unknown>) 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<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);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
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<Nuri> {
|
||||
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<Nuri> {
|
||||
// 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,
|
||||
|
||||
@@ -91,47 +91,25 @@ export async function sparqlUpdate(
|
||||
): Promise<void> {
|
||||
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<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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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<PrivKey>` and its
|
||||
// owner records the private half with `AddInboxCap` on the User branch — the same
|
||||
// branch as `AddLink`. So "which inboxes may I read" has one answer, and connecting
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<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", () => {
|
||||
const set = new Set<Item>([MINE, LINKED]);
|
||||
const seen: string[] = [];
|
||||
|
||||
Reference in New Issue
Block a user