Files
ng-eventually/packages/polyfill/src/shared-wallet/bootstrap.ts
T
Sylvain Duchesne 07dfe68473 feat: un dépôt est traité vingt secondes après, sans attendre son destinataire
Un dépôt attendait la prochaine connexion de son destinataire — potentiellement
des heures. Un vrai NextGraph aura un service qui traite les inbox en continu ;
il n'existe pas. On l'émule : après une écriture dans une inbox, une échéance
unique de vingt secondes draine l'inbox DE LA CIBLE.

C'est une usurpation d'identité, possible seulement parce qu'un portefeuille
partagé détient toutes les identités virtuelles. Elle est acceptable parce que
l'application n'apprend rien de faux : elle observe que les dépôts finissent par
converger, ce qui restera vrai avec un vrai service. Ce qui ne doit pas fuir,
c'est le mécanisme.

Trois gardes, tenues par du code et non par des consignes.

Rien n'atteint la surface publiée : les exports sont épinglés par un test, et
publier ceci reviendrait à publier un appel qui traite l'inbox d'autrui — après
quoi il ne resterait rien du modèle de confidentialité.

Le drainage agit avec un détenteur EXPLICITE, jamais l'identité ambiante. Le
propriétaire vient d'un enregistrement de routage (shim:inboxOwner), et cet
identifiant est passé à chaque étape. C'était le vrai danger : readLinks et
myInboxes demandent getCurrentUser() au moment où elles s'exécutent, donc un
drainage lancé pendant la session d'Alice aurait classé les capacités de Bob
chez elle. Le test l'épingle — après le drainage, Alice n'a aucune capacité sur
le document concerné, et les deux Links sont bien chez Bob, durablement.

Et les échecs remontent au journal d'accès au lieu de disparaître. Une boucle
différée qui avale ses erreurs, c'est la famille retirée en 8c8ade7 et e32b6d0.

Coalescence : une seule échéance en attente par cible, et deux drainages d'une
même inbox ne se chevauchent jamais — processInbox écrit ce qu'il applique.

Limite assumée : si la page disparaît avant l'échéance, le dépôt attend la
prochaine connexion. C'est le comportement honnête d'une émulation qui tient la
place d'un service absent.
2026-08-16 15:17:49 +02:00

402 lines
20 KiB
TypeScript

/**
* 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<RegistrySession>;
/** 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<StoreRegistryDeps, "getSession" | "normalizeId" | "pointerGuard">
>;
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<RegistrySession> => {
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();
}