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
+10
View File
@@ -0,0 +1,10 @@
# Doc-debt — app-contract
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED packages/polyfill/src/surface/lifecycle.ts @2026-08-12 (session f93872b5-293a-4916-a353-181409a96d42)
- TOUCHED packages/polyfill/src/index.ts @2026-08-12 (session f93872b5-293a-4916-a353-181409a96d42)
- TOUCHED examples/notebook/app.ts @2026-08-12 (session f93872b5-293a-4916-a353-181409a96d42)
- TOUCHED docs/api-contract.md @2026-08-12 (session f93872b5-293a-4916-a353-181409a96d42)
@@ -28,8 +28,6 @@ export function configure(c: EventuallyConfig): void;
export interface EventuallyConfig {
ng: NgLike; // the `ng` object from @ng-org/web
useShape: UseShapeLike; // `useShape` from @ng-org/orm
getSession?: () => Promise<RegistrySession>; // resolve the session (a thunk)
normalizeId?: (id: string) => string;
sharedWallet?: SharedWalletConfig; // { fileUrl, password, importUrl? }
debugAccessLog?: boolean;
init?: (...args: any[]) => any;
@@ -110,6 +108,8 @@ Only a document's owner writes to it. Holding its read key never grants a write.
`ensureIdentity()` settles the identity, completes the connection work it starts, and returns the identity. It takes no identifier, and no other call takes one.
**The session is the package's, not yours.** You never build one, and no call takes one. Call this package's `init` (not the one you passed to `configure`): it captures the session the SDK delivers to `init`'s callback and keeps it, then calls your callback with that same event untouched — so an application that wants the `session_id` for the `docs` primitives reads it there, and one that does not may pass no callback at all. Identity normalisation is the package's too: `@Alice`, `alice ` and `ALICE` are one person.
`createEntityDoc` throws if the document cannot be recorded in its store.
## Non-guarantees
+7
View File
@@ -0,0 +1,7 @@
# Doc-debt — e2e-harness
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED packages/polyfill/e2e/polyfill-entry.ts @2026-08-12 (session f93872b5-293a-4916-a353-181409a96d42)
+11
View File
@@ -0,0 +1,11 @@
# Doc-debt — sign-in
> Presence of a block = doc to update. Processed → delete the block; no blocks left → delete this file.
> One block = one "big change": `why` + `files` + `verify` (leaves to review).
## Raw markers (consolidate into blocks, then delete)
- TOUCHED packages/polyfill/src/shared-wallet/session.ts @2026-08-12 (session f93872b5-293a-4916-a353-181409a96d42)
- TOUCHED packages/polyfill/src/shared-wallet/bootstrap.ts @2026-08-12 (session f93872b5-293a-4916-a353-181409a96d42)
- TOUCHED packages/polyfill/src/surface/lifecycle.ts @2026-08-12 (session f93872b5-293a-4916-a353-181409a96d42)
- TOUCHED packages/polyfill/src/shared-wallet/access-gate.ts @2026-08-12 (session f93872b5-293a-4916-a353-181409a96d42)
- TOUCHED packages/polyfill/src/shared-wallet/account-registry.ts @2026-08-12 (session f93872b5-293a-4916-a353-181409a96d42)
+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.
+8 -15
View File
@@ -69,29 +69,22 @@ configure({
fileUrl: "/shared-wallet.ngw",
password: (globalThis as { __NOTEBOOK_WALLET_PASSWORD__?: string }).__NOTEBOOK_WALLET_PASSWORD__ ?? "",
},
getSession: async () => {
const s = session ?? (await sessionReady);
return {
sessionId: s.session_id,
privateStoreId: (s as Record<string, string>).private_store_id!,
protectedStoreId: (s as Record<string, string>).protected_store_id,
publicStoreId: (s as Record<string, string>).public_store_id,
};
},
normalizeId: (id) => id.trim().replace(/^@/, "").toLowerCase(),
});
// The library's `init`, not the injected one — and this line is SDK-shaped, kept at
// migration. It settles the identity before handing the page to the broker, so the
// round-trip leaves with the identifier in the URL it carries. `realInit` called here
// would navigate away first, and the barrier would never show.
let session: { session_id: string } | null = null;
//
// The callback is this application's own business, and only its own: it keeps the session
// because the SPARQL primitives below take a `session_id`, exactly as the real SDK's do.
// It used to ALSO hand the library a thunk unwrapping this event into a session shape —
// plumbing every consumer wrote identically, and this one wrote wrong twice. The library
// catches the same event on its way through `init` now (2026-08-12), so what is left here
// is only what this app itself reads.
const sessionReady = new Promise<{ session_id: string }>((resolve) => {
init((event: { status: string; session?: { session_id: string } }) => {
if (event.status === "loggedin" && event.session) {
session = event.session;
resolve(event.session);
}
if (event.status === "loggedin" && event.session) resolve(event.session);
}, true, []);
});
+15 -5
View File
@@ -35,6 +35,7 @@ import * as registryInternals from "../src/shared-wallet/account-registry";
// the reason `setCurrentUser` / `configureStoreRegistry` are no longer published. It
// reaches them by their internal path, like the rest of its machinery.
import {
adoptCurrentUser,
configureStoreRegistry,
setCurrentUser,
getCaps,
@@ -116,14 +117,23 @@ configure({
* hand the page to the broker (`surface/lifecycle.ts`). A page with no identity would
* raise the barrier instead and never hand over, so the harness supplies one.
*
* Set BEFORE `configureStoreRegistry` deliberately: until the registry is wired,
* `setCurrentUser` fires no connection work (`bootstrap.ts`), so this costs the batch
* neither an account nor a broker round-trip. Every check that cares about identity sets
* its own anyway — this one is only what the page opened as.
* Recorded with `adoptCurrentUser`, which names the identity and stops there — the
* session-free half, the same one the access gate settles with (`bootstrap.ts`). Naming it
* through `setCurrentUser` would FIRE the connection work: it costs the batch an account
* lookup and a broker round-trip for a boot identity nothing reads, and it registers a
* connection in flight for a user that does not exist. It used to fire nothing here for an
* incidental reason — the registry was still unwired at this line — and `configure` wires
* it now, so the intent is stated by the call instead of by the ordering. Every check that
* cares about identity sets its own anyway; this one is only what the page opened as.
*/
const BOOT_IDENTITY = "e2e-harness";
setCurrentUser(BOOT_IDENTITY);
adoptCurrentUser(BOOT_IDENTITY);
// The harness keeps its OWN route to the session, substituted through the internal wiring
// path AFTER `configure` has pointed the registry at the package's. Not redundancy: the
// checks below tear a session down and start another (`session_stop` + `session_start`, the
// reconnection cold-start), and only this page knows about the second one — the package's
// holder is fed by `init()`'s callback, which the broker fires once per page.
configureStoreRegistry({
// The registry (+ subscribe/inbox/read-model) reach the session
// through this. It resolves once the broker connects.
+16 -1
View File
@@ -100,7 +100,11 @@ export type { NG } from "@ng-org/web";
*/
export { configure } from "./shared-wallet/bootstrap";
export type { EventuallyConfig } from "./shared-wallet/bootstrap";
export type { RegistrySession } from "./shared-wallet/account-registry";
// `RegistrySession` went with `getSession` (2026-08-12): it was published for exactly one
// reason — an application typed the session thunk it injected with it — and a published
// type whose signature is gone is a promise about the target that nothing keeps. Upstream a
// session is RETURNED, never assembled, so no consumer has a session shape to declare. It
// stays DEFINED in `shared-wallet/account-registry.ts`, where the library uses it.
// --- what this block deliberately does NOT contain --------------------------
//
@@ -116,6 +120,17 @@ export type { RegistrySession } from "./shared-wallet/account-registry";
// harness is allowed to do and an application is not.
// - `connectedUser` — `ensureIdentity` awaits it. Upstream, opening the session IS the
// connection; no application awaits a second call, so ours should not either.
//
// And two FIELDS of `EventuallyConfig` on 2026-08-12, for the same reason one call up:
//
// - `getSession` — the session is `init()`'s to deliver, and this package's to keep. An
// application that assembles one out of `session_id` / `private_store_id` / … is
// building a shape the target never asks for, and every consumer built the same one.
// - `normalizeId` — the identities being normalized are this package's own invention, so
// there was never a decision here for a consumer to make.
//
// Both are still substitutable through `shared-wallet/bootstrap`'s `configureStoreRegistry`
// — the suites and the e2e harness need it, and nothing published reaches it.
// ── the access gate — polyfill-era in substance, one line in the app ────────
// One call before the app renders. It shows a technical barrier only while the shared
@@ -84,6 +84,7 @@ import {
getConfig,
getCurrentUser,
getStoreRegistryDeps,
normalizeIdentityId,
} from "./bootstrap";
import { connectedUser } from "../emulated-verifier/connect";
import type { PrincipalId } from "../model/types";
@@ -94,17 +95,19 @@ import type { PrincipalId } from "../model/types";
* Not a detail: the identifier arrives from three places — typed at the gate, read from
* the URL after the broker round-trip, read from storage — and if any of them normalizes
* differently, that path keys onto a DIFFERENT virtual user. `@Erin` from the URL and
* `erin` typed at the gate must be one space, not two. So there is one normalizer, the
* injected one, and the gate borrows it rather than keeping its own `toLowerCase()`.
* `erin` typed at the gate must be one space, not two. So there is ONE normalizer the
* package's {@link normalizeIdentityId} — and the gate borrows whatever the registry is
* keying on rather than keeping its own `toLowerCase()`.
*
* Falls back to the library's own default when the registry is not configured yet, which
* is possible since the gate can run before anything else.
* Falls back to that same rule when the registry is not configured yet, which is possible
* since the gate can run before anything else. Not a second copy of it: the fallback and
* the default are the same function, so the two can no longer drift apart.
*/
function normalizeIdentity(raw: string): string {
try {
return getStoreRegistryDeps().normalizeId(raw);
} catch {
return raw.trim().replace(/^@/, "").toLowerCase();
return normalizeIdentityId(raw);
}
}
@@ -262,12 +262,13 @@ export interface RegistrySession {
function normalize(id: string): string {
const key = getStoreRegistryDeps().normalizeId(id);
// The reserved namespace's whole guarantee is that no user id can land in it, and
// that guarantee is NOT ours to make: `normalizeId` is injected by the consumer
// application, and the library's own default only trims — nothing stops a caller
// from passing an id that already starts with the sentinel. A collision here is not
// a cosmetic clash: a user would key onto an infrastructure account and read or
// write documents that are not theirs. So it is checked rather than assumed.
// The reserved namespace's whole guarantee is that no user id can land in it, and the
// rule that produces the key does not enforce it: the package's own `normalizeIdentityId`
// trims, strips a leading `@` and lowercases — nothing stops an id that already starts
// with the sentinel — and the internal wiring path lets a suite or the e2e harness
// substitute another rule entirely. A collision here is not a cosmetic clash: a user
// would key onto an infrastructure account and read or write documents that are not
// theirs. So it is checked rather than assumed.
if (isReserved(key)) {
throw new Error(
"[ng-eventually] account-registry: `normalizeId` produced a key inside the " +
@@ -24,17 +24,43 @@ import { resetPublicStoreFetches } from "../emulated-verifier/public-store";
import { setAccessLog } from "./access-log";
import { inspectOutbox } from "./outbox-log";
import { startConnect } from "../emulated-verifier/connect";
import { resetSharedWalletSession, sharedWalletSession } from "./session";
/**
* Consumer-injected dependencies of the storeRegistry (polyfill-era). The
* registry itself is generic (it knows only native scopes); the consumer wires
* up how to reach the shared-wallet session and how to normalize an identity id
* used as the shim key. Removed at migration along with the whole shim.
* How an identity id becomes the key a virtual user is filed under.
*
* The package's rule, not a consumer's choice (2026-08-12): `@Alice`, `alice ` and `ALICE`
* are ONE person's space. It has to be one rule, because the identifier arrives from three
* places typed at the barrier, read back from the URL after the broker round-trip, read
* from storage and any of them keying differently silently opens a SECOND space whose
* documents the first one cannot see.
*
* **NO COUNTERPART.** Upstream there is nothing to normalize: a wallet holds one user, and
* `session_start` takes the id the wallet gives. This exists only because one wallet here
* hosts everybody, and it disappears with them.
*/
export function normalizeIdentityId(id: string): string {
return id.trim().replace(/^@/, "").toLowerCase();
}
/**
* Dependencies of the storeRegistry (polyfill-era) INTERNAL, and no longer an
* application's business.
*
* The registry itself is generic (it knows only native scopes); these two say how to reach
* the shared-wallet session and how to key an identity in the shim. An application used to
* supply both through {@link EventuallyConfig}; the package owns them now (2026-08-12), and
* this interface is the substitution path its OWN suites use the unit fakes need a
* synchronous session, and the e2e harness holds one the broker gave it directly.
*
* Internal is carried by the module, not by a comment: nothing here is re-exported from
* `src/index.ts`, so the published entry cannot reach it. Removed at migration with the
* whole shim.
*/
export interface StoreRegistryDeps {
/** Resolve the current shared-wallet session (id + private-store anchor). */
getSession: () => Promise<RegistrySession>;
/** Normalize an identity id for shim keying. Default: trim (identity-ish). */
/** Normalize an identity id for shim keying. Default: {@link normalizeIdentityId}. */
normalizeId?: (id: string) => string;
/**
* POINTER micro-guard budget. The account records now live in a subscribable
@@ -60,20 +86,29 @@ export interface StoreRegistryDeps {
* "here is what you need to run", and two bootstrap calls is one more thing to delete
* at migration than there needs to be. Merged 2026-08-07; the registry's own wiring
* function stays internal.
*
* Two fields left on 2026-08-12, and what left with them
* `getSession` and `normalizeId` were published, and both made an application build
* something the target never asks it for:
*
* - `getSession` upstream a session is RETURNED (`init()`'s callback delivers it,
* `session_start` hands one back). An application that has to ASSEMBLE one out of
* `session_id` / `private_store_id` / is coding against a shape with no successor.
* The package captures the event instead ({@link ../surface/lifecycle}.init) and holds
* the session ({@link ./session}).
* - `normalizeId` the identities it normalizes are this package's own invention (one
* wallet, many virtual users). There is nothing upstream to normalize, so there was
* nothing for a consumer to decide; see {@link normalizeIdentityId}.
*
* Every consumer wrote the same plumbing for both, and the reference integration's copy
* carried two defects at once. Both remain substitutable through {@link StoreRegistryDeps},
* which the published entry cannot reach.
*/
export interface EventuallyConfig {
/** The REAL `@ng-org/web` `ng` (injected to avoid a hard import / alias loop). */
ng: NgLike;
/** The REAL `@ng-org/orm` `useShape`. */
useShape: UseShapeLike;
/**
* Resolve the wallet session. Shared-wallet only: upstream the session IS the user, so
* there is nothing to inject an application opens its wallet and the SDK knows.
* A thunk, so it may be given before the session exists.
*/
getSession?: () => Promise<RegistrySession>;
/** Normalize an identity id for shim keying. Default: trim. */
normalizeId?: (id: string) => string;
/**
* POINTER micro-guard budget see {@link StoreRegistryDeps.pointerGuard}. Left unset
* a single read, which keeps the synchronous unit fakes fast.
@@ -138,16 +173,14 @@ export function configure(c: EventuallyConfig): void {
// skip the barrier on a top-level page, which is the one thing it exists to prevent.
currentUser = null;
setAccessLog(c.debugAccessLog ?? false);
// The session wiring is part of the same act — see {@link EventuallyConfig}. Omitted
// only by unit suites that never touch the registry; those get the same
// "must be configured" error they got before, from `getStoreRegistryDeps`.
if (c.getSession) {
configureStoreRegistry({
getSession: c.getSession,
...(c.normalizeId ? { normalizeId: c.normalizeId } : {}),
...(c.pointerGuard ? { pointerGuard: c.pointerGuard } : {}),
});
}
// Wire the registry onto what the PACKAGE owns. Unconditional since 2026-08-12: there is
// no longer anything for a caller to supply here, so there is no longer a case where
// configuring the library leaves the registry half-wired. A suite that needs its own
// session or key rule calls `configureStoreRegistry` AFTER this, and overrides it.
configureStoreRegistry({
getSession: sharedWalletSession,
...(c.pointerGuard ? { pointerGuard: c.pointerGuard } : {}),
});
}
/** @internal — used by the SDK-shaped wrappers to reach the injected real SDK. */
@@ -157,10 +190,13 @@ export function getConfig(): EventuallyConfig {
}
/** Reset the injected config back to un-configured (mainly for tests, so a
* suite that calls configure() can restore the not-configured guard state). */
* suite that calls configure() can restore the not-configured guard state).
* The captured session goes with it: it arrived through the config's `init`, so leaving
* it behind would hand the next `configure()` the previous one's session. */
export function resetConfig(): void {
cfg = null;
currentUser = null;
resetSharedWalletSession();
}
/**
@@ -189,7 +225,7 @@ export function configureStoreRegistry(deps: StoreRegistryDeps): void {
};
registryDeps = {
getSession,
normalizeId: deps.normalizeId ?? ((id: string) => id.trim()),
normalizeId: deps.normalizeId ?? normalizeIdentityId,
// Default: single read (no re-read). Only the real-broker consumers (app + e2e)
// opt into the bounded pointer micro-guard; unit fakes stay synchronous.
pointerGuard: deps.pointerGuard ?? { attempts: 1 },
@@ -0,0 +1,114 @@
/**
* The wallet session, held by the PACKAGE never assembled by an application.
*
* Why this is not the application's business
* Upstream a session is RETURNED: `session_start` hands one back, and `init()`'s callback
* delivers `{ status: "loggedin", session }` (`@ng-org/web` `dist/ngweb.js:124`, VERIFIED
* the callback is invoked once, with the `Session` the broker just opened). Nowhere does an
* application build a session out of raw fields.
*
* It did here, and only here: every consumer had to wrap `init()` in a promise, catch that
* event, and hand the library back a thunk unwrapping `session_id` / `private_store_id` /
* `protected_store_id` / `public_store_id`. Identical plumbing in every application, with
* nothing to migrate to and it went wrong twice in the reference integration alone (a
* deadlock, and a share that silently reached nobody). So the package captures the event
* itself (`surface/lifecycle.ts`) and keeps the session here.
*
* What survives migration
* NO COUNTERPART as a module: upstream nothing relays the session, because the SDK holds
* the one the wallet opened. What survives is the application-side gesture this removes
* which is none at all.
*/
import type { RegistrySession } from "./account-registry";
/**
* The session as it stands, and a promise for the first one to arrive.
*
* Both, because the two questions differ: a caller after the fact wants the CURRENT session
* (a reconnection opens a new one, and reads must route through it), while a caller during
* startup has to wait for the first. Answering the first question with a settled promise
* would pin the very first session forever.
*/
let current: RegistrySession | null = null;
let announce!: (s: RegistrySession) => void;
let arrival = openArrival();
function openArrival(): Promise<RegistrySession> {
return new Promise<RegistrySession>((resolve) => {
announce = resolve;
});
}
/**
* The session this package holds the current one, or the first to arrive.
*
* It WAITS rather than refusing: before `init()` has been delegated to, no session can
* exist and nothing else will make one. A thunk that threw there is what shipped the
* silent-abandon defect (`emulated-verifier/connect.ts` swallows the throw, the run
* abandons, and the caller that joins it resolves having restored nothing).
*/
export function sharedWalletSession(): Promise<RegistrySession> {
return current !== null ? Promise.resolve(current) : arrival;
}
/**
* Read a lifecycle event, and keep the session if it carries one.
*
* Answers whether it did, so a caller can tell "the session landed" from "some other
* event went by" without reaching in. Every event that is not a `loggedin` carrying a
* session is ignored the callback is a general lifecycle channel, and inventing a
* session out of a partial event would be worse than having none.
*
* The session id is RELAYED, never rebuilt and that is load-bearing
* Upstream declares it `string | number` (`Session`, `index.d.ts:266`) and the broker
* returns a NUMBER; the whole chain below here types it `string` and hands it to `ng.*`,
* whose binding takes it as-is. So this reads the field and passes it on untouched. It is
* not a detail: normalizing it to a string was written here first, and the applicative e2e
* refused every call in the batch with `Deserialization error of session_id JsValue("1")`
* the wasm side deserializes the id by its own type, and a stringified number is not it.
*
* The `string` in the declared shape is therefore inherited, not asserted: the inaccuracy
* is the chain's and predates this module (every consumer's thunk declared it the same way
* and relayed the same value). Widening it belongs to the chain, not to the capture.
*/
export function captureSession(event: unknown): boolean {
if (typeof event !== "object" || event === null) return false;
const { status, session } = event as { status?: unknown; session?: unknown };
if (status !== "loggedin") return false;
if (typeof session !== "object" || session === null) return false;
const {
session_id: sessionId,
private_store_id: privateStoreId,
protected_store_id: protectedStoreId,
public_store_id: publicStoreId,
} = session as {
session_id?: string;
private_store_id?: string;
protected_store_id?: string;
public_store_id?: string;
};
// An event missing either anchor is not a session; the id is checked for PRESENCE only,
// since its runtime type is the broker's to choose and ours to relay.
if (sessionId === undefined || sessionId === null) return false;
if (typeof privateStoreId !== "string") return false;
current = {
sessionId,
privateStoreId,
...(typeof protectedStoreId === "string" ? { protectedStoreId } : {}),
...(typeof publicStoreId === "string" ? { publicStoreId } : {}),
};
announce(current);
return true;
}
/**
* Forget the captured session (a fresh `configure()`, or a test).
*
* The pending promise is REPLACED rather than left resolved: a suite that reset and then
* awaited again must wait for the next session, not be handed the previous one's.
*/
export function resetSharedWalletSession(): void {
current = null;
arrival = openArrival();
}
+25 -1
View File
@@ -22,6 +22,7 @@
import { getConfig } from "../shared-wallet/bootstrap";
import { settleIdentity } from "../shared-wallet/access-gate";
import { captureSession } from "../shared-wallet/session";
/**
* Forwards to the real `@ng-org/web` `init`, once the identifier is in the address bar.
@@ -38,11 +39,34 @@ import { settleIdentity } from "../shared-wallet/access-gate";
*
* The "not injected" error stays SYNCHRONOUS: it is a wiring mistake rather than a runtime
* one, and it threw synchronously before this forwarder had anything to await.
*
* It also LISTENS on the way through, and that is the one argument it touches
* The real `init` delivers the session by calling its callback with
* `{ status: "loggedin", session }` once, and it is the only channel that ever produces
* one (`@ng-org/web` `dist/ngweb.js:113-137`, VERIFIED). Until 2026-08-12 every application
* had to catch that event itself and hand the library a thunk unwrapping it, which is a
* shape the target never asks anyone to build and which two consumers in a row got wrong.
*
* So the callback in position 0 is WRAPPED: the wrapper reads the event, keeps the session
* (`shared-wallet/session.ts`), and then calls the caller's callback with that same event,
* unchanged and un-narrowed. Nothing else about the call moves the remaining arguments and
* the return value pass straight through, and the caller's callback still sees exactly what
* the real `init` sent it. It is the one place a wrapper can be, because it is the one place
* that knows both what the caller asked and what the SDK will answer.
*
* A caller that passes NO callback is the same act with nobody listening upstream accepts
* it (`callback: Function | null`, and the call site is guarded). The wrapper still goes in,
* so the package gets its session either way, and calls nothing afterwards.
*/
export function init(...args: any[]): any {
const f = getConfig().init;
if (!f) throw new Error("[ng-eventually] init() not injected — pass it to configure()");
return settleIdentity().then(() => f(...args));
const [callback, ...rest] = args;
const listen = (event: unknown): unknown => {
captureSession(event);
return typeof callback === "function" ? callback(event) : undefined;
};
return settleIdentity().then(() => f(listen, ...rest));
}
/** Forwards to the real `@ng-org/orm` `initNg` (ORM signals). */
+7 -4
View File
@@ -71,15 +71,18 @@ afterEach(() => {
});
function configured() {
configureStoreRegistry({
getSession: async () => ({ sessionId: "s", privateStoreId: "did:ng:o:p" }),
normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(),
});
configure({
ng: {} as never,
useShape: (() => {}) as never,
sharedWallet: { fileUrl: "/w.ngw", password: "pw" },
});
// AFTER `configure`, which wires the registry onto the package's own session — a session
// that only `init()` can open, and no page here calls it. The substitution is what lets
// these tests reach the gate without a broker, and it must be the last word.
configureStoreRegistry({
getSession: async () => ({ sessionId: "s", privateStoreId: "did:ng:o:p" }),
normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(),
});
}
test("an identity already set is left alone — the gate never re-asks", async () => {
+5 -1
View File
@@ -9,6 +9,7 @@
import { test, expect, mock, afterEach } from "bun:test";
import { configure, ensureIdentity, storeRegistry } from "../src/index";
import {
configureStoreRegistry,
resetCaps,
resetConfig,
resetStoreRegistry,
@@ -89,8 +90,11 @@ function inject(failWriteMatching?: RegExp) {
configure({
ng: { doc_create, sparql_update, sparql_query } as never,
useShape: (() => {}) as never,
getSession: async () => SESSION,
});
// The session comes from `init()` upstream and from the package's capture here, so a
// suite with no browser and no broker substitutes one through the internal wiring path —
// the same one the e2e harness uses. AFTER `configure`, which wires the package's own.
configureStoreRegistry({ getSession: async () => SESSION });
resetRegistryCache();
resetCaps();
setCurrentUser(null);
+81 -14
View File
@@ -17,7 +17,13 @@
import { test, expect, afterEach } from "bun:test";
import { configure, ensureIdentity } from "../src/index";
import { init } from "../src/surface/lifecycle";
import { resetConfig, resetStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import {
configureStoreRegistry,
resetConfig,
resetStoreRegistry,
setCurrentUser,
} from "../src/shared-wallet/bootstrap";
import { sharedWalletSession } from "../src/shared-wallet/session";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
const KEY = "ng-eventually:identity";
@@ -125,26 +131,39 @@ afterEach(() => {
for (const name of PAGE_GLOBALS) Reflect.deleteProperty(globalThis, name);
});
/** The event the real `init` sends its callback once the broker answers — `ngweb.js:124`. */
const BROKER_SESSION = { session_id: "s", private_store_id: "did:ng:o:p" };
const loggedIn = (session: Record<string, unknown> = BROKER_SESSION) => ({
status: "loggedin",
session,
});
/**
* The consumer's real wiring, reduced to the cycle it creates.
*
* An application resolves its session FROM `init()`'s callback and hands the library a
* thunk that waits for it (`examples/notebook/app.ts`). So before `init()` runs the session
* does not exist and cannot: nothing else resolves it. That is why the injected `init`
* here resolves it a `getSession` that answered straight away would be a state the real
* system never reaches, and it is precisely the state under which the deadlock below is
* invisible.
* The session exists only from `init()`'s callback: nothing else in the system opens one,
* so before `init()` runs there is none and there cannot be. That is why the injected
* `init` here is what produces it a session that answered straight away would be a state
* the real system never reaches, and it is precisely the state under which the deadlock
* below is invisible.
*
* It produces it the way the real one does: by calling the callback it was HANDED, once,
* with `{ status: "loggedin", session }`. Since 2026-08-12 that callback is the library's
* wrapper, so this also exercises the capture; `sessionReady` here is this fixture's own
* view of the same instant, kept so the assertions can name it.
*
* The spy records what the real `init()` reads at the moment it is called the address
* bar and returns a promise, as the real one does.
*/
function consumerWiring() {
function consumerWiring(session: Record<string, unknown> = BROKER_SESSION) {
let arrived!: (s: RegistrySession) => void;
const sessionReady = new Promise<RegistrySession>((resolve) => { arrived = resolve; });
const calls: { href: string; args: unknown[] }[] = [];
const returned = { itsOwnReturnValue: true };
const injectedInit = (...args: unknown[]): Promise<unknown> => {
calls.push({ href: String((globalThis as { location?: { href: string } }).location?.href), args });
const callback = args[0];
if (typeof callback === "function") void (callback as (e: unknown) => unknown)(loggedIn(session));
arrived({ sessionId: "s", privateStoreId: "did:ng:o:p" });
return Promise.resolve(returned);
};
@@ -156,9 +175,14 @@ function configured(wiring: ReturnType<typeof consumerWiring>, opts: { sharedWal
ng: {} as never,
useShape: (() => {}) as never,
init: wiring.injectedInit,
...(opts.sharedWallet === false ? {} : { sharedWallet: { fileUrl: "/w.ngw", password: "pw" } }),
});
// The registry reaches the session through the internal wiring path, pointed at THIS
// fixture's promise — which, like the package's own, only `init()` can resolve. Without
// that the deadlock test below would be measuring a session that arrives by itself.
configureStoreRegistry({
getSession: wiring.getSession,
normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(),
...(opts.sharedWallet === false ? {} : { sharedWallet: { fileUrl: "/w.ngw", password: "pw" } }),
});
}
@@ -260,17 +284,60 @@ test("`init` and `ensureIdentity` in the same tick raise ONE barrier, not two",
});
test("arguments and return value pass through untouched — it is still a forwarder", async () => {
// Settling is added BEFORE the delegate, never around it: `init` takes a callback and
// upstream returns a promise, so anything this wrapper altered on the way in or out
// would be a difference the application has to unlearn at migration.
// Settling is added BEFORE the delegate, never around it, and the return value comes back
// as it left: anything this wrapper altered on the way in or out is a difference the
// application has to unlearn at migration.
//
// The ONE exception is the callback, wrapped since 2026-08-12 so the package can keep the
// session the SDK delivers through it. What must therefore hold is not that the same
// function object arrives — it does not — but that the caller's callback still sees
// exactly the event the SDK sent, unchanged and un-narrowed. That is the property an
// application depends on, and the only one that survives migration.
const wiring = consumerWiring();
configured(wiring);
inBrowser(APP, fakeStorage(), "top-level");
setCurrentUser("juno");
const callback = (): void => {};
const seen: unknown[] = [];
const callback = (event: unknown): void => void seen.push(event);
const result = await within(init(callback, true, ["a-broker"]));
expect(wiring.calls[0]!.args).toEqual([callback, true, ["a-broker"]]);
expect(wiring.calls[0]!.args.slice(1)).toEqual([true, ["a-broker"]]);
expect(seen).toEqual([loggedIn()]);
expect(result).toBe(wiring.returned);
});
test("the session id is RELAYED, not rebuilt — what the broker sent is what is kept", async () => {
// The real broker answers `session_id: 1` — a NUMBER (upstream types it `string | number`,
// `index.d.ts:266`) — and the wasm binding deserializes it by that type. Normalizing it to
// a string was written into the capture first, and the applicative e2e refused every call
// in the batch: `Deserialization error of session_id JsValue("1")`. Nothing downstream
// reads this value, it only travels; so the capture must relay it untouched.
const wiring = consumerWiring({ session_id: 1, private_store_id: "did:ng:o:p" });
configured(wiring);
inBrowser(APP + "?ng-id=otto", fakeStorage(), "in the broker iframe");
await within(init(() => {}, true, []));
const relayed: unknown = (await within(sharedWalletSession())).sessionId;
expect(relayed).toBe(1);
});
test("the package holds the session even when the caller passes NO callback", async () => {
// Upstream the callback is optional (`callback: Function | null`), and an application
// that wants nothing from the lifecycle channel legitimately passes none. The session
// still has to reach the library, or every read that follows waits on a session that
// was delivered to nobody — the same silence, from the opposite direction.
//
// Inside the iframe: the only side where a session is ever opened.
const wiring = consumerWiring();
configured(wiring);
inBrowser(APP + "?ng-id=nell", fakeStorage(), "in the broker iframe");
await within(init(undefined, true, []));
await expect(within(sharedWalletSession())).resolves.toEqual({
sessionId: "s",
privateStoreId: "did:ng:o:p",
});
});
+59 -42
View File
@@ -9,23 +9,39 @@
* The mechanism, so a future reader can judge a change against it
* Settling the identity called `setCurrentUser`, which FIRES the connection work. Firing is
* not awaiting, but it is still running: the work's first act is `resolveAccount`, which
* awaits the consumer's `getSession` thunk. Settling happens before `init()` has been
* delegated to and the reference application builds its `sessionReady` promise AROUND
* that very `init()` call, so at that instant the promise it would wait on does not exist.
* The thunk could not answer. It threw, `resolveAccount` answered null, and the run
* abandoned before restoring a single capability after registering itself as the
* connection in flight. The `connectedUser()` that `ensureIdentity()` awaits then JOINED
* that abandoned run instead of doing the work, and resolved having done nothing. The
* application rendered, and Bob held no key to a note that had been shared with him.
* awaits the session. Settling happens before `init()` has been delegated to and the
* reference application built its `sessionReady` promise AROUND that very `init()` call, so
* at that instant the promise it would wait on did not exist. The session could not be
* answered. It threw, `resolveAccount` answered null, and the run abandoned before
* restoring a single capability after registering itself as the connection in flight. The
* `connectedUser()` that `ensureIdentity()` awaits then JOINED that abandoned run instead of
* doing the work, and resolved having done nothing. The application rendered, and Bob held
* no key to a note that had been shared with him.
*
* So what is pinned is not "the calls happen in this order" a regression would still call
* them in order. It is what each half TOUCHES: settling must not call the session thunk at
* all, and signing in must not come back until the thunk has actually answered.
* them in order. It is what each half TOUCHES: settling must not reach for the session at
* all, and signing in must not come back until the session has actually answered.
*
* What moved on 2026-08-12, and what it does not excuse
* The session is no longer the application's to supply: the package captures it from
* `init()`'s callback and holds it (`shared-wallet/session.ts`), so the promise the
* connection work waits on is now the library's own. That closes the *shape* of the failure
* above a holder that WAITS cannot throw, so a run can no longer abandon for want of an
* answer. It closes nothing about the ordering: the session still arrives only through
* `init()`, so a half that reached for it too early would still be waiting on something
* that does not exist yet. Both properties below are therefore still worth their assertion,
* and the fixture instruments the package's own holder rather than a consumer's thunk.
*/
import { test, expect, afterEach } from "bun:test";
import { configure, ensureIdentity } from "../src/index";
import { init } from "../src/surface/lifecycle";
import { resetConfig, resetStoreRegistry, setCurrentUser } from "../src/shared-wallet/bootstrap";
import {
configureStoreRegistry,
resetConfig,
resetStoreRegistry,
setCurrentUser,
} from "../src/shared-wallet/bootstrap";
import { sharedWalletSession } from "../src/shared-wallet/session";
import type { RegistrySession } from "../src/shared-wallet/account-registry";
const APP = "https://app.example/";
@@ -73,39 +89,46 @@ afterEach(() => {
/**
* The reference application's bootstrap, in its real order (`examples/notebook/app.ts`):
* `configure()` first, then `init()` called from INSIDE the executor that builds the very
* promise the session thunk waits on.
* `configure()`, then `init()` and nothing else, since the session stopped being
* something an application assembles.
*
* That shape is not a curiosity of this fixture, it is what the e2e serves: in the bundle,
* `sessionReady` is a hoisted `var`, so while the executor runs it is still `undefined` and
* the thunk has nothing to wait on. It therefore **refuses** rather than blocking and an
* application is entitled to refuse, since at that moment there is genuinely nothing to
* return. A thunk that merely blocked would hide the whole defect, which is why the double
* here refuses exactly as the application's does.
* The injected `init` produces the session the way the real one does: by calling the
* callback it was handed, with `{ status: "loggedin", session }` (`ngweb.js:124`). Before
* it is called, nothing in the system can make the session exist which is the whole
* cycle these two tests measure.
*
* The injected `init` resolves the session, as the real one does through its callback:
* before it is called, nothing in the system can make the session exist.
* The counters sit on the REGISTRY's route to the session, substituted through the internal
* wiring path. That is where the connection work actually asks, so it is where "was it
* reached, and did it answer" can be told apart. `refused` is kept though the package's
* holder cannot throw: a future change that put a refusing thunk back on this route is
* exactly the regression the file exists to catch, and a counter nobody kept would let it
* back in silently.
*/
function bootTheApplication(identifier: string) {
/** What the consumer's thunk was asked, and what it was able to say. */
/** What the registry's route to the session was asked, and what it was able to say. */
const thunk = { asked: 0, answered: 0, refused: 0 };
let session: RegistrySession | null = null;
// Deliberately assigned AFTER `init()` runs — see above. `undefined` until then.
let sessionReady: Promise<RegistrySession> | undefined;
let arrive!: (s: RegistrySession) => void;
configure({
ng: {} as never, // no store behind it: the assertions are about what is REACHED
useShape: (() => {}) as never,
init: (..._args: unknown[]): Promise<string> => {
arrive({ sessionId: "s", privateStoreId: "did:ng:o:private" });
init: (...args: unknown[]): Promise<string> => {
const callback = args[0];
if (typeof callback === "function") {
void (callback as (e: unknown) => unknown)({
status: "loggedin",
session: { session_id: "s", private_store_id: "did:ng:o:private" },
});
}
return Promise.resolve("delegated");
},
sharedWallet: { fileUrl: "/w.ngw", password: "pw" },
});
configureStoreRegistry({
getSession: async (): Promise<RegistrySession> => {
thunk.asked += 1;
try {
const s = session ?? (await sessionReady!);
const answer = { sessionId: s.sessionId, privateStoreId: s.privateStoreId };
const answer = await sharedWalletSession();
thunk.answered += 1;
return answer;
} catch (refusal) {
@@ -114,26 +137,20 @@ function bootTheApplication(identifier: string) {
}
},
normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(),
sharedWallet: { fileUrl: "/w.ngw", password: "pw" },
});
inBrokerIframe(`${APP}?ng-id=${encodeURIComponent(identifier)}`);
let delegated!: Promise<unknown>;
sessionReady = new Promise<RegistrySession>((resolve) => {
arrive = resolve;
delegated = init(() => {}, true, []) as Promise<unknown>;
});
void sessionReady.then((s) => { session = s; });
const delegated = init(() => {}, true, []) as Promise<unknown>;
return { thunk, delegated };
}
test("settling the identity never asks the application for a session", async () => {
test("settling the identity never reaches for a session", async () => {
// The session-free half, taken at its word. `init()` awaits settling and nothing else, so
// by the time it has delegated, the consumer's thunk must not have been called ONCE —
// not called-and-blocked, not called-and-refused, not called at all. Anything the gate
// reaches that ends up at `getSession` is outside the half it claims to be.
// by the time it has delegated, the session must not have been asked for ONCE — not
// asked-and-blocked, not asked-and-refused, not asked at all. Anything the gate reaches
// that ends up at the session is outside the half it claims to be.
const app = bootTheApplication("bob");
await app.delegated;
@@ -146,7 +163,7 @@ test("signing in does not come back until the connection work has reached a live
// The consequence, from the application's side. `ensureIdentity()` promises that what was
// shared with you is readable when it resolves; it can only keep that promise by having
// restored and drained, and both begin by resolving the account — which needs the
// session. So a run that resolved without the thunk ever ANSWERING did no such work,
// session. So a run that resolved without the session ever ANSWERING did no such work,
// whatever it reported. That is exactly the state Bob's page was in.
const app = bootTheApplication("bob");
await app.delegated;
@@ -365,7 +365,9 @@ test("injection: a malicious id still round-trips through the shim", async () =>
expect(back?.docPublic).toBe(rec.docPublic);
});
test("normalizeId defaults to trim when not provided", async () => {
// The package's own key rule applies when nothing substitutes one — it is the default of
// the internal wiring path, not something a consumer chooses (2026-08-12).
test("the identity key rule defaults to the package's when not provided", async () => {
const ng = makeFakeNg();
configure({ ng: ng as any, useShape: (() => {}) as any });
// Synchronous fake store → no sync lag; disable the anti-fork retry backoff.
+4 -3
View File
@@ -184,10 +184,11 @@ test("no published name says `wallet` where the target says `user`", () => {
// --- the invariant the internal contract flagged as a migration risk -------
test("a reserved-namespace key cannot be produced by a consumer's normalizeId", async () => {
test("a reserved-namespace key cannot be produced by the identity key rule", async () => {
// The reserved namespace hosts infrastructure accounts, and its guarantee is that no
// user id lands there. That guarantee is not the library's to make — `normalizeId` is
// injected by the consumer — so a careless one must be refused, not trusted. A
// user id lands there. The rule that produces the key does not enforce it — the
// package's own only trims, strips a leading `@` and lowercases, and the internal wiring
// path lets a suite substitute another — so a careless one must be refused, not trusted. A
// collision would key a user onto an infrastructure account: reads and writes on
// documents that are not theirs.
const { configureStoreRegistry, resetStoreRegistry } = await import("../src/shared-wallet/bootstrap");