/** * The injection store — where the consumer application plugs the real SDK in, and * where the emulation keeps the state that only exists because one wallet hosts every * identity: the injected `ng`/`useShape`, the registry dependencies, WHO is currently * connected, and the `CapRegistry` singleton keyed by that holder. * * **NO COUNTERPART at any layer, by construction.** Upstream nothing is injected: the * app imports the SDK, and "who am I" is the session — there is no current-user relay * because a wallet has exactly one user. This module is the shape of that absence, so * it belongs with the shared-wallet machinery and evaporates whole at migration. * * Extracted from `polyfill.ts` on 2026-08-03. Before that, every internal module * imported the published ENTRY to reach the config, which made the entry a dependency * of the code it publishes — cycles `polyfill` <-> `connect` and `polyfill` <-> `inbox`. * The entry now only re-exports; the internals import this module instead. */ import type { NgLike, UseShapeLike, Nuri, NuriLike, PrincipalId, ReadCap } from "../model/types"; import type { SharedWalletConfig } from "./access-gate"; import { toNuri } from "../model/nuri"; import type { RegistrySession } from "./account-registry"; import { CapRegistry } from "../emulated-verifier/caps"; import { resetPublicStoreFetches } from "../emulated-verifier/public-store"; import { setAccessLog } from "./access-log"; import { inspectOutbox } from "./outbox-log"; import { startConnect } from "../emulated-verifier/connect"; import { cancelScheduledInboxProcessing } from "../emulated-verifier/inbox-processor"; import { resetSharedWalletSession, sharedWalletSession } from "./session"; /** * 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; /** 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 * doc-shim (`did:ng:o:...`) reached through a well-known write-once POINTER triple * in the store-root graph. The doc-shim read is barrier-AUTHORITATIVE, so accounts * need NO retry (this replaces the deleted account-level `provisionRetry`). The * ONLY residual sync-lag window is the store-root pointer read itself — one * write-once triple. This bounded guard re-reads JUST that pointer a few times if a * fresh cold read misses it; it can never provision or fork an account (worst case: * a couple extra reads before an existing pointer is seen). Enable it where the REAL * broker is used (app + e2e). Left UNSET (the default) → `attempts: 1` = single * read, keeping the synchronous unit fakes fast and unchanged. */ pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number }; } /** * Everything the polyfill needs, in ONE call. * * It used to take two — `configure` for the SDK injection, `configureStoreRegistry` for * the session — because the two belonged to different internals. That is a reason the * library has, not one an application should pay for: from a caller's side both are * "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; /** * POINTER micro-guard budget — see {@link StoreRegistryDeps.pointerGuard}. Left unset * → a single read, which keeps the synchronous unit fakes fast. */ pointerGuard?: { attempts?: number; baseMs?: number; maxStepMs?: number }; /** * The shared wallet this deployment hands out, and what the access gate needs to do * it (`shared-wallet/access-gate.ts`). Absent → no gate; the caller sets the identity * itself. Disappears with the gate: upstream a user opens their own wallet. */ sharedWallet?: SharedWalletConfig; /** * Turn on the OFF-by-default document access log (see {@link ./access-log}): * every real read/write is printed, prefixed by the active identity, to * diagnose the shared-wallet isolation leak. Also enablable without a code * change via the env var `NG_EVENTUALLY_ACCESS_LOG=1`. Default: false. */ debugAccessLog?: boolean; /** REAL `@ng-org/web` `init` (lifecycle) — forwarded by the lib's `init()`. */ init?: (...args: any[]) => any; /** REAL `@ng-org/orm` `initNg` (ORM signals) — forwarded by the lib's `initNg()`. */ initNg?: (...args: any[]) => any; } let cfg: EventuallyConfig | null = null; let currentUser: PrincipalId | null = null; /** * What hands the page to the broker — the polyfill's `init()` reduced to a thunk, kept so a * page that comes BACK from the hand-over can run it AGAIN ({@link ./access-gate}). * * It lives here rather than in either module that uses it because it is made of the injected * `init` and the caller's arguments, and this is where the injection is: `init()` registers * it (`../surface/lifecycle.ts`), the barrier runs it when someone confirms a second time. * Kept for the life of the page — the return it exists for happens long after the call. */ let handOver: (() => void) | null = null; /** Required fields of StoreRegistryDeps after defaults are applied. `pointerGuard` * defaults to `{ attempts: 1 }` (single read) when the consumer leaves it unset. */ type ResolvedRegistryDeps = Required< Pick >; let registryDeps: ResolvedRegistryDeps | null = null; /** * Does the registry reach the session through the package's OWN holder? * * {@link configure} points it there, and that is what every application gets. The library's * suites and the e2e harness substitute a route of their own * ({@link configureStoreRegistry}) and hold a session no `init()` of this package opened — * so "`init()` was never called, therefore no session can ever arrive" is a true statement * about the package's holder and about nothing else. Recorded at the wiring rather than * asked afterwards: the wiring WRAPS the injected thunk, so it can no longer be recognised. */ let ownSessionRoute = false; /** * The map key of the current identity — deliberately NOT the raw id. * * A virtual user IS a shim account, and the shim keys accounts by the * consumer-injected `normalizeId` ("@Alice" and "alice" are ONE account, with one * set of scope documents). This record must key the same way, or a consumer that * spells its own id differently between two calls gets a SECOND record and stops * reading its own documents — the caps are filed under one spelling and looked up * under the other. Falls back to the raw id while the registry deps are not yet * configured (nothing can be filed before that anyway). */ function capsHolder(): PrincipalId | null { if (currentUser === null) return null; return registryDeps ? registryDeps.normalizeId(currentUser) : currentUser; } /** * The emulated cap registry — one record PER identity (per virtual user), * resolved through {@link capsHolder} on every call. So switching identity * SWITCHES heldByHolder (nothing to reset, nothing wiped); see `caps.ts`. Empty until * the first cap is issued, and while it is empty the read filter passes through * (no regression). */ let caps = new CapRegistry(capsHolder); export function configure(c: EventuallyConfig): void { cfg = c; // Not taken from the config: an application never supplies its own identity — upstream // it comes FROM the wallet a person opened. Accepting one here would also let a caller // skip the barrier on a top-level page, which is the one thing it exists to prevent. currentUser = null; setAccessLog(c.debugAccessLog ?? false); // 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. */ export function getConfig(): EventuallyConfig { if (!cfg) throw new Error("[ng-eventually] configure() must be called before use"); return cfg; } /** Reset the injected config back to un-configured (mainly for tests, so a * 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; // Any deferred inbox drain still waiting goes with it: it would fire into a library that // no longer has an `ng` to read or write with, and report a failure about a session that // no longer exists. See `emulated-verifier/inbox-processor.ts`. cancelScheduledInboxProcessing(); // The hand-over goes with it, for the same reason: it is made of the config's injected // `init`, so leaving it behind would let a revived barrier delegate to the PREVIOUS // application's SDK. handOver = null; resetSharedWalletSession(); } /** * Remember how this page is handed to the broker. Called by the polyfill's `init()` on its * way through, before it delegates. * * @internal Never published: an application does not perform the hand-over, it calls `init`. */ export function rememberHandOver(delegate: () => void): void { handOver = delegate; } /** * The registered hand-over, or `null` when `init()` has never been called — in which case * nothing ever navigated, so there is no return from a hand-over to serve. * * @internal */ export function getHandOver(): (() => void) | null { return handOver; } /** * Wire the storeRegistry's dependencies. INTERNAL since 2026-08-07: an application * passes these to {@link configure}, which calls this. Still exported for the library's * own suites, which wire the registry alone. */ export function configureStoreRegistry(deps: StoreRegistryDeps): void { // Fire the outbox inspection (Volet 3 of the low-level data-path trace) once, // on the FIRST successful `getSession()` resolution — the most reliable // "a session is established" signal available: every low-level reader/writer // (store-registry, open-repo, read-model, subscribe, inbox) reaches its // session through this SAME injected `getSession`, so wrapping it HERE catches // the first success from whichever caller happens to run first, instead of // tying the probe to one particular call site. Only on SUCCESS (an error // propagates untouched, exactly as before) and only ONCE per // `configureStoreRegistry()` call (a fresh session config → a fresh check). let outboxInspected = false; const getSession = async (): Promise => { const session = await deps.getSession(); if (!outboxInspected) { outboxInspected = true; inspectOutbox(); } return session; }; ownSessionRoute = deps.getSession === sharedWalletSession; registryDeps = { getSession, 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 }, }; } /** @internal — used by the storeRegistry to reach its injected dependencies. */ export function getStoreRegistryDeps(): ResolvedRegistryDeps { if (!registryDeps) { throw new Error("[ng-eventually] configureStoreRegistry() must be called before use"); } return registryDeps; } /** @internal — see {@link ownSessionRoute}. */ export function sessionRouteIsThePackages(): boolean { return ownSessionRoute; } /** Reset storeRegistry deps (mainly for tests). */ export function resetStoreRegistry(): void { registryDeps = null; ownSessionRoute = false; } /** * Record who is acting — and touch nothing else. Answers whether it CHANGED, which is * what decides whether there is any connecting to do. * * Split out of {@link setCurrentUser} so that the two things it did — naming the identity * and reaching for the session on its behalf — can be asked for separately. Only the * session-FREE half of signing in uses this one ({@link ./access-gate}.settleIdentity), * and that is the whole of why it exists: see the warning on {@link setCurrentUser}. * * @internal Never published. A consumer names its identity through the access gate. */ export function adoptCurrentUser(id: PrincipalId | null): boolean { const changed = currentUser !== id; currentUser = id; return changed; } /** * Set the current identity id — who the SDK is reading/writing as — **and connect it**. * In the target this is the wallet user established at wallet-import time; here the * consumer relays that id through this call so the read filter and the inbox `from` know * who is acting. Passing `null` clears it (no identity yet, e.g. during startup). * * ── This call REACHES THE SESSION, and a caller must know it ────────────── * "Fire-and-forget" says nothing about whether the session is touched — only about who * waits. The fired work asks `resolveAccount` on its first line, which awaits the * consumer's `getSession` thunk (`connect.ts` → `account-registry.ts:280`). So this * setter is unusable at any moment the session cannot yet answer, and calling it there is * not merely wasteful — it **poisons** the work: the thunk throws, `resolveAccount` * returns null, the run abandons before restoring anything, and it is that abandoned run * that the next `connectedUser()` JOINS instead of doing the work (`connect.ts:56`). * Nothing anywhere throws; a user simply cannot read what was shared with it. * * That is not hypothetical — it shipped. The reference application calls the polyfill's * `init()` from inside the executor that is still building its own `sessionReady` * promise, so the thunk could not answer, by construction. Whoever settles an identity * before a session can exist wants {@link adoptCurrentUser} and an awaited * `connectedUser()` later. * * Gated on the registry being configured, and that is not a test convenience: an * identity set before the registry is wired has nothing to restore and no inbox to * reach. The consumer's real sequence is `configureStoreRegistry` then `setCurrentUser`; * anything else can call `connectedUser()` explicitly. */ export function setCurrentUser(id: PrincipalId | null): void { const changed = adoptCurrentUser(id); // Connecting a user is what triggers inbox processing — the library's job, not // the app's. Fire-and-forget: this setter is synchronous and every consumer calls // it from synchronous code, so the work announces itself through the cap // registry's change signal instead of making callers await. See `connect.ts`. if (changed && id !== null && registryDeps !== null) startConnect(); } export function getCurrentUser(): PrincipalId | null { return currentUser; } /** The emulated cap registry — what the current identity holds, plus the emulated * public store. The read filter and the read-model consult it. */ export function getCaps(): CapRegistry { return caps; } /** * Do I hold this document's key? * * The only question the model admits. There is no "may principal P read D" anywhere * upstream and there cannot be: reading IS key possession, so a cap-introspection API * would have to invent an ACL the engine does not have (`docs/api-contract.md` § 10). * * Returns a BOOLEAN, not the cap. It used to hand the value back, and the only consumer * that used it did so to pass it to `share` — which now takes the document instead. * Nothing an application does requires holding a key: upstream it never sees one, the * verifier fills `ContactDetails.read_cap` itself. So the surface answers the question * and keeps the key. */ export function hasCap(nuri: NuriLike): boolean { return caps.capFor(toNuri(nuri, "hasCap")) !== undefined; } /** * Drop EVERY holder's caps (tests / a fresh wallet). This is **not** what an identity * change does: switching identity switches heldByHolder, it never wipes one — if it * wiped, durability would be a lie and per-session re-declaration would come back * under another name. Nothing in the library calls this on `setCurrentUser`. */ export function resetCaps(): void { // Clear IN PLACE rather than rebuilding: whoever subscribed to the registry's // change signal (`watchShape`) stays subscribed to the live instance instead of // silently holding a listener on an orphaned one. caps.clear(); // …and forget which documents were already asked about, or the emulated public-store // fetch would answer from a memo taken before the wipe and hand back caps this // registry no longer holds. resetPublicStoreFetches(); }