88f396a7ac
Le trou trouvé par l'e2e contre le broker en ligne : `docs.docCreate` ne
déposait aucun cap pour le créateur, donc un consommateur pouvait créer un
document par la primitive publique puis se voir refuser sa lecture et son
écriture. En amont c'est impossible — `doc_create` commite
`AddRepo { read_cap }` sur la branche Store du store, et le créateur le détient
dès le premier instant. Délibérément non répliqué dans `physical.ts` : les
documents du shim n'appartiennent à aucun utilisateur virtuel, et
`store-registry` classe leurs caps là où il sait à qui ils sont.
e2e : 22 passés / 8 échoués → 39 / 0. Les autres échecs venaient du harnais,
qui agissait comme une seconde identité sans l'établir, ou lisait un document
quelconque comme une inbox. Un run e2e contre un wallet persistant exige une
identité FRAÎCHE par run : `walletInbox(id)` rend l'inbox stable pour son
propriétaire — c'est son intérêt — donc un id fixe accumule les dépôts des runs
précédents (vert au 2e run, rouge au 3e, à code inchangé).
Revue adverse de la documentation, 9 défauts, tous vérifiés à la source avant
correction :
- « chaque document a une inbox native » est FAUX. Seuls les repos de store
public et protected en ont une (`site.rs:128,149`) ; `new_store_default` n'en
pose que `if !private` et `doc_create` laisse `inbox: None`. Le store privé
n'en a pas non plus. Ce que le code fait est donc une ANTICIPATION — assumée
et notée comme telle dans `documentInbox`, le brief et l'ADR discovery. Ce qui
est vérifié, c'est la FORME : `AddInboxCapV0` est clé par `repo_id`.
- `InboxMsgContent::Link` est une variante unit sans charge utile : l'inbox ne
transporte aucun ReadCap. `shareCap` était juste et le reste ; ses citations
sont complétées aux deux bouts (émetteur `unimplemented!()`, récepteur qui
ignore `details.read_cap`).
- les 3 stores appartiennent au user (`SiteV0`), pas au wallet ;
- le TODO `OpenRepo` ne concerne pas la lecture cross-wallet — il est dans
`open_branch_`, après `RepoNotFound` ; charger par cap, c'est
`load_repo_from_read_cap` ;
- la liste des méthodes JS était un sous-ensemble présenté comme la surface
(77 exportées) ;
- `outbox-log.ts` n'enregistre rien : il inspecte l'outbox du SDK ;
- l'ADR private-store-nuri-scope citait `orm_start_graph` au présent, remplacé
par `ensureRepoOpen` ;
- l'incident write-loss plaçait `disconnections_sender.send` dans `broker.rs` ;
- la section « Apps & services » n'a aucune citation et rien ne lui correspond
dans le moteur : marquée à re-confirmer, pas à citer comme vérifiée.
Aussi : `fileOwnCaps` n'existe plus (`holdOwnCap` / `readStoreCaps` /
`fileOwnStructure`) — pointeur mort corrigé dans `caps.ts`.
156 lines
6.9 KiB
TypeScript
156 lines
6.9 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 "./polyfill";
|
|
import { logAccess, enabled as accessLogEnabled } from "./access-log";
|
|
import { isNuri } from "./nuri";
|
|
import { assertMayReach } from "./reach";
|
|
import type { Nuri } from "./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,
|
|
anchor?: Nuri,
|
|
label = "sparqlUpdate",
|
|
): Promise<void> {
|
|
const { ng } = getConfig();
|
|
// The boundary: a write may only touch what the connected virtual user reaches.
|
|
if (anchor !== undefined) assertMayReach(anchor, "docs.sparqlUpdate");
|
|
// `label` is a lib-internal access-log tag, NOT forwarded to `ng`.
|
|
logAccess("WRITE", anchor ?? "(no anchor)", label);
|
|
return ng.sparql_update(sessionId, query, anchor);
|
|
}
|
|
|
|
/**
|
|
* Deposit into ANOTHER virtual user's inbox — the one write that legitimately
|
|
* crosses the boundary, and therefore the one that skips {@link assertMayReach}.
|
|
*
|
|
* Why this is a separate primitive rather than a flag: depositing is not "a write
|
|
* that happens to be allowed", it is a different act. You cannot read the inbox you
|
|
* deposit into, you hold no cap for it, and you get nothing back — upstream it is an
|
|
* anonymous sealed box. Naming the exception makes it greppable and keeps
|
|
* {@link sparqlUpdate} free of a bypass that would otherwise be reusable for
|
|
* anything.
|
|
*
|
|
* The recipient's ownership of the inbox is what bounds this: `inbox.post` is the
|
|
* only caller, and reading is guarded separately (`inbox.read`).
|
|
*/
|
|
export async function depositInto(
|
|
sessionId: string,
|
|
query: string,
|
|
targetInbox: Nuri,
|
|
label = "deposit",
|
|
): Promise<void> {
|
|
const { ng } = getConfig();
|
|
logAccess("WRITE", targetInbox, label, " (cross-user deposit)");
|
|
return ng.sparql_update(sessionId, query, targetInbox);
|
|
}
|
|
|
|
/**
|
|
* Run a SPARQL SELECT/CONSTRUCT/ASK query → the raw SDK result.
|
|
*
|
|
* 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,
|
|
anchor?: Nuri,
|
|
label = "sparqlQuery",
|
|
): Promise<unknown> {
|
|
const { ng } = getConfig();
|
|
// 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");
|
|
// `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;
|
|
}
|