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