0b936d2119
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.
154 lines
7.2 KiB
TypeScript
154 lines
7.2 KiB
TypeScript
/**
|
|
* Low-level document + SPARQL primitives.
|
|
*
|
|
* These call the real injected `ng` (`getConfig().ng`) directly — never the
|
|
* public `ng` proxy (`makeNg`). This is a validated hard constraint, not a style
|
|
* choice: the public `ng` is a JS `Proxy` over `@ng-org/web`'s iframe-RPC proxy,
|
|
* and layering our Proxy on top breaks `doc_create`'s `postMessage` marshaling
|
|
* with **`DataCloneError: function ... could not be cloned`** — the footgun this
|
|
* rule exists to prevent. Reaching the real `ng` held in the config avoids the
|
|
* double-proxy. Do not import from `./ng-proxy`.
|
|
*
|
|
* Signatures mirror the real `@ng-org/web` `ng` surface (verified against the
|
|
* app's storeRegistry usage), so this is a drop-in for those raw calls.
|
|
*/
|
|
|
|
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, 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
|
|
// through `sparqlQuery`, every write through `sparqlUpdate` (+ container creation
|
|
// through `docCreate`) — each ultimately calling the real injected `ng` here. The
|
|
// access log is therefore instrumented HERE so no access path escapes it. Callers
|
|
// pass a semantic `label` (readDoc|readUnion|listMyEntityDocs|writeEntity|deposit
|
|
// |…); it is a lib-internal probe param, NOT forwarded to the real `ng` (the docs
|
|
// primitives forward the exact SDK signature — see test/docs.test.ts). When the
|
|
// log is OFF (default) the extra param is inert and costs one boolean read.
|
|
|
|
/** Count rows in a raw SPARQL SELECT result, tolerant of the possible shapes. */
|
|
function rowCount(result: unknown): number {
|
|
if (!result) return 0;
|
|
if (Array.isArray(result)) return result.length;
|
|
const anyRes = result as { results?: { bindings?: unknown[] } };
|
|
return anyRes.results?.bindings?.length ?? 0;
|
|
}
|
|
|
|
/**
|
|
* Create one document → its NURI.
|
|
*
|
|
* Mirrors `ng.doc_create(session_id, crdt, cls, dest, store_repo?)`. For a graph
|
|
* document in the (shared) private store: `docCreate(sid, "Graph", "data:graph",
|
|
* "store")` (store_repo left undefined → private store).
|
|
*/
|
|
export async function docCreate(
|
|
sessionId: string,
|
|
crdt: string,
|
|
cls: string,
|
|
dest: string,
|
|
store?: unknown,
|
|
): Promise<Nuri> {
|
|
const { ng } = getConfig();
|
|
const nuri = await ng.doc_create(sessionId, crdt, cls, dest, store);
|
|
// The BROKER boundary. `ng` is a permissive property bag (`NgLike`), so what
|
|
// comes back is `any` and this function's `Promise<Nuri>` would otherwise be an
|
|
// unchecked promise — every typed NURI downstream rests on it. Validate once,
|
|
// here, rather than let a non-reference propagate as a document.
|
|
if (typeof nuri !== "string" || !isNuri(nuri)) {
|
|
throw new Error(
|
|
`[ng-eventually] docCreate: the broker returned something that is not a NextGraph reference: ${JSON.stringify(nuri)}`,
|
|
);
|
|
}
|
|
// **Creating a document gives you its cap.** Upstream that is not a courtesy but
|
|
// the mechanism: `doc_create` commits `AddRepo { read_cap }` to the store's Store
|
|
// branch, so the creator holds it from the first instant. Without this, a caller
|
|
// could create a document through this primitive and then be refused reading or
|
|
// writing it — which is what the e2e run against the live broker exposed.
|
|
//
|
|
// `physical.ts`'s counterpart deliberately does NOT do this: the shim's own
|
|
// documents belong to no virtual user, and `store-registry` files their caps
|
|
// itself, where it knows whose they are.
|
|
getCaps().mint(nuri);
|
|
// A container creation is a WRITE; the NURI only exists after the call.
|
|
logAccess("WRITE", nuri, "docCreate");
|
|
return nuri;
|
|
}
|
|
|
|
/**
|
|
* Run a SPARQL UPDATE (INSERT/DELETE DATA, etc.).
|
|
*
|
|
* Mirrors `ng.sparql_update(session_id, query, anchor?)`, where `anchor` is the
|
|
* document NURI the update is scoped/base'd to (optional).
|
|
*/
|
|
export async function sparqlUpdate(
|
|
sessionId: string,
|
|
query: string,
|
|
anchorLike?: NuriLike,
|
|
label = "sparqlUpdate",
|
|
): Promise<void> {
|
|
const { ng } = getConfig();
|
|
const anchor = anchorLike === undefined ? undefined : toNuri(anchorLike, "docs.sparqlUpdate");
|
|
// The boundary, in two questions that are NOT the same one.
|
|
//
|
|
// 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");
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Run a SPARQL SELECT/CONSTRUCT/ASK query → the raw SDK result.
|
|
*
|
|
* Mirrors `ng.sparql_query(session_id, query, base?, anchor?)`. `base` is the
|
|
* query base IRI (usually `undefined`); `anchor` is the document NURI to query.
|
|
*/
|
|
export async function sparqlQuery(
|
|
sessionId: string,
|
|
query: string,
|
|
base?: string,
|
|
anchorLike?: NuriLike,
|
|
label = "sparqlQuery",
|
|
): Promise<unknown> {
|
|
const { ng } = getConfig();
|
|
const anchor = anchorLike === undefined ? undefined : toNuri(anchorLike, "docs.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.
|
|
//
|
|
// 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
|
|
// rows under an identity that should see nothing) can be appended. Skip the
|
|
// rowCount work entirely when the log is off.
|
|
if (accessLogEnabled()) {
|
|
// `rows` here are raw RDF triple bindings (the SPARQL `?s ?p ?o` result), NOT
|
|
// domain objects — one document's entity is spread across several triple rows.
|
|
// Spell that out so the log isn't mistaken for an object count (the app-level
|
|
// object/shape count is logged separately by useShapeQuery → dataStats).
|
|
logAccess("READ", anchor ?? "(no anchor)", label, " → " + rowCount(result) + " triple-rows");
|
|
}
|
|
return result;
|
|
}
|