feat(example): une app d'exemple, écrite comme un consommateur

Le harnais e2e parlait à un sac de méthodes posé sur `window.__sdk`. Il prouvait
que les fonctions s'exécutaient, jamais qu'on pouvait écrire une application avec
— et cet écart a livré un vrai défaut : l'inbox d'un document était verte en test
et inutilisable en vrai, parce que le harnais faisait traverser une adresse d'une
identité à l'autre par une variable, ce qu'aucune application ne peut faire.

`examples/notebook` est une application minimale en DOM natif, qui résout
`@ng-eventually/client` comme un consommateur externe (workspace, dépendance
déclarée, aucun import privilégié). Elle ne peut faire que ce qu'une application
peut faire.

Elle s'est déjà payée deux fois pendant son écriture :

- `UnionSubject.subject` et `.graph` étaient typés `string` alors que ce sont
  toujours des références de document. Un consommateur devait donc caster ce
  qu'il venait de lire avant de le repasser — un cast à cet endroit précis
  rouvre la confusion que les types template literal existent pour fermer.
- l'écran d'accès normalisait ce que l'utilisateur SAISIT mais pas ce que l'URL
  porte, si bien qu'un lien `?ng-id=@Erin` ouvrait un espace différent de celui
  de la même personne tapant `erin`. Une seule normalisation désormais, celle
  du registre.

Le domaine est volontairement mince — des notes — mais suffit à exercer le
placement par scope, la possession de caps, le partage dirigé, les inbox par
document et la lecture réactive.

