Files
ng-eventually/packages/polyfill/src/emulated-verifier/public-store.ts
T
Sylvain Duchesne 737729c9ce refactor: le paquet s'appelle polyfill, « SDK » désigne celui de NextGraph
Le nom @ng-eventually/sdk entrait en collision avec le SDK de NextGraph, dont
ce paquet est justement un polyfill. Impossible d'écrire « le SDK » sans lever
l'ambiguïté à chaque phrase — et le contrat publié, lu par une application,
était le pire endroit pour laisser traîner ça.

packages/sdk → packages/polyfill, @ng-eventually/sdk → @ng-eventually/polyfill,
contract_sdk-surface → contract_polyfill-surface, e2e/sdk-entry.ts →
e2e/polyfill-entry.ts, docs/sdk-reference.md → docs/polyfill-reference.md.

Les occurrences de « SDK » qui désignent celui de NextGraph restent intactes,
y compris les chemins dans nextgraph-rs (sdk/js/orm, sdk/js/web). Le tri s'est
fait occurrence par occurrence, pas par substitution.

Le contrat énonce désormais son identité en une phrase : « This package is a
polyfill of NextGraph's SDK. »
2026-08-10 17:14:25 +02:00

221 lines
11 KiB
TypeScript

/**
* public-store — a document in a PUBLIC store gives up its ReadCap to whoever asks.
*
* ── The upstream mechanism this emulates — a DECLARED model, so a BET ──────
* **Labelled VERIFIED until 2026-08-10, wrongly.** What supports it is a doc COMMENT
* on a type nothing constructs — a statement of intent, not of behaviour — and this
* repo's own rules say both halves of that: a comment describing the current state is
* not the intent, and an absent implementation is not evidence either. So this is a
* bet, and `docs/document-links.md` § 5 and `docs/readcap-and-nuri-model.md` § 4sexies
* already called it one. This header now says the same word.
*
* What IS read in source: `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 whoever asks — and whom the peer brokers allow**.
* That condition is part of the mechanism, not decoration: the broker decides, by
* pinning the outer overlay (`expose_outer`, `engine/broker/src/server_storage/core/overlay.rs:103-133`).
* Nothing about the reader; everything about where the document sits and how brokers
* serve it.
*
* And what is NOT wired, which is precisely why this is a bet: both `PinRepo`
* constructors hard-code `expose_outer: false`
* (`engine/net/src/actors/client/pin_repo.rs:51,79`), so no client ever asks for the
* exposure; and `ExtTopicSyncReq` — the anonymous branch-sync such a link needs — is
* declared and falls into `unimplemented!()` (`engine/net/src/types.rs:4523,4533`).
* The emulation follows the model the engine DECLARES, in a place the engine does not
* yet serve. That is this library's intended posture, named here as the bet it is.
*
* ── 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 hands the key to whoever has the reference, where the
* brokers serving that store allow it (see the condition above).
*
* 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 { registerUpdate } from "./register-write";
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 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 `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<ReadCap | null>>();
/** 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 registerUpdate(
s.sessionId,
`DELETE WHERE { <${HEADER_BRANCH_SUBJECT}> <${P.exposedReadCap}> ?c }`,
doc,
"exposeReadCap:clear",
);
await registerUpdate(
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);
}
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. 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
// 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) 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 null;
}
/**
* 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)));
}