feat: le polyfill possède la session et la normalisation des identités

Pour démarrer, une application devait écrire une promesse autour du callback
d'init(), attraper l'événement loggedin, puis fournir un thunk getSession qui
dépiaute session_id et les trois identifiants de store dans notre forme. Plus un
normalizeId. C'est précisément la plomberie que ce paquet existe pour absorber :
chaque application la réécrirait à l'identique, et c'est elle qui a produit deux
défauts aujourd'hui — un blocage et un partage cassé en silence.

En amont, une session est RENDUE ; une application n'en assemble jamais une à
partir de champs bruts. Et les identités virtuelles sont une invention du
polyfill, donc leur normalisation lui appartient.

Le wrapper init() enveloppe désormais le callback de l'appelant : il capture
l'événement, en dérive la session, puis appelle le callback avec le même
événement. Le paquet n'appelle jamais init de sa propre initiative — il
l'enveloppe. Sans callback, il capture quand même.

getSession et normalizeId quittent la surface publiée. Le chemin d'injection
reste pour les harnais, mais inatteignable depuis l'entrée : vérifié par un
import à l'exécution et par un configure() refusé à la compilation.

Défaut trouvé et corrigé en route : le broker envoie session_id en NOMBRE, et le
convertir en chaîne faisait refuser tous les appels par le binding wasm. La
valeur ne fait que transiter, elle est relayée telle quelle. Reste que toute la
chaîne la type string — inexactitude antérieure à ce commit, à traiter à part.