170 tests unitaires, typecheck vert sur la lib, l'exemple et le harnais.
This commit is contained in:
Sylvain Duchesne
2026-08-05 18:25:47 +02:00
parent 66a40fbb89
commit d35e735c8b
12 changed files with 814 additions and 19 deletions
+15
View File
@@ -120,6 +120,21 @@ async function main(): Promise<void> {
const info = await sdkGet<any>(frame, "sessionInfo");
check("broker session connected", info?.session_id !== undefined && info?.session_id !== null, `session=${JSON.stringify(info)}`);
// ── access gate ─────────────────────────────────────────────────────────
console.log("\n── access gate ──");
await step("the gate asks on a first access, and settles the identity normalized", async () => {
const r = await sdk<any>(frame, "accessGateFirstVisit", "@Erin");
check(
"barrier shown, Entrer disabled while empty, identity normalized, barrier removed",
r.shown === true && r.disabledWhenEmpty === true && r.identity === "erin" && r.stillMounted === false,
`shown=${r.shown} disabledWhenEmpty=${r.disabledWhenEmpty} identity=${r.identity} stillMounted=${r.stillMounted}`,
);
});
await step("the gate stays away when the identity is already known", async () => {
const r = await sdk<any>(frame, "accessGateReturningVisit", "erin");
check("no barrier for a returning user", r.shown === false && r.identity === "erin", `shown=${r.shown}`);
});
// ── docs primitives ─────────────────────────────────────────────────────
console.log("\n── docs primitives ──");
await step("docCreate returns a usable NURI", async () => {
+50 -1
View File
@@ -19,6 +19,7 @@ import {
configure,
configureStoreRegistry,
setCurrentUser,
getCurrentUser,
capFor,
getCaps,
resetCaps,
@@ -40,7 +41,7 @@ import {
// `storeRegistry` above is the app-facing slice; these are the shim internals.
import * as registryInternals from "../src/shared-wallet/account-registry";
import * as virtualUsers from "../src/shared-wallet/virtual-users";
import { isNuri } from "@ng-eventually/client";
import { isNuri, ensureIdentity } from "@ng-eventually/client";
import type { Nuri, ShapeObservable, ShapeQuery } from "@ng-eventually/client";
const { IdentityStore } = virtualUsers;
@@ -96,6 +97,10 @@ configure({
ng: realNg,
useShape: fakeUseShape,
init: realInit,
// The harness already holds an open wallet (the suite imports it itself), so it never
// needs the gate's assisted import. These values exist only so the gate has something
// to render when the access-gate steps exercise it — they are never used to import.
sharedWallet: { fileUrl: "/harness-not-used.ngw", password: "harness" },
});
configureStoreRegistry({
@@ -167,6 +172,50 @@ const identity = new IdentityStore(
return { walletName, b64: btoa(bin), len: bytes.length };
},
/**
* THE ACCESS GATE, in a real browser — ported from the consumer's
* `barriere-acces-identifiant` feature, which the library took over with the flow.
*
* Unit tests pin the resolution ORDER (`test/access-gate.test.ts`); only a real DOM can
* pin the barrier itself: that it appears on a first access, that entering a value
* settles the identity normalized, and — the one that matters most — that it does NOT
* appear when the identity is already known, since a returning user seeing the barrier
* again is the visible face of the silent bug (a second virtual space).
*/
async accessGateFirstVisit(raw: string) {
setCurrentUser(null);
try { window.localStorage.removeItem("ng-eventually:identity"); } catch {}
const done = ensureIdentity();
const gate = document.querySelector('[data-ng-eventually="access-gate"]');
const root = gate?.shadowRoot ?? null;
const input = root?.querySelector("input") as HTMLInputElement | null;
const button = root?.querySelector("button.go") as HTMLButtonElement | null;
const shown = input !== null && button !== null;
const disabledWhenEmpty = button?.disabled ?? null;
if (input && button) {
input.value = raw;
input.dispatchEvent(new Event("input"));
button.click();
}
await done;
return {
shown,
disabledWhenEmpty,
identity: getCurrentUser(),
stillMounted: document.querySelector('[data-ng-eventually="access-gate"]') !== null,
};
},
/** The barrier must stay away once an identity is known. */
async accessGateReturningVisit(known: string) {
setCurrentUser(known);
await ensureIdentity();
return {
shown: document.querySelector('[data-ng-eventually="access-gate"]') !== null,
identity: getCurrentUser(),
};
},
// ── docs primitives ──────────────────────────────────────────────────────
async docCreate() {
const s = await sessionReady;
@@ -39,7 +39,32 @@
* a second virtual user, and the returning user silently lands in an empty space.
*/
import { getConfig, getCurrentUser, setCurrentUser } from "./bootstrap";
import {
getConfig,
getCurrentUser,
getStoreRegistryDeps,
setCurrentUser,
} from "./bootstrap";
/**
* 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";
@@ -50,6 +75,11 @@ 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. */
@@ -58,8 +88,6 @@ export interface SharedWalletConfig {
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";
@@ -69,11 +97,15 @@ 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();
// 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;
}
return globalThis.localStorage?.getItem(STORAGE_KEY) ?? null;
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
}
@@ -95,11 +127,17 @@ function rememberIdentity(id: string): void {
/**
* 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, prefill: string | null): Promise<string> {
function askForIdentity(cfg: SharedWalletConfig): Promise<string> {
const importUrl = cfg.importUrl ?? DEFAULT_IMPORT_URL;
return new Promise((resolve) => {
const host = document.createElement("div");
@@ -130,8 +168,8 @@ function askForIdentity(cfg: SharedWalletConfig, prefill: string | null): Promis
.hint { color: #999; font-size: 12px; margin: 6px 0 0; }
</style>
<div class="veil"><div class="card">
<h1>${cfg.appName ?? "Accès"}</h1>
<p class="sub">Espace de test</p>
<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>
@@ -164,7 +202,6 @@ function askForIdentity(cfg: SharedWalletConfig, prefill: string | null): Promis
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);
@@ -208,8 +245,8 @@ export async function ensureIdentity(): Promise<void> {
);
}
const chosen = await askForIdentity(cfg, null);
const normalized = chosen.trim().toLowerCase();
const chosen = await askForIdentity(cfg);
const normalized = normalizeIdentity(chosen);
rememberIdentity(normalized);
setCurrentUser(normalized);
}
+12 -3
View File
@@ -57,10 +57,19 @@ void sparqlUpdate;
/** One subject read from a doc, with its properties (predicate → values). */
export interface UnionSubject {
/** The subject IRI (`?s`) — in the polyfill, the doc's own NURI. */
subject: string;
/**
* The subject IRI (`?s`) — in the polyfill, the doc's own NURI.
*
* Typed `Nuri`, not `string`: both fields are always document references here (the
* read is anchored per document and the subject is pinned to the anchor), and typing
* them loosely forced a consumer to cast whatever it had just read before it could
* pass it back — `shareNote(note.doc)`, `leaveMessage(note.doc)`. A cast at that
* boundary re-opens exactly the confusion the template literal types exist to close.
* Found by writing the example application (`examples/notebook`).
*/
subject: Nuri;
/** The graph (doc NURI) the subject was read from. */
graph: string;
graph: Nuri;
/** predicate IRI → the list of object values (literals or IRIs) for it. */
props: Record<string, string[]>;
}
+31 -1
View File
@@ -6,7 +6,14 @@
* 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 {
configure,
configureStoreRegistry,
resetConfig,
resetStoreRegistry,
setCurrentUser,
getCurrentUser,
} from "../src/polyfill";
import { ensureIdentity } from "../src/shared-wallet/access-gate";
const KEY = "ng-eventually:identity";
@@ -32,12 +39,17 @@ function inPage(search: string, storage: ReturnType<typeof fakeStorage>) {
afterEach(() => {
setCurrentUser(null);
resetConfig();
resetStoreRegistry();
delete (globalThis as any).location;
delete (globalThis as any).localStorage;
delete (globalThis as any).history;
});
function configured() {
configureStoreRegistry({
getSession: async () => ({ sessionId: "s", privateStoreId: "did:ng:o:p" }),
normalizeId: (id: string) => id.trim().replace(/^@/, "").toLowerCase(),
});
configure({
ng: {} as never,
useShape: (() => {}) as never,
@@ -92,3 +104,21 @@ test("no shared wallet configured → it refuses, rather than inventing a space"
inPage("", fakeStorage());
await expect(ensureIdentity()).rejects.toThrow(/no shared wallet configured/i);
});
test("the URL value is NORMALIZED on the way in — `@Erin` and `erin` are one space", async () => {
// Ported from the consumer's `identifiant-resolution` feature, and it caught a real
// defect here: the gate normalized what a user TYPED but not what the URL carried, so
// a link with `?ng-id=@Erin` keyed onto a different virtual user than the same person
// typing `erin`. One normalizer — the injected one — for all three entry paths.
configured();
inPage("?ng-id=@Erin", fakeStorage());
await ensureIdentity();
expect(getCurrentUser()).toBe("erin");
});
test("a stored value is normalized too — an old entry cannot key onto a second space", async () => {
configured();
inPage("", fakeStorage({ [KEY]: "@Frank" }));
await ensureIdentity();
expect(getCurrentUser()).toBe("frank");
});