fix(caps): créer un document en donne le cap, + corriger 9 faits NextGraph
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`.
This commit is contained in:
@@ -234,9 +234,10 @@ Consequences a consumer must internalize:
|
||||
permission enum (`engine/repo/src/types.rs:1729`, `PermissionV0`) has `WriteAsync`/
|
||||
`WriteSync` but **no** add-only/append permission and **no** public-writable grant.
|
||||
To surface data to others without a shared write, use the **inbox** (any identity —
|
||||
even anonymous — can deposit into a document's native inbox; the owner materializes
|
||||
deposits) or make the document **public-readable** and let each identity own its own
|
||||
document.
|
||||
even anonymous — can deposit; only the owner reads back) or make the document
|
||||
**public-readable** and let each identity own its own document. *Per-document inboxes
|
||||
are this library's, not the engine's: upstream only the public and protected store
|
||||
repos carry one (`engine/verifier/src/site.rs:128,149`).*
|
||||
|
||||
The consumer asks the SDK for what it needs and trusts the result; it does not
|
||||
construct NURIs, pick union-vs-anchor, or reason about caps. The domain-shaped list
|
||||
|
||||
@@ -224,7 +224,9 @@ async function main(): Promise<void> {
|
||||
// ── inbox ───────────────────────────────────────────────────────────────
|
||||
console.log("\n── inbox ──");
|
||||
await step("inbox post → read round-trip", async () => {
|
||||
const r = await sdk<any>(frame, "inboxPostRead", { k: "a" }, { k: "b" });
|
||||
// Fresh user per run: an inbox is stable for its owner, so a reused id would
|
||||
// read back the previous runs' deposits too (the wallet persists).
|
||||
const r = await sdk<any>(frame, "inboxPostRead", "@inbox-user-" + Date.now(), { k: "a" }, { k: "b" });
|
||||
const payloads = (r.deposits || []).map((d: any) => JSON.stringify(d.payload));
|
||||
check(
|
||||
"post then read returns both deposits (sorted)",
|
||||
@@ -233,7 +235,7 @@ async function main(): Promise<void> {
|
||||
);
|
||||
});
|
||||
await step("inbox watch fires on deposit", async () => {
|
||||
await sdk(frame, "inboxWatchStart");
|
||||
await sdk(frame, "inboxWatchStart", "@watcher-" + Date.now());
|
||||
await frame.waitForFunction(() => (window as any).__sdk.inboxWatchState().fires >= 1, { timeout: 20000 });
|
||||
const base = await sdkGet<any>(frame, "inboxWatchState");
|
||||
await sdk(frame, "inboxWatchDeposit", { landed: true });
|
||||
@@ -329,7 +331,7 @@ async function main(): Promise<void> {
|
||||
);
|
||||
});
|
||||
await step("shareCap: a cap delivered to an inbox reveals the doc", async () => {
|
||||
const r = await sdk<any>(frame, "capsShareCap");
|
||||
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",
|
||||
r.before === 0 && r.after === 1 && r.surfacedDeposits === 0,
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
getCaps,
|
||||
resetCaps,
|
||||
shareCap,
|
||||
connectedUser,
|
||||
} from "@ng-eventually/client/polyfill";
|
||||
import {
|
||||
docs,
|
||||
@@ -368,10 +369,18 @@ const identity = new IdentityStore(
|
||||
},
|
||||
|
||||
// ── inbox ────────────────────────────────────────────────────────────────
|
||||
async inboxPostRead(payloadA: unknown, payloadB: unknown) {
|
||||
const s = await sessionReady;
|
||||
const target = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
|
||||
setCurrentUser("inbox-user");
|
||||
/**
|
||||
* `id` must be FRESH per run (run.ts stamps it). A user's inbox is stable over time —
|
||||
* that is the point of it — so re-running against a reused id accumulates the previous
|
||||
* runs' deposits on a persistent wallet, and the exact-count assertion drifts. The
|
||||
* thing to make disposable is the user, not the inbox.
|
||||
*/
|
||||
async inboxPostRead(id: string, payloadA: unknown, payloadB: unknown) {
|
||||
// The target must be that user's OWN inbox, not an arbitrary document: you may
|
||||
// deposit into anyone's, you may only read your own. Establishing the identity
|
||||
// FIRST is what makes `walletInbox` resolve (and file) that user's inbox.
|
||||
setCurrentUser(id);
|
||||
const target = await storeRegistry.walletInbox(id);
|
||||
await inbox.post(target, { payload: payloadA, from: null, ts: 1000 });
|
||||
await inbox.post(target, { payload: payloadB, from: null, ts: 2000 });
|
||||
const deposits = await inbox.read(target);
|
||||
@@ -380,9 +389,12 @@ const identity = new IdentityStore(
|
||||
},
|
||||
// watch (doc_subscribe-based) fires when a deposit lands.
|
||||
_inboxWatch: { fires: 0, lastLen: -1, unsub: () => {}, target: "" },
|
||||
async inboxWatchStart() {
|
||||
const s = await sessionReady;
|
||||
const target = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
|
||||
/** `id` fresh per run, for the same reason as {@link inboxPostRead}. */
|
||||
async inboxWatchStart(id: string) {
|
||||
// Watching an inbox is READING it continuously, so the watcher stays connected
|
||||
// for the whole probe — including across `inboxWatchDeposit`.
|
||||
setCurrentUser(id);
|
||||
const target = await storeRegistry.walletInbox(id);
|
||||
const rec = { fires: 0, lastLen: -1, unsub: () => {}, target };
|
||||
(window as any).__sdk._inboxWatch = rec;
|
||||
rec.unsub = inbox.watch(target, (deposits) => {
|
||||
@@ -393,9 +405,7 @@ const identity = new IdentityStore(
|
||||
},
|
||||
async inboxWatchDeposit(payload: unknown) {
|
||||
const rec = (window as any).__sdk._inboxWatch;
|
||||
setCurrentUser("watcher");
|
||||
await inbox.post(rec.target, { payload, from: null });
|
||||
setCurrentUser(null);
|
||||
},
|
||||
inboxWatchState() {
|
||||
const r = (window as any).__sdk._inboxWatch;
|
||||
@@ -403,6 +413,7 @@ const identity = new IdentityStore(
|
||||
},
|
||||
inboxWatchStop() {
|
||||
(window as any).__sdk._inboxWatch.unsub();
|
||||
setCurrentUser(null);
|
||||
},
|
||||
// spoof guard: depositing as another principal throws.
|
||||
async inboxSpoofGuard() {
|
||||
@@ -440,10 +451,16 @@ const identity = new IdentityStore(
|
||||
},
|
||||
async entityDocsBounded(idA: string, idB: string) {
|
||||
storeRegistry.resetRegistryCache();
|
||||
// Each user creates its OWN documents: you act as one virtual user at a time,
|
||||
// and the caps of what you create are filed under the identity you were acting
|
||||
// as. Creating B's document while connected as A is not a thing the model has.
|
||||
setCurrentUser(idA);
|
||||
const dA1 = await storeRegistry.createEntityDoc(idA, "public");
|
||||
const dA2 = await storeRegistry.createEntityDoc(idA, "public");
|
||||
setCurrentUser(idB);
|
||||
const dB1 = await storeRegistry.createEntityDoc(idB, "public");
|
||||
// listMyEntityDocs(A) → only A's docs (poll: the index append can lag).
|
||||
setCurrentUser(idA);
|
||||
let listA: string[] = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
storeRegistry.resetRegistryCache();
|
||||
@@ -451,6 +468,7 @@ const identity = new IdentityStore(
|
||||
if (listA.includes(dA1) && listA.includes(dA2)) break;
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
}
|
||||
setCurrentUser(null);
|
||||
return {
|
||||
dA1, dA2, dB1,
|
||||
listA,
|
||||
@@ -474,6 +492,10 @@ const identity = new IdentityStore(
|
||||
async reconnectSeed(id: string, scope: "public" | "protected" | "private") {
|
||||
storeRegistry.resetRegistryCache();
|
||||
const s = await sessionReady;
|
||||
// Seed AS the user whose document this is — otherwise the cap of the created
|
||||
// document is filed under nobody and the very session that created it is
|
||||
// refused the write below.
|
||||
setCurrentUser(id);
|
||||
const entityNuri = await storeRegistry.createEntityDoc(id, scope);
|
||||
const marker = "recon-" + Date.now();
|
||||
await docs.sparqlUpdate(
|
||||
@@ -505,11 +527,24 @@ const identity = new IdentityStore(
|
||||
* fail-without-the-fix proof (see run.ts's reconnection step comment).
|
||||
*/
|
||||
async reconnectRead(id: string, scope: "public" | "protected" | "private", entityNuri: string, marker: string) {
|
||||
// DIAGNOSTIC: a RAW anchored read of the entity doc with NO open at all, first
|
||||
// thing in the fresh session — reports how many rows the bare anchored query
|
||||
// resolves for a not-yet-opened repo (the premise: 0 until opened). Uses the
|
||||
// low-level docs primitive directly, bypassing readUnion's open step.
|
||||
const s = session ?? (await sessionReady);
|
||||
// A fresh session holds nothing in memory: connect AS the user so the caps are
|
||||
// restored from the durable registers (own documents from the Store branches,
|
||||
// received ones from the Links) before anything is read back.
|
||||
setCurrentUser(id);
|
||||
await connectedUser();
|
||||
|
||||
storeRegistry.resetRegistryCache();
|
||||
const listed = await storeRegistry.listMyEntityDocs(id, scope);
|
||||
// DIAGNOSTIC: a RAW anchored read of the entity doc with NO open — reports how
|
||||
// many rows the bare anchored query resolves for a not-yet-opened repo (the
|
||||
// premise: 0 until opened). Uses the low-level docs primitive directly, bypassing
|
||||
// readUnion's open step.
|
||||
//
|
||||
// Placed AFTER `listMyEntityDocs`, which is what restores the caps of the user's
|
||||
// own documents from the Store branch. Before it, the boundary refuses the read
|
||||
// and the probe would measure the guard rather than the open — a number that
|
||||
// looks like the premise holding while proving nothing about it.
|
||||
let rawRowCount = -1;
|
||||
try {
|
||||
const raw: any = await docs.sparqlQuery(s.session_id, "SELECT ?s ?p ?o WHERE { ?s ?p ?o }", undefined, asNuri(entityNuri));
|
||||
@@ -517,9 +552,6 @@ const identity = new IdentityStore(
|
||||
} catch (e: any) {
|
||||
rawRowCount = -2; // threw (e.g. RepoNotFound / InvalidNuri)
|
||||
}
|
||||
|
||||
storeRegistry.resetRegistryCache();
|
||||
const listed = await storeRegistry.listMyEntityDocs(id, scope);
|
||||
const subjects = await readModel.readUnion(listed.length ? listed : [asNuri(entityNuri)]);
|
||||
const markers: string[] = [];
|
||||
for (const subj of subjects) {
|
||||
@@ -770,24 +802,30 @@ const identity = new IdentityStore(
|
||||
* "receive" operation exists, and no principal is ever named to the registry.
|
||||
* Runs against the REAL broker inbox document, so it exercises the whole path.
|
||||
*/
|
||||
async capsShareCap() {
|
||||
async capsShareCap(friendId: string) {
|
||||
const s = await sessionReady;
|
||||
resetCaps();
|
||||
const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
|
||||
const friendInbox = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
|
||||
injectedSetItems = [{ "@graph": doc, "@id": "1", v: "shared-item" }];
|
||||
// The recipient's OWN inbox — the address a cap is delivered to. Resolved while
|
||||
// connected as them, since that is who owns it and who may later read it.
|
||||
// `friendId` is fresh per run: this test's assertions survive accumulated caps, but
|
||||
// the recipient's durable Links would grow run after run on a persistent wallet,
|
||||
// making every later `connectedUser()` re-apply a longer and longer history.
|
||||
setCurrentUser(friendId);
|
||||
const friendInbox = await storeRegistry.walletInbox(friendId);
|
||||
|
||||
setCurrentUser("owner-O");
|
||||
const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined);
|
||||
injectedSetItems = [{ "@graph": doc, "@id": "1", v: "shared-item" }];
|
||||
getCaps().open(doc, "protected");
|
||||
const cap = capFor(doc)!;
|
||||
|
||||
setCurrentUser("friend");
|
||||
setCurrentUser(friendId);
|
||||
const before = [...(libUseShape(null, null) as Iterable<any>)].length;
|
||||
|
||||
setCurrentUser("owner-O");
|
||||
await shareCap(cap, friendInbox);
|
||||
|
||||
setCurrentUser("friend");
|
||||
setCurrentUser(friendId);
|
||||
const absorbed = await inbox.read(friendInbox); // processing it applies the cap
|
||||
const after = [...(libUseShape(null, null) as Iterable<any>)].length;
|
||||
|
||||
|
||||
@@ -26,8 +26,10 @@
|
||||
*
|
||||
* This class is the in-memory record of what the connected holder currently holds:
|
||||
* upstream's local user storage, not a durable register. The durable ones are
|
||||
* emulated in `store-registry.ts` (`fileOwnCaps` for created documents, `addLink` /
|
||||
* `readLinks` for received ones), and `connect.ts` restores from them.
|
||||
* emulated in `store-registry.ts` — for created documents, `holdOwnCap` writes and
|
||||
* `readStoreCaps` reads the Store branch back; for received ones, `addLink` /
|
||||
* `readLinks` on the User branch. `connect.ts` restores the Links at connection;
|
||||
* the own-document caps come back through `listMyEntityDocs`.
|
||||
*
|
||||
* One record PER holder, since one shared wallet hosts every identity. Switching
|
||||
* identity therefore SWITCHES records; it never wipes one (a wipe would make
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* app's storeRegistry usage), so this is a drop-in for those raw calls.
|
||||
*/
|
||||
|
||||
import { getConfig } from "./polyfill";
|
||||
import { getCaps, getConfig } from "./polyfill";
|
||||
import { logAccess, enabled as accessLogEnabled } from "./access-log";
|
||||
import { isNuri } from "./nuri";
|
||||
import { assertMayReach } from "./reach";
|
||||
@@ -61,6 +61,16 @@ export async function docCreate(
|
||||
`[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;
|
||||
|
||||
@@ -229,10 +229,19 @@ function capOfPayload(payload: unknown): ReadCap | null {
|
||||
* Reaching several recipients means calling this once per inbox, which is what the
|
||||
* real model does too: each delivery is sealed to one recipient.
|
||||
*
|
||||
* Upstream this path is a GAP, not a disagreement: the field exists
|
||||
* (`ContactDetails.read_cap`) but its message construction is `unimplemented!()`
|
||||
* and the receiver discards the cap. The shape is right; the implementation is
|
||||
* absent, so we emulate it meanwhile.
|
||||
* Upstream this path is a GAP, not a disagreement — verified at both ends:
|
||||
* - the field exists, `ContactDetails.read_cap: Option<ReadCap>`
|
||||
* (`engine/net/src/types.rs:4233`), but building a message that carries one is
|
||||
* `read_cap: if with_readcap { unimplemented!() }` (`types.rs:3786`);
|
||||
* - and the receiver ignores it: `InboxMsgContent::ContactDetails` writes only
|
||||
* `ng:site`/`ng:protected` + `ng:*_inbox` into a fresh contact document
|
||||
* (`engine/verifier/src/inbox_processor.rs:778-830`), never `details.read_cap`.
|
||||
*
|
||||
* Do NOT read `InboxMsgContent::Link` as the intended channel either: it is a **unit
|
||||
* variant carrying nothing** (`engine/net/src/types.rs:4251`).
|
||||
*
|
||||
* The shape is right; the implementation is absent at both ends, so we emulate it
|
||||
* meanwhile.
|
||||
*/
|
||||
export async function shareCap(cap: ReadCap, toInbox: Nuri): Promise<void> {
|
||||
if (!hasReadCap(cap)) {
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* Stopgap / polyfill-era. Emulates the target infrastructure — where each
|
||||
* user owns their own public/protected/private stores — on top of one shared
|
||||
* wallet. It creates one document per (account × scope) inside that shared
|
||||
* wallet (via the `docs.docCreate` primitive), so the `scope`
|
||||
* wallet (via `physical.physicalCreate` — the UNGUARDED primitive, since a store is
|
||||
* machinery and its cap is filed here, where it is known whose it is; the public
|
||||
* `docs.docCreate` files the creator's cap itself), so the `scope`
|
||||
* (`public|protected|private`) is a logical attribute tracked here, not a
|
||||
* physical NextGraph store. Isolation is enforced by the app layer + the
|
||||
* emulated cap registry, not by crypto.
|
||||
@@ -1015,12 +1017,21 @@ export async function userStoreDoc(id: string, scope: Scope): Promise<Nuri> {
|
||||
* The inbox of a document this user owns — resolved, and created on first ask.
|
||||
*
|
||||
* Upstream a repo carries `inbox: Option<PrivKey>` (`engine/repo/src/repo.rs:126`):
|
||||
* an inbox is a keypair on the document, whose PRIVATE half its owner holds. That
|
||||
* half is recorded with `AddInboxCap { repo_id, overlay, priv_key }` — *"into the
|
||||
* user branch, so that a user can share with all its device"*
|
||||
* (`engine/repo/src/types.rs:1969-1981`), the same branch that carries `AddLink`.
|
||||
* So "which inboxes may I read" is answered by the User branch, and that is what
|
||||
* this emulates.
|
||||
* an inbox is a keypair on the repo, whose PRIVATE half its owner holds. That half is
|
||||
* recorded with `AddInboxCap { repo_id, overlay, priv_key }` — *"into the user branch,
|
||||
* so that a user can share with all its device"* (`engine/repo/src/types.rs:1973`), the
|
||||
* same branch that carries `AddLink`. So "which inboxes may I read" is answered by the
|
||||
* User branch, and that is what this emulates.
|
||||
*
|
||||
* **This ANTICIPATES: no document has an inbox upstream today.** `new_store_default`
|
||||
* attaches one only `if !private` (`engine/verifier/src/verifier.rs:2994`), and
|
||||
* `doc_create` goes through `new_repo_default`, which leaves `inbox: None`
|
||||
* (`engine/repo/src/repo.rs:574`) — the only two `AddInboxCap` commits in the engine
|
||||
* are for the public and protected STORE repos (`engine/verifier/src/site.rs:128,149`).
|
||||
* What is verified is the SHAPE: the record is keyed by `repo_id`, so it accommodates
|
||||
* an inbox on any repo. What is not verified is that anything upstream will create one
|
||||
* per document. At migration this either becomes native or stays emulated here; either
|
||||
* way the consumer-facing act is unchanged.
|
||||
*
|
||||
* Lazy on purpose: creating an inbox document for every entity up front would
|
||||
* double every `createEntityDoc` for inboxes most documents never receive anything
|
||||
|
||||
Reference in New Issue
Block a user