fix: une liste qu'on ne peut pas ouvrir n'est pas une liste
listMyEntityDocs rendait la liste des documents même quand la lecture des clés échouait. Vu de l'appelant, une liste dont les documents ne s'ouvrent pas est INDISCERNABLE d'une liste dont ils s'ouvrent : rien ne signale la différence jusqu'à une lecture ultérieure qui revient vide, la cause étant alors loin derrière. C'était un choix délibéré — ne pas transformer un appel publié en levée, la liste étant déjà en main à ce moment-là. C'est précisément ce qui en faisait une demi-vérité plutôt qu'un raccourci. Et c'était le dernier membre connu de la famille qui a produit une panne chez une application cette semaine. Les deux lectures sur lesquelles l'appel repose remontent désormais : la branche Main dit quels documents sont là, la branche Store dit ce qui ouvre chacun. Un tableau vide signifie donc que ce compte n'a rien créé dans cette portée, et jamais que le store n'a pas été lu. readUserStore avalait le même échec pour son propre compte ; son autre appelant, ownsDocument, garde le comportement actuel par un catch explicite et documenté — toutes ses réponses étant des refus, il échoue en fermeture, ce qui est la règle qu'e32b6d0 avait posée. C'est un changement de comportement d'un appel publié, donc le contrat le dit, et docs/api-contract.md aussi. Au passage, deux citations pourries corrigées en citant un SYMBOLE plutôt qu'une ligne — types.rs:4251 désignait DialogRequest et non Link, index.d.ts:138 désignait const ng et non le type NG. Les douze autres références numériques du voisinage ont été vérifiées : aucune n'avait bougé.
This commit is contained in:
@@ -200,7 +200,8 @@ export async function publishInboxAddress(doc: Nuri, inbox: Nuri): Promise<void>
|
||||
* "I own nothing in this store" against "every document I own is invisible and nothing
|
||||
* said so". {@link restoreOwnCaps} is the caller that cannot survive the confusion, since
|
||||
* its whole contract is that the connection either did the restore or says it did not.
|
||||
* A caller that genuinely prefers to carry on catches it itself, and one of them does.
|
||||
* `listMyEntityDocs` used to be the exception; it stopped being one on 2026-08-17, because
|
||||
* a listing handed back without its keys is the same confusion one layer up (see it).
|
||||
*/
|
||||
// @provenance readStoreCaps kind=aligned level=1 ref=engine/repo/src/types.rs:AddRepoV0 — the replay of the Store branch — what reloads a store's documents with their keys
|
||||
export async function readStoreCaps(storeDoc: Nuri): Promise<ReadCap[]> {
|
||||
@@ -340,6 +341,15 @@ export async function documentInboxAddress(doc: Nuri): Promise<Nuri | undefined>
|
||||
* register of the documents it created — across the three scopes, which is the only
|
||||
* place that records authorship. Holding a cap is NOT ownership: a cap can be
|
||||
* received, and a recipient must not be able to open an inbox on what it merely reads.
|
||||
*
|
||||
* The ONE caller that catches `readUserStore` rather than propagating it, deliberately.
|
||||
* Every answer this function feeds is a REFUSAL — the write guard (`reach.mustNotAttempt`)
|
||||
* and `openDocumentInbox` both only ever ask it for permission — so a store that did not
|
||||
* answer closes the door, which is the safe side and writes nothing. That is the ruling
|
||||
* made when this family was swept (commit `e32b6d0`): the sites that fail CLOSED were
|
||||
* left, the ones that answer a caller with a fabricated value were not. The refusal names
|
||||
* the wrong reason ("not yours" for "could not tell"), which is the price, and it is
|
||||
* bounded — nobody acts on this answer except by being denied.
|
||||
*/
|
||||
// @provenance ownsDocument kind=aligned level=1 ref=engine/repo/src/types.rs:AddRepoV0 — authorship is what the Store branch records; holding a cap is NOT ownership, since a cap can be received
|
||||
export async function ownsDocument(doc: Nuri): Promise<boolean> {
|
||||
@@ -350,7 +360,11 @@ export async function ownsDocument(doc: Nuri): Promise<boolean> {
|
||||
for (const scope of ["public", "protected", "private"] as const) {
|
||||
const store = storeOf(record, scope);
|
||||
if (!store) continue;
|
||||
if ((await readUserStore(store)).includes(doc)) return true;
|
||||
try {
|
||||
if ((await readUserStore(store)).includes(doc)) return true;
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " ownsDocument: store unreadable:", error);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -163,7 +163,7 @@ import { makeNg } from "./surface/ng-proxy";
|
||||
/**
|
||||
* SDK-identical `ng` (wrapped). Drop-in replacement for `@ng-org/web`'s `ng`.
|
||||
*
|
||||
* Declared `NG` — upstream's own type for its `ng` (`index.d.ts:138`). It was published as
|
||||
* Declared `NG` — upstream's own type for its `ng` (`index.d.ts:NG`). It was published as
|
||||
* `Record<string, any>` until 2026-08-14, which announced a different surface from the one
|
||||
* it forwards to: an application got no completion and no check on any of the 88 members.
|
||||
*/
|
||||
|
||||
@@ -85,7 +85,7 @@ import { hasReadCap, isNuri } from "../model/nuri";
|
||||
import { mintCap } from "../emulated-verifier/caps";
|
||||
import { mustNotAttempt } from "../emulated-verifier/reach";
|
||||
import { accessLogPrefix, logStage, shortNuri } from "./access-log";
|
||||
import type { InboxScope, Nuri, ReadCap, Scope } from "../model/types";
|
||||
import type { InboxScope, Nuri, Scope } from "../model/types";
|
||||
|
||||
// --- sharedWalletShim model ----------------------------------------------
|
||||
|
||||
@@ -1219,7 +1219,20 @@ export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
|
||||
return entityNuri;
|
||||
}
|
||||
|
||||
/** Read the entity-document NURIs contained in ONE scope index document. */
|
||||
/**
|
||||
* Read the entity-document NURIs contained in ONE scope index document.
|
||||
*
|
||||
* **Propagates a failed read**, like {@link readStoreCaps} and `readLinks` next to it, and
|
||||
* for the same reason: empty and unreadable are the same value here — "this account put
|
||||
* nothing in this store" against "the store did not answer" — and the first is a fact an
|
||||
* application acts on while the second is a breakdown it must be told about. Swallowing the
|
||||
* difference is what {@link listMyEntityDocs} exists downstream of, and answering an empty
|
||||
* listing for a read that never happened is the failure-as-absence this library was bitten
|
||||
* by twice.
|
||||
*
|
||||
* A caller that genuinely prefers to carry on catches it itself, and one does: `ownsDocument`,
|
||||
* whose answer is a REFUSAL either way (see it).
|
||||
*/
|
||||
export async function readUserStore(indexDoc: Nuri): Promise<Nuri[]> {
|
||||
const s = await session();
|
||||
const out: Nuri[] = [];
|
||||
@@ -1231,24 +1244,20 @@ export async function readUserStore(indexDoc: Nuri): Promise<Nuri[]> {
|
||||
// before reading it. Idempotent per session; no-op with the unit fake ng. See
|
||||
// open-repo.ts.
|
||||
await ensureRepoOpen(indexDoc);
|
||||
try {
|
||||
const res = await sparqlQuery(
|
||||
s.sessionId,
|
||||
// NO explicit `GRAPH <…>` clause — read the anchored DEFAULT graph (see
|
||||
// the note in createEntityDoc). The `indexDoc` anchor scopes the query.
|
||||
`SELECT ?e WHERE { <${MAIN_BRANCH_SUBJECT}> <${P.contains}> ?e }`,
|
||||
undefined,
|
||||
indexDoc,
|
||||
"readUserStore",
|
||||
);
|
||||
for (const row of readBindings(res)) {
|
||||
// SPARQL boundary again (see canonicalDoc): narrow, do not cast — a stored
|
||||
// value that is not a NextGraph reference is not an entity document.
|
||||
const v = bindingValue(row, "e");
|
||||
if (v && isNuri(v)) out.push(v);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " readUserStore failed:", error);
|
||||
const res = await sparqlQuery(
|
||||
s.sessionId,
|
||||
// NO explicit `GRAPH <…>` clause — read the anchored DEFAULT graph (see
|
||||
// the note in createEntityDoc). The `indexDoc` anchor scopes the query.
|
||||
`SELECT ?e WHERE { <${MAIN_BRANCH_SUBJECT}> <${P.contains}> ?e }`,
|
||||
undefined,
|
||||
indexDoc,
|
||||
"readUserStore",
|
||||
);
|
||||
for (const row of readBindings(res)) {
|
||||
// SPARQL boundary again (see canonicalDoc): narrow, do not cast — a stored
|
||||
// value that is not a NextGraph reference is not an entity document.
|
||||
const v = bindingValue(row, "e");
|
||||
if (v && isNuri(v)) out.push(v);
|
||||
}
|
||||
logStage("readUserStore(" + shortNuri(indexDoc) + ") → " + out.length + " entities");
|
||||
return out;
|
||||
@@ -1276,6 +1285,20 @@ export async function userStoreDoc(id: string, scope: Scope): Promise<Nuri> {
|
||||
* and reads the contained NURIs — NO cross-account fan-out, so it never touches
|
||||
* another account's unsynced docs. This is the helper a consumer application uses
|
||||
* for its own my-entities path, instead of the all-accounts `listEntityDocs`.
|
||||
*
|
||||
* **Only a VERIFIED answer comes back as a listing.** Both reads it stands on propagate —
|
||||
* the store's Main branch, which says WHICH documents are there, and its Store branch,
|
||||
* which hands back the KEY of each. A listing is a promise that what it names can be
|
||||
* opened, and until 2026-08-17 the key read was tolerated here: the caller got the
|
||||
* documents without the means to read them, and nothing on the surface told the two apart.
|
||||
* From the outside a listing you can open and one you cannot are the same array — the
|
||||
* difference only appears at the next read, coming back empty, long after the cause is
|
||||
* gone. That is the shape of the outage this library shipped twice
|
||||
* (`resolveAccount` answering "no such account" for a read that failed, `connectedUser`
|
||||
* resolving over a restore that never ran), so the tolerance goes with it.
|
||||
*
|
||||
* Nothing to list is NOT a failure and stays silent: a store that answers with zero
|
||||
* documents means this account created none, which is a fact.
|
||||
*/
|
||||
export async function listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]> {
|
||||
const record = await ensureAccount(id);
|
||||
@@ -1294,18 +1317,11 @@ export async function listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]
|
||||
// holder when it runs — so hand the captured key back rather than trust that the
|
||||
// identity has not moved. See `caps.holderKey`.
|
||||
const holderKey = caps.holderKey();
|
||||
// Tolerant HERE, and only here: `readStoreCaps` propagates (see it, and `readLinks`
|
||||
// beside it), because the connection's restore cannot report success over a read that
|
||||
// never answered. This caller returns the LISTING, which it already has by now, and a
|
||||
// listing without its keys is what it answered before this distinction existed —
|
||||
// unchanged rather than quietly turned into a throw on a published call.
|
||||
let stored: ReadCap[] = [];
|
||||
try {
|
||||
stored = await readStoreCaps(store);
|
||||
} catch (error) {
|
||||
console.error(accessLogPrefix() + " readStoreCaps failed:", error);
|
||||
}
|
||||
for (const cap of stored) caps.learnFor(holderKey, cap);
|
||||
// PROPAGATES, like `readStoreCaps` itself and like `restoreOwnCaps` next door. Holding
|
||||
// the listing already is exactly why this must not swallow: the array is in hand, so
|
||||
// returning it costs nothing and says something false — these are yours, here they are.
|
||||
// A caller has no second question to ask that would reveal the keys are missing.
|
||||
for (const cap of await readStoreCaps(store)) caps.learnFor(holderKey, cap);
|
||||
// Which store a document sits in is a registry fact, not one recorded beside the
|
||||
// caps, so it is re-applied here. Marking only — the caps just came from the Store
|
||||
// branch above, and minting a second one beside them is the trap `holdOwnCap` warns
|
||||
|
||||
@@ -363,7 +363,7 @@ function capOfPayload(payload: unknown): ReadCap | null {
|
||||
* is the omission, not the length of the list.)*
|
||||
*
|
||||
* Do NOT read `InboxMsgContent::Link` as the intended channel either: it is a **unit
|
||||
* variant carrying nothing** (`engine/net/src/types.rs:4251`).
|
||||
* variant carrying nothing** (`engine/net/src/types.rs:InboxMsgContent::Link`).
|
||||
*
|
||||
* The shape is right; the implementation is absent at both ends, so we emulate it
|
||||
* meanwhile.
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* `listMyEntityDocs` answers a VERIFIED listing, or it surfaces — never a half-truth.
|
||||
*
|
||||
* The call stands on two reads of the same store document, one behind the other: the Main
|
||||
* branch says WHICH documents are in there, the Store branch hands back the KEY of each
|
||||
* (the emulated `AddRepo { read_cap }`). Both have to answer for the array it returns to
|
||||
* mean what a caller reads into it — *these are your documents, here they are*.
|
||||
*
|
||||
* Neither failure is visible from the outside, and that is the whole reason this file
|
||||
* exists. A listing whose keys never arrived is the SAME `Nuri[]` as one whose keys did:
|
||||
* the caller learns nothing at the call, and finds out at the next read, which comes back
|
||||
* empty with the cause long gone. An empty listing over a store that did not answer is
|
||||
* worse still — it says "you created nothing", and an application renders that.
|
||||
*
|
||||
* That is the shape this library shipped twice already: `resolveAccount` answering "no such
|
||||
* account" for a read that FAILED (`8c8ade7`), and `connectedUser` resolving over a restore
|
||||
* that never ran (`f6d1734`, which reached a consuming application). The listing was the
|
||||
* last member of the family, tolerated on the argument that this caller already holds the
|
||||
* array by the time the key read fails — which is exactly what makes returning it a lie
|
||||
* rather than a shortcut.
|
||||
*
|
||||
* ── The fault is the broker's, and it is one a real one produces ──────────
|
||||
* The two reads are two round-trips. Between them the connection can die — that is all
|
||||
* that is simulated here: the broker answers normally, then stops answering, and every
|
||||
* later query rejects the way the wasm binding rejects a `RepoNotFound`/transport error.
|
||||
* Nothing reaches into the library to make one of its functions throw artificially, and
|
||||
* nothing plants a state the wallet could not be in: the documents below were CREATED
|
||||
* through the surface, over the same wallet, in the session before.
|
||||
*/
|
||||
|
||||
import { test, expect, describe, mock, beforeEach } from "bun:test";
|
||||
import { configure, storeRegistry, readUnion } from "../src/index";
|
||||
import { configureStoreRegistry } from "../src/shared-wallet/bootstrap";
|
||||
import { sparqlUpdate } from "../src/surface/docs";
|
||||
import type { NgLike, Nuri, Scope, UseShapeLike } from "../src/model/types";
|
||||
import { SESSION, forgetEverything, makeWallet, signIn, type Quad } from "./wallet-fake";
|
||||
|
||||
const SHIM = "urn:ng-eventually:shim";
|
||||
/** The Main-branch read — "which documents are in this store". */
|
||||
const LISTING_READ = `<${SHIM}:contains>`;
|
||||
/** The Store-branch read — "and what is the key of each". */
|
||||
const KEY_READ = `<${SHIM}:readCap>`;
|
||||
|
||||
const TITLE = "urn:test:title";
|
||||
|
||||
/**
|
||||
* Wire the library onto `quads` over a broker that STOPS ANSWERING from the first query
|
||||
* `lostAt` accepts — that one included, and every one after it, for the rest of the page.
|
||||
*
|
||||
* A dropped connection is not selective, so neither is this: the switch decides *when* the
|
||||
* link dies, never *which* call is unlucky. Passing `() => false` is a broker that stays up.
|
||||
*/
|
||||
function bootPage(quads: Quad[], lostAt: (query: string) => boolean): void {
|
||||
const wallet = makeWallet(quads);
|
||||
let lost = false;
|
||||
const ng = {
|
||||
doc_create: wallet.doc_create,
|
||||
sparql_update: wallet.sparql_update,
|
||||
sparql_query: mock(async (...a: unknown[]) => {
|
||||
if (lost || lostAt(a[1] as string)) {
|
||||
lost = true;
|
||||
throw new Error("BrokerError: connection lost");
|
||||
}
|
||||
return wallet.sparql_query(...a);
|
||||
}),
|
||||
};
|
||||
configure({ ng: ng as unknown as NgLike, useShape: (() => {}) as unknown as UseShapeLike });
|
||||
configureStoreRegistry({ getSession: async () => SESSION, normalizeId: (id) => id.trim() });
|
||||
}
|
||||
|
||||
/**
|
||||
* The session before: alice signs in and creates one document, over a broker that works.
|
||||
*
|
||||
* Returns the reference she keeps, and the quads the wallet now holds — the durable half
|
||||
* that outlives the page.
|
||||
*/
|
||||
async function aFirstSessionThatCreated(scope: Scope): Promise<{ quads: Quad[]; note: Nuri }> {
|
||||
const quads: Quad[] = [];
|
||||
forgetEverything();
|
||||
bootPage(quads, () => false);
|
||||
await signIn("alice");
|
||||
const note = await storeRegistry.createEntityDoc(scope);
|
||||
await sparqlUpdate(
|
||||
SESSION.sessionId,
|
||||
`INSERT DATA { <urn:test:note> <${TITLE}> "the note" }`,
|
||||
note,
|
||||
);
|
||||
return { quads, note };
|
||||
}
|
||||
|
||||
/**
|
||||
* The page comes back over the same wallet and alice signs in — and only THEN does the link
|
||||
* die, on `read`. Signing in succeeds, so the failure lands inside the one call under test
|
||||
* rather than upstream of it, which is what makes the assertion about `listMyEntityDocs`.
|
||||
*/
|
||||
async function aSecondSessionLosingTheBrokerOn(quads: Quad[], read: string): Promise<void> {
|
||||
let armed = false;
|
||||
forgetEverything();
|
||||
bootPage(quads, (query) => armed && query.includes(read));
|
||||
await signIn("alice");
|
||||
armed = true;
|
||||
}
|
||||
|
||||
const ALL_SCOPES: Scope[] = ["public", "protected", "private"];
|
||||
|
||||
beforeEach(() => {
|
||||
forgetEverything();
|
||||
});
|
||||
|
||||
describe("listMyEntityDocs answers a verified listing or surfaces", () => {
|
||||
// The NORMAL case first, and it is not a formality: a call that threw unconditionally
|
||||
// would pass both failure branches below. This is what the two of them are a departure
|
||||
// from — the listing answers, and every document it names opens.
|
||||
for (const scope of ALL_SCOPES) {
|
||||
test(`[${scope}] a broker that answers gives a listing whose documents open`, async () => {
|
||||
const { quads, note } = await aFirstSessionThatCreated(scope);
|
||||
|
||||
forgetEverything();
|
||||
bootPage(quads, () => false);
|
||||
await signIn("alice");
|
||||
|
||||
expect(await storeRegistry.listMyEntityDocs(scope)).toContain(note);
|
||||
const subjects = await readUnion([note]);
|
||||
expect(subjects.map((s) => s.props[TITLE]?.[0])).toEqual(["the note"]);
|
||||
});
|
||||
}
|
||||
|
||||
// The tolerance this file was written to remove: the listing is IN HAND when the key read
|
||||
// fails, so returning it costs the library nothing and tells the caller something false.
|
||||
for (const scope of ALL_SCOPES) {
|
||||
test(`[${scope}] the keys not answering surfaces, instead of a keyless listing`, async () => {
|
||||
const { quads, note } = await aFirstSessionThatCreated(scope);
|
||||
await aSecondSessionLosingTheBrokerOn(quads, KEY_READ);
|
||||
|
||||
await expect(storeRegistry.listMyEntityDocs(scope)).rejects.toThrow(/BrokerError/);
|
||||
// And the point of the refusal, stated as the caller sees it: nothing came back that
|
||||
// could be mistaken for "here are your documents".
|
||||
const answered = await storeRegistry.listMyEntityDocs(scope).catch(() => null);
|
||||
expect(answered).toBeNull();
|
||||
expect(answered).not.toEqual([note]);
|
||||
});
|
||||
}
|
||||
|
||||
// The other half of the same promise, and the more dangerous value of the two: an empty
|
||||
// array reads as "this account created nothing", which an application renders as an
|
||||
// empty screen rather than as a breakdown.
|
||||
for (const scope of ALL_SCOPES) {
|
||||
test(`[${scope}] the listing itself not answering surfaces, instead of "you own nothing"`, async () => {
|
||||
const { quads } = await aFirstSessionThatCreated(scope);
|
||||
await aSecondSessionLosingTheBrokerOn(quads, LISTING_READ);
|
||||
|
||||
await expect(storeRegistry.listMyEntityDocs(scope)).rejects.toThrow(/BrokerError/);
|
||||
const answered = await storeRegistry.listMyEntityDocs(scope).catch(() => null);
|
||||
expect(answered).toBeNull();
|
||||
expect(answered).not.toEqual([]);
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user