diff --git a/docs/api-contract.md b/docs/api-contract.md index 389a2e5..eb82181 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -612,7 +612,7 @@ Exported, but not SDK surface. Coding against these builds knowledge that migrat ### `@ng-eventually/client` — `src/index.ts` ```text -direct: BaseType, DeepSignalSet, DocChange, DocChangeType, InboxScope, NG, NgLike, Nuri, PrincipalId, ReadCap, Schema, Scope, ShapeObservable, ShapeQuery, ShapeType, UnionSubject, Unsubscribe, UseShapeLike, assertNuri, docChangeType, escapeIri, escapeLiteral, hasReadCap, init, initNg, isNuri, ng, readUnion, subscribeDoc, subscribeDocs, useShape, watchShape +direct: BaseType, DeepSignalSet, DocChange, DocChangeType, InboxScope, NG, NgLike, Nuri, PrincipalId, ReadCap, Schema, Scope, ShapeObservable, ShapeQuery, ShapeType, SharedWalletConfig, UnionSubject, Unsubscribe, UseShapeLike, assertNuri, docChangeType, ensureIdentity, escapeIri, escapeLiteral, hasReadCap, init, initNg, isNuri, ng, readUnion, subscribeDoc, subscribeDocs, useShape, watchShape docs: depositInto, docCreate, sparqlQuery, sparqlUpdate inbox: Deposit, PostOptions, materialize, post, postToDocument, processInbox, read, readSynced, shareCap, watch storeRegistry: createEntityDoc, documentInboxAddress, listMyEntityDocs, openDocumentInbox, resolveScopeGraph, resolveWriteGraph, userInbox diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index fc0bb0e..260fa6e 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -25,6 +25,11 @@ export { useShape } from "./surface/use-shape"; export { watchShape } from "./surface/watch-shape"; export type { ShapeQuery, ShapeObservable } from "./surface/watch-shape"; export { init, initNg } from "./surface/lifecycle"; +// The access gate: one call, before the app renders. It shows a technical barrier only +// while the shared wallet needs one — the day the wallet supplies the identity it +// resolves silently, and this line stays as it is (`shared-wallet/access-gate.ts`). +export { ensureIdentity } from "./shared-wallet/access-gate"; +export type { SharedWalletConfig } from "./shared-wallet/access-gate"; export * as inbox from "./surface/inbox"; export * as docs from "./surface/docs"; export { subscribeDoc, subscribeDocs, docChangeType } from "./surface/subscribe"; diff --git a/packages/client/src/shared-wallet/access-gate.ts b/packages/client/src/shared-wallet/access-gate.ts new file mode 100644 index 0000000..a396f6b --- /dev/null +++ b/packages/client/src/shared-wallet/access-gate.ts @@ -0,0 +1,215 @@ +/** + * 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, setCurrentUser } from "./bootstrap"; + +/** 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. + */ +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; + /** Shown as the gate's heading. The deployment's name, not a domain concept. */ + appName?: 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()) { + // Persist it in THIS partition too, so a later reload without the param prefills. + globalThis.localStorage?.setItem(STORAGE_KEY, fromUrl.trim()); + return fromUrl.trim(); + } + return globalThis.localStorage?.getItem(STORAGE_KEY) ?? 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. + * + * 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, prefill: string | null): Promise { + 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 = ` + +
+

${cfg.appName ?? "Accès"}

+

Espace de test

+
1
+
Télécharger le portefeuille
+ Télécharger le fichier +
+
2
+
Mot de passe
+ ${cfg.password} +
+
3
+
Importer une fois
+ Ouvrir l'application portefeuille +
+
4
+
Votre identifiant
+ +

Il identifie votre espace (mis en minuscules).

