54f8389e9e
L'app d'exemple a servi de juge, et elle a immédiatement montré ce que l'inventaire ne montrait pas : pour partager une note elle résolvait l'inbox du destinataire, pour lire ses messages elle résolvait l'adresse de la sienne. Deux gestes qu'aucune application n'aura à faire une fois la chose native — donc deux gestes qu'elle ne doit pas apprendre. - `shareCap(cap, toUser)` remplace `shareCap(cap, toInbox)`. Partager est un acte envers quelqu'un ; où est son inbox regarde la bibliothèque. - `inbox.readForDocument(doc)` : le propriétaire lit ses messages en nommant la note, comme le déposant la nomme pour en laisser un. - `storeRegistry.userInbox` et `documentInboxAddress` sortent de la surface publiée. Ils restent joignables en interne, où le shim en a besoin. Sortent aussi de `/polyfill`, chacun parce qu'une app qui code contre apprend ce qu'il faudra désapprendre : - `getCaps` / `CapRegistry` — la salle des machines. La question du consommateur est `capFor(doc)` : est-ce que je le détiens ? Le registre n'a ni successeur ni forme inerte ; ce qui s'appuie dessus sera à réécrire, pas à laisser en place. - `getCurrentUser` — une app sait qui elle a connecté ; le redemander à la bibliothèque est une commodité du wallet partagé. - `virtualUsers` / `IdentityStore` — se souvenir d'une identité entre deux sessions est aussi le travail de l'app en amont. L'écran d'accès persiste ce dont IL a besoin ; rien d'autre n'a à être exposé. Reste sur `/polyfill` ce qu'une app appelle vraiment : `configure` et `setCurrentUser`. Le reste y est du test ou de l'injection interne. 170 tests unitaires, e2e 42/42 contre le broker, typecheck vert sur la bibliothèque, l'exemple et le harnais.
259 lines
9.6 KiB
TypeScript
259 lines
9.6 KiB
TypeScript
/**
|
|
* Notebook — a minimal application written against `@ng-eventually/client`.
|
|
*
|
|
* It exists for two reasons, and the second is the one that matters:
|
|
*
|
|
* 1. **It shows how to use the library.** Every call here is what a real consumer
|
|
* writes; there is no test scaffolding, no privileged import, no reaching into the
|
|
* library's internals. If something is awkward here, it is awkward for everyone.
|
|
*
|
|
* 2. **It is what the e2e suite drives.** The suite used to talk to a bag of methods on
|
|
* `window.__sdk`, which proved the functions ran but never that an application could
|
|
* be written with them — and that gap shipped a real defect: a document's inbox was
|
|
* green in tests and unusable in practice, because the harness handed the address
|
|
* across an identity boundary through a variable. No application can do that. This
|
|
* app can only do what an application can do, so a test that passes here means the
|
|
* surface is usable, not merely callable.
|
|
*
|
|
* ── The domain is deliberately thin ───────────────────────────────────────
|
|
* Notes. Each user writes their own, may publish one, may hand a reader the key to a
|
|
* private one, and may leave a message on someone else's note. That is enough to
|
|
* exercise placement by scope, capability possession, directed sharing, per-document
|
|
* inboxes and reactive reads — without inventing a product.
|
|
*
|
|
* ── Plain DOM, on purpose ─────────────────────────────────────────────────
|
|
* The library imposes no framework, so its example must not adopt one: a consumer
|
|
* reading this should see the SDK calls, not a component tree. The UI here is the
|
|
* shortest thing that makes each act reachable.
|
|
*/
|
|
|
|
import {
|
|
docs,
|
|
ensureIdentity,
|
|
inbox,
|
|
isNuri,
|
|
readUnion,
|
|
storeRegistry,
|
|
subscribeDoc,
|
|
type Nuri,
|
|
type Scope,
|
|
} from "@ng-eventually/client";
|
|
import { capFor, configure, configureStoreRegistry, setCurrentUser } from "@ng-eventually/client/polyfill";
|
|
import { ng as realNg, init as realInit } from "@ng-org/web";
|
|
|
|
// --- the domain, such as it is ---------------------------------------------
|
|
|
|
const TITLE = "urn:notebook:title";
|
|
const BODY = "urn:notebook:body";
|
|
|
|
interface Note {
|
|
doc: Nuri;
|
|
title: string;
|
|
body: string;
|
|
}
|
|
|
|
// --- bootstrap: the two polyfill-era calls ---------------------------------
|
|
//
|
|
// Everything else an application calls is SDK surface, preserved at migration. These
|
|
// two are the scaffolding: `configure` becomes inert (the app will import the real SDK)
|
|
// and the identity will come from the wallet instead of a barrier.
|
|
|
|
let session: { session_id: string } | null = null;
|
|
const sessionReady = new Promise<{ session_id: string }>((resolve) => {
|
|
realInit((event: { status: string; session?: { session_id: string } }) => {
|
|
if (event.status === "loggedin" && event.session) {
|
|
session = event.session;
|
|
resolve(event.session);
|
|
}
|
|
}, true, []);
|
|
});
|
|
|
|
configure({
|
|
ng: realNg,
|
|
useShape: (() => {}) as never, // this example reads through `readUnion`, not the ORM
|
|
init: realInit,
|
|
sharedWallet: {
|
|
fileUrl: "/shared-wallet.ngw",
|
|
password: (globalThis as { __NOTEBOOK_WALLET_PASSWORD__?: string }).__NOTEBOOK_WALLET_PASSWORD__ ?? "",
|
|
},
|
|
});
|
|
|
|
configureStoreRegistry({
|
|
getSession: async () => {
|
|
const s = session ?? (await sessionReady);
|
|
return {
|
|
sessionId: s.session_id,
|
|
privateStoreId: (s as Record<string, string>).private_store_id!,
|
|
protectedStoreId: (s as Record<string, string>).protected_store_id,
|
|
publicStoreId: (s as Record<string, string>).public_store_id,
|
|
};
|
|
},
|
|
normalizeId: (id) => id.trim().replace(/^@/, "").toLowerCase(),
|
|
});
|
|
|
|
// --- the acts ---------------------------------------------------------------
|
|
|
|
/** Write a new note in `scope`. The document is created, then filled. */
|
|
async function writeNote(scope: Scope, title: string, body: string): Promise<Nuri> {
|
|
const me = currentIdentity();
|
|
const doc = await storeRegistry.createEntityDoc(me, scope);
|
|
const s = await sessionReady;
|
|
await docs.sparqlUpdate(
|
|
s.session_id,
|
|
`INSERT DATA { <${doc}> <${TITLE}> "${escape(title)}" ; <${BODY}> "${escape(body)}" }`,
|
|
doc,
|
|
);
|
|
return doc;
|
|
}
|
|
|
|
/** My notes in `scope`, read the way the library intends: list, then read. */
|
|
async function myNotes(scope: Scope): Promise<Note[]> {
|
|
const docsOfScope = await storeRegistry.listMyEntityDocs(currentIdentity(), scope);
|
|
const subjects = await readUnion(docsOfScope);
|
|
return subjects.map((s) => ({
|
|
doc: s.subject,
|
|
title: s.props[TITLE]?.[0] ?? "(sans titre)",
|
|
body: s.props[BODY]?.[0] ?? "",
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Read someone else's note from its link.
|
|
*
|
|
* The link is what circulates in this model — you do not discover a note, you are given
|
|
* its link. `hasReadCap` is the door an untrusted string goes through.
|
|
*/
|
|
async function readSharedNote(link: string): Promise<Note | null> {
|
|
if (!isNuri(link)) return null;
|
|
const [note] = await readUnion([link]);
|
|
if (!note) return null;
|
|
return {
|
|
doc: note.subject,
|
|
title: note.props[TITLE]?.[0] ?? "(sans titre)",
|
|
body: note.props[BODY]?.[0] ?? "",
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Hand a reader the key to one of my notes.
|
|
*
|
|
* Names the PERSON. Where their inbox is, and whether they have one yet, is the
|
|
* library's business — an application will never handle an inbox address once this is
|
|
* native, so it does not handle one now.
|
|
*/
|
|
async function shareNote(doc: Nuri, withUser: string): Promise<void> {
|
|
const cap = capFor(doc);
|
|
if (!cap) throw new Error("this note is not mine to share");
|
|
await inbox.shareCap(cap, withUser);
|
|
}
|
|
|
|
/** Open a note for messages — only its owner can, and only they will read them. */
|
|
async function openNoteForMessages(doc: Nuri): Promise<void> {
|
|
await storeRegistry.openDocumentInbox(doc);
|
|
}
|
|
|
|
/** Leave a message on someone else's note. One call, naming the NOTE. */
|
|
async function leaveMessage(doc: Nuri, text: string): Promise<void> {
|
|
await inbox.postToDocument(doc, { payload: { text } });
|
|
}
|
|
|
|
/** The messages left on one of my notes — named by the note, like leaving one. */
|
|
async function messagesOn(doc: Nuri): Promise<string[]> {
|
|
const deposits = await inbox.readForDocument(doc);
|
|
return deposits.map((d) => String((d.payload as { text?: string })?.text ?? ""));
|
|
}
|
|
|
|
/** Re-render whenever a note changes — locally or from a peer. */
|
|
function watchNote(doc: Nuri, onChange: () => void): () => void {
|
|
return subscribeDoc(doc, onChange);
|
|
}
|
|
|
|
// --- identity ---------------------------------------------------------------
|
|
|
|
let identity = "";
|
|
function currentIdentity(): string {
|
|
if (!identity) throw new Error("not signed in yet");
|
|
return identity;
|
|
}
|
|
|
|
/**
|
|
* Sign in. The library shows its access barrier when it needs one; the day the wallet
|
|
* supplies the identity, this resolves silently and nothing here changes.
|
|
*/
|
|
async function signIn(): Promise<void> {
|
|
await ensureIdentity();
|
|
await sessionReady;
|
|
identity = readIdentityBack();
|
|
}
|
|
|
|
/** The library owns the identity; the app asks for it rather than remembering it. */
|
|
function readIdentityBack(): string {
|
|
return new URLSearchParams(location.search).get("ng-id") ?? localStorage.getItem("ng-eventually:identity") ?? "";
|
|
}
|
|
|
|
function escape(s: string): string {
|
|
return s.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n");
|
|
}
|
|
|
|
// --- the thinnest UI that makes each act reachable --------------------------
|
|
|
|
const el = (id: string): HTMLElement => document.getElementById(id)!;
|
|
const val = (id: string): string => (el(id) as HTMLInputElement).value.trim();
|
|
|
|
async function refresh(): Promise<void> {
|
|
const scope = (el("scope") as HTMLSelectElement).value as Scope;
|
|
const notes = await myNotes(scope);
|
|
el("notes").innerHTML = notes
|
|
.map(
|
|
(n) => `<li data-doc="${n.doc}">
|
|
<b class="t">${n.title}</b> — <span class="b">${n.body}</span>
|
|
<button class="share" data-doc="${n.doc}">partager</button>
|
|
<button class="open" data-doc="${n.doc}">ouvrir aux messages</button>
|
|
<button class="msgs" data-doc="${n.doc}">messages</button>
|
|
</li>`,
|
|
)
|
|
.join("");
|
|
el("who").textContent = identity;
|
|
}
|
|
|
|
function wire(): void {
|
|
el("write").addEventListener("click", async () => {
|
|
await writeNote((el("scope") as HTMLSelectElement).value as Scope, val("title"), val("body"));
|
|
await refresh();
|
|
});
|
|
el("openLink").addEventListener("click", async () => {
|
|
const note = await readSharedNote(val("link"));
|
|
el("shared").textContent = note ? `${note.title} — ${note.body}` : "(illisible)";
|
|
});
|
|
el("leave").addEventListener("click", async () => {
|
|
await leaveMessage(val("onNote") as Nuri, val("message"));
|
|
el("left").textContent = "déposé";
|
|
});
|
|
el("notes").addEventListener("click", async (e) => {
|
|
const target = e.target as HTMLElement;
|
|
const doc = target.dataset.doc as Nuri | undefined;
|
|
if (!doc) return;
|
|
if (target.classList.contains("share")) {
|
|
await shareNote(doc, val("shareWith"));
|
|
el("shareResult").textContent = "partagé";
|
|
} else if (target.classList.contains("open")) {
|
|
await openNoteForMessages(doc);
|
|
el("shareResult").textContent = "ouverte aux messages";
|
|
} else if (target.classList.contains("msgs")) {
|
|
el("messages").textContent = (await messagesOn(doc)).join(" | ") || "(aucun)";
|
|
}
|
|
});
|
|
}
|
|
|
|
async function main(): Promise<void> {
|
|
wire();
|
|
await signIn();
|
|
await refresh();
|
|
}
|
|
|
|
void main();
|
|
|
|
// The e2e suite drives this app through the DOM. It exposes nothing else: a test that
|
|
// needed a back door would be testing something an application cannot do.
|
|
(globalThis as { __notebook?: unknown }).__notebook = { watchNote, setCurrentUser };
|