/** * The wallet lifecycle: mint one by driving the wallet application's real interface, get its * bytes out (`.ngw` contents, file optional), and put a `.ngw` file into a browser profile. * * ── Why the real interface and not a shortcut ──────────────────────────────── * A wallet obtained any other way is not the one a person has. The wallet application is an * application like any other, so this drives it: click for click, field for field. That is * also what makes the harness notice when the flow upstream changes, instead of testing * against a fixture that quietly stopped resembling it. * * The addresses and selectors are DESCRIPTION and live in `nextgraph-ui.ts`. * * ── On the fixed waits in these flows ──────────────────────────────────────── * The creation and import flows below contain a handful of `waitForTimeout` calls, each on a * step where the wallet application offers NO observable signal that the work is finished * (unlocking a wallet bootstraps the verifier's repos from the broker and paints nothing). * They are inherited as-is, with their measured durations, and they are the only fixed waits * in this package — everything else waits for a condition. They are the first thing to replace * if the wallet application ever grows a marker to wait on. */ import type { BrowserContext, Page } from "playwright"; import { execSync } from "node:child_process"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; import { closeQuietly, within } from "./deadline"; import { closeContext, launchWatchedContext, newPage } from "./browser"; import { newRunProfile, type RunProfile } from "./profiles"; import { serveOnEphemeralPort } from "./serve"; import { setupBrokerPage } from "./broker"; import { WALLET_APP, WALLET_CREATION, WALLET_IMPORT } from "./nextgraph-ui"; import type { ExportedWallet } from "./wallet-export-page"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); /** `bun build` is a local bundle; a minute is already ten times what it takes. */ const BUILD_MS = 60_000; /** * The whole wallet export measures ~7 s against a real broker. Bounded at 60 s ≈ 8x. * * It was two minutes once, and that cost a run twice over: the export hung, and the suite * spent two full minutes reaching a verdict it could have reached in one — before dying * without a summary, because an export runs in the SETUP, ahead of every journey. */ const EXPORT_MS = 60_000; /** The export page appearing, then its session connecting. Both against a live broker. */ const EXPORT_PAGE_MS = 30_000; const EXPORT_CONNECT_MS = 60_000; /** A wallet's name and the password that opens it. */ export interface WalletCredentials { readonly name: string; readonly password: string; } /** * The name a wallet gets when the caller has no opinion about it. * * A wallet's name is what a person types to pick one among several, and nothing in NextGraph * keys off it — so a caller minting the only wallet it will ever hold has nothing to decide * here. The PASSWORD never gets a default: it is the only thing between the file and whoever * finds it, and a defaulted one would be a published secret. */ export const DEFAULT_WALLET_NAME = "ng-wallet"; /** * Create a wallet in `ctx`'s profile by walking the wallet application, then unlock it once. * * The first unlock is not decoration: it is what bootstraps the verifier's repos from the * broker, and a wallet that has never been unlocked is not usable by an application. */ export async function createWalletInContext(ctx: BrowserContext, credentials: WalletCredentials): Promise { const page = ctx.pages()[0] ?? (await newPage("the wallet creation flow", ctx)); page.on("pageerror", () => {}); await page.goto(WALLET_APP.home, { waitUntil: "domcontentloaded", timeout: 30000 }); const createButton = page.getByText(WALLET_CREATION.createWallet, { exact: true }); await createButton.waitFor({ state: "visible", timeout: 15000 }); await createButton.click(); await page.waitForURL(WALLET_CREATION.termsRoute, { timeout: 15000 }).catch(() => {}); const acceptButton = page.getByText(WALLET_CREATION.acceptTerms, { exact: true }); await acceptButton.waitFor({ state: "visible", timeout: 15000 }); await acceptButton.click(); const usernameInput = page.locator(WALLET_CREATION.username); await usernameInput.waitFor({ state: "visible", timeout: 30000 }); await usernameInput.fill(credentials.name); const passwordInput = page.locator(WALLET_CREATION.password); await passwordInput.waitFor({ state: "visible", timeout: 5000 }); await passwordInput.fill(credentials.password); const submitButton = page.getByText(WALLET_CREATION.submit, { exact: false }); await submitButton.waitFor({ state: "visible", timeout: 5000 }); await submitButton.click(); await page.waitForURL(WALLET_CREATION.landsOn, { timeout: 30000 }); await page.waitForTimeout(2000); // First login → bootstrap the verifier repos from the broker. This is what a brand-new // wallet does on its very first unlock. const walletLink = page.getByText(WALLET_CREATION.loginWithThisWallet); if (await walletLink.isVisible({ timeout: 5000 }).catch(() => false)) { await walletLink.click(); await page.waitForTimeout(1000); } const loginPassword = page.locator(WALLET_CREATION.passwordField); await loginPassword.waitFor({ state: "visible", timeout: 10000 }); await loginPassword.fill(credentials.password); await loginPassword.press("Enter"); await page.waitForTimeout(10000); } /** * This run's physical user: a profile of its own, and a wallet minted into it. The creation * context is closed — the caller opens its own contexts over `profile.dir`, one at a time. * * One per run, never inherited from a previous one: see `profiles.ts` for why that is a * property of the directory rather than a rule anyone has to remember. */ export async function mintWalletProfile(purpose: string, credentials: WalletCredentials): Promise { const profile = newRunProfile(purpose); const ctx = await launchWatchedContext("wallet-creation", profile.dir); try { await createWalletInContext(ctx, credentials); } finally { const { closeContext } = await import("./browser"); await closeContext("wallet-creation", ctx); } return profile; } /** * The same, with the context left OPEN. * * For the cold-start case: the caller then opens its application in the SAME profile, i.e. the * very first application session over a wallet that has never run one. Closing and relaunching * would not be the same thing. */ export async function mintWalletProfileKeepingContext( purpose: string, credentials: WalletCredentials, ): Promise<{ ctx: BrowserContext; profile: RunProfile }> { const profile = newRunProfile(purpose); const ctx = await launchWatchedContext("fresh-wallet", profile.dir); await createWalletInContext(ctx, credentials); const first = ctx.pages()[0]; if (first !== undefined) await first.close().catch(() => {}); return { ctx, profile }; } /** * A context on an EMPTY profile: no wallet, no local repo cache. * * Empty local storage ⇒ empty verifier repo cache ⇒ the reconnection cold-start: a wallet's * repos are on the broker but NOT in this profile, so a session over it starts with nothing * local. The caller imports a wallet (see {@link importWalletFile}) before opening the * application. */ export async function emptyProfileContext( purpose: string, ): Promise<{ ctx: BrowserContext; profile: RunProfile }> { const profile = newRunProfile(purpose); const ctx = await launchWatchedContext("clean-profile", profile.dir); return { ctx, profile }; } /** * Import a `.ngw` wallet FILE into the profile `page` belongs to, then unlock it. * * After this the profile holds the wallet — but NOT the repos' local cache — so the next * application session over it hits the broker-only cold-start. * * The password is a PARAMETER and has no default. An access barrier that DISPLAYS a password * can then be tested by reading it off its own screen and passing it here, which is the only * way to tell that what the barrier shows is what actually opens the file. A default would * make that step untestable: the import would succeed on a barrier showing anything at all, * including nothing. */ export async function importWalletFile(page: Page, ngwPath: string, password: string): Promise { await page.goto(WALLET_APP.login, { waitUntil: "domcontentloaded" }); // Let the application render and attach the file input (uploading too early → EncryptionError). await page.waitForTimeout(3000); await page.locator(WALLET_IMPORT.fileInput).waitFor({ state: "attached", timeout: 15000 }); await page.setInputFiles(WALLET_IMPORT.fileInput, ngwPath); const passwordInput = page.locator(WALLET_IMPORT.passwordField).first(); await passwordInput.waitFor({ state: "visible", timeout: 15000 }); await passwordInput.fill(password); await passwordInput.press("Enter"); const confirm = page.getByRole("button", { name: WALLET_IMPORT.confirm }); if (await confirm.isVisible({ timeout: 2000 }).catch(() => false)) await confirm.click().catch(() => {}); await page.waitForTimeout(8000); // unlock + verifier bootstrap from the broker } /** The export page, bundled once per run. */ let exportBundle: string | null = null; function buildExportBundle(): string { if (exportBundle !== null) return exportBundle; const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), "ng-e2e-export-")), "wallet-export-page.js"); const entry = path.join(__dirname, "wallet-export-page.ts"); execSync(`bun build ${entry} --outfile ${out} --bundle --format=esm`, { stdio: "pipe", cwd: __dirname, timeout: BUILD_MS, }); exportBundle = fs.readFileSync(out, "utf-8"); return exportBundle; } /** * The bytes of the wallet held by `ctx`'s profile — the contents of a `.ngw` file, without a * `.ngw` file. * * Why an application's own suite needs this: a deployment that hands a wallet out — an access * barrier with a download link, say — must be tested against a REAL wallet. Serving a * placeholder there makes the download step a decoration: importing it cannot let anybody in, * so the check that the link works cannot fail for the right reason. * * ── Why BYTES are the primitive and the file the convenience ───────────────── * Every caller here already has the bytes in hand — they arrive from the broker iframe — and * only some of them want a file. This used to write one unconditionally, so a suite that * serves a wallet from memory had to name a temp path, write it, read it straight back and * remember to remove it: three steps and a cleanup to get back what the function had. A caller * that genuinely wants a file writes these bytes (or calls {@link exportWalletFile}), which is * one step in the direction nobody has to undo. */ export async function exportWalletBytes( ctx: BrowserContext, walletPassword: string, ): Promise { const bundle = buildExportBundle(); const html = 'wallet export' + ''; const { url, close } = await serveOnEphemeralPort((req, res) => { if (req.url === "/wallet-export-page.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); } }); const page = await newPage("the wallet export", ctx); page.on("pageerror", () => {}); try { const frame = await setupBrokerPage(page, url, walletPassword); await frame.waitForFunction( () => (window as unknown as { __ngWalletExport?: unknown }).__ngWalletExport !== undefined, { timeout: EXPORT_PAGE_MS }, ); await frame.waitForFunction( () => (window as unknown as { __ngWalletExport: { status(): string } }).__ngWalletExport.status() === "connected", { timeout: EXPORT_CONNECT_MS }, ); // `frame.evaluate` has NO timeout of its own — a bridge call that never settles is one of // the two ways this harness used to hang for ever. const exported = await within("the wallet bytes from the broker iframe", EXPORT_MS, () => frame.evaluate( () => ( window as unknown as { __ngWalletExport: { file(): Promise } } ).__ngWalletExport.file(), ), ); return new Uint8Array(Buffer.from(exported.b64, "base64")); } finally { await closeQuietly("the wallet export page", () => page.close()); close(); } } /** * The same wallet, written to `ngwPath`, returning its size in bytes. * * For the callers that want a FILE — a browser's file input takes a path, and a person * provisioning a deployment has to put the wallet somewhere. Everyone else takes the bytes. */ export async function exportWalletFile( ctx: BrowserContext, ngwPath: string, walletPassword: string, ): Promise { const bytes = await exportWalletBytes(ctx, walletPassword); fs.writeFileSync(ngwPath, bytes); return bytes.length; } /** * A wallet that did not exist a moment ago, as the bytes of a `.ngw`: mint one by walking the * wallet application, take its bytes out, and drop everything else. * * The one call behind all three callers who need a fresh wallet — this package's own suite, a * consuming application's suite, and a person provisioning a deployment (see `bin/mint-wallet.ts`, * which is this function plus a `writeFileSync` and two `console.log`s). * * ── What it deliberately does NOT keep ─────────────────────────────────────── * The browser profile the wallet was minted in is discarded here. That is not a shortcut: the * wallet's repos live on the broker and the bytes returned are what opens them, so the profile * is scaffolding in every one of the three cases — including the durable one, where what is * kept is the FILE the caller writes, never a browser directory. * * A suite that needs the profile to survive — because its actors sign in THROUGH it rather than * importing the file — composes the two halves itself: {@link mintWalletProfile} for a profile * that lasts the run, then {@link exportWalletBytes} over it. Same minting, same export, * different lifetime, which is the only thing the two paths disagree about. */ export async function mintWalletBytes( password: string, name: string = DEFAULT_WALLET_NAME, ): Promise { const profile = await mintWalletProfile(`minting the wallet "${name}"`, { name, password }); try { // A context of its own over the profile, opened after the creation one closed — the order // `mintWalletProfile` already imposes, and the one every export in this repository uses. const ctx = await launchWatchedContext("wallet-export", profile.dir); try { return await exportWalletBytes(ctx, password); } finally { await closeContext("wallet-export", ctx); } } finally { profile.discard(); } }