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
@@ -33,7 +33,7 @@ export interface EventuallyConfig {
}
// ── identity — one await before the application renders ──────────────────
export async function ensureIdentity(): Promise<void>;
export async function ensureIdentity(): Promise<PrincipalId>; // returns who you are
// ── addressing ───────────────────────────────────────────────────────────
export type Nuri = `did:ng:${string}`;
@@ -43,12 +43,12 @@ export type Scope = "public" | "protected" | "private";
export type InboxScope = "public" | "protected";
// ── placement: where an application's documents live ─────────────────────
export const storeRegistry: {
createEntityDoc(id: string, scope: Scope): Promise<Nuri>;
listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]>;
export const storeRegistry: { // no identity parameter — the session is one user's
createEntityDoc(scope: Scope): Promise<Nuri>;
listMyEntityDocs(scope: Scope): Promise<Nuri[]>;
resolveScopeGraph(scope: Scope): Promise<Nuri>;
resolveWriteGraph(id: string, scope: Scope): Promise<Nuri>;
openDocumentInbox(doc: Nuri): Promise<Nuri>;
resolveWriteGraph(scope: Scope): Promise<Nuri>;
openDocumentInbox(doc: NuriLike): Promise<Nuri>;
};
// ── reading ──────────────────────────────────────────────────────────────
@@ -69,13 +69,13 @@ export const docs: {
// ── inbox: giving to read, and depositing ────────────────────────────────
export const inbox: {
share(doc: NuriLike, toUser: string): Promise<void>; // give a reader the key
post(targetInbox: Nuri, opts: PostOptions): Promise<void>;
post(targetInbox: NuriLike, opts: PostOptions): Promise<void>;
postToDocument(doc: NuriLike, opts: PostOptions): Promise<void>;
read(targetInbox: Nuri): Promise<Deposit[]>; // only your own
read(targetInbox: NuriLike): Promise<Deposit[]>; // only your own
readForDocument(doc: NuriLike): Promise<Deposit[]>;
readSynced(targetInbox: Nuri): Promise<Deposit[]>;
processInbox(targetInbox: Nuri): Promise<Deposit[]>;
watch(targetInbox: Nuri, onDeposits: (d: Deposit[]) => void): () => void;
readSynced(targetInbox: NuriLike): Promise<Deposit[]>;
processInbox(targetInbox: NuriLike): Promise<Deposit[]>;
watch(targetInbox: NuriLike, onDeposits: (d: Deposit[]) => void): () => void;
materialize: typeof read;
};
export interface Deposit { from: PrincipalId | null; payload: unknown; ts: number }
@@ -104,7 +104,11 @@ export function initNg(...args: any[]): any;
**An inbox is read only by its owner.** Anyone may deposit; only the owner reads.
**`ensureIdentity()` is the whole of signing in.** It resolves who you are and waits for the connection work (restoring what others shared with you, draining your inboxes). It takes **no identifier**, deliberately — naming your own identity is the part that disappears, so it is not in the signature. After it resolves, what was shared with you is readable.
**`ensureIdentity()` is the whole of signing in, and it tells you who you are.** It settles the identity, waits for the connection work (restoring what others shared with you, draining your inboxes), and **returns the identity**. It takes no identifier — naming your own identity is the part that disappears — but it hands one back, because knowing which user you are is something an application legitimately has upstream too. Keep it for display; **no call takes it**: a session belongs to one user, so placement is named by scope alone.
**Sharing names a person who exists.** `inbox.share(doc, toUser)` refuses a recipient nobody has signed in as, rather than creating them — you cannot address a name you invented.
**A failed creation fails.** `createEntityDoc` throws if the document cannot be recorded in its store, instead of returning a reference that would read empty forever.
**One polyfill-era call.** `configure` is the only published symbol with no counterpart in the target, and therefore the whole of what an application deletes at migration. Everything else is replaced in place by the real SDK.
+15 -8
View File
@@ -97,7 +97,7 @@ Divergence: none in behaviour (pure forwarding), but the wrapper erases the para
### Today — `@ng-eventually/sdk`
```ts
export async function ensureIdentity(): Promise<void>; // shared-wallet/access-gate.ts
export async function ensureIdentity(): Promise<PrincipalId>; // shared-wallet/access-gate.ts
export interface SharedWalletConfig { fileUrl: string; password: string; importUrl?: string }
```
@@ -109,7 +109,7 @@ One call, before the application renders. It resolves the identity from the URL
The substance is pure scaffolding. Every step it performs exists only because one wallet hosts several identities: upstream a user opens THEIR wallet, it contains THEIR site (`SensitiveWalletV0.personal_identity()`, `engine/wallet/src/types.rs:576-579`), and `session_start(wallet_name, user_id)` takes an id that came FROM the wallet. There is nothing to name and nothing to choose. The step that takes an identifier is the one that inverts the model, and it is the reason the whole gate is scaffolding.
The call site is a different matter. An application still has to wait for a session before it renders, and that will still be one awaited call at the same place. So the signature was designed to survive: **it takes no identifier**, deliberately — naming one is the part that disappears, so it must not appear in the parameters. The day the wallet supplies the identity, `ensureIdentity` resolves without showing anything and the caller's line is unchanged.
The call site is a different matter. An application still has to wait for a session before it renders, and that will still be one awaited call at the same place. So the signature was designed to survive: **it takes no identifier and RETURNS one**, deliberately. Naming an identity is the part that disappears, so it must not be a parameter; but knowing which identity you are is something an application legitimately has upstream — it passes `user_id` to `session_start(wallet_name, user_id)` (`index.d.ts:276`), having got it from the wallet it opened. Here the gate chooses it, so the gate hands it back. Without that, the example application had to read the gate's own private storage key.
What a consumer must NOT conclude:
@@ -390,7 +390,7 @@ export async function readSynced(targetInbox: Nuri): Promise<Deposit[]>;
export async function processInbox(targetInbox: Nuri): Promise<Deposit[]>;
// inbox.ts:492
export function watch(
targetInbox: Nuri,
targetInbox: NuriLike,
onDeposits: (deposits: Deposit[]) => void,
_opts?: { intervalMs?: number },
): () => void;
@@ -410,6 +410,8 @@ Consequences per function:
- `post` / `postToDocument` — the sender-side act exists in the model (the broker routes `InboxPost` natively, `engine/net/src/server_broker.rs`); its JS surface does not. **The future SDK's name and signature are unknown**`docs/nextgraph-current-state.md:187` records that nothing is announced. `postToDocument`'s resolution step (find the document's inbox address) rides on a **deliberate divergence**: this lib PUBLISHES the address on the document (Header-branch emulation), whereas upstream an address is only ever TRANSMITTED (`ContactDetails` carries `ng:site_inbox`/`ng:protected_inbox`, `engine/verifier/src/inbox_processor.rs:778-830`; the verifier's `inboxes` table is session-local, rebuilt empty — `verifier.rs:520,2820`). Documented in `docs/briefs/2026-08-03-document-inbox-addressing.md`.
- `share` — a **gap upstream, not a disagreement**, verified at both ends: `ContactDetails.read_cap: Option<ReadCap>` exists (`engine/net/src/types.rs:4233`) but building a message with it is `unimplemented!()` (`types.rs:3786`), its only caller passes `with_readcap: false`, and the receiving arm never reads the field (`inbox_processor.rs:778-830`). `InboxMsgContent::Link` is a **unit variant carrying nothing** (`types.rs:4252`) — do not read it as the delivery channel. The recipient-side filing the lib emulates is real: `AddLink { read_cap }` on the User branch (`engine/repo/src/types.rs:1939-1948`). The consumer's *act* (share one document's cap to one inbox) is target-shaped; only the transport is emulated.
- `read` / `materialize` / `readSynced` / `processInbox` / `watch`**stand-ins for the recipient's own verifier processing**, which has no consumer-facing JS surface upstream and may never have this list-of-deposits shape. A consumer should treat "my inbox gets processed when I connect, and applied caps just appear in what I hold" as the durable contract (that is what `connectedUser` automates, § 13); code that leans on enumerating raw deposits as a mailbox UI is coding against emulation detail it may have to unlearn. The consumer-payload case (`Deposit.payload` as app data) maps to `InboxMsgContent` variants upstream (`types.rs:4249-4260`), of which only `ContactDetails` and `SocialQuery` are more than unit variants today — arbitrary app payloads through the inbox are an **ASSUMPTION**, constrained by the model only in that messages are sealed, per-recipient, and applied by the recipient.
- `readForDocument(doc)` — the owner's side of a document's inbox, named by the DOCUMENT. Same LEVEL-1 SHAPE ruling as `read`: it is the recipient's own processing, which has no consumer-facing JS surface upstream, and enumerating its deposits is emulation detail. It exists so an application never handles an inbox address.
- `share(doc, toUser)` **refuses an unknown recipient** since 2026-08-10. It used to provision one: a mistyped name minted that name's stores and an inbox, and the cap landed where nobody looks. Upstream a deposit is sealed to an inbox pubkey that reached you through an inbound contact, so you cannot address a name you invented.
- `watch`'s `_opts?: { intervalMs?: number }` is accepted and **ignored** (kept for signature compatibility with a removed polling watcher) — dead surface, see § 15.
---
@@ -518,11 +520,16 @@ export interface RegistrySession {
}
// PUBLISHED — the whole `storeRegistry` namespace, and nothing else.
export async function createEntityDoc(id: string, scope: Scope): Promise<Nuri>;
export async function listMyEntityDocs(id: string, scope: Scope): Promise<Nuri[]>;
// NO identity parameter, since 2026-08-10: a session belongs to one user, and the
// target's own `doc_create(session_id, …)` carries no user at all. Passing one's own
// identity to every placement call was a gesture with no successor — and it forced an
// application to KNOW its identity, which it could only do by reading the access gate's
// private storage key. `ensureIdentity()` returns it now; these take it from the session.
export async function createEntityDoc(scope: Scope): Promise<Nuri>;
export async function listMyEntityDocs(scope: Scope): Promise<Nuri[]>;
export async function resolveScopeGraph(scope: Scope): Promise<Nuri>;
export async function resolveWriteGraph(id: string, scope: Scope): Promise<Nuri>;
export async function openDocumentInbox(doc: Nuri): Promise<Nuri>;
export async function resolveWriteGraph(scope: Scope): Promise<Nuri>;
export async function openDocumentInbox(doc: NuriLike): Promise<Nuri>;
// NOT published — internal, kept here because the target rulings below still cover them.
// userStoreDoc, userInbox, documentInboxAddress, isOwnInbox, myInboxes,
@@ -533,7 +540,7 @@ export async function openDocumentInbox(doc: Nuri): Promise<Nuri>;
### Target — split by what each piece maps to
- **`createEntityDoc(id, scope)` → level 2, VERIFIED direction.** Target: `doc_create(session_id, crdt, class_name, destination, store_repo)` aimed at the identity's real per-scope store (see § 7 for the store-targeting nuance — the nodejs SDK already takes `store_type`/`store_repo` strings). The two writes the lib performs by hand are **native side effects** of `doc_create` upstream: the `ldp:contains` listing on the store's Main branch and the `AddRepo { read_cap }` on its Store branch (`engine/verifier/src/request_processor.rs:697-710`). The `id` parameter disappears (the session IS the identity); expect `createEntityDoc(id, scope)` to become `doc_create(sid, …, storeOf(scope))` with no listing/cap bookkeeping.
- **`createEntityDoc(id, scope)` → level 2, VERIFIED direction.** Target: `doc_create(session_id, crdt, class_name, destination, store_repo)` aimed at the identity's real per-scope store (see § 7 for the store-targeting nuance — the nodejs SDK already takes `store_type`/`store_repo` strings). The two writes the lib performs by hand are **native side effects** of `doc_create` upstream: the `ldp:contains` listing on the store's Main branch and the `AddRepo { read_cap }` on its Store branch (`engine/verifier/src/request_processor.rs:697-710`). The `id` parameter is already gone from the published call (2026-08-10); expect `createEntityDoc(scope)` to become `doc_create(sid, …, storeOf(scope))` with no listing/cap bookkeeping.
- **`listMyEntityDocs(id, scope)` → level 1/2, VERIFIED mechanism.** Upstream the listing is the store's `ldp:contains` graph (written at `request_processor.rs:706-708`), readable with an anchored `sparql_query` on the store; the caps come back by replaying the Store branch (`AddRepo::verify``load_repo_from_read_cap`). The function's shape (give me my per-scope doc NURIs) survives; its implementation becomes one native read.
- **`userStoreDoc(id, scope)` / `resolveScopeGraph(scope)` / `resolveWriteGraph(id, scope)` → level 2, VERIFIED.** The target answers these from the session: `did:ng:` + `session.private_store_id | protected_store_id | public_store_id` (`Session`, `index.d.ts:264-272`). The store IS the container; the per-scope index document disappears.
- **`userInbox(id)` → level 1, VERIFIED counterpart with a different granularity.** Upstream a user's inboxes are their public and protected STORE repos' inboxes — the only two `AddInboxCap` commits in the engine (`engine/verifier/src/site.rs:128,149`). An identity-level "my inbox" therefore maps to a store inbox; the resolution moves into the lib/SDK and the consumer's act (deposit to an address, process my own) is unchanged.
@@ -1,24 +1,16 @@
# A consumer's test harness has no way to play several identities
# What a consumer's tests need from the contract, and cannot find in it
Raised by the first consumer (Festipod) on 2026-08-10, while migrating onto `@ng-eventually/sdk` against `contract_sdk-surface` @ `30f6263`. Three findings, one substantive and two defects in the contract's own text.
Raised by the first consumer (Festipod) on 2026-08-10, while migrating onto `@ng-eventually/sdk` against `contract_sdk-surface` @ `30f6263`. Three findings.
## 1. The substantive gap — a consumer harness cannot switch identity
## 1. The contract does not say how an identity comes to be established
`contract_sdk-surface` § *Guarantees* states the whole of signing in: `ensureIdentity()`, which takes no identifier, deliberately. The API contract adds, about `setCurrentUser`'s removal, that *"the e2e harness plays several identities on one page and reaches it by its internal path, which is what a harness is allowed to do and an application is not."*
The first version of this brief asked for a test entry exposing identity switching, on the strength of the API contract's remark that *"the e2e harness plays several identities on one page and reaches it by its internal path, which is what a harness is allowed to do and an application is not"* — true of this library's own harness, and unreachable for a consumer's, since `packages/sdk/package.json` maps exactly one entry and the resolver refuses a deep import (verified: `Cannot find module '@ng-eventually/sdk/src/shared-wallet/access-gate'`).
That sentence holds for **this library's own** harness. It does not hold for a consumer's, and the package makes sure of it: `packages/sdk/package.json` maps exactly one entry, `"." : "./src/index.ts"`. A deep import is refused by the resolver, verified from the consumer's tree:
**That request is withdrawn**, and the reason is worth recording because it is the library's own argument turned around. The consumer decided that its tests take no shortcut through the SDK and validate the application's behaviour rather than the SDK's. Under that rule, "two identities on one page" is not a capability to restore: it is not something a user does, it exists only because one wallet hosts several identities, and a test that used it would be testing the emulation. Multi-user behaviour gets tested the way it is lived — several browser contexts, each signing in as itself. So the surface is right as it stands, and the library should not add a testing entry on this consumer's account.
```
ROOT OK: configure, docChangeType, docs, ensureIdentity, inbox, init, initNg,
ng, readUnion, storeRegistry, subscribeDoc, subscribeDocs, useShape, watchShape
DEEP FAIL: Cannot find module '@ng-eventually/sdk/src/shared-wallet/access-gate'
```
What is genuinely missing is one step lower. `## Guarantees` says `ensureIdentity()` *"is the whole of signing in… it resolves who you are"* — and nowhere does the contract say **how** it resolves it, or what a deployment must arrange so that a given browser context comes up as a given identity. A consumer driving N real sessions has to arrange exactly that, and today it can only learn how by reading the library, which is the one thing the contract exists to prevent. `SharedWalletConfig` is described as *"what a DEPLOYMENT hands out"*, which is the same subject seen from the other side and equally silent on the mechanism.
So a consumer's multi-actor suite has **no path at all** — published or internal — to act as a second identity. The consequence is not cosmetic: a test that cannot obtain a second actor is forced to hand the first actor's values across the identity boundary through a shared variable, which is precisely the shape that hid a real bug in this library once already (a third party never reached the owner's inbox, and the green test proved nothing because the address crossed the boundary by JS scope). Losing the ability to write that test correctly costs more than the surface it saves.
What the consumer needs is narrow: **act as identity X for the duration of a block, then restore**. It is a harness capability, not an application one — the request is not to re-publish `setCurrentUser` on the application surface. A separate, explicitly-named test entry (`@ng-eventually/sdk/testing`, say) would keep the application surface exactly as it is while making the capability reachable; it would also carry its own deletion signal, since a consumer harness that plays several identities on one page is itself pure shared-wallet scaffolding.
Not proposing the shape — this is the library's call. Stating the need, and that it currently has no answer.
This is a documentation gap, not a surface gap: state, in the contract, what determines the identity `ensureIdentity()` resolves to, and which of those inputs a deployment controls. That is enough for a consumer to bring up several genuine sessions without touching anything internal.
## 2. `watchShape`'s published signature contradicts its own types
@@ -35,3 +27,20 @@ export function watchShape(query: ShapeQuery): ShapeObservable;
The inbox surface publishes `readSynced(targetInbox)` and `readForDocument(doc)`, but not their intersection. The consumer's materialization path depends on the **synced** guarantee specifically (`read` and `readSynced` differ by contract), and it addresses by document. Today it must therefore resolve an address itself to get the synced form — which is the exact gesture § *Guarantees* says an application never performs (*"an application never handles a key or an inbox address"*).
Either `readForDocument` carries the synced guarantee, or a `readSyncedForDocument(doc)` completes the pair. As it stands the document-addressed path is strictly weaker than the address-addressed one, and the contract does not say that is intentional.
## 4. Identity was taken out of the application's hands, but three published calls still demand one
This is the finding that actually cost the migration, and it is one incoherence seen from two sides.
`ensureIdentity()` returns `void`, and nothing else answers *"who am I"*`getCurrentUser` was removed on the sound argument that an application knows who it signed in. Under the previous surface that was true: the application named the identity, so it held the value. It no longer names it, and the gate that resolves it hands nothing back. So the premise the removal rested on has quietly stopped holding.
Meanwhile `storeRegistry.createEntityDoc(id, scope)`, `listMyEntityDocs(id, scope)` and `resolveWriteGraph(id, scope)` all take a mandatory `id: string`, and **the contract never says what it is**. Two readings are open and the contract separates them nowhere:
- `id` designates the **identity** — in which case an application that cannot obtain its own identity cannot call any of the three correctly, and a consumer forced to pass a constant merges every user's documents into one collection. Silently: nothing errors, the writes succeed, and isolation is gone.
- `id` designates a **collection key scoped inside the already-connected identity** — the reading `listMy…` suggests — in which case a constant is harmless and the parameter is just unexplained.
The consumer has taken the second reading and routed all six call sites through one documented constant, because the first reading offers it no legal move at all. That is a bet on an unstated semantic, recorded as a bet. **Please rule.**
Whichever way it goes, the pair needs to close: either the three calls stop taking an `id` (the session is the identity, which is what the API contract predicts for the target), or the surface answers *"who am I"* again. Right now it does neither, and the gap is invisible — a consumer that guesses wrong gets working code and broken isolation.
A second, smaller consequence of the same hole: the consumer's `currentUserId` now has to be read out of its own profile document, so it is empty until that read lands, where it used to be available synchronously and invariant. An action taken in that window is silently dropped instead of written.
+12 -22
View File
@@ -94,8 +94,7 @@ configure({
/** Write a new note in `scope`. The document is created, then filled. */
async function writeNote(scope: Scope, title: string, body: string): Promise<Nuri> {
const me = currentIdentity();
const doc = await storeRegistry.createEntityDoc(me, scope);
const doc = await storeRegistry.createEntityDoc(scope);
const s = await sessionReady;
await docs.sparqlUpdate(
s.session_id,
@@ -107,7 +106,7 @@ async function writeNote(scope: Scope, title: string, body: string): Promise<Nur
/** My notes in `scope`, read the way the library intends: list, then read. */
async function myNotes(scope: Scope): Promise<Note[]> {
const docsOfScope = await storeRegistry.listMyEntityDocs(currentIdentity(), scope);
const docsOfScope = await storeRegistry.listMyEntityDocs(scope);
const subjects = await readUnion(docsOfScope);
return subjects.map((s) => ({
doc: s.subject,
@@ -175,32 +174,23 @@ function watchNote(doc: Nuri, onChange: () => void): () => void {
// --- identity ---------------------------------------------------------------
let identity = "";
function currentIdentity(): string {
if (!identity) throw new Error("not signed in yet");
return identity;
}
/**
* Sign in. The library shows its access barrier when it needs one; the day the wallet
* supplies the identity, this resolves silently and nothing here changes.
* Sign in, and learn who you are.
*
* One await, and it covers everything: the gate resolves the identity AND waits for the
* One await, and it covers everything: the library settles the identity, waits for the
* connection work it fires (restoring what others shared with you, draining your
* inboxes). The application used to have to await that second part itself — the
* applicative e2e is what found it out, because a note someone had just shared read as
* unreadable, which looks like a permission problem and is a timing one. The library
* absorbed it: upstream, opening the session IS the connection, and no application
* awaits a second call.
* inboxes), and **returns the identity**. The application keeps it only to display it —
* no call takes it, because a session belongs to one user and the target's own
* `doc_create` carries no user at all.
*
* This used to read the library's private storage key to find out who it was, which is a
* boundary no consumer should be able to see. Writing this application is what made that
* visible.
*/
async function signIn(): Promise<void> {
await ensureIdentity();
identity = await ensureIdentity();
await sessionReady;
identity = readIdentityBack();
}
/** The library owns the identity; the app asks for it rather than remembering it. */
function readIdentityBack(): string {
return new URLSearchParams(location.search).get("ng-id") ?? localStorage.getItem("ng-eventually:identity") ?? "";
}
function escape(s: string): string {
+9 -9
View File
@@ -515,16 +515,16 @@ const identity = new IdentityStore(
// 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");
const dA1 = await storeRegistry.createEntityDoc("public");
const dA2 = await storeRegistry.createEntityDoc("public");
setCurrentUser(idB);
const dB1 = await storeRegistry.createEntityDoc(idB, "public");
const dB1 = await storeRegistry.createEntityDoc("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++) {
registryInternals.resetRegistryCache();
listA = await storeRegistry.listMyEntityDocs(idA, "public");
listA = await storeRegistry.listMyEntityDocs("public");
if (listA.includes(dA1) && listA.includes(dA2)) break;
await new Promise((r) => setTimeout(r, 1000));
}
@@ -556,7 +556,7 @@ const identity = new IdentityStore(
// 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 entityNuri = await storeRegistry.createEntityDoc(scope);
const marker = "recon-" + Date.now();
await docs.sparqlUpdate(
s.session_id,
@@ -569,7 +569,7 @@ const identity = new IdentityStore(
let listed: string[] = [];
for (let i = 0; i < 15; i++) {
registryInternals.resetRegistryCache();
listed = await storeRegistry.listMyEntityDocs(id, scope);
listed = await storeRegistry.listMyEntityDocs(scope);
if (listed.includes(entityNuri)) break;
await new Promise((r) => setTimeout(r, 1000));
}
@@ -595,7 +595,7 @@ const identity = new IdentityStore(
await connectedUser();
registryInternals.resetRegistryCache();
const listed = await storeRegistry.listMyEntityDocs(id, scope);
const listed = await storeRegistry.listMyEntityDocs(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
@@ -731,7 +731,7 @@ const identity = new IdentityStore(
registryInternals.resetRegistryCache();
const id = "@ws-" + handle;
setCurrentUser(id);
const doc = await storeRegistry.createEntityDoc(id, "protected");
const doc = await storeRegistry.createEntityDoc("protected");
const s = await sessionReady;
// Seed the entity doc with the shape's type + a title (anchored default graph).
await docs.sparqlUpdate(
@@ -742,7 +742,7 @@ const identity = new IdentityStore(
// Wait until this session sees the index append (data persisted on the broker).
for (let i = 0; i < 15; i++) {
registryInternals.resetRegistryCache();
const listed = await storeRegistry.listMyEntityDocs(id, "protected");
const listed = await storeRegistry.listMyEntityDocs("protected");
if (listed.includes(doc)) break;
await new Promise((r) => setTimeout(r, 1000));
}
@@ -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).