refactor: le paquet s'appelle polyfill, « SDK » désigne celui de NextGraph

Le nom @ng-eventually/sdk entrait en collision avec le SDK de NextGraph, dont
ce paquet est justement un polyfill. Impossible d'écrire « le SDK » sans lever
l'ambiguïté à chaque phrase — et le contrat publié, lu par une application,
était le pire endroit pour laisser traîner ça.

packages/sdk → packages/polyfill, @ng-eventually/sdk → @ng-eventually/polyfill,
contract_sdk-surface → contract_polyfill-surface, e2e/sdk-entry.ts →
e2e/polyfill-entry.ts, docs/sdk-reference.md → docs/polyfill-reference.md.

Les occurrences de « SDK » qui désignent celui de NextGraph restent intactes,
y compris les chemins dans nextgraph-rs (sdk/js/orm, sdk/js/web). Le tri s'est
fait occurrence par occurrence, pas par substitution.

Le contrat énonce désormais son identité en une phrase : « This package is a
polyfill of NextGraph's SDK. »
This commit is contained in:
Sylvain Duchesne
2026-08-10 17:14:25 +02:00
parent 49b046268e
commit 737729c9ce
88 changed files with 122 additions and 106 deletions
@@ -0,0 +1,288 @@
/**
* The access gate — the whole shared-wallet sign-in, moved out of consumer applications.
*
* ── Why this lives in the library ─────────────────────────────────────────
* Every step below exists ONLY because one wallet hosts several identities. An
* application that implements them is writing code it will have to delete, and worse,
* code that teaches its authors a model NextGraph does not have: *"I name my identity"*.
* The first consumer had ~300 lines of it (a gate component, a screen, a wallet module,
* an identity context, three BDD features). That is the library's work, not theirs.
*
* Upstream, none of this exists. A user opens THEIR wallet, it contains THEIR site
* (`SensitiveWalletV0.personal_identity()`, `engine/wallet/src/types.rs:576-579`), and
* `session_start(wallet_name, user_id)` takes an id that came FROM the wallet. There is
* nothing to name and nothing to choose. So this module is pure scaffolding: it
* evaporates whole, and the one call it exposes becomes a plain "open the session".
*
* ── The three steps, and why each is here ─────────────────────────────────
* 1. **Hand over the wallet file.** A hosted broker cannot import a wallet inline during
* web-app auth — a first-time device has no wallet, so the redirect dead-ends. So the
* user downloads the `.ngw` and imports it once on the wallet app. The FILE is the
* right primitive: a TextCode is a transient 5-minute device-to-device transfer,
* unusable to embed.
* 2. **Show the shared password**, for that import.
* 3. **Take an identifier**, which names the virtual space. This is the step that
* inverts the model, and the reason the whole gate is scaffolding.
*
* ── The identifier crosses a storage boundary, and that is not incidental ──
* The flow runs in TWO contexts with SEPARATE localStorage partitions: the top-level
* page and the broker iframe (browsers partition storage by top-level site). A value
* written top-level is NOT the value the iframe reads. What DOES cross is the URL: the
* redirect embeds the full app URL, query included, and reloads it in the iframe. Hence
* the resolution order, which must not be "simplified":
*
* 1. `?ng-id=` in the URL — wins whenever present, because it is the only thing that
* crosses the frontier;
* 2. otherwise localStorage — same-partition convenience, and prefill on reload.
*
* Getting this wrong does not fail loudly: the iframe reads an empty identity, provisions
* a second virtual user, and the returning user silently lands in an empty space.
*/
import {
getConfig,
getCurrentUser,
getStoreRegistryDeps,
setCurrentUser,
} from "./bootstrap";
import { connectedUser } from "../emulated-verifier/connect";
import type { PrincipalId } from "../model/types";
/**
* Normalize an identifier the SAME way the shim keys accounts on.
*
* 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()`.
*
* 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.
*/
function normalizeIdentity(raw: string): string {
try {
return getStoreRegistryDeps().normalizeId(raw);
} catch {
return raw.trim().replace(/^@/, "").toLowerCase();
}
}
/** Where the gate stashes the identifier so a plain reload prefills it. */
const STORAGE_KEY = "ng-eventually:identity";
/** The URL parameter — the only channel that survives the broker round-trip. */
const URL_PARAM = "ng-id";
/**
* What a deployment must supply for the gate to run. These are not settings a user
* tunes: they are the shared wallet this deployment hands out, so they belong to
* whoever deploys, and they disappear with the gate.
*
* **The library reads no environment variable, ever.** The application resolves these at
* its own build — copying the `.ngw` into its bundle, injecting the password — and
* passes the VALUES here. A library that read `process.env` would impose its build
* system on every consumer, and would be untestable with other values.
*/
export interface SharedWalletConfig {
/** URL of the `.ngw` file served by the application's own bundle. */
fileUrl: string;
/** The shared password, shown for the one-time import. Zero-security by design. */
password: string;
/** The wallet app where the import happens. Defaults to the public one. */
importUrl?: string;
}
const DEFAULT_IMPORT_URL = "https://nextgraph.eu/#/wallet/login";
/** The identifier this device already used, from the URL first, then storage. */
function storedIdentity(): string | null {
try {
const fromUrl = new URLSearchParams(globalThis.location?.search ?? "").get(URL_PARAM);
if (fromUrl && fromUrl.trim()) {
// Normalized on the way IN: the URL carries whatever a user or a link put there
// (`@Erin`), and an un-normalized value keys onto a different virtual user than the
// same identifier typed at the gate.
const normalized = normalizeIdentity(fromUrl);
globalThis.localStorage?.setItem(STORAGE_KEY, normalized);
return normalized;
}
const stored = globalThis.localStorage?.getItem(STORAGE_KEY);
return stored ? normalizeIdentity(stored) : null;
} catch {
return null; // storage blocked (private mode, sandboxed iframe) — the gate asks again
}
}
/** Put the identifier where the round-trip can find it, then remember it locally. */
function rememberIdentity(id: string): void {
try {
globalThis.localStorage?.setItem(STORAGE_KEY, id);
const url = new URL(globalThis.location!.href);
url.searchParams.set(URL_PARAM, id);
globalThis.history?.replaceState(null, "", url.toString());
} catch {
// Nothing to do: without the param the round-trip loses the identity and the gate
// will ask again, which is the safe failure.
}
}
/**
* Show the gate and resolve with the identifier the user entered.
*
* No prefill parameter, deliberately: the gate is shown ONLY when no identity is known,
* so there is never a value to prefill. The consumer this was moved from did prefill,
* because its screen reappeared after the broker round-trip — here the URL carries the
* identity across that round-trip, so a returning user does not see the barrier at all.
* The need is met one level up rather than papered over in the form.
*
* Deliberately plain DOM: this is a technical barrier shown before an application
* renders, like a password prompt on a closed beta. Binding it to a UI framework would
* make every consumer adopt that framework for a screen that is going away.
*/
function askForIdentity(cfg: SharedWalletConfig): Promise<string> {
const importUrl = cfg.importUrl ?? DEFAULT_IMPORT_URL;
return new Promise((resolve) => {
const host = document.createElement("div");
host.setAttribute("data-ng-eventually", "access-gate");
// A shadow root so the application's stylesheet cannot reshape the barrier, and the
// barrier's cannot leak into the application.
const root = host.attachShadow({ mode: "open" });
root.innerHTML = `
<style>
:host { all: initial; }
.veil { position: fixed; inset: 0; z-index: 2147483647; display: flex;
align-items: center; justify-content: center; background: #fff;
font: 15px/1.5 system-ui, sans-serif; color: #222; padding: 24px; }
.card { width: 100%; max-width: 420px; }
h1 { font-size: 26px; margin: 0 0 2px; text-align: center; }
.sub { text-align: center; color: #888; margin: 0 0 22px; }
.step { display: flex; gap: 12px; margin-bottom: 16px; }
.n { flex: 0 0 24px; height: 24px; border-radius: 50%; background: #444; color: #fff;
display: flex; align-items: center; justify-content: center; font-size: 13px; }
.t { font-weight: 600; font-size: 14px; margin: 1px 0 6px; }
a, button, input { font: inherit; }
a { color: #0b5ed7; }
code { background: #f2f2f2; padding: 2px 6px; border-radius: 4px; user-select: all; }
input { width: 100%; padding: 9px 10px; border: 1px solid #bbb; border-radius: 6px; box-sizing: border-box; }
button.go { width: 100%; margin-top: 10px; padding: 10px; border: 0; border-radius: 6px;
background: #222; color: #fff; cursor: pointer; }
button.go[disabled] { opacity: .45; cursor: default; }
.hint { color: #999; font-size: 12px; margin: 6px 0 0; }
</style>
<div class="veil"><div class="card">
<h1>Accès</h1>
<p class="sub">Environnement de test</p>
<div class="step"><div class="n">1</div><div>
<div class="t">Télécharger le portefeuille</div>
<a href="${cfg.fileUrl}" download>Télécharger le fichier</a>
</div></div>
<div class="step"><div class="n">2</div><div>
<div class="t">Mot de passe</div>
<code>${cfg.password}</code>
</div></div>
<div class="step"><div class="n">3</div><div>
<div class="t">Importer une fois</div>
<a href="${importUrl}" target="_blank" rel="noreferrer">Ouvrir l'application portefeuille</a>
</div></div>
<div class="step"><div class="n">4</div><div>
<div class="t">Votre identifiant</div>
<input data-testid="ng-identity-input" placeholder="votre identifiant" />
<p class="hint">Il identifie votre espace (mis en minuscules).</p>
<button class="go" data-testid="ng-identity-enter" disabled>Entrer</button>
</div></div>
</div></div>`;
const input = root.querySelector("input") as HTMLInputElement;
const go = root.querySelector("button.go") as HTMLButtonElement;
const sync = (): void => { go.disabled = input.value.trim().length === 0; };
const enter = (): void => {
const value = input.value.trim();
if (!value) return;
host.remove();
resolve(value);
};
input.addEventListener("input", sync);
input.addEventListener("keydown", (e) => { if ((e as KeyboardEvent).key === "Enter") enter(); });
go.addEventListener("click", enter);
sync();
document.body.appendChild(host);
input.focus();
});
}
/**
* Ensure an identity is set for this session, showing the gate only if one is missing.
*
* The application calls this once, before it renders. It does NOT pass an identifier:
* naming one is the step that will disappear, so it must not appear in the signature —
* the day the wallet supplies the identity, this resolves without showing anything and
* the caller's code is unchanged.
*
* A returning user never sees the gate: the identifier survives the broker round-trip in
* the URL, and a plain reload finds it in storage.
*
* **It RETURNS the identity it settled**, and that is not a convenience — it is the only
* way an application can know who it is. Upstream the question does not arise: an app
* passes `user_id` to `session_start(wallet_name, user_id)` (`@ng-org/web`), having got it
* from the wallet it opened, so it holds its identity before the session exists. Here the
* GATE chooses it, so the gate is what hands it back. Without this the example
* application had to read the gate's own private storage key — a boundary no consumer
* should be able to see, let alone depend on.
*/
export async function ensureIdentity(): Promise<PrincipalId> {
const already = getCurrentUser();
if (already !== null) {
await connected();
return already;
}
const known = storedIdentity();
if (known) {
setCurrentUser(known);
await connected();
return known;
}
const cfg = getConfig().sharedWallet;
if (!cfg) {
// Not a misconfiguration to paper over: without a shared wallet there is nothing to
// hand the user, and silently continuing would provision an anonymous space.
throw new Error(
"[ng-eventually] access gate: no shared wallet configured. Pass `sharedWallet` to " +
"`configure()` — the wallet file URL and its password — or set the identity yourself.",
);
}
if (typeof document === "undefined") {
throw new Error(
"[ng-eventually] access gate: no identity set and no DOM to ask on (server-side or " +
"test context). Set one explicitly before calling.",
);
}
const chosen = await askForIdentity(cfg);
const normalized = normalizeIdentity(chosen);
rememberIdentity(normalized);
setCurrentUser(normalized);
await connected();
return normalized;
}
/**
* Wait for the connection work `setCurrentUser` fires — restoring what others shared
* with this user, draining its inboxes — before this call resolves.
*
* **Not a convenience: a correctness fix, found by the applicative e2e.** Setting an
* identity FIRES that work and does not wait for it. An application that rendered on
* `ensureIdentity()` alone could read a note someone had just shared with it as
* unreadable — which looks like a permission problem and is a timing one, in the one
* place where the difference is invisible (nothing throws; a read is simply empty).
*
* Doing it here rather than exposing `connectedUser()` is the point: the awaited thing
* has NO counterpart upstream — there, opening the session IS the connection, and no
* application awaits a second call. So the polyfill absorbs it, and an application's
* bootstrap keeps the shape it will still have after migration.
*/
async function connected(): Promise<void> {
await connectedUser();
}
@@ -0,0 +1,126 @@
/**
* access-log — an OFF-by-default observability probe for document access.
*
* Diagnostic tool for the shared-wallet isolation footgun: on ONE physical
* wallet, several virtual identities coexist, and a read must never surface a
* document scoped to another identity. When it does (identity B reading identity
* A's doc), the leak is invisible in the data — it looks like a normal read. This
* probe makes it VISIBLE: every real read/write is logged, prefixed by the ACTIVE
* identity (the discriminating virtual identity, NOT the constant physical user
* id), so replaying the scenario shows the exact line where a doc is accessed
* under the wrong identity.
*
* OFF by default → zero overhead, zero output. Turned on either by the SDK config
* option `debugAccessLog: true` (via {@link setAccessLog}) or, without touching
* the calling code, by the env var `NG_EVENTUALLY_ACCESS_LOG=1`. The `enabled()`
* gate is a single boolean read on the hot path when off.
*
* Polyfill-era, like the rest of /polyfill; removed at the real multi-store
* migration where the broker/verifier enforces isolation natively.
*/
import { getCurrentUser } from "./bootstrap";
/** Access kind: a document READ or a document WRITE. */
export type AccessOp = "READ" | "WRITE";
// Config-driven toggle (set by configure() via setAccessLog); default OFF.
let configEnabled = false;
/**
* Env override: `NG_EVENTUALLY_ACCESS_LOG=1` (or `true`) turns the log on without
* a code change in the caller. Read once, tolerant of env access throwing (e.g.
* a locked-down runtime), so it never breaks the hot path.
*/
function envEnabled(): boolean {
try {
const v = (globalThis as any)?.process?.env?.NG_EVENTUALLY_ACCESS_LOG;
return v === "1" || v === "true";
} catch {
return false;
}
}
/** Set the config-driven toggle (called from configure()). */
export function setAccessLog(on: boolean): void {
configEnabled = on;
}
/** Whether access logging is currently on (config OR env). */
export function enabled(): boolean {
return configEnabled || envEnabled();
}
/**
* The identity to prefix an access line with: the ACTIVE virtual identity
* (`getCurrentUser`) — the account/space the operation is scoped under, which is
* the discriminating signal for the isolation leak. NOT the physical user id
* (shared, constant → useless). `(none)` when no identity is set yet (startup).
* Exported so every other polyfill-layer log site (store-registry, inbox,
* outbox-log, …) shares the exact same identity resolution as the access log,
* instead of re-deriving it — see {@link accessLogPrefix}.
*/
export function activeIdentity(): string {
return getCurrentUser() ?? "(none)";
}
/**
* The common line prefix for every polyfill-layer low-level-data-path log:
* `[<identity>][polyfill]` — identity FIRST (the discriminating scan signal),
* `[polyfill]` glued right after with no space between the two brackets. Used by
* {@link logAccess} itself, by the unified `console.error`s in store-registry.ts /
* inbox.ts, and by the BARRIER / stage-resolution / OUTBOX lines (open-repo.ts,
* store-registry.ts, outbox-log.ts) — one single prefix builder so every polyfill
* log line is visually groupable by identity when scanning a live session.
*/
export function accessLogPrefix(): string {
return "[" + activeIdentity() + "][polyfill]";
}
/**
* Emit one CONCISE diagnostic line — ONLY when {@link enabled} — prefixed by
* {@link accessLogPrefix}. Used for the precise data-path trace (BARRIER
* resolution, per-stage resolution outcome, the empty-OUTBOX line): a single
* line per stage/event, never a dump. Callers pass the fully-composed suffix
* (e.g. `"BARRIER " + shortNuri(nuri) + " synced (842ms)"`).
*/
export function logStage(line: string): void {
if (!enabled()) return;
console.log(accessLogPrefix() + " " + line);
}
/**
* Shorten a NURI for the access log: the full form (`did:ng:o:<RepoID>:v:<...>`,
* ~100 chars) is too verbose to scan. Drop the `did:ng:o:` prefix and the `:v:<...>`
* overlay suffix, and keep the first 8 chars of the RepoID + an ellipsis — short but
* still identifiable (`vDlwbZio…`). A NURI that doesn't match the expected shape is
* returned unchanged (best-effort, this is only a diagnostic label).
*/
export function shortNuri(nuri: string): string {
const withoutPrefix = nuri.replace(/^did:ng:o:/, "");
const repoId = withoutPrefix.split(":v:")[0] ?? withoutPrefix;
return repoId.length > 8 ? repoId.slice(0, 8) + "…" : repoId;
}
/**
* Log one document access — but ONLY when {@link enabled}. Off → returns
* immediately, prints nothing. Format:
* `[<identity>][polyfill] READ <shortNuri> (<label>)` — identity FIRST (the
* discriminating scan signal), `[polyfill]` glued right after with no space
* between the two brackets — optionally with `<extra>` appended (e.g. ` → 3
* rows`, a strong signal a doc rendered data under an identity that should see
* nothing). The `[polyfill]` tag marks these as SDK-layer access logs (distinct
* from the consumer app's own logs). The NURI is shortened by {@link shortNuri}
* to keep the line scannable.
*/
export function logAccess(
op: AccessOp,
nuri: string,
label: string,
extra?: string,
): void {
if (!enabled()) return;
console.log(
accessLogPrefix() + " " + op + " " + shortNuri(nuri) + " (" + label + ")" + (extra ?? ""),
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,276 @@
/**
* 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";
/**
* 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.
*/
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). */
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.
*/
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.
*/
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;
/** Initial current user; may also be set later via {@link setCurrentUser}. */
currentUser?: PrincipalId;
/**
* 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;
/** 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;
/**
* 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;
currentUser = c.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 } : {}),
});
}
}
/** @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). */
export function resetConfig(): void {
cfg = null;
currentUser = null;
}
/**
* 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;
};
registryDeps = {
getSession,
normalizeId: deps.normalizeId ?? ((id: string) => id.trim()),
// 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;
}
/** Reset storeRegistry deps (mainly for tests). */
export function resetStoreRegistry(): void {
registryDeps = null;
}
/**
* Set the current identity id — who the SDK is reading/writing as. 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).
*/
export function setCurrentUser(id: PrincipalId | null): void {
const changed = currentUser !== id;
currentUser = 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`.
//
// Gated on the registry being configured, and that is not a test convenience: an
// identity set before the session resolves has nothing to restore and no inbox to
// reach, so firing would be I/O that can only fail. The consumer's real sequence
// is `configureStoreRegistry` then `setCurrentUser`; anything else can call
// `connectedUser()` explicitly.
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();
}
@@ -0,0 +1,98 @@
/**
* outbox-log — read-only diagnostic inspection of `@ng-org/web`'s offline write
* outbox, at session bootstrap (polyfill-era, low-level-data-path trace).
*
* ── What this surfaces ──────────────────────────────────────────────────────
* `@ng-org/web` (the real injected SDK) queues writes made while offline/
* disconnected in an "outbox", persisted client-side in `sessionStorage` by the
* WASM verifier (see `sdk/rust/src/local_broker.rs` `JsStorageConfig::
* get_js_storage_config` in the `nextgraph-rs` core repo — read-only reference,
* NOT vendored here). A non-empty outbox at session start is an ANOMALY worth
* surfacing unconditionally: it means writes from a previous (disconnected)
* session are still queued and haven't reached the broker yet.
*
* ── sessionStorage key shapes (verified in the core repo, not guessed) ──────
* The outbox is keyed per LOCAL PEER id (`peer_id`, the persistent local peer's
* pubkey — NOT the ng-eventually shim's `account`/`identity` concept), via two
* key families written by `session_write`/read by `session_read`:
* - `ng_peer_last_seq@<peerId>` — the peer's last reserved seq number.
* - `ng_outboxes@<peerId>@start` — the seq number the outbox starts at.
* - `ng_outboxes@<peerId>@<00000-idx>` — one queued (base64url + BARE-encoded)
* event per zero-padded index, contiguous from 0 until the first miss (the
* exact shape `outbox_read_function` walks — see `local_broker.rs`).
* We don't know `peerId` ahead of time (it's internal to the injected SDK), so
* we DISCOVER it by scanning `sessionStorage` for `@start` markers instead of
* requiring it to be passed in — this also means the probe works unchanged
* however many peers/wallets the browser session has touched.
*
* ── Read-only, defensive, best-effort ────────────────────────────────────────
* This NEVER writes or deletes a key (unlike the real `outbox_read_function`,
* which drains on read) — it only counts. The queued event bytes are opaque
* (BARE-encoded Rust structs, base64url'd); decoding them to report concrete
* write TARGETS (topics/docs) would mean duplicating the WASM verifier's wire
* format in this polyfill, which is explicitly out of scope (SDK internals live
* in the `@ng-eventually/polyfill`-independent core repo, per this repo's
* doctrine) — so only the pending COUNT is reported, never fabricated targets.
* `sessionStorage` access itself can throw (sandboxed iframe, disabled storage —
* see the exact error string handled in the core repo's `main.ts`
* `convert_error`), so the whole probe is wrapped in one try/catch: unavailable
* → skip silently, never throw into the caller.
*
* Polyfill-era; removed at the real multi-store migration alongside the rest of
* this low-level trace instrumentation.
*/
import { accessLogPrefix, logStage } from "./access-log";
/** Matches an outbox "start" marker key, capturing the peer id. */
const OUTBOX_START_KEY = /^ng_outboxes@(.+)@start$/;
/** Safety bound on the per-peer index walk, so a corrupted/mocked storage
* (e.g. a `@start` marker with no matching index gaps) can't spin forever.
* Real outboxes are queued-while-offline writes — nowhere near this size. */
const MAX_SCAN_PER_PEER = 10_000;
/**
* Inspect the outbox NOW and log its state — count only, never targets (see
* module doc). Non-empty → `console.warn`, ALWAYS printed (anomaly, not gated
* by the access-log flag). Empty → a normal {@link logStage} line, gated by the
* access-log flag like the rest of the low-level trace. Read-only: never
* mutates `sessionStorage`. Never throws.
*/
export function inspectOutbox(): void {
try {
const storage = (globalThis as any)?.sessionStorage;
if (!storage) return;
const peers = new Set<string>();
const length: number = storage.length ?? 0;
for (let i = 0; i < length; i++) {
const key = storage.key?.(i);
if (!key) continue;
const m = OUTBOX_START_KEY.exec(key);
const peerId = m?.[1];
if (peerId) peers.add(peerId);
}
let total = 0;
for (const peer of peers) {
let idx = 0;
while (idx < MAX_SCAN_PER_PEER) {
const idxKey = "ng_outboxes@" + peer + "@" + String(idx).padStart(5, "0");
if (storage.getItem(idxKey) === null) break;
total++;
idx++;
}
}
if (total > 0) {
// Anomaly: ALWAYS visible, regardless of the access-log flag.
console.warn(accessLogPrefix() + " OUTBOX " + total + " pending write(s)");
} else {
logStage("OUTBOX empty");
}
} catch {
// sessionStorage unavailable / access denied — skip silently. Diagnostic
// only, never a hard dependency of the read/write path.
}
}
@@ -0,0 +1,137 @@
/**
* physical — the polyfill's OWN machinery, operating on the PHYSICAL user.
*
* ── Two levels, two APIs, and only one of them is the app's ───────────────
* NextGraph sees exactly one user: the physical one, whose wallet everybody opens.
* On top of it the library fabricates **virtual users** — what the consumer calls
* an identity. Those are two different levels, and conflating them is how a
* boundary gets a hole in it:
*
* | | Level | Who calls it | Guarded |
* |---|---|---|---|
* | `docs.*`, `subscribeDoc` | the **virtual user** | the consumer app, and the library on the user's behalf | YES — confined to the connected user (`reach.ts`) |
* | this module | the **physical user** | the library's own machinery, and nothing else | no — it *is* the machinery the boundary is built on |
*
* **Nothing here is exported from the package.** `index.ts` must never re-export
* this module: an app holding these functions could read any document of any
* virtual user, which is precisely the boundary they exist below.
*
* ── Why a separate module rather than exemptions ──────────────────────────
* The store-root pointer and the doc-shim — the index of virtual users — cannot be
* subject to the boundary: resolving *which* documents a virtual user owns is what
* makes virtual users exist at all. An earlier version handled that with a list of
* exempt NURIs consulted by the guard. Separating the FUNCTIONS is stronger: the
* machinery does not call the guarded primitive and get waved through, it calls a
* different primitive that was never guarded. There is no exemption list to widen,
* to get wrong, or to infer.
*
* The rule for deciding which side a call belongs to:
*
* > Does this operate on the index of virtual users (the shim), or on the content
* > of one virtual user? The first is machinery; everything else is the user's,
* > and is confined.
*
* A virtual user's own stores, its inbox and its documents are the user's — they go
* through `docs.*` and are guarded, even though the library is what calls them.
*
* At migration this module disappears with the shim: there is no physical/virtual
* split once each user opens their own wallet.
*/
import { getConfig } from "./bootstrap";
import { logAccess } from "./access-log";
import { subscribeDocUnguarded } from "../surface/subscribe";
import { openRepoUnguarded } from "../emulated-verifier/open-repo";
import type { DocChange, DocChangeType, Unsubscribe } from "../surface/subscribe";
import { isNuri } from "../model/nuri";
import type { Nuri } from "../model/types";
/**
* Create a document as the PHYSICAL user — the shim's own documents (the doc-shim,
* a virtual user's store documents at provisioning time, an inbox document).
*
* Creation is the one operation with no boundary to check: the document does not
* exist yet, so nobody can hold its cap. What matters is who is credited with it
* afterwards, which the caller decides by filing the cap among the caps that holder holds.
*/
export async function physicalCreate(
sessionId: string,
crdt = "Graph",
cls = "data:graph",
dest = "store",
store?: unknown,
): Promise<Nuri> {
const { ng } = getConfig();
const nuri = await ng.doc_create(sessionId, crdt, cls, dest, store);
if (typeof nuri !== "string" || !isNuri(nuri)) {
throw new Error(
`[ng-eventually] physicalCreate: the broker returned something that is not a NextGraph reference: ${JSON.stringify(nuri)}`,
);
}
logAccess("WRITE", nuri, "physicalCreate");
return nuri;
}
/**
* Read as the PHYSICAL user — for the shim only (the store-root pointer, the
* doc-shim's account records).
*
* Unguarded by design: this is how the library learns which documents a virtual
* user owns, so it cannot itself depend on knowing that. Do not reach for it to
* read a virtual user's content — that is `docs.sparqlQuery`, which is confined.
*/
export async function physicalQuery(
sessionId: string,
query: string,
base: string | undefined,
anchor: Nuri,
label = "physicalQuery",
): Promise<unknown> {
const { ng } = getConfig();
const result = await ng.sparql_query(sessionId, query, base, anchor);
logAccess("READ", anchor, label, " (physical)");
return result;
}
/** Write as the PHYSICAL user — the shim's own records. See {@link physicalQuery}. */
export async function physicalUpdate(
sessionId: string,
query: string,
anchor: Nuri,
label = "physicalUpdate",
): Promise<void> {
const { ng } = getConfig();
logAccess("WRITE", anchor, label, " (physical)");
return ng.sparql_update(sessionId, query, anchor);
}
// --- the rest of the privileged door ---------------------------------------
//
// Moved here 2026-08-03 so that ONE module is the machinery's entire unguarded API,
// which is what this module's own doctrine asked for (see the header: separate
// functions, never exemptions). Before this they lived beside their guarded twins in
// `surface/subscribe.ts` and `emulated-verifier/open-repo.ts` — one import away from
// being reached by mistake.
/**
* Subscribe as the PHYSICAL user — the shim's own documents. The machinery's
* counterpart to `subscribeDoc`; never exported from the package.
*/
export function subscribePhysicalDoc(
nuri: Nuri,
onChange: (r: DocChange, type: DocChangeType) => void,
): Unsubscribe {
return subscribeDocUnguarded(nuri, onChange);
}
/**
* Open a repo as the PHYSICAL user — the shim's own documents (store-root, doc-shim).
* The machinery's counterpart to `ensureRepoOpen`: resolving WHICH documents a virtual
* user owns cannot itself be confined to that user.
*
* Never exported from the package.
*/
export async function ensurePhysicalRepoOpen(nuri: Nuri): Promise<void> {
if (!nuri) return;
return openRepoUnguarded(nuri);
}
@@ -0,0 +1,96 @@
/**
* accounts — a framework-agnostic store for the current identity id.
*
* The identity a session acts as is established when its wallet is imported; the
* SDK is told who that is via the current-identity call. This small store just
* persists that id (in an injected storage) so it survives reloads and a second
* device, re-opening the same wallet, lands on the same identity. It carries no
* notion of a login step, a password, or a username — only an opaque identity id.
*
* Framework-agnostic on purpose: no React, no DOM assumption beyond an optional
* storage. A consumer's React `Context`/`Provider` wraps `useState` around
* {@link IdentityStore.set}/{@link IdentityStore.clear}. The lib does not force a
* React dependency. Removed against real NextGraph, where the wallet session is
* the source of the identity id.
*/
/** localStorage key holding the current identity id. */
export const ACCOUNT_STORAGE_KEY = "ng-eventually.account.id";
/**
* Minimal storage contract (a subset of the Web `Storage` interface). The
* consumer injects one — `window.localStorage` in a browser, a fake in tests —
* so this stays framework/DOM-agnostic. When none is available (SSR, no
* `window`), pass `null` and the store degrades to in-memory-null (no persist).
*/
export interface VirtualUserStorage {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
/**
* The persisted current identity id. A tiny store around an injected
* {@link VirtualUserStorage}. It holds no framework state; the consumer's Provider
* mirrors `get()` into framework state and re-reads after `set`/`clear`.
*/
export class IdentityStore {
private readonly storage: VirtualUserStorage | null;
private readonly key: string;
constructor(storage: VirtualUserStorage | null, key: string = ACCOUNT_STORAGE_KEY) {
this.storage = storage;
this.key = key;
}
/** The current identity id. null = no identity set yet. */
get(): string | null {
if (!this.storage) return null;
try {
return this.storage.getItem(this.key);
} catch {
return null;
}
}
/**
* Persist the (trimmed) identity id. An empty/blank value is ignored and the
* previous id is kept (returns the resulting id, or null). No NextGraph call.
*/
set(id: string): string | null {
const clean = id.trim();
if (!clean) return this.get();
if (this.storage) {
try {
this.storage.setItem(this.key, clean);
} catch {
/* ignore — staging, no security */
}
}
return clean;
}
/** Clear the persisted identity id. No NextGraph call. */
clear(): void {
if (this.storage) {
try {
this.storage.removeItem(this.key);
} catch {
/* ignore */
}
}
}
}
/**
* Convenience factory using `globalThis.localStorage` when present, else a
* null (non-persisting) store — so the same call is safe in browser and SSR.
*/
export function browserIdentityStore(key: string = ACCOUNT_STORAGE_KEY): IdentityStore {
const ls =
typeof globalThis !== "undefined" &&
(globalThis as { localStorage?: VirtualUserStorage }).localStorage
? (globalThis as { localStorage: VirtualUserStorage }).localStorage
: null;
return new IdentityStore(ls, key);
}