--- name: test-convt-web description: Run and verify the convt.app website (apps/web, TanStack Start on Cloudflare Workers) and its billing Worker (apps/billing) locally, with Postgres, Mailpit, the OAuth mock and the Polar and Resend mock, signed in as a fixture account, in a browser at desktop and mobile widths. Use when changing anything under apps/web, apps/billing, packages/db, packages/billing, packages/mail, packages/license, tools/oauth-mock or tools/billing-mock, or when asked to preview, screenshot or check the site. --- # Test the convt website `apps/web` is a TanStack Start app built with Vite and the Cloudflare plugin, deployed with Wrangler as the `convt-web` Worker. `apps/billing` is the `convt-billing` Worker: the Polar webhook route, the billing crons, and the `BillingRpc` entrypoint the site calls through its `BILLING` service binding. It holds every billing secret and the license signing key; the site holds none. The landing page is static. The account pages (`/sign-in`, `/dashboard`, `/dashboard/licenses`, `/dashboard/billing`, `/dashboard/api`, `/account`) use Better Auth and read Postgres through the `HYPERDRIVE` binding. Locally everything runs on this machine: a Postgres and a Mailpit container per checkout, an OAuth mock that stands in for GitHub and Google, and a billing mock (`tools/billing-mock`) that speaks the parts of Polar's API and Resend's `/emails` that convt-billing calls, serves Polar-like checkout and portal pages, and signs webhooks with Polar's real scheme. No real email, OAuth app, payment account or database is ever used, and convt-billing signs keys with the local dev key in `.convt-dev/license.key`. ## Doctor ```sh docker info --format '{{.ServerVersion}}' # Docker must run; no sudo needed bash scripts/db.sh status # this checkout's containers and ports ss -ltnp 'sport = :3000' # who owns the default web port ``` `scripts/db.sh` names its containers `convt-pg-` and `convt-mail-` after a hash of the checkout path and labels them `convt.checkout=`, so other checkouts and other projects' Postgres containers are never touched. Ports are random on 127.0.0.1; `.convt-dev/services.env` (mode 0600, gitignored) records them with generated passwords and `BETTER_AUTH_SECRET`. ## Launch ```sh work=$(mktemp -d /tmp/convt-web.XXXXXX) setsid bun run dev:web >"$work/dev.log" 2>&1 "$work/pgid" for _ in $(seq 90); do grep -q 'dev-web: http' "$work/dev.log" && break; sleep 1; done origin=$(grep -o 'dev-web: http://localhost:[0-9]*' "$work/dev.log" | cut -d' ' -f2) curl -fsS "$origin/api/auth/ok" # {"ok":true} bun run db:seed # fixture accounts; safe to repeat ``` `bun run dev:web` runs `scripts/dev-web.sh`: `db.sh up`, migrations, the OAuth mock on `OAUTH_MOCK_PORT`, the billing mock on `BILLING_MOCK_PORT` (delivering webhooks to the site's `/webhooks/polar`), `apps/web/.dev.vars` and `apps/billing/.dev.vars` from `services.env`, then Vite on 3000 or the next free port with `BETTER_AUTH_URL` set to match. convt-billing runs inside Vite as an auxiliary Worker; outside production the site forwards `/webhooks/*` and `/__billing/*` to it, because an auxiliary Worker has no port of its own. After seeding, mirror the fixtures into the billing mock with `bun tools/billing-mock/src/preload.ts` (it is in-memory, so repeat it after every restart). Readiness is `/api/auth/ok` returning 200. Server-side errors appear in `dev.log`, not the browser console. `setsid` puts Vite and the mock in one process group so cleanup can stop exactly those. For a production-like check, build and serve the Worker with Wrangler. Pass the database through the Hyperdrive variable and point `BETTER_AUTH_URL` at Wrangler's port: ```sh set -a; . .convt-dev/services.env; set +a bun run build cd apps/web && CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE=$DATABASE_URL \ setsid bunx wrangler dev --port 8788 --ip 127.0.0.1 --var BETTER_AUTH_URL:http://localhost:8788 & ``` It reads the other values from `.dev.vars`, which the build copies into `dist/server`. Never run `bun run deploy` or `wrangler deploy` without the user's explicit authorization. ## Sign in as a fixture All fixtures are on the reserved `.test` domain and exist only in local databases: | Email | Shows | | ----------------------- | ------------------------------------------------------------------------------------ | | `new@convt.test` | Signed in, bought nothing. | | `trial@convt.test` | Pro monthly trial ending in 3 days, trial key. | | `desktop@convt.test` | Desktop order, key, invoice and one Mac. | | `pro@convt.test` | Pro yearly, older Desktop key, two Macs, API with two keys and usage, GitHub linked. | | `lapsed@convt.test` | Pro ended last month, last key, a failed invoice. | | `api@convt.test` | API only: one key, usage, six failed jobs. | | `unclaimed@convt.test` | No account: a Desktop purchase that signing up with this address claims. | | `refunded@convt.test` | A Desktop purchase refunded in full: the key shows REFUNDED. | | `pastdue@convt.test` | Pro monthly whose renewal failed: past due, last month's key. | | `disputed@convt.test` | A Desktop purchase with a lost chargeback: the key shows DISPUTED. | | `apipending@convt.test` | API enrollment waiting for a card (pending). | `trial@` has no key (a trial is not paid coverage); a database seeded before P7 keeps its old trial key, because licenses are never deleted. Every subscription fixture has payment coverage and a `billing_customers` row with mock ids. Sign in at `/sign-in` with the email, then read the code from Mailpit (its web UI is `$MAILPIT_URL`): ```sh curl -fsS "$MAILPIT_URL/api/v1/search?query=to:pro@convt.test" \ | python3 -c 'import json,re,sys; print(re.search(r"\d{6}", json.load(sys.stdin)["messages"][0]["Subject"]).group())' ``` Each address may receive 3 codes per 15 minutes and each IP 10 per hour; every local request comes from the same IP. To clear the limits in this checkout's own database: `bash scripts/db.sh psql owner -c 'delete from otp_send_limits; delete from rate_limits'`. GitHub and Google buttons go to the OAuth mock's authorize page, which lists its identities: `google-gmail` and `google-workspace` (verified addresses), `google-thirdparty` and `google-unverified` (must confirm by code), `github-verified`, `github-public-differs`, `github-no-email`, `github-pro` (signs in to `pro@convt.test`), and `error` (access denied). Append `&identity=` to the authorize URL to skip the page and `&email=
` to sign up with another address. ## Drive and evidence Use headless `agent-browser` for UI verification, with a named session and its own persistent profile. Put both `--session` and `--profile` on every invocation. Never use a personal browser profile. UI work includes opening the real page and inspecting desktop and mobile screenshots in both themes. The repeatable checks are scripts in `apps/web/e2e`, each printing PASS or FAIL lines and saving screenshots: ```sh cd apps/web E2E_SESSION=convt-web E2E_SHOTS=$work/shots bash e2e/sign-in.sh # code, emailed link, OAuth identities, verify-email E2E_SESSION=convt-web E2E_SHOTS=$work/shots bash e2e/fixtures.sh # every fixture, 5 pages, 1280 and 390 px, light and dark E2E_SESSION=convt-web E2E_SHOTS=$work/shots bash e2e/account.sh # activate, copy key, rename, change email, connect, sign out E2E_SESSION=convt-web E2E_SHOTS=$work/shots bash e2e/billing.sh # checkout, keys, trial, switch, refund, API, emails, deletion ``` They read the site origin from `apps/web/.dev.vars`; set `E2E_URL` to test Wrangler instead. `billing.sh` drives the mock's hosted checkout and its `/admin/*` endpoints (end a trial, renew, fail a card, refund, change settings), runs crons through `$E2E_URL/__billing/scheduled?cron=...`, and screenshots each billing screen and the four emails (Mailpit's `/view/.html`) in both themes. Run it once per fresh `dev:web`: it changes mock settings and deletes an account. `account.sh` calls a source module directly to prove `getLicenseKey` refuses another user's license, which only works under Vite. A layout change needs desktop and 390 px screenshots in both themes; look at each one with the Read tool. Activate opens `convt://activate?key=` through a temporary link. `account.sh` stubs `HTMLAnchorElement.prototype.click` to check the URL, because Chrome lets no page script stub `location`. Opening the link in the real desktop app is a GUI check (see test-convt-desktop): register the handler with `integrations/linux/install.py` and use `CONVT_LICENSE_STORE=file` with `HOME` and the XDG directories in a `mktemp` directory. Seeded keys are signed with `.convt-dev/license.key`, whose public half local builds embed. ## Desktop sign-in `/device` is the page the desktop app opens to sign in (P8). It needs a signed-in, verified session, so a signed-out visit goes through `/sign-in` and comes back. Approve stores a five-minute one-time code in `verifications` and opens `convt://auth?state=...&code=...`; Cancel opens `...&error=access_denied`. The app then calls `POST /api/device/token` (code and verifier), `POST /api/device/license` (bearer device token; returns the Pro key from convt-billing's `currentProKey`) and `POST /api/device/sign-out`. These routes use no cookies and skip the Origin check; the logic and limits are in `src/server/device-auth.ts`. `test/integration/device.test.ts` covers the flow, one-use codes, revocation, cross-account isolation and rate limits. A full run with the real app is in `test-convt-desktop`, "Sign-in and renewal". Signed-in devices appear under Macs on the dashboard, where Sign out revokes their token. ## Billing locally - The mock's admin API: `curl -X POST $BILLING_MOCK_URL/admin/ -d '{...}'` with `state` (GET), `clock` (`advanceDays`), `trial-end`, `renew`, `card` (`decline`), `retry-payment`, `refund` (`order_id`, `amount`), `dispute` (`open`, then `lost`/`won`/`prevented`), `settings` (`allow_multiple_subscriptions`), `product` (price drift), `webhooks` (`mode` auto or hold, `dropNext`, `duplicate`, `delayMs`, `reorder`, `forgeNext`, `scheme` standard or legacy, `url`), `flush`, `resend-fault` (`timeout`, `delay`, `5xx`), `complete-checkout`. Polar sends no dispute webhook; disputes arrive through the reconciler (`cron=*/15 * * * *`). - Crons: `curl "$origin/__billing/scheduled?cron=*+*+*+*+*"` drains the outbox and advances deletions; `*%2F15+*+*+*+*` runs the reconciler; `17+3+*+*+*` the daily check and digest (to `alerts@convt.test` in Mailpit). - `bun run billing:outbox list` shows ambiguous and dead emails; `resolve sent|resend` records Leo's decision. - The second dev mode is `cd apps/billing && CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE_BILLING=$BILLING_DATABASE_URL bunx wrangler dev --port 8790 --test-scheduled`, with the mock's webhook URL set to it (`admin/webhooks` `url`). The site under `wrangler dev` reaches it through the dev registry; on a machine short of file watches (EMFILE in the log) that registry never finishes starting. - Billing Worker settings and guards: `packages/billing/src/env.ts`. In production it refuses loopback provider or mail URLs and a signing key that does not match `LICENSE_PUBLIC_KEY` or is a dev key. ## Tests ```sh cd apps/web && bun test test/unit # views, redirects, Origin, redactor, env, table shape bun run --cwd apps/web test:integration # Better Auth in process against a throwaway Postgres bun run db:ci # schema drift, down files, all integration tests, convt-server cd packages/billing && bun test test/unit && bun run test:integration # verifier, catalog, guards; ingest, keys, outbox, reconciler, deletion cd tools/billing-mock && bun test # the mock through the real SDK client, validateEvent, Resend idempotency cd packages/mail && bun test # template snapshots and escaping, Resend outcomes ``` The billing integration tests run the mock in process through the real Polar SDK client, as `convt_billing` against a disposable database, with an injected clock; one test builds `convt-cli` and activates an issued key with `CONVT_LICENSE_STORE=file` in a temporary HOME. A change to an email template changes its snapshot: bump `templateVersion` in `packages/mail/src/templates.ts` with it. Integration tests and `db:ci` start their own labeled tmpfs Postgres and remove it by id on exit. ## Preview for another device Secure cookies are off in development, but the sign-in origin must be the one the device uses. Start with `PUBLIC_ORIGIN=http://: PORT= MOCK_HOST=0.0.0.0 MOCK_PUBLIC_URL=http://: bun run dev:web`, which binds Vite to all interfaces and sets `BETTER_AUTH_URL` and the mock's authorize URL to those addresses. This path was not exercised when P6 was verified, so check sign-in end to end the first time. Verify the URL from the device that will open it, and report which devices actually loaded the page. Read the remote-preview skill before starting or sharing a preview. Bind to the verified Tailscale address and report which devices actually loaded the page. ## Checks ```sh bun run check && bun run check-types && bun run build ``` ## Cleanup `kill -- -"$(cat "$work/pgid")"` stops Vite and the OAuth mock you started (only that process group). Close browser sessions with `agent-browser --session close`. The containers stay for reuse; `bun run db:down` removes this checkout's containers and its database volume (after the ownership guard), and `bash scripts/db.sh prune` removes those of checkouts that no longer exist. ## P9 cloud converter and API keys Read test-convt-server and `docs/p9-cloud-plan.md` to start the API, MinIO and sandbox worker. Configure `CONVT_API_URL` and the shared `CONVT_WEB_TOKEN_SECRET` in the local web Worker. Use the task session `convt-p9` and profile `~/.agent-browser/profiles/convt-p9`, on every command. Check `/dashboard/api`: create a named key, inspect the shown-once field, dismiss it, reload, revoke it and verify a revoked key fails authentication. Inspect spend, reservations, cap reached and not-enrolled states. Check `/dashboard/api/convert`: pick a real file, choose a supported target, observe upload and job progress, download and inspect the result. Test malformed input, cancellation, an unavailable cancel endpoint and the retry button, the 2 GB file guard, exhausted monthly allowance, unpaid and trial states. Temporary reservations used to reach a limit must be cancelled afterward; restore any temporary cap only if it still has the value the test set. Never rewrite settled usage to stage a screenshot. Capture desktop and 390 px screenshots in light and dark, inspect each image, and verify no horizontal overflow. Check keyboard focus and accessible names. Save P9 evidence in `/home/leo/projects/convt-ui-brief/screens/p9/`. If Vite hits EMFILE, restart only the owned process with `CHOKIDAR_USEPOLLING=1`. Hot reload can reset a selected file; finish source edits before the final browser run. Production Worker, Railway Buckets CORS and real payment enrollment remain separate launch checks. ## Billing security regressions Run `bun test apps/billing/test` to verify the public webhook body's streaming reader. A body without Content-Length must stop and cancel when it crosses 256 KiB, exact-limit raw bytes must survive unchanged, and non-POST requests must consume no body. The billing integration deletion case includes Pro usage and pending API usage: only the API fact for the subscription being revoked may delay deletion. `bun audit` and the database dependency test must reject vulnerable esbuild versions; `bun run db:ci` checks the overridden loader against real migration tooling.