feat: un document en store public sert son ReadCap, une référence nue suffit
Le modèle amont est explicite dans `PublicRepoLinkV0` : le lien ne porte AUCUN `read_cap`, et son commentaire dit pourquoi — *"The latest ReadCap of the branch will be downloaded from the outerOverlay, if the peer brokers listed below allow it […] the public site are served differently by brokers"* (engine/net/src/types.rs:5098). La clé n'est pas remise par un émetteur : elle est donnée par le réseau à qui la demande, parce que le broker a épinglé l'overlay externe (`expose_outer`). La bibliothèque refusait jusqu'ici la forme sans cap quel que soit le store. Sûr dans le bon sens, mais une application ne pouvait pas exprimer « fais circuler, la référence suffit » — le seul acte que le modèle rend gratuit — et son unique contournement était de distribuer la clé, ce qui détruit la confidentialité composable. `emulated-verifier/public-store.ts` émule le mécanisme SANS toucher à la garde. La possession reste l'unique critère : un document public est lisible non par exception mais parce que son cap est *obtenable*. Chaque porte de lecture demande d'abord (`readUnion`, `docs.sparqlQuery`, `ensureRepoOpen`, `documentInboxAddress`), puis le chemin ordinaire s'applique. Lire n'est pas écrire. Ce que le store sert est un droit de LECTURE : `learnFromPublicStore` le classe à part et `assertMayWrite` refuse l'écriture dessus. Sans cela une référence nue achetait une écriture, ce qu'aucun store amont n'accorde. Autres conséquences : - `recordInPublicStore` (marquer + frapper) devient `markInPublicStore` (marquer). Frapper un second cap à côté de celui qu'on vient de télécharger donnerait deux clés différentes le jour où la constante devient un secret. - `hasCap` quitte la porte polyfill : il se lisait « ai-je le droit de lire ceci ? » et un document public y répondait `false` jusqu'à ce qu'on demande son cap. Aucun appelant hors des tests. - Les tests cross-user ne font plus traverser de cap par une variable JS : Bob n'obtient que la référence nue, comme une vraie application. Écarts documentés plutôt que masqués : le pari sur un modèle DÉCLARÉ (`expose_outer` est câblé à `false` côté client et `ExtTopicSyncReq` est `unimplemented!()`), la découverte limitée à ce qu'on sait déjà nommer, `useShape` qui n'a pas d'await à dépenser, et l'absence de `locator`. 179 tests unitaires, e2e 42/42 contre le broker en ligne.
This commit is contained in:
@@ -298,7 +298,7 @@ async function main(): Promise<void> {
|
||||
const t = Date.now();
|
||||
const r = await sdk<any>(frame, "documentInboxDeposit", "@owner-" + t, "@depositor-" + t);
|
||||
check(
|
||||
"the depositor RESOLVES the same inbox from the document, deposits into it, and the address stays out of the data",
|
||||
"the depositor holds only the BARE reference, resolves the same inbox from it, deposits, and the address stays out of the data",
|
||||
r.sameInbox === true &&
|
||||
r.openRefused === true &&
|
||||
JSON.stringify(r.deposits) === JSON.stringify([{ viaPostToDocument: true }, { joining: true }]) &&
|
||||
@@ -383,15 +383,15 @@ async function main(): Promise<void> {
|
||||
const linkOpensPublic =
|
||||
r.strangerWithLinkView.length === 1 && r.strangerWithLinkView.includes("public-item");
|
||||
check(
|
||||
"owner reads held docs only; stranger reads nothing; the repo link opens the published one",
|
||||
"the read-filtered view decides on possession alone: owner sees what he holds, a stranger nothing, and a filed cap opens it",
|
||||
ownerReadsHeld && ownerMissesUnheld && strangerReadsNothing && linkOpensPublic,
|
||||
`owner=${JSON.stringify(r.ownerView)} stranger=${JSON.stringify(r.strangerView)} withLink=${JSON.stringify(r.strangerWithLinkView)}`,
|
||||
`owner=${JSON.stringify(r.ownerView)} stranger=${JSON.stringify(r.strangerView)} withCap=${JSON.stringify(r.strangerWithLinkView)}`,
|
||||
);
|
||||
});
|
||||
await step("shareCap: a cap delivered to an inbox reveals the doc", async () => {
|
||||
const r = await sdk<any>(frame, "capsShareCap", "@friend-" + Date.now());
|
||||
check(
|
||||
"shareCap → inbox processed → the shared doc becomes readable, and the delivery is not surfaced",
|
||||
"share → inbox processed → the shared doc becomes readable, and the delivery is not surfaced",
|
||||
r.before === 0 && r.after === 1 && r.surfacedDeposits === 0,
|
||||
`before=${r.before} after=${r.after} surfaced=${r.surfacedDeposits}`,
|
||||
);
|
||||
|
||||
@@ -837,10 +837,11 @@ const identity = new IdentityStore(
|
||||
];
|
||||
setCurrentUser("owner-O");
|
||||
getCaps().open("did:ng:o:protdoc", "protected");
|
||||
const link = getCaps().recordInPublicStore("did:ng:o:pubdoc");
|
||||
const link = getCaps().open("did:ng:o:pubdoc", "public");
|
||||
const ownerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
|
||||
// A stranger holds nothing — including the PUBLISHED document, until the repo
|
||||
// link reaches them (§5: whoever has the URL reads the content).
|
||||
// A stranger holds nothing, and this VIEW asks nobody: it is pure possession, with
|
||||
// no round-trip to spend (see `read-filter.ts`). That a public store would serve
|
||||
// the cap is proven on the read paths, not here.
|
||||
setCurrentUser("stranger");
|
||||
const strangerView = [...(libUseShape(null, null) as Iterable<any>)].map((i) => i.v);
|
||||
getCaps().learn(link);
|
||||
@@ -861,20 +862,19 @@ const identity = new IdentityStore(
|
||||
* owner opens it, a third party RESOLVES its address from the document itself and
|
||||
* deposits, the owner reads it back.
|
||||
*
|
||||
* The point of the step is the resolution: nothing hands `depositorId` the address.
|
||||
* It gets the document's link (which is what circulates in this model) and must find
|
||||
* where to deposit on its own — which is exactly what a consumer app has to do, and
|
||||
* what a unit test passing the NURI through a variable cannot prove.
|
||||
* The point of the step is the resolution: the depositor is handed the document's
|
||||
* BARE reference — the only thing an application circulates — and must find where to
|
||||
* deposit on its own. It reads the document at all because the document sits in a
|
||||
* public store, which serves its read cap to whoever asks (`public-store.ts`); no key
|
||||
* crosses the identity boundary, here or in any real application.
|
||||
*/
|
||||
async documentInboxDeposit(ownerId: string, depositorId: string) {
|
||||
registryInternals.resetRegistryCache();
|
||||
setCurrentUser(ownerId);
|
||||
const doc = await storeRegistry.createEntityDoc(ownerId, "public");
|
||||
const ownerInbox = await storeRegistry.openDocumentInbox(doc);
|
||||
const link = getCaps().capFor(doc)!; // out-of-band: the harness plays 'the owner sent it'
|
||||
|
||||
setCurrentUser(depositorId);
|
||||
getCaps().learn(link);
|
||||
const resolved = await documentInboxAddress(doc);
|
||||
// The one-call form an app actually uses: it names the DOCUMENT, never an inbox.
|
||||
await inbox.postToDocument(doc, { payload: { viaPostToDocument: true }, ts: 900 });
|
||||
|
||||
@@ -38,6 +38,7 @@ import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
|
||||
import { escapeLiteral } from "../surface/sparql";
|
||||
import { hasReadCap, isNuri } from "../model/nuri";
|
||||
import { mustNotAttempt } from "./reach";
|
||||
import { fetchReadCap } from "./public-store";
|
||||
import { ensureRepoOpen } from "./open-repo";
|
||||
import { accessLogPrefix } from "../shared-wallet/access-log";
|
||||
import {
|
||||
@@ -106,8 +107,9 @@ export function holdOwnCap(id: string, scope: Scope, doc: Nuri, cap: ReadCap): v
|
||||
// second mint would produce a DIFFERENT key and the document would be unreadable
|
||||
// by the very session that created it. Mint once, store it, hold that one.
|
||||
caps.learn(cap);
|
||||
// Publication is a registry fact, not a stored one, so it is applied separately.
|
||||
if (scope === "public") caps.recordInPublicStore(doc);
|
||||
// Which store the document sits in is a registry fact, applied separately — and a
|
||||
// MARK only, for the same reason the cap above is learned rather than re-minted.
|
||||
if (scope === "public") caps.markInPublicStore(doc);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -217,6 +219,11 @@ export async function documentInboxAddress(doc: Nuri): Promise<Nuri | undefined>
|
||||
// deposit for a document I cannot read" is not a refused question, it is a question
|
||||
// with no referent. Answering `undefined` here keeps the caller's shape (an address
|
||||
// or none) instead of turning the boundary into an exception it must catch.
|
||||
// …but ask the (emulated) network first: a document in a public store serves its cap
|
||||
// to whoever asks (public-store.ts), and "where do I deposit for this public
|
||||
// document" is exactly the question a third party arrives with, holding nothing but
|
||||
// the reference.
|
||||
await fetchReadCap(doc);
|
||||
if (mustNotAttempt(doc)) return undefined;
|
||||
const s = await session();
|
||||
try {
|
||||
|
||||
@@ -37,10 +37,14 @@
|
||||
*
|
||||
* ── Sharing ───────────────────────────────────────────────────────────────
|
||||
* Not here: the unit of sharing is the document and the recipient is an INBOX, so
|
||||
* sharing is `inbox.shareCap(cap, toInbox)` — a **Link** deposit — and receiving is
|
||||
* sharing is `inbox.share(doc, toUser)` — a **Link** deposit — and receiving is
|
||||
* the recipient processing their inbox. Handing over a store's cap is NOT the
|
||||
* gesture: it would give away everything that store contains, present and future.
|
||||
*
|
||||
* And for a document in a PUBLIC store there is no sharing act at all: the store hands
|
||||
* its cap to whoever asks (`public-store.ts`), so what circulates is the bare
|
||||
* reference. Filed apart (`learnFromPublicStore`) because it grants reading only.
|
||||
*
|
||||
* ── What this module does NOT do ──────────────────────────────────────────
|
||||
* Enforce. The shape is right after P1a; the isolation is still fake. Per-document
|
||||
* encryption and closing the read paths that bypass the guard (`docs.sparqlQuery`,
|
||||
@@ -83,14 +87,26 @@ export class CapRegistry {
|
||||
/** holder → the caps they hold, indexed by the cap-less NURI. */
|
||||
private heldByHolder = new Map<string, Map<Nuri, ReadCap>>();
|
||||
/**
|
||||
* Documents in a PUBLIC store, as this emulation records it. This is NOT a read grant:
|
||||
* such a document is read by
|
||||
* whoever HOLDS the link, exactly like §5 of the brief says ("whoever has the
|
||||
* URL reads the content"), and holding it means having received it. The set
|
||||
* exists so the library can refuse to surface a document its holder never
|
||||
* in a public store. *(This fed `discovery.submitToIndex`, removed 2026-07-30.)*
|
||||
* Documents this session knows to sit in a PUBLIC store — a fact about each
|
||||
* DOCUMENT, so global rather than per-holder, unlike everything else here.
|
||||
*
|
||||
* It is not itself a right. What being in a public store buys is that the document's
|
||||
* cap can be DOWNLOADED by anyone who asks (`emulated-verifier/public-store.ts`,
|
||||
* emulating `PublicRepoLinkV0`'s *"downloaded from the outerOverlay"*); once it has
|
||||
* been, the holder holds it like any other and this set records only how it got there.
|
||||
*/
|
||||
private inPublicStore = new Set<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
|
||||
@@ -137,6 +153,11 @@ export class CapRegistry {
|
||||
);
|
||||
}
|
||||
const target = targetOf(cap);
|
||||
// Filing is the STRONG claim — I created this document, or its cap was deposited
|
||||
// for me. Either one supersedes "a public store served it to me", so the read-only
|
||||
// mark goes. {@link learnFromPublicStore} re-adds it after calling here, and only
|
||||
// when nothing was held before.
|
||||
this.servedToHolder().delete(target);
|
||||
const ring = this.heldCaps();
|
||||
if (ring.get(target) === cap) return false;
|
||||
ring.set(target, cap);
|
||||
@@ -168,6 +189,46 @@ export class CapRegistry {
|
||||
this.file(cap);
|
||||
}
|
||||
|
||||
/**
|
||||
* File a cap a PUBLIC STORE served me — `emulated-verifier/public-store.ts`, the
|
||||
* emulated *"downloaded from the outerOverlay"*. Held like any other cap, so reading
|
||||
* needs no special case anywhere; recorded apart because of what it is NOT.
|
||||
*
|
||||
* It is a READ grant and nothing else. Upstream a public store makes its repos
|
||||
* world-readable, never world-writable — writing needs the write cap, and
|
||||
* `verify_permission` fires on WRITE only. Here the write guard still consults the
|
||||
* read cap (write caps are decorative until P1b, see the module header), so without
|
||||
* this distinction a bare reference to a public document would buy a WRITE — a
|
||||
* consumer would build on it, and have to unlearn it at migration.
|
||||
*
|
||||
* A stronger claim on the same document erases the mark: {@link mint} (I created it)
|
||||
* and {@link learn} (it was deposited for me) both go through {@link file}, which
|
||||
* clears it. So a public document of my own is never read-only to me.
|
||||
*/
|
||||
learnFromPublicStore(cap: ReadCap): void {
|
||||
const target = targetOf(cap);
|
||||
const alreadyHeld = this.heldCaps().has(target);
|
||||
this.file(cap);
|
||||
// Only when this is the ONLY reason I hold it — filing never downgrades a claim.
|
||||
if (!alreadyHeld) this.servedToHolder().add(target);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the ONLY reason the current holder holds this document's cap that a public store
|
||||
* served it? Then it grants reading and nothing more — see {@link learnFromPublicStore}.
|
||||
*/
|
||||
isReadOnlyPublicCap(nuri: Nuri): boolean {
|
||||
return this.servedToHolder().has(targetOf(nuri));
|
||||
}
|
||||
|
||||
/** The current holder's public-store-served set, created on first use. */
|
||||
private servedToHolder(): Set<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
|
||||
* none — which is the whole answer the model can give. Absorbs the former
|
||||
@@ -181,48 +242,49 @@ export class CapRegistry {
|
||||
// --- publication (the public store) -------------------------------------
|
||||
|
||||
/**
|
||||
* Record that `nuri` sits in a PUBLIC store, and mint its cap.
|
||||
* Record that `nuri` sits in a PUBLIC store. A fact about the DOCUMENT, not a right
|
||||
* of anyone — hence a global set rather than a per-holder one, and hence no minting
|
||||
* here: what sitting in a public store buys is that the cap is **obtainable** by
|
||||
* whoever asks (`emulated-verifier/public-store.ts`), which is a separate act from
|
||||
* this one holding it.
|
||||
*
|
||||
* **Named for what it does here, not for what it means upstream** — and the gap is the
|
||||
* point. It was called `publishRepoLink`, and "publish" is banned in this repo
|
||||
* (`docs/readcap-and-nuri-model.md`, the traps block) precisely because it blurs three
|
||||
* acts: placing a document in a public store, making it findable, and handing out a
|
||||
* key. This method does the first and, as an emulation artefact, the third.
|
||||
* Marking and minting were one method (`recordInPublicStore`) until they were split:
|
||||
* the fetch path files the cap it DOWNLOADED, and minting a second one beside it
|
||||
* would produce a different key the day the stand-in constant becomes a real one —
|
||||
* the same trap `holdOwnCap` already documents.
|
||||
*
|
||||
* Upstream a document in a public store is readable because the STORE is public and
|
||||
* brokers serve it accordingly (`expose_outer`); a `PublicRepoLinkV0` carries no
|
||||
* `read_cap` at all (`engine/net/src/types.rs:5105-5127`). Here there is no broker that
|
||||
* serves differently, so possession stands in for it — the emulation is OVER-strict,
|
||||
* not inverted: it under-grants, and "circulate the reference" remains the right
|
||||
* gesture at migration. See `readcap-and-nuri-model.md` §4sexies.
|
||||
* Upstream nothing corresponds to this call: the store IS public, and the broker
|
||||
* exposes its outer overlay (`expose_outer`,
|
||||
* `engine/broker/src/server_storage/core/overlay.rs:103-133`). We record it because
|
||||
* one broker here serves every virtual user identically.
|
||||
*
|
||||
* NOT recursive: a document in a public store may REFERENCE private ones, and the
|
||||
* reference grants nothing on what it references. That non-recursiveness is what lets
|
||||
* a public object point at private content without disclosing it.
|
||||
*/
|
||||
recordInPublicStore(nuri: Nuri): ReadCap {
|
||||
const target = targetOf(nuri);
|
||||
this.inPublicStore.add(target);
|
||||
return this.mint(target);
|
||||
markInPublicStore(nuri: Nuri): void {
|
||||
this.inPublicStore.add(targetOf(nuri));
|
||||
}
|
||||
|
||||
/** Is `nuri` recorded as sitting in a public store? An emitter-side fact, not a right. */
|
||||
/** Is `nuri` recorded as sitting in a public store? A fact about the document. */
|
||||
isInPublicStore(nuri: Nuri): boolean {
|
||||
return this.inPublicStore.has(targetOf(nuri));
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a document the current holder owns in `scope`: its cap lands in their
|
||||
* what they hold, and a `public` one is additionally published as a repo link. Returns
|
||||
* the cap (the shareable link when public). Idempotent — the store-registry calls
|
||||
* it both when creating a document and when listing the holder's own documents
|
||||
* back, which is how a holder's caps are rebuilt on a fresh session.
|
||||
* Record a document the current holder owns in `scope`: its cap lands among what
|
||||
* they hold, and a `public` one is additionally marked as sitting in a public store.
|
||||
* Returns the cap. Idempotent — the registry calls it both when creating a document
|
||||
* and when listing the holder's own documents back, which is how a holder's caps are
|
||||
* rebuilt on a fresh session.
|
||||
*
|
||||
* Deliberately does NOT touch write caps: those are decorative until P1b, and
|
||||
* arming their guard here would be enforcement this batch does not do.
|
||||
*/
|
||||
open(nuri: Nuri, scope: Scope): ReadCap {
|
||||
return scope === "public" ? this.recordInPublicStore(nuri) : this.mint(nuri);
|
||||
const cap = this.mint(nuri);
|
||||
if (scope === "public") this.markInPublicStore(nuri);
|
||||
return cap;
|
||||
}
|
||||
|
||||
// --- enforcement gate ---------------------------------------------------
|
||||
@@ -291,6 +353,7 @@ export class CapRegistry {
|
||||
* NOT what an identity change does (that switches heldByHolder, see the header). */
|
||||
clear(): void {
|
||||
this.heldByHolder.clear();
|
||||
this.servedByHolder.clear();
|
||||
this.inPublicStore.clear();
|
||||
this.writers.clear();
|
||||
this.issued = false;
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
*/
|
||||
|
||||
import { mustNotAttempt } from "./reach";
|
||||
import { fetchReadCap } from "./public-store";
|
||||
import { getConfig, getStoreRegistryDeps } from "../shared-wallet/bootstrap";
|
||||
import { subscribeDocUnguarded, type Unsubscribe } from "../surface/subscribe";
|
||||
import { logStage, shortNuri } from "../shared-wallet/access-log";
|
||||
@@ -178,6 +179,12 @@ async function syncSession(): Promise<void> {
|
||||
*/
|
||||
export async function ensureRepoOpen(nuri: Nuri): Promise<void> {
|
||||
if (!nuri) return;
|
||||
// A repo in a PUBLIC store hands its cap to whoever asks — upstream by serving it on
|
||||
// the outer overlay (`PublicRepoLinkV0`, `engine/net/src/types.rs:5098`). So ASK
|
||||
// before deciding whether we may touch it, or the answer would be "no" purely for
|
||||
// want of asking, and a bare reference to a public document would never suffice.
|
||||
// Memoised and inert once the cap is held (see public-store.ts).
|
||||
await fetchReadCap(nuri);
|
||||
// RULE 2 — do not even attempt. Opening a repo IS an access: it subscribes and
|
||||
// pulls its state. A user that holds no cap for it has no business asking.
|
||||
// (`ensurePhysicalRepoOpen` is the machinery's door — see physical.ts.)
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* public-store — a document in a PUBLIC store gives up its ReadCap to whoever asks.
|
||||
*
|
||||
* ── The upstream mechanism this emulates (VERIFIED) ───────────────────────
|
||||
* `PublicRepoLinkV0` (`engine/net/src/types.rs:5098-5124`) carries `repo`,
|
||||
* `public_store` and `peers` — and **no `read_cap`**. Its own doc comment says why:
|
||||
*
|
||||
* > *"The latest ReadCap of the branch (or main branch) will be **downloaded from
|
||||
* > the outerOverlay**, if the peer brokers listed below allow it. […] This link is
|
||||
* > durable, because the public site are **served differently by brokers**."*
|
||||
*
|
||||
* So for a repo in a public store, the key is not something a sender hands over: it is
|
||||
* something the **network gives to anyone who asks**. The broker decides, by pinning
|
||||
* the outer overlay (`expose_outer`, `engine/broker/src/server_storage/core/overlay.rs:103-133`).
|
||||
* That is the whole of the property — nothing about the reader, everything about where
|
||||
* the document sits and how brokers serve it.
|
||||
*
|
||||
* ── What that means for the model, and why nothing is special-cased ───────
|
||||
* Possession stays the ONE criterion. A public document is readable not because the
|
||||
* guard makes an exception for it, but because its cap is **obtainable**: you ask, you
|
||||
* receive, you hold it, and from there the ordinary path applies. `reach.ts` is
|
||||
* untouched, and "whoever has the reference AND the key reads" still describes
|
||||
* everything — a public store simply hands the key to whoever has the reference.
|
||||
*
|
||||
* The consequence an application must be able to rely on: **a bare reference to a
|
||||
* document in a public store is enough**, and that is why nothing in this library
|
||||
* needs to put a key into a link (see `readcap-and-nuri-model.md` § 0 — a call that
|
||||
* returns a key where a reference was asked for is the failure mode to watch for).
|
||||
*
|
||||
* Non-recursive, like everything else here: a public document may REFERENCE a
|
||||
* protected one, and following that reference gets you a name, not a key. Only the
|
||||
* document actually sitting in the public store exposes its cap.
|
||||
*
|
||||
* ── The two halves, and which door each uses ──────────────────────────────
|
||||
* - {@link exposeReadCap} — the OWNER's side, at creation: the cap is written on the
|
||||
* document's Header branch, the compartment meant for what any reader may see. It
|
||||
* goes through the guarded surface, because the owner holds the document.
|
||||
* - {@link fetchReadCap} — the NETWORK's side: read through the **physical** door
|
||||
* (`shared-wallet/physical.ts`), unguarded, because that is precisely the point —
|
||||
* the broker serving an outer overlay does not ask who is asking. Using the guarded
|
||||
* read here would be circular: you would need the cap to obtain the cap.
|
||||
*
|
||||
* ── Where the emulation is honest about its shape ─────────────────────────
|
||||
* Upstream nothing is *written* anywhere to make a repo public: the store is public,
|
||||
* and the broker exposes its outer overlay. Here there is one broker serving every
|
||||
* virtual user identically, so "which documents are in a public store" has to be
|
||||
* recorded somewhere the machinery can read — and the document itself is the one place
|
||||
* that needs no index and no enumeration. At migration this whole module goes: the
|
||||
* scope stops being a fact we record and becomes the store the document lives in.
|
||||
*
|
||||
* The gap that leaves: a reader learns a document is public by ASKING that document,
|
||||
* so a document it has never heard of stays invisible. Upstream the broker would serve
|
||||
* it just the same. That limits discovery, not access — an application that holds the
|
||||
* reference reads, which is the property this module exists to provide.
|
||||
*/
|
||||
|
||||
import { sparqlUpdate } from "../surface/docs";
|
||||
import { physicalQuery, ensurePhysicalRepoOpen } from "../shared-wallet/physical";
|
||||
import { getCaps } from "../shared-wallet/bootstrap";
|
||||
import { escapeLiteral } from "../surface/sparql";
|
||||
import { hasReadCap, targetOf } from "../model/nuri";
|
||||
import { accessLogPrefix } from "../shared-wallet/access-log";
|
||||
import {
|
||||
P,
|
||||
HEADER_BRANCH_SUBJECT,
|
||||
readBindings,
|
||||
bindingValue,
|
||||
session,
|
||||
} from "../shared-wallet/account-registry";
|
||||
import type { Nuri, ReadCap } from "../model/types";
|
||||
|
||||
/**
|
||||
* Targets whose outer-overlay fetch has already been attempted in this session, with
|
||||
* its outcome. Memoised in BOTH directions on purpose: a hit spares a physical read,
|
||||
* and a miss spares repeating one for every read of a document this user cannot reach
|
||||
* — which is the common case (a protected document someone merely named).
|
||||
*
|
||||
* A scope never changes here (a document is created in a store and stays there), so a
|
||||
* cached miss cannot go stale for a document that existed when it was taken. It CAN
|
||||
* for one created afterwards by another user in the same page — {@link resetPublicStoreFetches}
|
||||
* is the way out, and it is what a session change / a wallet reset calls.
|
||||
*/
|
||||
const attempted = new Map<Nuri, Promise<boolean>>();
|
||||
|
||||
/** Forget every outer-overlay fetch (tests / a switched session or wallet). */
|
||||
export function resetPublicStoreFetches(): void {
|
||||
attempted.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Expose `cap` on `doc`'s Header branch — the emulated `expose_outer`. Called when a
|
||||
* document is created in a PUBLIC store, and only then: this is what makes the cap
|
||||
* obtainable by anyone, which for a public store is the intended property and for any
|
||||
* other scope would be a disclosure.
|
||||
*
|
||||
* Replacement, not addition, like every Header-branch register: one document has one
|
||||
* current cap, and two would leave a fetcher picking between them.
|
||||
*/
|
||||
export async function exposeReadCap(doc: Nuri, cap: ReadCap): Promise<void> {
|
||||
const s = await session();
|
||||
try {
|
||||
// Two separate updates: `DELETE WHERE { … }` is the form verified against the real
|
||||
// broker (`docs/decisions/sparql-delete-for-orm-objects.md`); a `;`-joined update
|
||||
// is not exercised anywhere in this library.
|
||||
await sparqlUpdate(
|
||||
s.sessionId,
|
||||
`DELETE WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.exposedReadCap}> ?c }`,
|
||||
doc,
|
||||
"exposeReadCap:clear",
|
||||
);
|
||||
await sparqlUpdate(
|
||||
s.sessionId,
|
||||
`INSERT DATA { <${HEADER_BRANCH_SUBJECT}> <${P.exposedReadCap}> "${escapeLiteral(cap)}" }`,
|
||||
doc,
|
||||
"exposeReadCap",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " exposeReadCap failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask the (emulated) network for `doc`'s ReadCap, and file it if it answers — the
|
||||
* emulated *"downloaded from the outerOverlay"*. Returns whether a cap was obtained.
|
||||
*
|
||||
* Nothing is asked when the cap is already held: a document you can read needs no
|
||||
* fetching, and skipping it keeps the ordinary path free of physical reads.
|
||||
*
|
||||
* Never throws — a document that is not in a public store simply answers nothing, which
|
||||
* is not an error but the normal case.
|
||||
*/
|
||||
export async function fetchReadCap(docLike: Nuri): Promise<boolean> {
|
||||
const doc = targetOf(docLike);
|
||||
const caps = getCaps();
|
||||
// Inert until the emulation is in force, like the guard it serves: before the first
|
||||
// cap exists everything reads anyway, so there is nothing to obtain and asking would
|
||||
// be a physical round-trip bought for nothing.
|
||||
if (!caps.isEnforcing()) return false;
|
||||
if (caps.capFor(doc) !== undefined) return true;
|
||||
let pending = attempted.get(doc);
|
||||
if (pending === undefined) {
|
||||
pending = downloadReadCap(doc);
|
||||
attempted.set(doc, pending);
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
/** The fetch itself, through the machinery's door. See the module header. */
|
||||
async function downloadReadCap(doc: Nuri): Promise<boolean> {
|
||||
const s = await session();
|
||||
try {
|
||||
// The repo has to be in the session before an anchored read resolves it — the
|
||||
// cold-start heal, through the PHYSICAL door: this is the emulated broker serving
|
||||
// an outer overlay, and it does not ask who is asking (see `open-repo.ts`).
|
||||
await ensurePhysicalRepoOpen(doc);
|
||||
const res = await physicalQuery(
|
||||
s.sessionId,
|
||||
`SELECT ?c WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.exposedReadCap}> ?c }`,
|
||||
undefined,
|
||||
doc,
|
||||
"fetchReadCap",
|
||||
);
|
||||
for (const row of readBindings(res)) {
|
||||
const cap = bindingValue(row, "c");
|
||||
// `targetOf` guards the one confusion that would matter: a cap exposed on
|
||||
// document A must not file a cap for document B. A document only ever speaks
|
||||
// for itself.
|
||||
if (cap && hasReadCap(cap) && targetOf(cap) === doc) {
|
||||
// File the cap that was DOWNLOADED — never a freshly minted one. They agree
|
||||
// today only because the stand-in value is a constant; with a real key (P1b)
|
||||
// a second mint would produce a different key and the document would not open.
|
||||
//
|
||||
// `learnFromPublicStore`, not `learn`: what the network hands out is a READ
|
||||
// grant. A public store makes its repos world-readable, never world-writable.
|
||||
getCaps().learnFromPublicStore(cap);
|
||||
getCaps().markInPublicStore(doc);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Not in a public store, not synced, or no such document — all of them mean the
|
||||
// same thing to the caller: no cap was obtained.
|
||||
console.error(accessLogPrefix() + " fetchReadCap failed:", error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask for a SET of documents' caps, in parallel — what a batch read does before it
|
||||
* decides which documents it may touch. Each fetch is independent and tolerant.
|
||||
*/
|
||||
export async function fetchReadCaps(docs: Nuri[]): Promise<void> {
|
||||
const unique = [...new Set(docs.filter(Boolean))];
|
||||
if (unique.length === 0) return;
|
||||
await Promise.all(unique.map((d) => fetchReadCap(d)));
|
||||
}
|
||||
@@ -116,6 +116,31 @@ export function assertMayReach(nuri: Nuri, op: string): void {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reading is not writing — refuse a write on a document whose cap the holder has ONLY
|
||||
* because a public store served it.
|
||||
*
|
||||
* Upstream a public store makes its repos world-readable and never world-writable: the
|
||||
* outer overlay hands out the ReadCap (`PublicRepoLinkV0`,
|
||||
* `engine/net/src/types.rs:5098`), writing needs the write cap, and `verify_permission`
|
||||
* fires on WRITE only. This emulation's write guard otherwise consults the READ cap
|
||||
* (write caps are decorative until P1b — `caps.ts` header), so without this the
|
||||
* public-store fetch would turn every bare reference into a write right.
|
||||
*
|
||||
* Narrow on purpose: it closes the case this batch opened, not the pre-existing one —
|
||||
* a cap RECEIVED in an inbox still passes the write guard here, and upstream would not.
|
||||
* That conflation is P1b's, and widening this check to cover it would be enforcement
|
||||
* this batch does not do.
|
||||
*/
|
||||
export function assertMayWrite(nuri: Nuri, op: string): void {
|
||||
if (!getCaps().isReadOnlyPublicCap(targetOf(nuri))) return;
|
||||
throw new Error(
|
||||
`[ng-eventually] ${op}: refused — this document is in a public store, which serves ` +
|
||||
"its READ cap to anyone. Reading it is not writing to it: a write needs the write " +
|
||||
`cap, and no store hands that out. ${JSON.stringify(nuri)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* **Rule 2 — do not even attempt**, at the CALLERS (`read-model`, `open-repo`,
|
||||
* `subscribe`'s callers…).
|
||||
|
||||
@@ -13,6 +13,16 @@
|
||||
* nothing on that document — which is exactly the native behavior, and why
|
||||
* fine-grained isolation requires one document per entity. Removed at migration.
|
||||
*
|
||||
* ── What this filter cannot do, and where that shows ──────────────────────
|
||||
* It is SYNCHRONOUS and decides from what the holder holds at that instant. A document
|
||||
* in a PUBLIC store hands its cap to whoever asks (`public-store.ts`), but asking is a
|
||||
* round-trip — so this view drops such a document until some read path has asked.
|
||||
* Every path this library owns does ask (`readUnion`, `docs.sparqlQuery`,
|
||||
* `ensureRepoOpen`, `documentInboxAddress`), which covers `watchShape`; what it does
|
||||
* not cover is `useShape`, whose signature is the real ORM's and has no await to
|
||||
* spend. An application reaching a public document through `useShape` alone, having
|
||||
* read it nowhere first, sees nothing. A polyfill-era limit, removed with the module.
|
||||
*
|
||||
* Note there is no `user` parameter anywhere below, and that is the point: reading
|
||||
* is key possession, so the only question askable is "do I hold this document's
|
||||
* cap?". "May principal P read document D?" is an ACL question the real model
|
||||
|
||||
@@ -22,12 +22,11 @@ export {
|
||||
getStoreRegistryDeps,
|
||||
resetStoreRegistry,
|
||||
setCurrentUser,
|
||||
hasCap,
|
||||
resetCaps,
|
||||
} from "./shared-wallet/bootstrap";
|
||||
|
||||
// Cap surface — polyfill-era (caps are emulated now; native at migration).
|
||||
// Re-exported here so the whole polyfill API lives under /polyfill. `shareCap`
|
||||
// Re-exported here so the whole polyfill API lives under /polyfill. `share`
|
||||
// lives in `inbox.ts` because sharing IS an inbox deposit (upstream: a sealed
|
||||
// message carrying the cap), but it is surfaced here so the cap vocabulary stays
|
||||
// on the polyfill side of the boundary rather than in the SDK-identical entry.
|
||||
@@ -48,8 +47,17 @@ export { connectedUser } from "./emulated-verifier/connect";
|
||||
// application's job upstream too. The gate persists what IT needs
|
||||
// (`shared-wallet/access-gate.ts`); nothing else has to be exposed.
|
||||
//
|
||||
// And one more, removed 2026-08-06 with the public-store emulation:
|
||||
//
|
||||
// - `hasCap(doc)` — "do I hold this document's cap?". It read like "may I read this?",
|
||||
// and once a public store serves its caps to whoever asks
|
||||
// (`emulated-verifier/public-store.ts`) the two answers part company: a readable
|
||||
// document answers `false` right up until something asks for it. Nor is the question
|
||||
// one the target answers — upstream you open a document and find out. It had no
|
||||
// caller outside the tests, which now use the internal registry directly.
|
||||
//
|
||||
// What remains here is the whole polyfill-era surface: `configure`, `setCurrentUser`,
|
||||
// `capFor`, `shareCap` and the test resets. Two of them are what an application calls.
|
||||
// `share`, `connectedUser` and the test resets. Two of them are what an application calls.
|
||||
|
||||
// --- identity persistence (polyfill-era, no SDK counterpart) ----------------
|
||||
//
|
||||
|
||||
@@ -77,6 +77,7 @@ import {
|
||||
documentInboxAddress,
|
||||
} from "../emulated-verifier/branch-registers";
|
||||
import { ensureRepoOpen } from "../emulated-verifier/open-repo";
|
||||
import { exposeReadCap } from "../emulated-verifier/public-store";
|
||||
import { ensurePhysicalRepoOpen, subscribePhysicalDoc } from "./physical";
|
||||
import { escapeLiteral, escapeIri, assertNuri } from "../surface/sparql";
|
||||
import { hasReadCap, isNuri } from "../model/nuri";
|
||||
@@ -121,6 +122,7 @@ export const P = {
|
||||
readCap: `${SHIM}:readCap`, // store branch → the ReadCap of a document IN this store
|
||||
inboxCap: `${SHIM}:inboxCap`, // user branch → an inbox this user may READ
|
||||
inboxAddress: `${SHIM}:inboxAddress`, // header branch → WHERE to deposit for this document
|
||||
exposedReadCap: `${SHIM}:exposedReadCap`, // header branch → the cap a PUBLIC store serves to anyone
|
||||
} as const;
|
||||
// Fixed subject of the per-(account×scope) index document. The index doc plays
|
||||
// the role of the future store-container: it lists the NURIs of the entity
|
||||
@@ -949,6 +951,12 @@ export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
|
||||
}
|
||||
// …and the creator holds THAT cap for this session.
|
||||
holdOwnCap(id, scope, entityNuri, cap);
|
||||
// A document in a PUBLIC store hands its cap to whoever asks — upstream because the
|
||||
// broker exposes the outer overlay and the ReadCap is downloaded from it
|
||||
// (`PublicRepoLinkV0`, `engine/net/src/types.rs:5098`), here because the cap is put
|
||||
// where the machinery can fetch it. This is what makes a BARE reference enough for a
|
||||
// public document, so nothing in this library ever has to put a key into a link.
|
||||
if (scope === "public") await exposeReadCap(entityNuri, cap);
|
||||
// NO inbox here, and NOT the owner's own inbox published as this document's address.
|
||||
// Upstream an inbox belongs to exactly ONE repo: the verifier routes an incoming
|
||||
// message by `inboxes: PubKey → RepoId` (`engine/verifier/src/verifier.rs:1677,1928`)
|
||||
@@ -1036,9 +1044,11 @@ export async function listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]
|
||||
if (holder !== null && accountKey(holder) === accountKey(id)) {
|
||||
const caps = getCaps();
|
||||
for (const cap of await readStoreCaps(store)) caps.learn(cap);
|
||||
// A `public` store's documents are also published links — the publication fact
|
||||
// lives in the registry, not in the store, so it is re-applied here.
|
||||
if (scope === "public") for (const d of docs) caps.recordInPublicStore(d);
|
||||
// Which store a document sits in is a registry fact, not one recorded beside the
|
||||
// caps, so it is re-applied here. Marking only — the caps just came from the Store
|
||||
// branch above, and minting a second one beside them is the trap `holdOwnCap` warns
|
||||
// about.
|
||||
if (scope === "public") for (const d of docs) caps.markInPublicStore(d);
|
||||
}
|
||||
return docs;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { SharedWalletConfig } from "./access-gate";
|
||||
import { toNuri } from "../model/nuri";
|
||||
import type { RegistrySession } from "./account-registry";
|
||||
import { CapRegistry } from "../emulated-verifier/caps";
|
||||
import { resetPublicStoreFetches } from "../emulated-verifier/public-store";
|
||||
import { setAccessLog } from "./access-log";
|
||||
import { inspectOutbox } from "./outbox-log";
|
||||
import { startConnect } from "../emulated-verifier/connect";
|
||||
@@ -215,7 +216,7 @@ export function getCaps(): CapRegistry {
|
||||
* would have to invent an ACL the engine does not have (`docs/api-contract.md` § 10).
|
||||
*
|
||||
* Returns a BOOLEAN, not the cap. It used to hand the value back, and the only consumer
|
||||
* that used it did so to pass it to `shareCap` — which now takes the document instead.
|
||||
* that used it did so to pass it to `share` — which now takes the document instead.
|
||||
* Nothing an application does requires holding a key: upstream it never sees one, the
|
||||
* verifier fills `ContactDetails.read_cap` itself. So the surface answers the question
|
||||
* and keeps the key.
|
||||
@@ -235,5 +236,9 @@ export function resetCaps(): void {
|
||||
// change signal (`watchShape`) stays subscribed to the live instance instead of
|
||||
// silently holding a listener on an orphaned one.
|
||||
caps.clear();
|
||||
// …and forget which documents were already asked about, or the emulated public-store
|
||||
// fetch would answer from a memo taken before the wipe and hand back caps this
|
||||
// registry no longer holds.
|
||||
resetPublicStoreFetches();
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
import { getCaps, getConfig } from "../shared-wallet/bootstrap";
|
||||
import { logAccess, enabled as accessLogEnabled } from "../shared-wallet/access-log";
|
||||
import { isNuri, toNuri } from "../model/nuri";
|
||||
import { assertMayReach } from "../emulated-verifier/reach";
|
||||
import { assertMayReach, assertMayWrite } from "../emulated-verifier/reach";
|
||||
import { fetchReadCap } from "../emulated-verifier/public-store";
|
||||
import type { Nuri, NuriLike } from "../model/types";
|
||||
|
||||
// The low common point for ALL document access: every read in the SDK routes
|
||||
@@ -91,7 +92,16 @@ export async function sparqlUpdate(
|
||||
const { ng } = getConfig();
|
||||
const anchor = anchorLike === undefined ? undefined : toNuri(anchorLike, "docs.sparqlUpdate");
|
||||
// The boundary: a write may only touch what the connected virtual user reaches.
|
||||
if (anchor !== undefined) assertMayReach(anchor, "docs.sparqlUpdate");
|
||||
//
|
||||
// NO public-store fetch here, unlike the read below, and that asymmetry is the point:
|
||||
// a public store makes its repos world-READABLE. Writing needs the write cap, which
|
||||
// it never serves. A cap this holder has only because the network handed it over is
|
||||
// therefore refused a write outright — otherwise a bare reference to a public
|
||||
// document would buy one, and a consumer would build on something that fails upstream.
|
||||
if (anchor !== undefined) {
|
||||
assertMayReach(anchor, "docs.sparqlUpdate");
|
||||
assertMayWrite(anchor, "docs.sparqlUpdate");
|
||||
}
|
||||
// `label` is a lib-internal access-log tag, NOT forwarded to `ng`.
|
||||
logAccess("WRITE", anchor ?? "(no anchor)", label);
|
||||
return ng.sparql_update(sessionId, query, anchor);
|
||||
@@ -140,7 +150,15 @@ export async function sparqlQuery(
|
||||
// The boundary: an ANCHORED read may only touch what the connected virtual user
|
||||
// reaches. An anchorless query spans the local union — a different problem (it is
|
||||
// O(wallet size), and the read path never uses it), not one this guard can bound.
|
||||
if (anchor !== undefined) assertMayReach(anchor, "docs.sparqlQuery");
|
||||
//
|
||||
// Asking the (emulated) network first, as `ensureRepoOpen` does: a document in a
|
||||
// public store gives its cap to whoever asks, and this is a door an application can
|
||||
// reach with nothing but a bare reference. Inert once the cap is held, memoised
|
||||
// otherwise — see public-store.ts.
|
||||
if (anchor !== undefined) {
|
||||
await fetchReadCap(anchor);
|
||||
assertMayReach(anchor, "docs.sparqlQuery");
|
||||
}
|
||||
// `label` is a lib-internal access-log tag, NOT forwarded to `ng`.
|
||||
const result = await ng.sparql_query(sessionId, query, base, anchor);
|
||||
// Log AFTER the read so the row count (a strong leak signal: a doc rendering
|
||||
|
||||
@@ -341,7 +341,7 @@ export async function readForDocument(docLike: NuriLike): Promise<Deposit[]> {
|
||||
* Refuse to READ an inbox that is not the current wallet's.
|
||||
*
|
||||
* Depositing into someone else's inbox is the one legitimate cross-wallet act (it
|
||||
* is how a link reaches another wallet at all — see {@link post} / {@link shareCap});
|
||||
* is how a link reaches another wallet at all — see {@link post} / {@link share});
|
||||
* READING one is not, and it is not symmetric with it. Since caps travel as
|
||||
* deposits, an unguarded read let anyone who knew an inbox NURI collect the caps
|
||||
* addressed to its owner, which defeats directed sharing entirely.
|
||||
@@ -354,7 +354,7 @@ async function assertOwnInbox(targetInbox: Nuri, op: string): Promise<void> {
|
||||
if (getCurrentUser() === null) {
|
||||
throw new Error(
|
||||
`[ng-eventually] inbox.${op}: no identity is set, so no inbox belongs to this ` +
|
||||
"session — call setCurrentUser() first. Depositing (post/shareCap) stays open.",
|
||||
"session — call setCurrentUser() first. Depositing (post/share) stays open.",
|
||||
);
|
||||
}
|
||||
if (!(await isOwnInbox(targetInbox))) {
|
||||
@@ -376,7 +376,7 @@ async function assertOwnInbox(targetInbox: Nuri, op: string): Promise<void> {
|
||||
* sealed-inbox path is available. The consumer interprets each deposit's
|
||||
* `payload`.
|
||||
*
|
||||
* Cap deliveries ({@link shareCap}) are applied inline and NOT returned: they land
|
||||
* Cap deliveries ({@link share}) are applied inline and NOT returned: they land
|
||||
* in what the current holder holds, like the verifier applying a queued message.
|
||||
* That is why receiving a cap needs no dedicated operation — a consumer already
|
||||
* watching its inbox gets them, and the resulting change re-triggers the
|
||||
@@ -500,7 +500,7 @@ export async function readSynced(targetInboxLike: NuriLike): Promise<Deposit[]>
|
||||
/**
|
||||
* PROCESS an inbox: read it, and **apply** what it contains.
|
||||
*
|
||||
* Applying a {@link shareCap} Link means filing it durably — `storeRegistry.addLink`,
|
||||
* Applying a {@link share} Link means filing it durably — `storeRegistry.addLink`,
|
||||
* the emulated `AddLink { read_cap }` on the User branch of the private store — so
|
||||
* the cap survives the session. Upstream this is what a verifier does when it
|
||||
* processes queued messages: an inbox is a **queue you consume**, not a store you
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
*
|
||||
* **No inbox ADDRESS is published here**, deliberately (`userInbox`,
|
||||
* `documentInboxAddress`, removed 2026-08-05). An application deposits with
|
||||
* `inbox.postToDocument(doc, …)`, shares with `inbox.shareCap(cap, toUser)` and reads
|
||||
* `inbox.postToDocument(doc, …)`, shares with `inbox.share(doc, toUser)` and reads
|
||||
* its own with `inbox.readForDocument(doc)` — always naming a document or a person,
|
||||
* never an address. Upstream an address is resolved from a profile and never handled by
|
||||
* a caller, so exposing one taught a step that has to be unlearned. The example
|
||||
|
||||
@@ -157,19 +157,24 @@ export async function readUnion(docsLike: NuriLike[]): Promise<UnionSubject[]> {
|
||||
const unique = [...new Set(docsLike.filter(Boolean))].map((d) => toNuri(d, "readUnion"));
|
||||
if (unique.length === 0) return [];
|
||||
|
||||
// RULE 2 — do not even attempt. Drop the documents whose cap this user does not
|
||||
// hold BEFORE opening or reading anything: upstream you cannot address a repo you
|
||||
// have no cap for, so asking about one is not "a read that will be refused", it is
|
||||
// a read that has no meaning. (The passage points enforce rule 1 regardless — see
|
||||
// reach.ts — so a lapse here is caught, not exploited.)
|
||||
const reachable = unique.filter((d) => !mustNotAttempt(d));
|
||||
|
||||
// COLD-START heal (polyfill-era): on a fresh session over a persistent wallet the
|
||||
// target repos are not yet in `self.repos`, so an anchored read would return 0
|
||||
// rows. Open/subscribe each repo ONCE (idempotent, per session) and await its
|
||||
// initial-state push before the anchored reads. No-op once opened / when the
|
||||
// injected `ng` has no `doc_subscribe` (unit fake). See open-repo.ts.
|
||||
await ensureReposOpen(reachable);
|
||||
//
|
||||
// Called on the WHOLE set, before the boundary is consulted, because opening is also
|
||||
// where a document in a PUBLIC store hands over its cap (see public-store.ts): a
|
||||
// document filtered out first would never get the chance to answer. `ensureRepoOpen`
|
||||
// still refuses to open what this user may not touch — it asks, it does not enter.
|
||||
await ensureReposOpen(unique);
|
||||
|
||||
// RULE 2 — do not even attempt. Drop the documents whose cap this user does not
|
||||
// hold before reading anything: upstream you cannot address a repo you have no cap
|
||||
// for, so asking about one is not "a read that will be refused", it is a read that
|
||||
// has no meaning. (The passage points enforce rule 1 regardless — see reach.ts — so
|
||||
// a lapse here is caught, not exploited.)
|
||||
const reachable = unique.filter((d) => !mustNotAttempt(d));
|
||||
|
||||
// One anchored query per doc, in parallel, tolerant (a bad doc yields []).
|
||||
const perDoc = await Promise.all(
|
||||
|
||||
@@ -160,6 +160,11 @@ describe("access-log: ON via configure({ debugAccessLog: true })", () => {
|
||||
it("sparqlQuery emits a READ line with identity, nuri, label, and row-count", async () => {
|
||||
injectFake(true);
|
||||
setCurrentUser("alice");
|
||||
// `setCurrentUser` FIRES the connection work; draining it here (and only then
|
||||
// dropping the caps it filed) is what keeps this test about the log and not about
|
||||
// whether a background connect happened to win the race.
|
||||
await connectedUser();
|
||||
resetCaps();
|
||||
const { lines, restore } = spyConsoleLog();
|
||||
try {
|
||||
await sparqlQuery("sid-log", "SELECT * {}", undefined, "did:ng:o:q", "myLabel");
|
||||
@@ -178,6 +183,11 @@ describe("access-log: ON via configure({ debugAccessLog: true })", () => {
|
||||
it("sparqlUpdate emits a WRITE line with identity, anchor nuri, and label", async () => {
|
||||
injectFake(true);
|
||||
setCurrentUser("alice");
|
||||
// `setCurrentUser` FIRES the connection work; draining it here (and only then
|
||||
// dropping the caps it filed) is what keeps this test about the log and not about
|
||||
// whether a background connect happened to win the race.
|
||||
await connectedUser();
|
||||
resetCaps();
|
||||
const { lines, restore } = spyConsoleLog();
|
||||
try {
|
||||
await sparqlUpdate("sid-log", "INSERT DATA {}", "did:ng:o:w", "writeLabel");
|
||||
@@ -194,6 +204,11 @@ describe("access-log: ON via configure({ debugAccessLog: true })", () => {
|
||||
it("docCreate emits a WRITE line with identity and the returned nuri", async () => {
|
||||
injectFake(true);
|
||||
setCurrentUser("alice");
|
||||
// `setCurrentUser` FIRES the connection work; draining it here (and only then
|
||||
// dropping the caps it filed) is what keeps this test about the log and not about
|
||||
// whether a background connect happened to win the race.
|
||||
await connectedUser();
|
||||
resetCaps();
|
||||
const { lines, restore } = spyConsoleLog();
|
||||
try {
|
||||
await docCreate("sid-log", "Graph", "data:graph", "store");
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* function turns a bare reference into a cap.
|
||||
*/
|
||||
import { test, expect } from "bun:test";
|
||||
import { CapRegistry } from "../src/emulated-verifier/caps";
|
||||
import { CapRegistry, mintCap } from "../src/emulated-verifier/caps";
|
||||
import { hasReadCap, targetOf } from "../src/model/nuri";
|
||||
import type { ReadCap } from "../src/model/types";
|
||||
|
||||
@@ -87,24 +87,52 @@ test("a cap received (learn) reads, exactly like one minted", () => {
|
||||
expect(bob.caps.capFor(doc)).toBe(cap);
|
||||
});
|
||||
|
||||
test("recordInPublicStore returns a cap-bearing link; reading it still means HOLDING it", () => {
|
||||
// A public store SERVES its documents' caps (`emulated-verifier/public-store.ts`).
|
||||
// This registry is one level below that: it records WHERE a document sits, and it
|
||||
// files a served cap apart from one that was minted or deposited — because the two
|
||||
// grant different things.
|
||||
test("markInPublicStore records where a document sits, and mints nothing", () => {
|
||||
const { caps, become } = registry("alice");
|
||||
const doc = "did:ng:o:public-doc";
|
||||
const link = caps.recordInPublicStore(doc);
|
||||
caps.markInPublicStore(doc);
|
||||
|
||||
expect(hasReadCap(link)).toBe(true);
|
||||
expect(targetOf(link)).toBe(doc);
|
||||
expect(caps.isInPublicStore(doc)).toBe(true);
|
||||
expect(caps.isInPublicStore("did:ng:o:other")).toBe(false);
|
||||
|
||||
// Publication is not a world-wide read grant: whoever HAS the URL reads it.
|
||||
// Marking is not holding: the fact is about the document, the cap is about a holder.
|
||||
expect(caps.capFor(doc)).toBeUndefined();
|
||||
become("bob");
|
||||
expect(caps.capFor(doc)).toBeUndefined();
|
||||
caps.learn(link); // bob received the link (e.g. from the discovery index)
|
||||
expect(caps.capFor(doc)).toBe(link);
|
||||
});
|
||||
|
||||
test("open(): a public document is published as a link, a private one is not", () => {
|
||||
test("a cap SERVED by a public store reads, and is refused a write", () => {
|
||||
const { caps, become } = registry("alice");
|
||||
const doc = "did:ng:o:public-doc";
|
||||
const served = mintCap(doc);
|
||||
|
||||
become("bob");
|
||||
caps.learnFromPublicStore(served);
|
||||
expect(caps.capFor(doc)).toBe(served); // he reads it, like any held cap
|
||||
expect(caps.isReadOnlyPublicCap(doc)).toBe(true); // …and only that
|
||||
|
||||
// A stronger claim supersedes it: a cap DEPOSITED for me is not the network's copy.
|
||||
caps.learn(served);
|
||||
expect(caps.isReadOnlyPublicCap(doc)).toBe(false);
|
||||
});
|
||||
|
||||
test("the owner of a public document is never read-only on it", () => {
|
||||
const { caps, become } = registry("alice");
|
||||
const doc = "did:ng:o:mine";
|
||||
caps.open(doc, "public"); // alice created it
|
||||
|
||||
// A third party fetching the same document must not affect her claim on it.
|
||||
become("bob");
|
||||
caps.learnFromPublicStore(mintCap(doc));
|
||||
expect(caps.isReadOnlyPublicCap(doc)).toBe(true);
|
||||
become("alice");
|
||||
expect(caps.isReadOnlyPublicCap(doc)).toBe(false);
|
||||
});
|
||||
|
||||
test("open(): a public document is marked as sitting in a public store, a private one is not", () => {
|
||||
const { caps } = registry();
|
||||
const pub = caps.open("did:ng:o:pub", "public");
|
||||
const prot = caps.open("did:ng:o:prot", "protected");
|
||||
|
||||
@@ -26,12 +26,22 @@ import {
|
||||
} from "../src/shared-wallet/account-registry";
|
||||
import { documentInboxAddress, openDocumentInbox } from "../src/emulated-verifier/branch-registers";
|
||||
import type { RegistrySession } from "../src/shared-wallet/account-registry";
|
||||
import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,hasCap,resetCaps,setCurrentUser,share,connectedUser} from "../src/polyfill";
|
||||
import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,resetCaps,setCurrentUser,share,connectedUser} from "../src/polyfill";
|
||||
import { post, postToDocument, read as readInbox } from "../src/surface/inbox";
|
||||
import { readUnion } from "../src/surface/read-model";
|
||||
import { sparqlUpdate } from "../src/surface/docs";
|
||||
import type { Nuri } from "../src/model/types";
|
||||
|
||||
/**
|
||||
* Do I hold this document's cap? Possession, asked of the internal registry — the
|
||||
* polyfill door stopped publishing this (see `polyfill.ts`), because as an app-facing
|
||||
* question it reads like "may I read this?" and a public store's document answers
|
||||
* `false` until something has asked for its cap.
|
||||
*/
|
||||
function hasCap(nuri: Nuri): boolean {
|
||||
return getCaps().capFor(nuri) !== undefined;
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
resetConfig();
|
||||
resetStoreRegistry();
|
||||
@@ -168,6 +178,10 @@ function makeFakeNg() {
|
||||
if (query.includes(`<${SHIM}:link>`)) {
|
||||
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:link`).map((q) => ({ c: { value: q.o } })) } };
|
||||
}
|
||||
// Header-branch `exposedReadCap` SELECT — what a PUBLIC store serves to anyone.
|
||||
if (query.includes(`<${SHIM}:exposedReadCap>`)) {
|
||||
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:exposedReadCap`).map((q) => ({ c: { value: q.o } })) } };
|
||||
}
|
||||
if (query.includes(`<${SHIM}:contains>`)) {
|
||||
return { results: { bindings: quads.filter((q) => q.g === anchor && q.p === `${SHIM}:contains`).map((q) => ({ e: { value: q.o } })) } };
|
||||
}
|
||||
@@ -207,7 +221,13 @@ async function readValues(docs: Nuri[], p: string): Promise<string[]> {
|
||||
|
||||
/**
|
||||
* Alice's world: a protected document holding a secret, and a public document that
|
||||
* REFERS to it by bare NURI. Returns what each actor could plausibly come to hold.
|
||||
* REFERS to it by bare NURI.
|
||||
*
|
||||
* What crosses to the other actors is **the bare reference of the public document and
|
||||
* nothing else** — no cap, no link with a key in it. That is the whole discipline of
|
||||
* this file: an application circulates references, and if a test had to hand a key
|
||||
* across an identity boundary through a JS variable, the feature it claims to prove
|
||||
* would have no path in any real application.
|
||||
*/
|
||||
async function aliceSetsUpHerDocuments() {
|
||||
setCurrentUser("alice");
|
||||
@@ -219,8 +239,7 @@ async function aliceSetsUpHerDocuments() {
|
||||
// grants nothing. This is the whole point of the scenario.
|
||||
await write(pubDoc, REFERS_TO, protDoc);
|
||||
|
||||
const pubLink = getCaps().capFor(pubDoc)!; // out-of-band: the test plays 'Alice sent Bob the link' // the shareable repo link of the public doc
|
||||
return { protDoc, pubDoc, pubLink };
|
||||
return { protDoc, pubDoc };
|
||||
}
|
||||
|
||||
/** Follow the reference found in the public document — what a reader actually does. */
|
||||
@@ -232,11 +251,11 @@ function referenceFoundIn(values: string[]): Nuri {
|
||||
|
||||
test("Bob: reads the public document, sees the reference, and cannot read through it", async () => {
|
||||
inject();
|
||||
const { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments();
|
||||
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
|
||||
|
||||
setCurrentUser("bob");
|
||||
// Bob was given the public document's link — "whoever has the URL reads it".
|
||||
getCaps().learn(pubLink);
|
||||
// Bob holds the BARE reference and nothing else. The document sits in a public
|
||||
// store, so the store serves him its cap — he never received a key from anyone.
|
||||
|
||||
// He reads the public document and finds the reference.
|
||||
const refs = await readValues([pubDoc], REFERS_TO);
|
||||
@@ -250,7 +269,7 @@ test("Bob: reads the public document, sees the reference, and cannot read throug
|
||||
|
||||
test("Charlie: same public document, same reference — and he reads through it", async () => {
|
||||
inject();
|
||||
const { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments();
|
||||
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
|
||||
const CHARLIE_INBOX = await userInbox("charlie", "protected");
|
||||
|
||||
// Alice decides Charlie may read that ONE document, and delivers its cap to his
|
||||
@@ -259,7 +278,6 @@ test("Charlie: same public document, same reference — and he reads through it"
|
||||
await share(protDoc, "charlie");
|
||||
|
||||
setCurrentUser("charlie");
|
||||
getCaps().learn(pubLink);
|
||||
await readInbox(CHARLIE_INBOX); // processing the inbox files the cap
|
||||
|
||||
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
|
||||
@@ -270,18 +288,16 @@ test("Charlie: same public document, same reference — and he reads through it"
|
||||
|
||||
test("the ONLY difference between Bob and Charlie is each of them holds", async () => {
|
||||
inject();
|
||||
const { protDoc, pubLink } = await aliceSetsUpHerDocuments();
|
||||
const { protDoc } = await aliceSetsUpHerDocuments();
|
||||
const CHARLIE_INBOX = await userInbox("charlie", "protected");
|
||||
|
||||
setCurrentUser("alice");
|
||||
await share(protDoc, "charlie");
|
||||
|
||||
setCurrentUser("bob");
|
||||
getCaps().learn(pubLink);
|
||||
const bobSees = await readValues([protDoc], SECRET);
|
||||
|
||||
setCurrentUser("charlie");
|
||||
getCaps().learn(pubLink);
|
||||
await readInbox(CHARLIE_INBOX);
|
||||
const charlieSees = await readValues([protDoc], SECRET);
|
||||
|
||||
@@ -293,11 +309,10 @@ test("the ONLY difference between Bob and Charlie is each of them holds", async
|
||||
// that was empty becomes full — with nothing re-declared and nobody re-authorized.
|
||||
test("dynamic: a cap delivered to Bob's inbox makes the refused document readable, and signals it", async () => {
|
||||
inject();
|
||||
const { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments();
|
||||
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
|
||||
const BOB_INBOX = await userInbox("bob", "protected");
|
||||
|
||||
setCurrentUser("bob");
|
||||
getCaps().learn(pubLink);
|
||||
const ref = referenceFoundIn(await readValues([pubDoc], REFERS_TO));
|
||||
|
||||
// Before: named, unreadable.
|
||||
@@ -331,16 +346,24 @@ test("dynamic: a cap delivered to Bob's inbox makes the refused document readabl
|
||||
unsub();
|
||||
});
|
||||
|
||||
test("a bare reference to the PUBLIC document is not enough either — the link is", async () => {
|
||||
// The property this whole batch exists for, stated on its own: WHERE a document sits
|
||||
// decides whether a bare reference is enough. Upstream a public store's repos are
|
||||
// served on the outer overlay and their ReadCap is downloaded from it
|
||||
// (`PublicRepoLinkV0`, `engine/net/src/types.rs:5098`) — so the same value transmitted
|
||||
// (a bare reference) yields a different outcome depending on the store, and never
|
||||
// because a key travelled.
|
||||
test("a bare reference is enough for a PUBLIC document, and not for a protected one", async () => {
|
||||
inject();
|
||||
const { protDoc, pubDoc, pubLink } = await aliceSetsUpHerDocuments();
|
||||
const { protDoc, pubDoc } = await aliceSetsUpHerDocuments();
|
||||
|
||||
setCurrentUser("bob");
|
||||
// Bob knows the public document's NURI but was never given its link.
|
||||
expect(await readValues([pubDoc], REFERS_TO)).toEqual([]);
|
||||
|
||||
getCaps().learn(pubLink);
|
||||
// Bob has been given nothing but the two NURIs.
|
||||
expect((await readValues([pubDoc], REFERS_TO)).length).toBe(1);
|
||||
expect(await readValues([protDoc], SECRET)).toEqual([]);
|
||||
|
||||
// And what he obtained for the public one is a READ grant, not a write right: a
|
||||
// public store serves its read cap, no store hands out the write cap.
|
||||
await expect(write(pubDoc, SECRET, "bob-was-here")).rejects.toThrow(/public store/i);
|
||||
});
|
||||
|
||||
// THE POINT OF THE LINK: a cap survives because it was APPLIED, not because the
|
||||
@@ -369,7 +392,10 @@ test("a Link is APPLIED durably: the cap survives with the inbox emptied", async
|
||||
setCurrentUser("alice");
|
||||
await createEntityDoc("alice", "private"); // re-arms: a cap exists again
|
||||
setCurrentUser("bob");
|
||||
expect(await readValues([protDoc], SECRET)).toEqual([]); // bob holds nothing yet
|
||||
// Checked SYNCHRONOUSLY, before yielding: `setCurrentUser` fires the connection work
|
||||
// itself, and that work is precisely what restores the cap. An awaited check here
|
||||
// would be asserting who won a race, not what the library does.
|
||||
expect(hasCap(protDoc)).toBe(false); // bob holds nothing yet
|
||||
|
||||
// Connecting restores it — from the User branch, since the inbox has nothing left.
|
||||
await connectedUser();
|
||||
@@ -396,13 +422,12 @@ test("a document has its own inbox: anyone deposits, only the owner reads", asyn
|
||||
const doc = await createEntityDoc("alice", "public");
|
||||
const aliceInbox = await openDocumentInbox(doc);
|
||||
expect(aliceInbox).not.toBe(await userInbox("alice", "protected"));
|
||||
const link = getCaps().capFor(doc)!; // the repo link alice circulates — links DO travel
|
||||
|
||||
// Bob RESOLVES the address himself, from the document. The only thing he is handed
|
||||
// is the link, which is the one thing the model says circulates. The address is not
|
||||
// passed to him — if it had to be, there would be no way for an app to get it.
|
||||
// Bob RESOLVES the address himself, from the BARE reference — the only thing he is
|
||||
// handed, and the only thing an application circulates. The document is in a public
|
||||
// store, so the store serves him its read cap; the address is not passed to him,
|
||||
// because if it had to be there would be no way for an app to get it.
|
||||
setCurrentUser("bob");
|
||||
getCaps().learn(link);
|
||||
const bobTarget = await documentInboxAddress(doc);
|
||||
expect(bobTarget).toBe(aliceInbox); // …and it is the SAME inbox alice reads
|
||||
await post(bobTarget!, { payload: { joining: true }, ts: 1 });
|
||||
@@ -422,11 +447,8 @@ test("opening an inbox on someone else's document is refused, not silently forke
|
||||
const doc = await createEntityDoc("alice", "public");
|
||||
const aliceInbox = await openDocumentInbox(doc);
|
||||
|
||||
const link = getCaps().capFor(doc)!;
|
||||
|
||||
// Bob holds the document — that is a READ right, and it is not ownership.
|
||||
// Bob can READ the document (it is in a public store) — and reading is not ownership.
|
||||
setCurrentUser("bob");
|
||||
getCaps().learn(link);
|
||||
await expect(openDocumentInbox(doc)).rejects.toThrow(/already has an inbox|you may only open an inbox/i);
|
||||
// The address he resolves is still alice's, so his deposits reach her.
|
||||
expect(await documentInboxAddress(doc)).toBe(aliceInbox);
|
||||
@@ -436,13 +458,11 @@ test("a fresh document has NO inbox — one belongs to one document, and only it
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const doc = await createEntityDoc("alice", "public");
|
||||
const link = getCaps().capFor(doc)!;
|
||||
|
||||
// Not "the owner's inbox by default": upstream an inbox belongs to exactly ONE repo
|
||||
// (the verifier routes by `inboxes: PubKey → RepoId`), so pointing several documents
|
||||
// at one inbox is a relation the model cannot express.
|
||||
setCurrentUser("bob");
|
||||
getCaps().learn(link);
|
||||
expect(await documentInboxAddress(doc)).toBeUndefined();
|
||||
// …and depositing THROWS rather than vanishing — a lost deposit is the bug this
|
||||
// whole path exists to close.
|
||||
@@ -456,9 +476,7 @@ test("opening an inbox publishes ONE address, and re-opening does not accumulate
|
||||
const dedicated = await openDocumentInbox(doc);
|
||||
expect(await openDocumentInbox(doc)).toBe(dedicated); // idempotent
|
||||
|
||||
const link = getCaps().capFor(doc)!;
|
||||
setCurrentUser("bob");
|
||||
getCaps().learn(link);
|
||||
expect(await documentInboxAddress(doc)).toBe(dedicated);
|
||||
// The deposit reaches the owner, addressed by the document alone.
|
||||
await postToDocument(doc, { payload: { signingUp: true } });
|
||||
|
||||
@@ -11,16 +11,17 @@
|
||||
* What the read filter then shows:
|
||||
* (a) a document nobody shared is unreadable, and stays unreadable for a third
|
||||
* party after a share to someone else — sharing is per-document, per-inbox;
|
||||
* (b) a bare reference grants NOTHING (naming is not reading), while the repo
|
||||
* link of a published document opens it for whoever receives it;
|
||||
* (b) the read-filtered VIEW decides on possession alone — it is synchronous, so it
|
||||
* asks no store anything (a public store WOULD serve its cap; that is proven on
|
||||
* the read paths, in `cross-user-access.test.ts`);
|
||||
* (c) switching identity SWITCHES heldByHolder — it never wipes one.
|
||||
*/
|
||||
import { getCaps } from "../src/shared-wallet/bootstrap";
|
||||
import { test, expect, mock, afterAll } from "bun:test";
|
||||
import { createEntityDoc, resetRegistryCache, userInbox, listMyEntityDocs } from "../src/shared-wallet/account-registry";
|
||||
import type { RegistrySession } from "../src/shared-wallet/account-registry";
|
||||
import type { ReadCap } from "../src/model/types";
|
||||
import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,hasCap,resetCaps,setCurrentUser,share} from "../src/polyfill";
|
||||
import type { Nuri, ReadCap } from "../src/model/types";
|
||||
import {configure,configureStoreRegistry,resetStoreRegistry,resetConfig,resetCaps,setCurrentUser,share} from "../src/polyfill";
|
||||
import { read as readInbox } from "../src/surface/inbox";
|
||||
import { filterReadable } from "../src/emulated-verifier/read-filter";
|
||||
|
||||
@@ -35,6 +36,12 @@ const SESSION: RegistrySession = { sessionId: "sid", privateStoreId: "PRIV" };
|
||||
const SHIM = "urn:ng-eventually:shim";
|
||||
const INBOX = "urn:ng-eventually:inbox";
|
||||
|
||||
/** Possession, asked of the internal registry — see `polyfill.ts` on why the door
|
||||
* stopped publishing it. */
|
||||
function hasCap(nuri: Nuri): boolean {
|
||||
return getCaps().capFor(nuri) !== undefined;
|
||||
}
|
||||
|
||||
interface Quad { g: string; s: string; p: string; o: string }
|
||||
|
||||
/** Reverse of the lib's escapeLiteral: single left-to-right pass over `\x`. */
|
||||
@@ -247,21 +254,30 @@ test("a cap deposit is absorbed, not surfaced as a consumer deposit", async () =
|
||||
expect(hasCap(doc)).toBe(true); // …but it landed in bob's held caps
|
||||
});
|
||||
|
||||
// (b) A bare reference grants nothing; the repo link of a published document does.
|
||||
test("(b) a bare reference reads nothing; the repo link of a published document opens it", async () => {
|
||||
// (b) The ORM read filter is PURE POSSESSION — it asks nothing of anyone.
|
||||
//
|
||||
// Note what this does NOT say: that a bare reference to a public document is
|
||||
// unreadable. It is readable, through the read paths, because a public store serves
|
||||
// its cap (`emulated-verifier/public-store.ts`, and `cross-user-access.test.ts` proves
|
||||
// it). This filter sits below that: it is synchronous, it decides from what the holder
|
||||
// holds AT THAT MOMENT, and a document whose cap was never obtained is filtered out
|
||||
// whatever store it sits in. The library's own read paths ask first; the reactive ORM
|
||||
// view has no door to ask through, and that limit is recorded in `read-filter.ts`.
|
||||
test("(b) the read-filtered view decides on possession alone, with no lookup", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
const pub = await createEntityDoc("alice", "public");
|
||||
const items = [item(pub, "u1")];
|
||||
expect(getCaps().isInPublicStore(pub)).toBe(true);
|
||||
const link = getCaps().capFor(pub)!;
|
||||
const cap = getCaps().capFor(pub)!;
|
||||
|
||||
// bob HAS the document's bare NURI (it is right there in `items`) and reads nothing.
|
||||
// bob HAS the document's bare NURI (it is right there in `items`), holds no cap for
|
||||
// it, and the view drops it — no question asked of any store.
|
||||
setCurrentUser("bob");
|
||||
expect(view(items)).toEqual([]);
|
||||
|
||||
// Receiving the repo link — what a discovery entry actually carries — opens it.
|
||||
getCaps().learn(link);
|
||||
// Once the cap IS among what he holds — however it got there — the same view yields it.
|
||||
getCaps().learn(cap);
|
||||
expect(view(items)).toEqual(["u1"]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* public-store.test.ts — the emulated *"downloaded from the outerOverlay"*, in isolation.
|
||||
*
|
||||
* `cross-user-access.test.ts` proves the consequence end to end (Bob reads Alice's
|
||||
* public document from a bare reference). This file pins the primitive itself: what it
|
||||
* asks, what it refuses, and when it says nothing at all.
|
||||
*/
|
||||
import { test, expect, mock, afterEach } from "bun:test";
|
||||
import { exposeReadCap, fetchReadCap, resetPublicStoreFetches } from "../src/emulated-verifier/public-store";
|
||||
import { mintCap } from "../src/emulated-verifier/caps";
|
||||
import { getCaps } from "../src/shared-wallet/bootstrap";
|
||||
import {
|
||||
configure,
|
||||
configureStoreRegistry,
|
||||
resetConfig,
|
||||
resetStoreRegistry,
|
||||
resetCaps,
|
||||
setCurrentUser,
|
||||
} from "../src/polyfill";
|
||||
import type { Nuri } from "../src/model/types";
|
||||
|
||||
const SHIM = "urn:ng-eventually:shim";
|
||||
const SESSION = { sessionId: "sid-ps", privateStoreId: "PRIV-PS" };
|
||||
|
||||
interface Quad { g: string; s: string; p: string; o: string }
|
||||
|
||||
/** A fake `ng` holding just enough to answer the Header-branch `exposedReadCap` query. */
|
||||
function inject() {
|
||||
const quads: Quad[] = [];
|
||||
const sparql_update = mock(async (...a: unknown[]) => {
|
||||
const query = a[1] as string;
|
||||
const anchor = a[2] as string;
|
||||
if (/^\s*DELETE WHERE/.test(query)) {
|
||||
for (let i = quads.length - 1; i >= 0; i--) if (quads[i]!.g === anchor) quads.splice(i, 1);
|
||||
return undefined;
|
||||
}
|
||||
const m = query.match(/<([^>]+)>\s+<([^>]+)>\s+"([^"]*)"/);
|
||||
if (m) quads.push({ g: anchor, s: m[1]!, p: m[2]!, o: m[3]! });
|
||||
return undefined;
|
||||
});
|
||||
const sparql_query = mock(async (...a: unknown[]) => ({
|
||||
results: {
|
||||
bindings: quads
|
||||
.filter((q) => q.g === (a[3] as string) && q.p === `${SHIM}:exposedReadCap`)
|
||||
.map((q) => ({ c: { value: q.o } })),
|
||||
},
|
||||
}));
|
||||
configure({ ng: { doc_create: mock(async () => "did:ng:o:x"), sparql_update, sparql_query } as any, useShape: (() => {}) as any });
|
||||
configureStoreRegistry({ getSession: async () => SESSION });
|
||||
resetCaps();
|
||||
resetPublicStoreFetches();
|
||||
setCurrentUser(null);
|
||||
return { sparql_query, quads };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
resetConfig();
|
||||
resetStoreRegistry();
|
||||
resetCaps();
|
||||
setCurrentUser(null);
|
||||
});
|
||||
|
||||
/** Arm the emulation without giving the current holder anything: some OTHER document. */
|
||||
function armEmulation(): void {
|
||||
setCurrentUser("someone-else");
|
||||
getCaps().mint("did:ng:o:unrelated");
|
||||
}
|
||||
|
||||
const PUB = "did:ng:o:pub" as Nuri;
|
||||
|
||||
test("a cap exposed on a document is downloaded by a holder that has nothing", async () => {
|
||||
inject();
|
||||
setCurrentUser("alice");
|
||||
await exposeReadCap(PUB, mintCap(PUB));
|
||||
|
||||
setCurrentUser("bob");
|
||||
armEmulation();
|
||||
setCurrentUser("bob");
|
||||
expect(getCaps().capFor(PUB)).toBeUndefined();
|
||||
|
||||
expect(await fetchReadCap(PUB)).toBe(true);
|
||||
expect(getCaps().capFor(PUB)).toBe(mintCap(PUB));
|
||||
// …and what he got is a READ grant, recorded as such.
|
||||
expect(getCaps().isReadOnlyPublicCap(PUB)).toBe(true);
|
||||
expect(getCaps().isInPublicStore(PUB)).toBe(true);
|
||||
});
|
||||
|
||||
test("a document that exposes nothing yields nothing — that is the normal case, not an error", async () => {
|
||||
inject();
|
||||
armEmulation();
|
||||
setCurrentUser("bob");
|
||||
expect(await fetchReadCap("did:ng:o:protected" as Nuri)).toBe(false);
|
||||
expect(getCaps().capFor("did:ng:o:protected" as Nuri)).toBeUndefined();
|
||||
});
|
||||
|
||||
// A document speaks for itself and for nothing else. Without this, whoever can write
|
||||
// into one public document could file caps for every document they care to name.
|
||||
test("a cap naming ANOTHER document is refused, not filed", async () => {
|
||||
const { quads } = inject();
|
||||
setCurrentUser("alice");
|
||||
await exposeReadCap(PUB, mintCap(PUB));
|
||||
// Forge the exposed value so it names a different document.
|
||||
quads[0]!.o = mintCap("did:ng:o:someone-elses" as Nuri);
|
||||
|
||||
armEmulation();
|
||||
setCurrentUser("bob");
|
||||
expect(await fetchReadCap(PUB)).toBe(false);
|
||||
expect(getCaps().capFor(PUB)).toBeUndefined();
|
||||
expect(getCaps().capFor("did:ng:o:someone-elses" as Nuri)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("inert while no cap has been issued at all — nothing to obtain, nothing asked", async () => {
|
||||
const { sparql_query } = inject();
|
||||
setCurrentUser("bob");
|
||||
expect(await fetchReadCap(PUB)).toBe(false);
|
||||
expect(sparql_query).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test("asked once per document: the outcome is memoised, in both directions", async () => {
|
||||
const { sparql_query } = inject();
|
||||
setCurrentUser("alice");
|
||||
await exposeReadCap(PUB, mintCap(PUB));
|
||||
armEmulation();
|
||||
setCurrentUser("bob");
|
||||
|
||||
await fetchReadCap(PUB);
|
||||
const afterHit = sparql_query.mock.calls.length;
|
||||
await fetchReadCap(PUB); // held now → not even the memo is consulted
|
||||
expect(sparql_query.mock.calls.length).toBe(afterHit);
|
||||
|
||||
const absent = "did:ng:o:nothing-here" as Nuri;
|
||||
await fetchReadCap(absent);
|
||||
const afterMiss = sparql_query.mock.calls.length;
|
||||
await fetchReadCap(absent); // a miss is remembered too
|
||||
expect(sparql_query.mock.calls.length).toBe(afterMiss);
|
||||
});
|
||||
|
||||
test("resetting the caps forgets the memo — a stale yes would hand back what is no longer held", async () => {
|
||||
const { sparql_query } = inject();
|
||||
setCurrentUser("alice");
|
||||
await exposeReadCap(PUB, mintCap(PUB));
|
||||
armEmulation();
|
||||
setCurrentUser("bob");
|
||||
await fetchReadCap(PUB);
|
||||
|
||||
resetCaps(); // also calls resetPublicStoreFetches
|
||||
armEmulation();
|
||||
setCurrentUser("bob");
|
||||
const before = sparql_query.mock.calls.length;
|
||||
expect(await fetchReadCap(PUB)).toBe(true);
|
||||
expect(sparql_query.mock.calls.length).toBeGreaterThan(before); // asked again
|
||||
});
|
||||
@@ -18,7 +18,7 @@ function setup(initial: string | null = "alice") {
|
||||
const before = holder;
|
||||
holder = "alice";
|
||||
caps.mint("did:ng:o:alice");
|
||||
const link = caps.recordInPublicStore("did:ng:o:public");
|
||||
const link = caps.open("did:ng:o:public", "public");
|
||||
holder = before;
|
||||
return { caps, link, become: (id: string | null) => (holder = id) };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user