+ +
+
`; + + 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); + if (prefill) { input.value = prefill; } + 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. + */ +export async function ensureIdentity(): Promise { + if (getCurrentUser() !== null) return; + + const known = storedIdentity(); + if (known) { + setCurrentUser(known); + return; + } + + 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, null); + const normalized = chosen.trim().toLowerCase(); + rememberIdentity(normalized); + setCurrentUser(normalized); +} diff --git a/packages/client/src/shared-wallet/bootstrap.ts b/packages/client/src/shared-wallet/bootstrap.ts index 4114793..e63b02e 100644 --- a/packages/client/src/shared-wallet/bootstrap.ts +++ b/packages/client/src/shared-wallet/bootstrap.ts @@ -16,6 +16,7 @@ */ import type { NgLike, UseShapeLike, Nuri, PrincipalId, ReadCap } from "../model/types"; +import type { SharedWalletConfig } from "./access-gate"; import type { RegistrySession } from "./account-registry"; import { CapRegistry } from "../emulated-verifier/caps"; import { setAccessLog } from "./access-log"; @@ -49,12 +50,16 @@ export interface StoreRegistryDeps { } export interface EventuallyConfig { + /** + * 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; /** The REAL `@ng-org/web` `ng` (injected to avoid a hard import / alias loop). */ ng: NgLike; /** The REAL `@ng-org/orm` `useShape`. */ useShape: UseShapeLike; - /** Shared-wallet credentials — polyfill only (one wallet for everyone). */ - sharedWallet?: { name: string; secret: string }; /** Initial current user; may also be set later via {@link setCurrentUser}. */ currentUser?: PrincipalId; /** diff --git a/packages/client/test/access-gate.test.ts b/packages/client/test/access-gate.test.ts new file mode 100644 index 0000000..11cdd21 --- /dev/null +++ b/packages/client/test/access-gate.test.ts @@ -0,0 +1,94 @@ +/** + * The access gate's identity resolution. + * + * This is the piece whose failure is SILENT: get the order wrong and the broker iframe + * reads an empty identity, provisions a second virtual user, and the returning user + * lands in an empty space with no error anywhere. So the order is pinned, not trusted. + */ +import { test, expect, afterEach } from "bun:test"; +import { configure, resetConfig, setCurrentUser, getCurrentUser } from "../src/polyfill"; +import { ensureIdentity } from "../src/shared-wallet/access-gate"; + +const KEY = "ng-eventually:identity"; + +/** A localStorage double — the real one is absent in `bun test`. */ +function fakeStorage(initial: Record = {}) { + const map = new Map(Object.entries(initial)); + return { + getItem: (k: string) => map.get(k) ?? null, + setItem: (k: string, v: string) => void map.set(k, v), + removeItem: (k: string) => void map.delete(k), + get size() { return map.size; }, + }; +} + +/** Put the page in a given URL + storage state, as the browser would. */ +function inPage(search: string, storage: ReturnType) { + (globalThis as any).location = { search, href: "https://app.example" + search }; + (globalThis as any).localStorage = storage; + (globalThis as any).history = { replaceState: () => {} }; +} + +afterEach(() => { + setCurrentUser(null); + resetConfig(); + delete (globalThis as any).location; + delete (globalThis as any).localStorage; + delete (globalThis as any).history; +}); + +function configured() { + configure({ + ng: {} as never, + useShape: (() => {}) as never, + sharedWallet: { fileUrl: "/w.ngw", password: "pw" }, + }); +} + +test("an identity already set is left alone — the gate never re-asks", async () => { + configured(); + inPage("", fakeStorage()); + setCurrentUser("alice"); + await ensureIdentity(); + expect(getCurrentUser()).toBe("alice"); +}); + +test("the URL parameter WINS over storage — it is the only thing that crosses the frontier", async () => { + // The top-level page and the broker iframe have separate localStorage partitions, so a + // value written on one side is not the value the other reads. The URL survives the + // round-trip; storage does not. If storage won here, a user entering a second + // identifier would keep being sent back to the first one's space. + configured(); + inPage("?ng-id=fromurl", fakeStorage({ [KEY]: "fromstorage" })); + await ensureIdentity(); + expect(getCurrentUser()).toBe("fromurl"); +}); + +test("the URL parameter is copied into THIS partition, so a plain reload still knows", async () => { + configured(); + const storage = fakeStorage(); + inPage("?ng-id=carol", storage); + await ensureIdentity(); + expect(storage.getItem(KEY)).toBe("carol"); +}); + +test("with no parameter, storage answers — a reload does not re-ask", async () => { + configured(); + inPage("", fakeStorage({ [KEY]: "dana" })); + await ensureIdentity(); + expect(getCurrentUser()).toBe("dana"); +}); + +test("nothing known and no DOM to ask on → it refuses loudly", async () => { + // Continuing silently would provision an anonymous virtual space, which is the failure + // this module exists to prevent. The error names what the caller must do. + configured(); + inPage("", fakeStorage()); + await expect(ensureIdentity()).rejects.toThrow(/no DOM to ask on/i); +}); + +test("no shared wallet configured → it refuses, rather than inventing a space", async () => { + configure({ ng: {} as never, useShape: (() => {}) as never }); + inPage("", fakeStorage()); + await expect(ensureIdentity()).rejects.toThrow(/no shared wallet configured/i); +}); diff --git a/packages/client/test/vocabulary.test.ts b/packages/client/test/vocabulary.test.ts index 2962e43..270acdd 100644 Binary files a/packages/client/test/vocabulary.test.ts and b/packages/client/test/vocabulary.test.ts differ