12eba6eea6
Un audit de la surface contre la source amont en a trouvé cinq ; voici les
quatre mécaniques. La cinquième — l'adresse d'inbox, qui traverse sept symboles
— relève du dessin et reste ouverte.
L'identifiant de session bloquait. Amont le déclare string | number
(sdk/js/web/src/index.ts:16) et le binding désérialise un u64 ; nous exigions
une chaîne. Une application ne pouvait donc pas passer la valeur que le SDK
venait de lui remettre. Élargi à ce qu'amont déclare, sur toute la chaîne, et
jamais converti : une chaîne échoue pour de vrai (Deserialization error of
session_id JsValue("1"), observé).
sparqlUpdate annonçait Promise<void> alors qu'il relayait DÉJÀ les commits.
C'était donc un mensonge de typage, pas un comportement — et la doublure de test
qui rendait undefined, un état que le vrai broker ne produit jamais, est ce qui
l'a laissé sans contradicteur.
ng était publié en Record<string, any>, ce qui perdait les 88 membres typés
d'amont — 88, pas 77 : le chiffre de notre propre documentation était faux.
Et materialize, second nom publié de read, sans appelant ni contrepartie amont,
est retiré.
docs/api-contract.md qualifiait docs.* de passthrough « 1:1 ». C'était faux sur
les deux premiers points. Corrigé, pas complété : un document qui se déclare
vérifié et qui ment est pire qu'un document absent, parce qu'on cesse d'aller
voir.
Une déviation assumée : amont type le retour en any, interdit ici ; on rend
unknown, comme sparqlQuery le fait déjà pour le même any amont.
169 lines
8.3 KiB
TypeScript
169 lines
8.3 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).
|
|
*/
|
|
// The session id is `string | number` because that is what upstream DECLARES for it
|
|
// (`Session.session_id`, `sdk/js/web/src/index.ts:16` and the installed `index.d.ts:266`),
|
|
// and the wasm side deserializes it as a `u64` (`sdk/js/lib-wasm/src/lib.rs:352-358`
|
|
// `sparql_query`, `:452-457` `sparql_update`, `:1575` `doc_create`). It only ever TRAVELS
|
|
// through this chain — never normalise it, and above all never stringify it: a JS string
|
|
// fails that deserialization, observed live as
|
|
// `Deserialization error of session_id JsValue("1")`.
|
|
export async function docCreate(
|
|
sessionId: string | number,
|
|
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).
|
|
*
|
|
* Returns what the real method returns: upstream answers the COMMITS the update
|
|
* produced (`sdk/js/lib-wasm/src/lib.rs:481-483` serialises `AppResponseV0::Commits`;
|
|
* the installed `index.d.ts:297` types it `Promise<any>`). This function already
|
|
* relayed that value at runtime — only the declared type said `void`, which threw the
|
|
* answer away for every caller. Typed `unknown` rather than `any`, exactly as
|
|
* {@link sparqlQuery} already renders the same upstream `Promise<any>`: the value is
|
|
* the broker's to shape, and a caller that ignores it is unaffected.
|
|
*/
|
|
export async function sparqlUpdate(
|
|
sessionId: string | number,
|
|
query: string,
|
|
anchorLike?: NuriLike,
|
|
label = "sparqlUpdate",
|
|
): Promise<unknown> {
|
|
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 | number,
|
|
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;
|
|
}
|