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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user