diff --git a/README.md b/README.md index a70dcfb..e1c3ff5 100644 --- a/README.md +++ b/README.md @@ -168,8 +168,13 @@ the unused list" is not a reason to investigate it. - Inbox: the client `inbox` namespace deposits (`post`) and, in the shared-wallet emulation, reads the deposits back (`read` / `materialize` / `watch`) in place of the recipient's own inbox processing. -- Tests of the polyfill (against a real broker) live in this repo, so a consuming - app can test its features against a clean, mocked API. +- Tests of the polyfill (against a real broker) live in this repo, in **two** suites, + and the split is deliberate: `packages/sdk/e2e/run.ts` (`test:e2e`) characterises the + primitives and the platform contracts, while `packages/sdk/e2e/notebook.ts` + (`test:e2e:app`) drives the example application through the DOM, one browser page per + identity. Only the second can tell whether an application is *writable* — a harness + can pass a value between two identities through a variable, and an application cannot. + A consuming app can test its own features against a clean, mocked API. ## Status diff --git a/examples/notebook/README.md b/examples/notebook/README.md index 5f28dfe..05f5891 100644 --- a/examples/notebook/README.md +++ b/examples/notebook/README.md @@ -6,14 +6,25 @@ It exists for two reasons, and the second is the one that matters. **It shows how to use the library.** Every call in `app.ts` is what a real consumer writes. There is no test scaffolding, no privileged import, no reaching into the library's internals — it resolves `@ng-eventually/sdk` as an external consumer does. If something reads awkwardly here, it reads awkwardly for everyone. -**It is what the e2e suite is meant to drive** — and does not yet, which this line says out loud rather than implying otherwise. The suite still talks to a bag of methods on `window.__sdk` (`packages/sdk/e2e/sdk-entry.ts`), which proves the functions run but never that an application can be written with them. That gap shipped a real defect once: a document's inbox was green in tests and unusable in practice, because the harness handed an address across an identity boundary through a variable — something no application can do. This app can only do what an application can do, which is why the applicative scenarios belong here. +**It is what the applicative e2e suite drives** (`packages/sdk/e2e/notebook.ts`, `bun run test:e2e:app`). The other suite talks to a bag of methods on `window.__sdk`, which proves the functions run but never that an application can be written with them — and that gap shipped a real defect once: a document's inbox was green in tests and unusable in practice, because the harness handed an address across an identity boundary through a variable, something no application can do. Here each identity is its own browser page, and the only values that cross between them are the ones that cross in life: a note's reference, copied off one screen, and an identifier typed into a field. -It has already paid for itself twice: writing it surfaced that `UnionSubject` returned `string` where the values are always document references (so a consumer had to cast whatever it had just read before passing it back), and that the access gate normalized what a user typed but not what the URL carried. +It has already paid for itself: writing it surfaced that `UnionSubject` returned `string` where the values are always document references (so a consumer had to cast whatever it had just read before passing it back), and that the access gate normalized what a user typed but not what the URL carried. ## What it exercises -Signing in, writing notes by scope, listing one's own, reading a note received as a link, handing a reader the key to a private note, opening a note for messages, leaving a message on someone else's note, and reacting to changes. +Signing in, writing notes by scope, listing one's own, reading a note from its bare reference, handing a reader the key to a protected note, opening a note for messages, leaving a message on someone else's note, and reacting to changes. + +The four journeys the suite runs, and what each proves: + +| Journey | What it proves | +|---|---| +| Bob reads Alice's PUBLIC note from its reference alone | A public store serves its notes' keys — a bare reference is enough, and no key ever crosses | +| Alice's PROTECTED note stays shut until she shares it | The same gesture, the opposite outcome, decided by where the note sits and not by what was sent | +| Bob leaves a message on Alice's note, and only Alice reads it | A depositor FINDS the address from the note itself; depositing grants no reading | +| Each actor's list holds their own notes | The boundary, seen from the only place that matters: the screen | + +It has also found three defects of its own, each an application-side one the harness could not see: `connectedUser()` had to be awaited at sign-in (a note just shared with you reads as unreadable otherwise), a stale answer stayed on screen beside a fresh question, and changing the scope did not refresh the list. ## Running it -The e2e suite builds and serves it (`packages/sdk/e2e/`). To open it by hand you need a wallet: serve the folder with a bundled `app.js` and a `/shared-wallet.ngw`, and set `__NOTEBOOK_WALLET_PASSWORD__`. +`cd packages/sdk && bun run test:e2e:app` builds it, serves it, and drives it against the real broker. To open it by hand you need a wallet: serve the folder with a bundled `app.js` and a `/shared-wallet.ngw`, and set `__NOTEBOOK_WALLET_PASSWORD__`. diff --git a/examples/notebook/app.ts b/examples/notebook/app.ts index ada6881..4f219cf 100644 --- a/examples/notebook/app.ts +++ b/examples/notebook/app.ts @@ -7,13 +7,14 @@ * 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. + * 2. **It is what the applicative e2e suite drives** (`packages/sdk/e2e/notebook.ts`). + * The other suite talks to a bag of methods on `window.__sdk`, which proves the + * functions run 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 @@ -35,6 +36,7 @@ import { readUnion, storeRegistry, subscribeDoc, + connectedUser, type Nuri, type Scope, // Polyfill-era — these three go away, and they are the whole of what goes away. @@ -121,15 +123,20 @@ async function myNotes(scope: Scope): Promise { } /** - * Read someone else's note from its link. + * Read someone else's note from its REFERENCE. * - * The link is what circulates in this model — you do not discover a note, you are given - * its link. It arrives as a plain string, from a field or a URL, and goes straight in: - * the library validates it. Nothing to narrow, nothing to cast, and nothing that will - * have to change when the real SDK takes that same string. + * A reference is what circulates — you do not discover a note, someone gives you its + * reference (a message, a URL, a QR code). It carries no key, and that is the point: + * if the note is in a PUBLIC store the store hands its key to whoever asks, so the + * reference is enough; if it is protected, the reference names the note and opens + * nothing, until its owner shares it (see `shareNote`). + * + * It arrives as a plain string, from a field or a URL, and goes straight in: the + * library validates it. Nothing to narrow, nothing to cast, and nothing that will have + * to change when the real SDK takes that same string. */ -async function readSharedNote(link: string): Promise { - const [note] = await readUnion([link]); +async function readSharedNote(reference: string): Promise { + const [note] = await readUnion([reference]); if (!note) return null; return { doc: note.subject, @@ -182,11 +189,19 @@ function currentIdentity(): string { /** * 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. + * + * The `connectedUser()` await is not optional decoration, and the applicative e2e is + * what found that out: setting an identity FIRES the connection work — restoring what + * others shared with you, draining your inboxes — but does not wait for it. Render + * before it lands and a note someone just shared reads as unreadable, which looks like + * a permission problem and is a timing one. At migration this becomes the session + * opening, and the await stays exactly where it is. */ async function signIn(): Promise { await ensureIdentity(); await sessionReady; identity = readIdentityBack(); + await connectedUser(); } /** The library owns the identity; the app asks for it rather than remembering it. */ @@ -213,6 +228,7 @@ async function refresh(): Promise { +
${n.doc}
`, ) .join(""); @@ -220,15 +236,20 @@ async function refresh(): Promise { } function wire(): void { + // Changing the scope changes which notes are listed — without this the list keeps + // showing the previous scope's notes, which reads as "my note disappeared". + el("scope").addEventListener("change", () => void refresh()); 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("openRef").addEventListener("click", async () => { + el("shared").textContent = ""; // never show a previous answer beside a new question + const note = await readSharedNote(val("reference")); el("shared").textContent = note ? `${note.title} — ${note.body}` : "(illisible)"; }); el("leave").addEventListener("click", async () => { + el("left").textContent = ""; await leaveMessage(val("onNote") as Nuri, val("message")); el("left").textContent = "déposé"; }); @@ -236,6 +257,8 @@ function wire(): void { const target = e.target as HTMLElement; const doc = target.dataset.doc as Nuri | undefined; if (!doc) return; + el("shareResult").textContent = ""; + el("messages").textContent = ""; if (target.classList.contains("share")) { await shareNote(doc, val("shareWith")); el("shareResult").textContent = "partagé"; diff --git a/examples/notebook/index.html b/examples/notebook/index.html index c18e947..32a5358 100644 --- a/examples/notebook/index.html +++ b/examples/notebook/index.html @@ -12,6 +12,7 @@ button { cursor: pointer; border: 1px solid #bbb; border-radius: 5px; background: #f6f6f6; } ul { list-style: none; padding: 0; } li { padding: 6px 0; border-bottom: 1px solid #eee; } + code.ref { font-size: 11px; color: #888; user-select: all; } .out { color: #555; font-size: 13px; min-height: 1.2em; } @@ -40,8 +41,8 @@
Ouvrir une note reçue - - + +

diff --git a/examples/notebook/tsconfig.json b/examples/notebook/tsconfig.json index 3764c22..61b162a 100644 --- a/examples/notebook/tsconfig.json +++ b/examples/notebook/tsconfig.json @@ -12,9 +12,6 @@ "paths": { "@ng-eventually/sdk": [ "../../packages/sdk/src/index.ts" - ], - "@ng-org/web": [ - "../../node_modules/.bun/@ng-org+web@0.1.2-alpha.13/node_modules/@ng-org/web/dist/index.d.ts" ] } }, diff --git a/packages/sdk/e2e/notebook.ts b/packages/sdk/e2e/notebook.ts new file mode 100644 index 0000000..3f0915b --- /dev/null +++ b/packages/sdk/e2e/notebook.ts @@ -0,0 +1,282 @@ +/** + * The APPLICATIVE e2e suite — the same broker, driven through the example application. + * + * ── Why this exists beside `run.ts` ─────────────────────────────────────── + * `run.ts` drives a bag of methods on `window.__sdk`. That proves the functions RUN; it + * cannot prove an application can be written with them, and the difference has already + * cost a shipped defect: a document's inbox was green there and unusable in practice, + * because the harness handed an address across an identity boundary through a JS + * variable — a channel no application has. + * + * This suite has no such channel. It drives `examples/notebook` through the DOM, one + * browser page per identity, and the only things that cross between them are the ones + * that cross in reality: a note's REFERENCE (copied from Alice's screen, as a human + * would copy it into a message) and an identifier typed into a field. Everything else + * each actor must OBTAIN through the application. + * + * The division of labour with `run.ts`: platform contracts, primitive characterisation + * and cold-start regressions stay there — they need privileged access, fresh profiles + * and raw SPARQL, and they are about the broker, not about an application. What lives + * here is the journeys, and they read as journeys. + * + * ── Why a bare reference is allowed to cross ────────────────────────────── + * Because the model says it circulates: it names a note and grants nothing, and if the + * note sits in a public store its cap is served to whoever asks + * (`emulated-verifier/public-store.ts`). A test that had to pass a KEY between actors + * would be describing something no application can do — that is the line, and it is the + * reason the application displays each note's reference: what no screen shows, no user + * can circulate. + */ + +import { type BrowserContext, type Frame, type Page } from "playwright"; +import { execSync } from "node:child_process"; +import * as http from "node:http"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; +import { ensureWallet, launchWalletContext, setupBrokerPage } from "./broker"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const APP_DIR = path.resolve(__dirname, "..", "..", "..", "examples", "notebook"); +const BUNDLE_OUT = path.resolve(__dirname, ".dist", "notebook.js"); + +// ── reporting ─────────────────────────────────────────────────────────────── + +type Check = { name: string; ok: boolean; detail?: string }; +const results: Check[] = []; +function check(name: string, ok: boolean, detail?: string): void { + results.push({ name, ok, detail }); + console.log(` [${ok ? "PASS" : "FAIL"}] ${name}${detail ? " — " + detail : ""}`); +} +async function journey(name: string, fn: () => Promise): Promise { + console.log(`\n── ${name} ──`); + try { + await fn(); + } catch (e: any) { + check(name, false, "threw: " + String(e?.message ?? e)); + } +} + +// ── build + serve the application, exactly as a deployment would ──────────── + +function buildApp(): void { + fs.mkdirSync(path.dirname(BUNDLE_OUT), { recursive: true }); + execSync(`bun build ${path.join(APP_DIR, "app.ts")} --outfile ${BUNDLE_OUT} --bundle --format=esm`, { + stdio: "pipe", + cwd: APP_DIR, + }); +} + +function serveApp(): Promise<{ url: string; close: () => void }> { + const bundle = fs.readFileSync(BUNDLE_OUT, "utf-8"); + const html = fs.readFileSync(path.join(APP_DIR, "index.html"), "utf-8"); + const server = http.createServer((req, res) => { + if ((req.url ?? "").startsWith("/app.js")) { + res.writeHead(200, { "Content-Type": "application/javascript; charset=utf-8" }); + res.end(bundle); + } else { + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(html); + } + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const port = (server.address() as { port: number }).port; + resolve({ url: `http://127.0.0.1:${port}`, close: () => server.close() }); + }); + }); +} + +// ── one actor = one page, signed in as one identity ───────────────────────── + +/** + * An actor is a browser page carrying its own identity. Nothing is shared between two + * actors but the broker and the application's URL — which is what makes a value crossing + * from one to the other visible in this file, instead of hidden in a closure. + */ +interface Actor { + id: string; + frame: Frame; + page: Page; +} + +async function signIn(ctx: BrowserContext, appUrl: string, id: string): Promise { + const page = await ctx.newPage(); + page.on("pageerror", (e) => console.error(`[${id} pageerror]`, e.message)); + page.on("console", (m) => { + if (m.type() === "error") console.error(`[${id} console]`, m.text()); + }); + // `?ng-id=` is the ONE channel that survives the broker round-trip (the access gate's + // resolution order, `shared-wallet/access-gate.ts`), so a returning user never sees + // the barrier. Here it is also how the suite signs an actor in without typing. + const frame = await setupBrokerPage(page, `${appUrl}/?ng-id=${encodeURIComponent(id)}`); + await frame.locator('[data-testid="who"]').filter({ hasText: /\S/ }).waitFor({ timeout: 60000 }); + return { id, frame, page }; +} + +// ── the acts, expressed as the application expresses them ─────────────────── + +/** Show the notes of `scope` — the list is per-scope, so acting on a note means + * looking at the right shelf first. */ +async function showScope(a: Actor, scope: string): Promise { + await a.frame.locator('[data-testid="scope"]').selectOption(scope); +} + +async function writeNote(a: Actor, scope: string, title: string, body: string): Promise { + await a.frame.locator('[data-testid="title"]').fill(title); + await a.frame.locator('[data-testid="body"]').fill(body); + await showScope(a, scope); + await a.frame.locator('[data-testid="write"]').click(); + await a.frame.locator(`li:has-text("${title}")`).waitFor({ timeout: 60000 }); +} + +/** The reference the application SHOWS for a note — what a human would copy out. */ +async function referenceOnScreen(a: Actor, title: string): Promise { + return (await a.frame.locator(`li:has-text("${title}") code.ref`).textContent())?.trim() ?? ""; +} + +/** + * Paste a reference and open it. The application blanks its answer before reading, so + * waiting for a NON-EMPTY answer here cannot be satisfied by the previous one — a trap + * this suite fell into on its first run, where a stale "readable" made an unreadable + * note look readable. + */ +async function openReceivedNote(a: Actor, reference: string): Promise { + await a.frame.locator('[data-testid="reference"]').fill(reference); + await a.frame.locator('[data-testid="open-reference"]').click(); + const out = a.frame.locator('[data-testid="shared"]'); + await out.filter({ hasText: /\S/ }).waitFor({ timeout: 60000 }).catch(() => {}); + return (await out.textContent())?.trim() ?? ""; +} + +async function shareNoteWith(a: Actor, title: string, withId: string): Promise { + await a.frame.locator('[data-testid="share-with"]').fill(withId); + await a.frame.locator(`li:has-text("${title}") button.share`).click(); + await a.frame.locator('[data-testid="share-result"]').filter({ hasText: "partagé" }).waitFor({ timeout: 60000 }); +} + +async function openForMessages(a: Actor, title: string): Promise { + await a.frame.locator(`li:has-text("${title}") button.open`).click(); + await a.frame + .locator('[data-testid="share-result"]') + .filter({ hasText: "ouverte aux messages" }) + .waitFor({ timeout: 60000 }); +} + +async function leaveMessage(a: Actor, reference: string, text: string): Promise { + await a.frame.locator('[data-testid="on-note"]').fill(reference); + await a.frame.locator('[data-testid="message"]').fill(text); + await a.frame.locator('[data-testid="leave"]').click(); + await a.frame.locator('[data-testid="left"]').filter({ hasText: "déposé" }).waitFor({ timeout: 60000 }); +} + +async function readMessages(a: Actor, title: string): Promise { + await a.frame.locator(`li:has-text("${title}") button.msgs`).click(); + const out = a.frame.locator('[data-testid="messages"]'); + await out.filter({ hasText: /\S/ }).waitFor({ timeout: 60000 }).catch(() => {}); + return (await out.textContent())?.trim() ?? ""; +} + +/** Reload the page: what a user does, and what makes a durable fact distinguishable + * from one that only lived in this tab's memory. */ +async function reopen(ctx: BrowserContext, appUrl: string, a: Actor): Promise { + await a.page.close().catch(() => {}); + return signIn(ctx, appUrl, a.id); +} + +// ── the journeys ──────────────────────────────────────────────────────────── + +async function main(): Promise { + console.log("[e2e/app] building the example application..."); + buildApp(); + console.log("[e2e/app] ensuring the batch wallet..."); + await ensureWallet(); + const { url, close: closeServer } = await serveApp(); + console.log(`[e2e/app] application served at ${url}`); + + const t = Date.now().toString(36); + const ALICE = `alice-${t}`; + const BOB = `bob-${t}`; + let ctx: BrowserContext | null = null; + const startedAt = Date.now(); + + try { + ctx = await launchWalletContext(); + const alice = await signIn(ctx, url, ALICE); + check("Alice signs in and the application knows who she is", true, `who=${ALICE}`); + const bob = await signIn(ctx, url, BOB); + check("Bob signs in, in his own space", true, `who=${BOB}`); + + // 1. A public note travels on its reference alone — the property the public-store + // emulation exists for. Nothing but the reference crosses, and no key does. + let publicRef = ""; + await journey("Bob reads Alice's public note from its reference alone", async () => { + await writeNote(alice, "public", "Courses", "pain, café"); + publicRef = await referenceOnScreen(alice, "Courses"); + check("the application SHOWS the reference, so a human can circulate it", /^did:ng:/.test(publicRef), publicRef); + // The one value that crosses, and it crosses the way it would in life: copied off + // one screen, pasted into another. It carries no key. + const read = await openReceivedNote(bob, publicRef); + check("Bob reads it holding nothing but that reference", read.includes("Courses") && read.includes("pain, café"), read); + check("the reference carried no key", !publicRef.includes(":r:"), publicRef); + }); + + // 2. A protected note does NOT travel on its reference — until its owner shares it. + // Same gesture on Bob's side, opposite outcome, decided by where the note sits. + let secretRef = ""; + await journey("Alice's protected note stays shut until she gives Bob the key", async () => { + await writeNote(alice, "protected", "Anniversaire", "surprise pour Bob"); + secretRef = await referenceOnScreen(alice, "Anniversaire"); + const before = await openReceivedNote(bob, secretRef); + check("Bob can NAME it and reads nothing of it", !before.includes("surprise"), before || "(illisible)"); + + await shareNoteWith(alice, "Anniversaire", BOB); + // Bob reopens the application: connecting is what applies what was deposited for + // him. He calls nothing — there is no "receive" in this model. + const bob2 = await reopen(ctx!, url, bob); + const after = await openReceivedNote(bob2, secretRef); + check("after Alice shares it, the same reference opens it", after.includes("surprise pour Bob"), after); + bob.frame = bob2.frame; + bob.page = bob2.page; + }); + + // 3. A note opened for messages: anyone deposits, only its owner reads. Bob addresses + // the NOTE — he never names an inbox, and no application should have to. + await journey("Bob leaves a message on Alice's note, and only Alice reads it", async () => { + await showScope(alice, "public"); // her public shelf, where "Courses" lives + await openForMessages(alice, "Courses"); + // Bob has to REOPEN so the address published on the note is visible to his session. + const bob2 = await reopen(ctx!, url, bob); + await leaveMessage(bob2, publicRef, "j'apporte le café"); + const mine = await readMessages(alice, "Courses"); + check("Alice reads the message left on her note", mine.includes("j'apporte le café"), mine); + bob.frame = bob2.frame; + bob.page = bob2.page; + }); + + // 4. Each actor lists their OWN notes and nothing else — the boundary, seen from + // the only place that matters: what the screen shows. + await journey("each actor's list holds their own notes, and no one else's", async () => { + await showScope(alice, "public"); + await alice.frame.locator('li:has-text("Courses")').waitFor({ timeout: 60000 }); + const aliceList = (await alice.frame.locator('[data-testid="notes"]').textContent()) ?? ""; + await showScope(bob, "public"); + const bobList = (await bob.frame.locator('[data-testid="notes"]').textContent()) ?? ""; + check("Alice sees her own note", aliceList.includes("Courses"), aliceList.slice(0, 80)); + check("Bob's own list does not contain Alice's note", !bobList.includes("Courses"), bobList.slice(0, 80) || "(vide)"); + }); + } finally { + await ctx?.close().catch(() => {}); + closeServer(); + } + + const failed = results.filter((r) => !r.ok).length; + const minutes = ((Date.now() - startedAt) / 60000).toFixed(1); + console.log( + `\n══ Application e2e summary: ${results.length - failed} passed, ${failed} failed, ` + + `${results.length} total — ${minutes} min ══`, + ); + process.exit(failed === 0 ? 0 : 1); +} + +void main(); diff --git a/packages/sdk/e2e/run.ts b/packages/sdk/e2e/run.ts index b871ff8..13a05d6 100644 --- a/packages/sdk/e2e/run.ts +++ b/packages/sdk/e2e/run.ts @@ -294,18 +294,11 @@ async function main(): Promise { check("watch fires when a deposit lands", after.fires > base.fires && after.lastLen >= 1, `fires=${after.fires} lastLen=${after.lastLen}`); await sdk(frame, "inboxWatchStop"); }); - await step("a document's inbox: owner opens, a third party resolves and deposits", async () => { - const t = Date.now(); - const r = await sdk(frame, "documentInboxDeposit", "@owner-" + t, "@depositor-" + t); - check( - "the depositor holds only the BARE reference, resolves the same inbox from it, deposits, and the address stays out of the data", - r.sameInbox === true && - r.openRefused === true && - JSON.stringify(r.deposits) === JSON.stringify([{ viaPostToDocument: true }, { joining: true }]) && - !r.props.some((p: string) => p.startsWith("urn:ng-eventually:")), - `sameInbox=${r.sameInbox} openRefused=${r.openRefused} deposits=${JSON.stringify(r.deposits)} props=${JSON.stringify(r.props)}`, - ); - }); + // MOVED to the applicative suite (`e2e/notebook.ts`, "Bob leaves a message on Alice's + // note, and only Alice reads it"). This is the step that motivated that suite: it was + // green here while the feature was unusable, because the harness could hand the inbox + // address across an identity boundary through a variable — a channel no application + // has. Driven through two screens, the address has to be FOUND or the journey fails. await step("inbox spoof guard", async () => { const r = await sdk(frame, "inboxSpoofGuard"); check("post as another principal is rejected; self + anon allowed", r.spoofRejected && r.selfOk && r.anonOk, `spoof=${r.spoofRejected} self=${r.selfOk} anon=${r.anonOk}`); @@ -388,14 +381,9 @@ async function main(): Promise { `owner=${JSON.stringify(r.ownerView)} stranger=${JSON.stringify(r.strangerView)} withCap=${JSON.stringify(r.strangerWithLinkView)}`, ); }); - await step("shareCap: a cap delivered to an inbox reveals the doc", async () => { - const r = await sdk(frame, "capsShareCap", "@friend-" + Date.now()); - check( - "share → inbox processed → the shared doc becomes readable, and the delivery is not surfaced", - r.before === 0 && r.after === 1 && r.surfacedDeposits === 0, - `before=${r.before} after=${r.after} surfaced=${r.surfacedDeposits}`, - ); - }); + // MOVED to the applicative suite (`e2e/notebook.ts`, "Alice's protected note stays + // shut until she gives Bob the key"): sharing is a journey, and it is worth more + // driven through two screens than through two calls on one page. // ── accounts (IdentityStore) ──────────────────────────────────────────── console.log("\n── accounts (IdentityStore) ──"); diff --git a/packages/sdk/e2e/sdk-entry.ts b/packages/sdk/e2e/sdk-entry.ts index 3bc9a2b..89591b3 100644 --- a/packages/sdk/e2e/sdk-entry.ts +++ b/packages/sdk/e2e/sdk-entry.ts @@ -34,7 +34,6 @@ import { // `storeRegistry` above is the app-facing slice; these are the shim internals. import * as registryInternals from "../src/shared-wallet/account-registry"; import { getCaps, getCurrentUser, resetCaps } from "../src/shared-wallet/bootstrap"; -import { documentInboxAddress } from "../src/emulated-verifier/branch-registers"; import * as virtualUsers from "../src/shared-wallet/virtual-users"; import { ensureIdentity } from "@ng-eventually/sdk"; // The harness narrows for its OWN assertions; a consumer never has to (the entries take @@ -848,88 +847,15 @@ const identity = new IdentityStore( setCurrentUser(null); return { ownerView, strangerView, strangerWithLinkView }; }, - /** - * Sharing a cap the way the model does it: the owner deposits it into the - * recipient's INBOX, and the recipient processing that inbox absorbs it. No - * "receive" operation exists, and no principal is ever named to the registry. - * Runs against the REAL broker inbox document, so it exercises the whole path. - */ - /** - * The DEPOSIT side of a document's inbox, end to end against the real broker: the - * owner opens it, a third party RESOLVES its address from the document itself and - * deposits, the owner reads it back. - * - * The point of the step is the resolution: the depositor is handed the document's - * BARE reference — the only thing an application circulates — and must find where to - * deposit on its own. It reads the document at all because the document sits in a - * public store, which serves its read cap to whoever asks (`public-store.ts`); no key - * crosses the identity boundary, here or in any real application. - */ - async documentInboxDeposit(ownerId: string, depositorId: string) { - registryInternals.resetRegistryCache(); - setCurrentUser(ownerId); - const doc = await storeRegistry.createEntityDoc(ownerId, "public"); - const ownerInbox = await storeRegistry.openDocumentInbox(doc); - - setCurrentUser(depositorId); - const resolved = await documentInboxAddress(doc); - // The one-call form an app actually uses: it names the DOCUMENT, never an inbox. - await inbox.postToDocument(doc, { payload: { viaPostToDocument: true }, ts: 900 }); - // Opening one on someone else's document must be refused, not silently forked. - let openRefused = false; - try { - await storeRegistry.openDocumentInbox(doc); - } catch { - openRefused = true; - } - if (resolved) await inbox.post(resolved, { payload: { joining: true }, ts: 1000 }); - - setCurrentUser(ownerId); - const deposits = await inbox.read(ownerInbox); - // The address is machinery: it must not surface among the document's properties. - const subjects = await readUnion([doc]); - const props = Object.keys(subjects[0]?.props ?? {}); - setCurrentUser(null); - return { - sameInbox: resolved === ownerInbox, - openRefused, - deposits: deposits.map((d) => d.payload), - props, - }; - }, - async capsShareCap(friendId: string) { - const s = await sessionReady; - resetCaps(); - // The recipient's OWN inbox — the address a cap is delivered to. Resolved while - // connected as them, since that is who owns it and who may later read it. - // `friendId` is fresh per run: this test's assertions survive accumulated caps, but - // the recipient's durable Links would grow run after run on a persistent wallet, - // making every later `connectedUser()` re-apply a longer and longer history. - setCurrentUser(friendId); - const friendInbox = await registryInternals.userInbox(friendId, "protected"); - - setCurrentUser("owner-O"); - const doc = await docs.docCreate(s.session_id, "Graph", "data:graph", "store", undefined); - injectedSetItems = [{ "@graph": doc, "@id": "1", v: "shared-item" }]; - getCaps().open(doc, "protected"); - - setCurrentUser(friendId); - const before = [...(libUseShape(null, null) as Iterable)].length; - - setCurrentUser("owner-O"); - await inbox.share(doc, friendId); - - setCurrentUser(friendId); - const absorbed = await inbox.read(friendInbox); // processing it applies the cap - const after = [...(libUseShape(null, null) as Iterable)].length; - - resetCaps(); - injectedSetItems = []; - setCurrentUser(null); - // `absorbed` must be EMPTY: a cap delivery is infrastructure, never surfaced - // to the consumer as a deposit. - return { before, after, surfacedDeposits: absorbed.length }; - }, + // MOVED to the applicative suite, `e2e/notebook.ts` (2026-08-07): + // - `documentInboxDeposit` → "Bob leaves a message on Alice's note, and only Alice + // reads it". This one is WHY that suite exists: it was green here while the + // feature was unusable, because a harness can hand an inbox address across an + // identity boundary through a variable and an application cannot. + // - `capsShareCap` → "Alice's protected note stays shut until she gives Bob the key". + // + // What stays here is what an application does not do: primitives, characterisation, + // and the cold-start regressions. // ── accounts (IdentityStore) ───────────────────────────────────────────── identitySet(id: string) { return identity.set(id); }, diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 669b5b3..e7c8d7c 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -36,6 +36,7 @@ "scripts": { "test": "bun test", "test:e2e": "bun run e2e/run.ts", + "test:e2e:app": "bun run e2e/notebook.ts", "test:e2e:reactivity": "bun run e2e/reactivity-doc-subscribe.ts" } }