Une application écrit maintenant : configure({ ng, useShape, init, sharedWallet }).
This commit is contained in:
Sylvain Duchesne
2026-08-12 17:39:12 +02:00
parent 7a4d9b492f
commit cc8a95d303
20 changed files with 501 additions and 141 deletions
+21 -9
View File
@@ -26,8 +26,6 @@ Per the design principle (`README.md` § *Design principle*): an absent implemen
export interface EventuallyConfig {
ng: NgLike; // the REAL @ng-org/web ng
useShape: UseShapeLike; // the REAL @ng-org/orm useShape
getSession?: () => Promise<RegistrySession>; // the wallet session (a thunk)
normalizeId?: (id: string) => string;
pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number };
sharedWallet?: SharedWalletConfig; // the gate's, § 2bis
debugAccessLog?: boolean;
@@ -46,7 +44,16 @@ export function configure(c: EventuallyConfig): void;
| `connectedUser` | `ensureIdentity` awaits it. Upstream, opening the session IS the connection — no application awaits a second call |
| `getConfig`, `getStoreRegistryDeps`, `resetConfig`, `resetStoreRegistry` | internal wiring and test resets, reached by their internal path (2026-08-07, with the entry merge) |
So an application's whole bootstrap is `configure({ … })` plus `await ensureIdentity()` — and the second of those keeps its call site after migration.
**And two FIELDS of that one call, on 2026-08-12.** The count was already one; what was left inside it still made an application build things the target never asks anyone to build:
| Was published | Where it went |
|---|---|
| `getSession` (and the `RegistrySession` type with it, § 12) | the package's. Upstream a session is **returned**`init()`'s callback delivers `{ status: "loggedin", session }` (`@ng-org/web` `dist/ngweb.js:124`, VERIFIED) and `session_start` hands one back; nowhere does an application ASSEMBLE one out of `session_id` / `private_store_id` / …. Every consumer wrapped `init()` in a promise and wrote the same unwrapping thunk, with nothing to migrate it to. The lib's `init` wrapper captures the event on its way through (§ 2) and holds the session (`shared-wallet/session.ts`) |
| `normalizeId` | the package's, as `normalizeIdentityId` — trim, strip a leading `@`, lowercase. The identities it keys are the shared wallet's own virtual users, so there was never a decision here for a consumer to make; and one rule in one place is what stops the barrier, the URL and storage keying onto three different spaces |
Both remain substitutable through `configureStoreRegistry` (`shared-wallet/bootstrap.ts`), which the published entry does not re-export: the unit suites have no browser and the e2e harness holds a session the broker handed it directly, and neither is an application.
So an application's whole bootstrap is `configure({ ng, useShape, init, sharedWallet })` plus `await ensureIdentity()` — and the second of those keeps its call site after migration.
### Target
@@ -59,7 +66,8 @@ So an application's whole bootstrap is `configure({ … })` plus `await ensureId
### Today — `@ng-eventually/polyfill`
```ts
// lifecycle.ts:11 — settles the identity, then forwards to the real @ng-org/web init injected at configure()
// lifecycle.ts:11 — settles the identity, wraps the callback, then forwards to the real
// @ng-org/web init injected at configure()
export function init(...args: any[]): any;
// lifecycle.ts:18 — forwards to the real @ng-org/orm initNg injected at configure()
export function initNg(...args: any[]): any;
@@ -67,7 +75,7 @@ export function initNg(...args: any[]): any;
### Target
**PASSTHROUGH, VERIFIED at both levels.** The wrapper's `...args: any[]` is deliberately shapeless; the real signatures it forwards to are:
**PASSTHROUGH, VERIFIED at both levels — with one argument touched, deliberately.** `init`'s callback in position 0 is wrapped since 2026-08-12: the wrapper reads the event, keeps the session it carries (§ 1), and calls the caller's callback with that same event, unchanged. Everything else — the remaining arguments, the return value, what the callback observes — passes straight through, so an application's call site is what it would write against the real SDK. This is the only place the capture can sit: it is the only one that knows both what the caller asked and what the SDK will answer, and the alternative was every consumer re-implementing it (which is what it replaces). The real signatures forwarded to are:
```ts
// level 2 — @ng-org/web: index.d.ts:108, source sdk/js/web/src/index.ts:51
@@ -519,7 +527,8 @@ interface VirtualUserRecord {
docProtected: Nuri;
docPrivate: Nuri;
}
export interface RegistrySession {
// RegistrySession is INTERNAL since 2026-08-12 (shape kept here for the ruling below).
interface RegistrySession {
sessionId: string;
privateStoreId: string;
protectedStoreId?: string;
@@ -542,7 +551,9 @@ export async function openDocumentInbox(doc: NuriLike): Promise<Nuri>;
// userStoreDoc, userInbox, documentInboxAddress, isOwnInbox, myInboxes,
// addLink, readLinks, resolveAccount, ensureAccount, reservedAccount,
// resetRegistryCache, and the VirtualUserRecord type.
// `RegistrySession` IS published: a consumer types its injected `getSession` with it.
// `RegistrySession` joined them on 2026-08-12: it was published for ONE reason — a consumer
// typed the session thunk it injected with it — and that thunk is gone (§ 1). Upstream a
// session is RETURNED, never assembled, so no application has a session shape to declare.
```
### Target — split by what each piece maps to
@@ -662,14 +673,15 @@ Exported, but not SDK surface. Coding against these builds knowledge that migrat
### `@ng-eventually/polyfill` — `src/index.ts` (the only entry since 2026-08-07)
```text
direct: BaseType, DeepSignalSet, DocChange, DocChangeType, EventuallyConfig, NG, NgLike, Nuri, NuriLike, PrincipalId, RegistrySession, Schema, Scope, ShapeObservable, ShapeQuery, ShapeType, SharedWalletConfig, UnionSubject, Unsubscribe, UseShapeLike, configure, docChangeType, ensureIdentity, init, initNg, ng, readUnion, subscribeDoc, subscribeDocs, useShape, watchShape
direct: BaseType, DeepSignalSet, DocChange, DocChangeType, EventuallyConfig, NG, NgLike, Nuri, NuriLike, PrincipalId, Schema, Scope, ShapeObservable, ShapeQuery, ShapeType, SharedWalletConfig, UnionSubject, Unsubscribe, UseShapeLike, configure, docChangeType, ensureIdentity, init, initNg, ng, readUnion, subscribeDoc, subscribeDocs, useShape, watchShape
docs: docCreate, sparqlQuery, sparqlUpdate
inbox: Deposit, PostOptions, materialize, post, postToDocument, processInbox, read, readForDocument, readSynced, share, watch
storeRegistry: createEntityDoc, listMyEntityDocs, openDocumentInbox, resolveScopeGraph, resolveWriteGraph
```
**Of these, exactly ONE is polyfill-era with no target counterpart**`configure` (plus
the types `EventuallyConfig`, `RegistrySession`). It is the deletion list, and
the type `EventuallyConfig`; `RegistrySession` left the surface on 2026-08-12 with the
session thunk that was its only reason to be there). It is the deletion list, and
`src/index.ts` groups it under a heading that says so. `ensureIdentity` is a second in
substance — the shared-wallet gate — but its *call site* survives (§ 2bis).
@@ -8,9 +8,9 @@ The first version of this brief asked for a test entry exposing identity switchi
**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.
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.
**A larger claim was drafted here and is retracted before it could mislead.** It said the contract offers no way to determine which identity comes up, and that a consumer therefore has no reproducible test isolation. That is wrong: `EventuallyConfig` publishes both `sharedWallet` and `currentUser`, so bootstrap is exactly where a caller says which wallet to open and, when it must, which identity to come up as. The consumer had simply not wired those two fields yet — its own migration was incomplete, and the library's error message said so precisely (*"no shared wallet configured. Pass `sharedWallet` to `configure()`"*). A good error message did the work the brief was trying to do.
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.
What remains is small and is prose, not surface. `## Guarantees` describes `ensureIdentity()` as *"the whole of signing in… it resolves who you are"* without ever connecting that sentence to the two `EventuallyConfig` fields that determine the answer. The `## Surface` block carries them as bare lines in a config interface, where nothing marks them as the inputs to the one call a consumer must get right before anything renders. Naming that link — *these fields are what `ensureIdentity()` resolves from* — would have saved a wrong diagnosis here, and it is one sentence.
## 2. `watchShape`'s published signature contradicts its own types
@@ -34,13 +34,43 @@ This is the finding that actually cost the migration, and it is one incoherence
`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:
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 were open. **The library has now answered, by refusing — and the answer is measured, not argued.**
- `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 bet on the harmless reading (`id` = a collection key inside the already-connected identity, which `listMy…` suggests) and routed all its call sites through one constant, because the other reading offered it no legal move at all. Running the broker-backed suite settles it in one line. Each scenario opens as its own identity — verified, the access log shows `test-…-2`, `-3`, `-4`, `-5`, `-6`, `-7` — and every one of them fails on **the same document**:
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.**
```
[test-…-2][polyfill] createEntityDoc cap append failed:
[ng-eventually] createEntityDoc:addRepo: refused — the connected user does not hold
this document's cap. Naming a document does not grant access to it.
"did:ng:o:TFYUlJQnOkYUlC4T9XtGOBoz70opW_IO21VJ_ouF_Y0A:v:…"
```
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.
The first identity creates that document and owns it; every later identity names the same one and is refused. So **`id` is per-identity**, and a constant collapses every user onto one owner's document.
Two things follow, and the second is the blocker.
**The good news, worth saying plainly: the guarantee held.** The failure mode feared here was a silent merge — every user's documents in one collection, no error, isolation quietly gone. That is not what happened. *"Writing is ownership"* refused loudly and named the reason, and a consumer's wrong guess became a red test instead of a data leak. This is the surface doing exactly what it promises.
**The blocker: the three calls require the connected identity, and no published call returns it.** `ensureIdentity()` resolves it and returns `void`; `getCurrentUser` was removed on the argument that an application knows who it signed in — which was true while the application named the identity and stopped being true the moment it no longer does. So the consumer must pass a value it has no way to obtain. It currently works only where a deployment plants one (its test environment, through `configure({ currentUser })`); a real deployment plants nothing, and there the application is stuck.
The pair has to close, either way round: the three calls stop taking an `id` (the session IS the identity — what the API contract already predicts for the target), or the surface answers *"who am I"* again. Right now it does neither, and this is no longer a documentation nicety — it is the one thing standing between a consumer and a working multi-user deployment.
## 6. The barrier is the library's, and nothing can test that a first-time device gets in
The consumer has now deleted its own access screen and relies on the barrier `ensureIdentity()` shows — which is right, and is what the library asked for when it absorbed ~300 lines of gate from this same application.
Its one end-to-end check of a first-time device — a real browser with an empty profile, offered the wallet file and its password, importing it, and coming back signed in — drove that deleted screen's own DOM, so it went with the screen. The consumer still needs that coverage: a first-time device getting in is the single most consequential path a real user takes, and it is currently covered by nothing.
**The consumer is not asking for testids, and is rebuilding the check on its own side.** An end-to-end test should interact the way a person does — visible text and roles — rather than reach for identifiers planted inside someone else's component, so driving your barrier that way needs nothing published and is the more honest test besides.
**One question, because it decides whether that is possible at all**: is the barrier reachable by ordinary browser automation? A screen rendered inside a **shadow root**, or in its own iframe, is invisible to a locator that searches the light DOM, and no amount of "test it like a user" gets past that. If it is isolated, then either the isolation needs a documented way through for automation, or the flow is yours to cover and the contract should say so plainly — because right now each side can reasonably assume the other has it.
**Unrelated signature seen in the same run, reported without diagnosis** (78 occurrences, none fatal — the scenarios fail on the cap refusal above, not on this): `call_sdk Deserialization error of config Error: invalid type: JsValue(Function), expected any valid JSON value`. It appears only once `sharedWallet` is configured and `ensureIdentity()` actually opens a wallet — it was absent from every earlier run. The consumer passes `sharedWallet` as three plain strings, per `## Surface`, so whatever crosses that boundary as a function does not come from its call site.
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.
## 5. "Permissive in" is stated as a guarantee but is not uniform
`## Guarantees` opens with *"Every entry accepts `NuriLike` and validates at the door"*, and makes a point of it: a value read from storage, a URL or a form goes straight in, no guard to call, no cast to write. The signature block does not honour it uniformly — `storeRegistry.openDocumentInbox(doc: Nuri)` takes the precise type where its own deposit-side counterpart `inbox.readForDocument(doc: NuriLike)` takes the permissive one. Verified by the consumer's typechecker, not read from your source.
Either the guarantee is narrower than stated (say which entries it covers), or `openDocumentInbox` should widen. As written, a consumer that trusts the sentence gets a type error at exactly one call site and has no way to tell whether that is the rule or the exception.