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.
|
||||
|
||||
Reference in New Issue
Block a user