refactor(api): l'application ne nomme plus son identité — elle l'apprend

Lot C de la revue adverse. Cinq corrections, dont une qui change la forme de la surface.

**L'application ne pouvait pas obtenir son identité par l'API.** `ensureIdentity()`
rendait `void`, `getCurrentUser` n'est plus publié — et pourtant `createEntityDoc(id, …)`
et `listMyEntityDocs(id, …)` l'exigeaient. L'app d'exemple s'en sortait en lisant
`localStorage["ng-eventually:identity"]` et le paramètre `?ng-id`, deux constantes
PRIVÉES du portail d'accès. Une frontière qu'aucun consommateur ne devrait voir, et
encore moins dont il devrait dépendre.

Vérifié au niveau 2 avant de trancher : `session_start(wallet_name, user_id)` prend
l'identité — donc en amont l'application la DÉTIENT, elle la tient du portefeuille
qu'elle a ouvert. Ici c'est le portail qui la choisit, donc c'est au portail de la
rendre. Deux changements, tous deux vers la cible :

- `ensureIdentity()` rend l'identité qu'il a établie ;
- `createEntityDoc(scope)`, `listMyEntityDocs(scope)`, `resolveWriteGraph(scope)`
  perdent leur paramètre d'identité. En amont `doc_create(session_id, …)` ne porte
  aucun utilisateur : une session EST celle d'un utilisateur. Passer la sienne à chaque
  appel de placement était un geste sans successeur.

L'application garde l'identité pour l'afficher, et ne la passe plus à rien.

**`inbox.share` provisionnait un destinataire inexistant.** Une faute de frappe créait
les trois stores et l'inbox de ce nom, et la clé atterrissait où personne ne regarde —
sans la moindre erreur. En amont on ne peut pas viser un nom qu'on invente : un dépôt est
scellé vers une clé d'inbox qui vous est parvenue par un contact entrant. Refuser est
fidèle ; provisionner était l'invention.

**`createEntityDoc` avalait l'échec de ses deux écritures** et rendait quand même une
référence — le document n'était dans aucun store, donc la session suivante ne le listait
pas et sa lecture rendait vide, en silence. Il lève maintenant, comme `doc_create` en
amont propage les siennes.

**Deux entrées prenaient `Nuri` au lieu de `NuriLike`** (`inbox.watch`,
`openDocumentInbox`), ce qui contredisait la raison même pour laquelle aucune garde de
type n'est publiée. Et **deux messages d'erreur nommaient des symboles retirés**
(`storeRegistry.documentInboxAddress`, `setCurrentUser`) : une erreur qui envoie vers une
fonction inexistante est pire qu'une erreur muette.

Contrat d'API et feuille `contract_sdk-surface` mis à jour ; `readForDocument` et le refus
de `share` obtiennent enfin leur règle en §9.

189 tests unitaires, e2e 40/40 et applicatif 12/12.
This commit is contained in:
Sylvain Duchesne
2026-08-10 10:31:15 +02:00
parent b7dc8ca2c3
commit cdc09a1a1d
10 changed files with 193 additions and 91 deletions
@@ -37,7 +37,7 @@ import { sparqlQuery } from "../surface/docs";
import { registerUpdate } from "./register-write";
import { getCaps, getCurrentUser } from "../shared-wallet/bootstrap";
import { escapeLiteral } from "../surface/sparql";
import { hasReadCap, isNuri } from "../model/nuri";
import { hasReadCap, isNuri, toNuri } from "../model/nuri";
import { mustNotAttempt } from "./reach";
import { fetchReadCap } from "./public-store";
import { ensureRepoOpen } from "./open-repo";
@@ -60,7 +60,7 @@ import {
recordInbox,
type VirtualUserRecord,
} from "../shared-wallet/account-registry";
import type { InboxScope, Nuri, ReadCap, Scope } from "../model/types";
import type { InboxScope, Nuri, NuriLike, ReadCap, Scope } from "../model/types";
/**
* Does `nuri` belong to the CURRENT wallet as one of its inboxes? The predicate the
@@ -453,7 +453,10 @@ export async function readLinks(): Promise<ReadCap[]> {
* owner. To deposit into someone else's document, resolve
* {@link documentInboxAddress} and `inbox.post` into it.
*/
export async function openDocumentInbox(doc: Nuri): Promise<Nuri> {
export async function openDocumentInbox(docLike: NuriLike): Promise<Nuri> {
// Permissive in, precise out — see `model/nuri.ts`. Published through
// `surface/placement.ts`, so it is a door an application types against.
const doc = toNuri(docLike, "openDocumentInbox");
const holder = getCurrentUser();
if (holder === null) throw new Error("[ng-eventually] openDocumentInbox: no identity is set");
const known = (await readInboxCapsFor(doc)) ?? null;
@@ -480,8 +483,8 @@ export async function openDocumentInbox(doc: Nuri): Promise<Nuri> {
if (!(await ownsDocument(doc))) {
throw new Error(
"[ng-eventually] openDocumentInbox: refused — you may only open an inbox on a document " +
`you own. Deposit into its published address instead (storeRegistry.documentInboxAddress ` +
`then inbox.post): ${JSON.stringify(doc)}`,
"you own. To reach its owner, name the DOCUMENT: `inbox.postToDocument(doc, …)`, " +
`which resolves the address itself: ${JSON.stringify(doc)}`,
);
}
+19 -4
View File
@@ -46,6 +46,7 @@ import {
setCurrentUser,
} from "./bootstrap";
import { connectedUser } from "../emulated-verifier/connect";
import type { PrincipalId } from "../model/types";
/**
* Normalize an identifier the SAME way the shim keys accounts on.
@@ -220,14 +221,27 @@ function askForIdentity(cfg: SharedWalletConfig): Promise<string> {
*
* A returning user never sees the gate: the identifier survives the broker round-trip in
* the URL, and a plain reload finds it in storage.
*
* **It RETURNS the identity it settled**, and that is not a convenience — it is the only
* way an application can know who it is. Upstream the question does not arise: an app
* passes `user_id` to `session_start(wallet_name, user_id)` (`@ng-org/web`), having got it
* from the wallet it opened, so it holds its identity before the session exists. Here the
* GATE chooses it, so the gate is what hands it back. Without this the example
* application had to read the gate's own private storage key — a boundary no consumer
* should be able to see, let alone depend on.
*/
export async function ensureIdentity(): Promise<void> {
if (getCurrentUser() !== null) return connected();
export async function ensureIdentity(): Promise<PrincipalId> {
const already = getCurrentUser();
if (already !== null) {
await connected();
return already;
}
const known = storedIdentity();
if (known) {
setCurrentUser(known);
return connected();
await connected();
return known;
}
const cfg = getConfig().sharedWallet;
@@ -250,7 +264,8 @@ export async function ensureIdentity(): Promise<void> {
const normalized = normalizeIdentity(chosen);
rememberIdentity(normalized);
setCurrentUser(normalized);
return connected();
await connected();
return normalized;
}
/**
@@ -999,7 +999,16 @@ export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
"createEntityDoc",
);
} catch (error) {
// Not swallowed: a document absent from its store's listing is not a document. The
// next session's `listMyEntityDocs` omits it and `readUnion` returns nothing for it,
// so the caller has written content into a NURI that will read empty forever —
// silently. Upstream `doc_create` propagates its own commit failures
// (`engine/verifier/src/request_processor.rs:698,714`).
console.error(accessLogPrefix() + " createEntityDoc index append failed:", error);
throw new Error(
"[ng-eventually] createEntityDoc: the document was created but could not be recorded " +
`in its store, so it would be lost to the next session: ${String(error)}`,
);
}
// The second write: `AddRepo { read_cap }` on the Store branch. A separate
// statement, not a second triple in the one above, because upstream these are two
@@ -1017,7 +1026,13 @@ export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri> {
"createEntityDoc:addRepo",
);
} catch (error) {
// Same reasoning as the listing above: without its cap on the Store branch the
// creator cannot re-open its own document on a later session.
console.error(accessLogPrefix() + " createEntityDoc cap append failed:", error);
throw new Error(
"[ng-eventually] createEntityDoc: the document was created but its key could not be " +
`recorded, so its own creator would lose it: ${String(error)}`,
);
}
// …and the creator holds THAT cap for this session.
holdOwnCap(id, scope, entityNuri, cap);
+25 -5
View File
@@ -32,7 +32,7 @@ import { subscribeDoc } from "./subscribe";
import { ensureRepoOpen } from "../emulated-verifier/open-repo";
import { getCaps, getCurrentUser, getStoreRegistryDeps } from "../shared-wallet/bootstrap";
import { addLink, documentInboxAddress, isOwnInbox } from "../emulated-verifier/branch-registers";
import { userInbox, isKnownInbox } from "../shared-wallet/account-registry";
import { userInbox, isKnownInbox, resolveAccount } from "../shared-wallet/account-registry";
import { escapeLiteral } from "./sparql";
import { hasReadCap, toNuri } from "../model/nuri";
import {
@@ -229,8 +229,10 @@ export async function post(targetInboxLike: NuriLike, opts: PostOptions): Promis
* nowhere for this to go. Throwing rather than returning quietly is the whole lesson of
* this path: a deposit that vanishes without an error is worse than a refusal, and it
* is exactly the bug per-document inboxes shipped with
* (`docs/briefs/2026-08-03-document-inbox-addressing.md`). Call
* `storeRegistry.documentInboxAddress(doc)` first when "no inbox" is an expected case.
* (`docs/briefs/2026-08-03-document-inbox-addressing.md`). When "no inbox" is an expected
* case for the caller, catch it — there is deliberately no published way to ask an
* address in advance, because an application must name a document or a person, never an
* inbox.
*/
export async function postToDocument(docLike: NuriLike, opts: PostOptions): Promise<void> {
const doc = toNuri(docLike, "inbox.postToDocument");
@@ -337,6 +339,21 @@ export async function share(doc: NuriLike, toUser: string): Promise<void> {
// (The private store has no inbox at all — `new_store_default` attaches one only
// `if !private`, `verifier.rs:2994` — hence `InboxScope`, which makes "the private
// inbox" unwritable rather than merely empty.)
// The recipient must EXIST. `userInbox` provisions on first sight, so sharing with a
// name nobody has signed in as used to succeed silently: it minted that name's three
// stores and an inbox, and the cap landed where nobody will ever look. A mistyped
// recipient is the ordinary case, and it produced no error at all.
//
// Upstream you cannot address a name you invented: a deposit is sealed to an inbox
// PUBKEY (`InboxMsg::new`, `engine/net/src/types.rs:4299`) that reached you through an
// inbound `ContactDetails` — someone has to have reached you first. Refusing is the
// faithful behaviour; provisioning was the invention.
if ((await resolveAccount(toUser)) === null) {
throw new Error(
`[ng-eventually] inbox.share: no such recipient — nobody has signed in as ` +
`${JSON.stringify(toUser)}. Sharing does not create the person you share with.`,
);
}
await post(await userInbox(toUser, "protected"), { payload: { kind: LINK_KIND, cap } });
}
@@ -372,7 +389,7 @@ async function assertOwnInbox(targetInbox: Nuri, op: string): Promise<void> {
if (getCurrentUser() === null) {
throw new Error(
`[ng-eventually] inbox.${op}: no identity is set, so no inbox belongs to this ` +
"session — call setCurrentUser() first. Depositing (post/share) stays open.",
"session — call `ensureIdentity()` first. Depositing (post/share) stays open.",
);
}
if (!(await isOwnInbox(targetInbox))) {
@@ -570,10 +587,13 @@ export async function processInbox(targetInboxLike: NuriLike): Promise<Deposit[]
* the ORM fan-out hang — see {@link subscribeDoc}.)
*/
export function watch(
targetInbox: Nuri,
targetInboxLike: NuriLike,
onDeposits: (deposits: Deposit[]) => void,
_opts?: { intervalMs?: number },
): () => void {
// Permissive in, precise out — like every other public entry. It took a bare `Nuri`
// until 2026-08-10, which contradicted the very reason no type guard is published.
const targetInbox = toNuri(targetInboxLike, "inbox.watch");
let stopped = false;
let lastCount = -1;
+50 -11
View File
@@ -27,18 +27,57 @@
* application is the check: it must never name an inbox.
*/
export {
/** Create a document for ONE entity in `scope`, and record it in that scope's store. */
createEntityDoc,
/** The entity documents this user owns in `scope` — with their caps recovered. */
listMyEntityDocs,
/** The NURI to use as a READ scope for `scope` (what `useShape` is pointed at). */
resolveScopeGraph,
/** The NURI where GROUPED entities of `scope` are written (no per-entity document). */
resolveWriteGraph,
} from "../shared-wallet/account-registry";
/**
* **No IDENTITY parameter here either**, and that is the same reasoning one step further
* (2026-08-10). The registry's own functions take `(id, scope)` — machinery needs to name
* a user. An application does not: upstream `doc_create(session_id, …)` carries no user at
* all, because a session IS one user's. Passing one's own identity to every placement
* call is therefore a gesture with no successor, and it forced the application to KNOW
* its identity — which it could only do by reading the access gate's private storage key.
*
* The identity comes from `ensureIdentity()`, which returns it; these calls take the
* connected one from the session, exactly as the real SDK will.
*/
import {
createEntityDoc as registryCreateEntityDoc,
listMyEntityDocs as registryListMyEntityDocs,
resolveWriteGraph as registryResolveWriteGraph,
} from "../shared-wallet/account-registry";
import { getCurrentUser } from "../shared-wallet/bootstrap";
import type { Nuri, Scope } from "../model/types";
/** WHO is acting. Absent means the application has not signed in yet — a caller error,
* and one worth naming rather than turning into an empty result. */
function connectedIdentity(op: string): string {
const id = getCurrentUser();
if (id === null) {
throw new Error(
`[ng-eventually] storeRegistry.${op}: no identity is set. Call \`ensureIdentity()\` ` +
"first — it settles who you are and returns it.",
);
}
return id;
}
/** Create a document for ONE entity in `scope`, and record it in that scope's store. */
export async function createEntityDoc(scope: Scope): Promise<Nuri> {
return registryCreateEntityDoc(connectedIdentity("createEntityDoc"), scope);
}
/** The entity documents this user owns in `scope` — with their caps recovered. */
export async function listMyEntityDocs(scope: Scope): Promise<Nuri[]> {
return registryListMyEntityDocs(connectedIdentity("listMyEntityDocs"), scope);
}
/** The NURI where GROUPED entities of `scope` are written (no per-entity document). */
export async function resolveWriteGraph(scope: Scope): Promise<Nuri> {
return registryResolveWriteGraph(connectedIdentity("resolveWriteGraph"), scope);
}
/** The NURI to use as a READ scope for `scope` (what `useShape` is pointed at). */
export { resolveScopeGraph } from "../shared-wallet/account-registry";
/** Open an inbox on a document you OWN, so others can deposit into it. */
export { openDocumentInbox } from "../emulated-verifier/branch-registers";
// No `linkTo` here, and its absence is deliberate (it existed 2026-08-06, one day).