From 4fa2839de375b41236c793218f3d7d7dfb3fea2a Mon Sep 17 00:00:00 2001 From: Yash Nitin Virulkar Date: Tue, 3 Nov 2026 09:59:14 +0530 Subject: [PATCH] frontend, deploy: plan-choice dialog, read-failure handling, Caddy hardening --- control-plane/tests/test_caddy_hardening.py | 140 ++++++++++++++++++ control-plane/tests/test_caddy_routes.py | 23 ++- deploy/Caddyfile | 32 +++- deploy/e2e/false_receipt.sh | 109 ++++++++++++++ frontend/e2e/live/account.spec.ts | 70 +++++++++ frontend/e2e/live/overview.spec.ts | 8 + frontend/public/.well-known/security.txt | 4 + frontend/public/robots.txt | 4 + frontend/src/auth/AuthProvider.tsx | 3 + frontend/src/auth/FundingChoice.tsx | 69 ++++++--- frontend/src/auth/planChoice.test.ts | 79 ++++++++++ frontend/src/auth/planChoice.ts | 50 +++++++ frontend/src/chain/readFailure.test.ts | 25 ++++ frontend/src/chain/readFailure.ts | 10 ++ frontend/src/components/TopNav.tsx | 29 ++-- frontend/src/screens/live/LiveOverview.tsx | 3 +- frontend/src/screens/live/LiveProofLedger.tsx | 3 +- 17 files changed, 623 insertions(+), 38 deletions(-) create mode 100644 control-plane/tests/test_caddy_hardening.py create mode 100755 deploy/e2e/false_receipt.sh create mode 100644 frontend/public/.well-known/security.txt create mode 100644 frontend/public/robots.txt create mode 100644 frontend/src/auth/planChoice.test.ts create mode 100644 frontend/src/auth/planChoice.ts create mode 100644 frontend/src/chain/readFailure.test.ts create mode 100644 frontend/src/chain/readFailure.ts diff --git a/control-plane/tests/test_caddy_hardening.py b/control-plane/tests/test_caddy_hardening.py new file mode 100644 index 0000000..747322c --- /dev/null +++ b/control-plane/tests/test_caddy_hardening.py @@ -0,0 +1,140 @@ +"""What the public site must send, and how it answers a path that is not part of the app (deploy/Caddyfile). + +A test that asserts a guard is there is worth more than one that asserts the happy path works: each of these +failed in the 2026-11-01 test run (no security headers; every unknown path, including /.env, answered 200 +with the app shell). They read the file, so they run everywhere; the last one also asks Caddy itself when it +is installed. +""" + +import re +import shutil +import subprocess +from datetime import UTC, datetime +from fnmatch import fnmatch +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +CADDYFILE = ROOT / "deploy" / "Caddyfile" +PUBLIC = ROOT / "frontend" / "public" + + +def _text() -> str: + return CADDYFILE.read_text() + + +def _site_block() -> str: + match = re.search(r"(?ms)^mergit\.site, app\.mergit\.site \{.*?^\}", _text()) + assert match, "the mergit.site block is missing" + return match.group(0) + + +def _snippet() -> str: + match = re.search(r"(?ms)^\(site_headers\) \{.*?^\}", _text()) + assert match, "the site_headers snippet is missing" + return match.group(0) + + +@pytest.mark.parametrize( + "header", + [ + "Strict-Transport-Security", + "X-Frame-Options", + "X-Content-Type-Options", + "Referrer-Policy", + "Permissions-Policy", + ], +) +def test_every_response_of_the_site_carries_the_baseline_headers(header: str) -> None: + # In the site block itself, outside the handle blocks, so the API's responses get them too. + outside = re.sub(r"(?ms)^\thandle[^\n]*\{.*?^\t\}", "", _site_block()) + assert re.search(rf"(?m)^\t\t{header} ", outside), f"{header} is not set on every response" + + +def test_the_page_cannot_be_framed() -> None: + assert re.search(r'X-Frame-Options "?DENY"?', _site_block()) + + +def test_hsts_is_not_applied_to_subdomains_or_preloaded() -> None: + hsts = re.search(r"Strict-Transport-Security \"([^\"]+)\"", _site_block()) + assert hsts, "no HSTS" + assert "includeSubDomains" not in hsts.group(1) and "preload" not in hsts.group(1) + assert int(re.search(r"max-age=(\d+)", hsts.group(1)).group(1)) >= 31536000 # pyright: ignore[reportOptionalMemberAccess] + + +def test_the_csp_is_report_only_until_someone_has_watched_it_on_a_deployed_page() -> None: + snippet = _snippet() + assert "Content-Security-Policy-Report-Only" in snippet + assert not re.search(r"(?m)^\s*header\s+Content-Security-Policy\s", snippet), ( + "an enforcing CSP was added: it can break sign-in or the wallet if it was not watched first" + ) + for must in ("default-src 'self'", "frame-ancestors 'none'", "object-src 'none'", "base-uri 'self'"): + assert must in snippet + + +def test_the_page_is_not_cached_but_assets_still_are() -> None: + snippet = _snippet() + assert re.search(r'header @shell Cache-Control "no-cache"', snippet) + assert re.search(r"@shell not path /assets/\*", snippet) + assert "immutable" in snippet + + +def test_an_unknown_path_is_a_404_not_the_app_shell() -> None: + snippet = _snippet() + assert "try_files {path} /index.html" not in snippet, "every unknown path would answer 200 with the app" + assert re.search(r"(?m)^\trewrite @spa /index\.html$", snippet) + + +def test_the_apps_own_routes_still_reach_the_shell() -> None: + match = re.search(r"(?m)^\t@spa path ([^\n]+)$", _snippet()) + assert match, "no @spa route list" + patterns = match.group(1).split() + # The router's top-level paths (frontend/src/app/paths.ts) and a few real deep links. + for path in ( + "/", + "/signin", + "/demo", + "/demo/runs", + "/app", + "/app/runs/new", + "/app/proofs/abc", + "/app/settings", + ): + assert any(fnmatch(path, p) for p in patterns), f"{path} would 404 instead of opening the app" + for path in ("/.env", "/.git/config", "/nope-404", "/sitemap.xml", "/wp-login.php", "/v0/anything"): + assert not any(fnmatch(path, p) for p in patterns), f"{path} must not fall back to the app" + + +def test_the_router_has_no_top_level_route_the_site_would_404() -> None: + paths = (ROOT / "frontend" / "src" / "app" / "paths.ts").read_text() + top_level = { + m.group(1) for m in re.finditer(r'(?m)^\s+(?:landing|signIn|demo|liveApp):\s*"(/[^"]*)"', paths) + } + match = re.search(r"(?m)^\t@spa path ([^\n]+)$", _snippet()) + assert match and top_level + patterns = match.group(1).split() + missing = {p for p in top_level if not any(fnmatch(p, pat) for pat in patterns)} + assert missing == set(), f"the app has routes the Caddyfile would answer with 404: {sorted(missing)}" + + +def test_robots_and_security_txt_exist_and_security_txt_has_not_expired() -> None: + robots = (PUBLIC / "robots.txt").read_text() + assert "User-agent: *" in robots and "Disallow: /app/" in robots + security = (PUBLIC / ".well-known" / "security.txt").read_text() + assert re.search(r"(?m)^Contact: \S+", security) + expires = re.search(r"(?m)^Expires: (\S+)$", security) + assert expires, "security.txt needs an Expires line (RFC 9116)" + when = datetime.fromisoformat(expires.group(1).replace("Z", "+00:00")) + assert when > datetime.now(UTC), "security.txt has expired: renew its Expires date" + + +@pytest.mark.skipif(shutil.which("caddy") is None, reason="caddy is not installed here") +def test_caddy_accepts_the_file() -> None: + done = subprocess.run( + ["caddy", "validate", "--config", str(CADDYFILE), "--adapter", "caddyfile"], + capture_output=True, + text=True, + check=False, + ) + assert done.returncode == 0, done.stderr[-500:] diff --git a/control-plane/tests/test_caddy_routes.py b/control-plane/tests/test_caddy_routes.py index d0bcf3b..087cd3a 100644 --- a/control-plane/tests/test_caddy_routes.py +++ b/control-plane/tests/test_caddy_routes.py @@ -26,6 +26,11 @@ # Served to the gateway and the worker over the compose network, and deliberately not proxied. INTERNAL = ("/v0/internal", "/v0/chain-events") +# Browser routes that are withheld from the public origin until they are safe to expose: DELETE /v0/account +# removes the account, and what a deletion leaves behind and the once-per-person free credits are still open +# (accounts.delete_account). Move a path out of this list in the same change that adds it to the Caddyfile. +WITHHELD = ("/v0/account",) + def _proxied_patterns() -> list[str]: text = CADDYFILE.read_text() @@ -51,7 +56,7 @@ def _browser_paths() -> list[str]: paths = { re.sub(r"\{[^}]+\}", "x", r.path) for router in routers for r in router.routes if hasattr(r, "path") } # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType,reportUnknownVariableType,reportUnknownArgumentType] - return sorted(p for p in paths if not p.startswith(INTERNAL)) + return sorted(p for p in paths if not p.startswith(INTERNAL) and p not in WITHHELD) def test_every_browser_route_is_proxied_to_the_api() -> None: @@ -66,5 +71,21 @@ def test_the_internal_routes_stay_off_the_public_origin() -> None: assert not any(fnmatch(path, pat) for pat in patterns), f"{path} must not be proxied" +def test_withheld_routes_stay_off_the_public_origin() -> None: + patterns = _proxied_patterns() + for path in WITHHELD: + assert not any(fnmatch(path, pat) for pat in patterns), f"{path} is withheld and must not be proxied" + + +def test_a_withheld_route_is_a_real_route() -> None: + """A path left in WITHHELD after its route was removed or renamed would hide nothing and mislead.""" + paths = { + r.path # pyright: ignore[reportAttributeAccessIssue,reportUnknownMemberType,reportUnknownVariableType] + for r in routes_account.router.routes + if hasattr(r, "path") + } + assert set(WITHHELD) <= paths + + def test_the_check_would_notice_a_route_it_does_not_know() -> None: assert not any(fnmatch("/v0/brand-new/thing", pat) for pat in _proxied_patterns()) diff --git a/deploy/Caddyfile b/deploy/Caddyfile index 62db0a7..a387ba3 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -9,14 +9,20 @@ (site_headers) { encode zstd gzip - header { - X-Content-Type-Options nosniff - Referrer-Policy strict-origin-when-cross-origin - } @assets path /assets/* header @assets Cache-Control "public, max-age=31536000, immutable" - # The apps use createBrowserRouter, so any unknown path must fall back to index.html. - try_files {path} /index.html + # The page itself is never cached: a new deploy has new asset names, and a stale index.html would point at old ones. + @shell not path /assets/* + header @shell Cache-Control "no-cache" + # Report-only: it shows in the browser console what would be blocked, without blocking. Watch it on a deployed + # page (sign in, open a run, the wallet picker) and fix every violation before turning it into an enforcing + # Content-Security-Policy. Enforcing it blind could break sign-in or the wallet. Clickjacking is covered + # already by X-Frame-Options below. + header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://avatars.githubusercontent.com; font-src 'self' data:; connect-src 'self' https://gateway.mergit.site https://store.mergit.site https://sepolia.base.org https://api.github.com; frame-ancestors 'none'; base-uri 'self'; object-src 'none'" + # The app is a single-page app, so its own routes fall back to index.html. Anything else that is not a file is a + # real 404 (not the app shell with a 200, which scanners flag and search engines index as soft 404s). + @spa path / /signin /signin/* /demo /demo/* /app /app/* + rewrite @spa /index.html } # The site: the frontend on live Base Sepolia data, and the orchestration API on the same origin. It is served on both @@ -27,7 +33,19 @@ # send people back to, since each allows one callback address), EXTRA_DOMAINS=mergit.site (wallet sign-in is accepted # from both) and COOKIE_DOMAIN=mergit.site (the session cookie is shared, so signing in on one signs in on the other). mergit.site, app.mergit.site { - @api path /v0/auth/* /v0/account /v0/account/* /v0/wallet-interest /v0/plans /v0/plans/* /v0/agents /v0/agents/* + # The bare /v0/account is not listed on purpose: it is DELETE /v0/account, deleting the account. It stays off the public + # origin until the gaps in what a deletion removes and in the once-per-person free credits are decided (see + # accounts.delete_account). /v0/account/* below is every other account route. + @api path /v0/auth/* /v0/account/* /v0/wallet-interest /v0/plans /v0/plans/* /v0/agents /v0/agents/* + # On every response, the page's and the API's. HSTS has no includeSubDomains or preload: other names under this + # domain are not all known to be HTTPS-only, and a preload entry cannot be taken back quickly. + header { + Strict-Transport-Security "max-age=31536000" + X-Frame-Options "DENY" + X-Content-Type-Options "nosniff" + Referrer-Policy "strict-origin-when-cross-origin" + Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" + } handle @api { reverse_proxy api:8000 } diff --git a/deploy/e2e/false_receipt.sh b/deploy/e2e/false_receipt.sh new file mode 100755 index 0000000..92989ef --- /dev/null +++ b/deploy/e2e/false_receipt.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +# End-to-end check 2 (deploy/e2e/false_receipt.md), driven without the planner: a dishonest operator who holds the +# platform's upload and event tokens commits a receipt that says the check passed when it failed. Expect the chain to +# accept the commitment (the signature is genuine), the validator to record `invalid`, and the plan's money to come back. +# +# deploy/e2e/false_receipt.sh run it where the secret files below exist +# DRY_RUN=1 deploy/e2e/false_receipt.sh build and check the false receipt locally, send nothing +# +# is the plan's requester. Use a fresh address nobody holds a key for (`cast wallet new` and keep only +# the address): the plan is funded from free credits, which are per address. +# +# Reads, and never prints: EVENTS_TOKEN, STORE_WRITE_TOKEN and RPC_URL from deploy/secrets/testnet.env, and the agent's +# runtime key from ../mergit-contracts/.env.services (MERGIT_AGENT_KEY_). For a dry run only, AGENT_KEY in the +# environment replaces the file. Optional: AGENT_ID (default 1), GATEWAY_URL, STORE_URL, WAIT_S (default 420). +# Needs cargo, cast, curl and python3. The forged-receipt builder is a test fixture: no service or image enables it. +set -euo pipefail +set +x +cd "$(dirname "$0")/../.." +[ $# -eq 1 ] || { echo "usage: $0 " >&2; exit 2; } +requester=$1 +[[ $requester =~ ^0x[0-9a-fA-F]{40}$ ]] || { echo "requester must be an address" >&2; exit 2; } +agent=${AGENT_ID:-1} +gateway=${GATEWAY_URL:-https://gateway.mergit.site} +store=${STORE_URL:-https://store.mergit.site} +wait_s=${WAIT_S:-420} +deployment=../mergit-contracts/deployments/base-sepolia.json +for tool in cargo cast curl python3; do command -v "$tool" >/dev/null || { echo "$tool is required" >&2; exit 1; }; done +[ -e "$deployment" ] || { echo "$deployment missing" >&2; exit 1; } +field() { python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))[sys.argv[2]])' "$deployment" "$1"; } +escrow=$(field escrow); registry=$(field receipt_registry); chain_id=$(field chain_id) + +# Secrets are read into variables of this process only; nothing below echoes them. +if [ -n "${DRY_RUN:-}" ]; then + key=${AGENT_KEY:?DRY_RUN needs AGENT_KEY (any throwaway key) in the environment} + events_token=dry-run; store_token=dry-run; rpc= +else + [ -e deploy/secrets/testnet.env ] || { echo "deploy/secrets/testnet.env missing" >&2; exit 1; } + [ -e ../mergit-contracts/.env.services ] || { echo "../mergit-contracts/.env.services missing" >&2; exit 1; } + events_token=$(set -a; . deploy/secrets/testnet.env; echo "${EVENTS_TOKEN:-}") + store_token=$(set -a; . deploy/secrets/testnet.env; echo "${STORE_WRITE_TOKEN:-}") + rpc=$(set -a; . deploy/secrets/testnet.env; echo "${RPC_URL:-}") + key=$(set -a; . ../mergit-contracts/.env.services; v="MERGIT_AGENT_KEY_$agent"; echo "${!v:-}") + [ -n "$events_token" ] && [ -n "$store_token" ] && [ -n "$rpc" ] || { echo "a token or RPC_URL is empty in testnet.env" >&2; exit 1; } + [ -n "$key" ] || { echo "MERGIT_AGENT_KEY_$agent is not in .env.services" >&2; exit 1; } +fi + +uuid=$(cat /proc/sys/kernel/random/uuid) +plan_id=$(cast keccak "$uuid") +task_id=$(cast keccak "$(cast abi-encode --packed 'f(bytes32,uint32,uint32)' "$plan_id" 0 1)") +out=$(mktemp -d) +echo "plan_uuid=$uuid"; echo "plan_id=$plan_id"; echo "task_id=$task_id"; echo "files: $out" + +echo "1/6 building the false receipt (this compiles on first use)" +cargo run -q -p mergit-receipt --features forgery-fixture --example false_receipt -- \ + --task "$task_id" --plan "$plan_id" --agent "$agent" --requester "$requester" \ + --key "$key" --chain-id "$chain_id" --registry "$registry" --out "$out" +echo "2/6 the verifier must call it invalid (PHI_RESULT_CONTRADICTED)" +verdict=$(cargo run -q -p mergit-verifier -- "$out/bundle.json" --chain-id "$chain_id" --registry "$registry" || true) +echo "$verdict" | head -5 +echo "$verdict" | grep -q "PHI_RESULT_CONTRADICTED" || { echo "FAIL: the verifier did not flag the forgery" >&2; exit 1; } +if [ -n "${DRY_RUN:-}" ]; then echo "dry run: stopping before anything is sent"; exit 0; fi + +phi_hash=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["receipt"]["header"]["phi_hash"])' "$out/bundle.json") +now() { date -u +%Y-%m-%dT%H:%M:%S.000Z; } +post_event() { # type payload-json -> HTTP status + local body; body=$(python3 -c 'import json,sys,uuid; print(json.dumps({"event_id": str(uuid.uuid4()), "type": sys.argv[1], "at": sys.argv[2], "payload": json.loads(sys.argv[3])}))' "$1" "$(now)" "$2") + curl -sS -o /dev/null -w '%{http_code}' -X POST "$gateway/v0/events" -H "Authorization: Bearer $events_token" \ + -H "Idempotency-Key: $(cat /proc/sys/kernel/random/uuid)" -H 'content-type: application/json' --data "$body" +} + +echo "3/6 funding the plan from free credits" +code=$(post_event plan.approved "{\"plan_id\":\"$plan_id\",\"plan_uuid\":\"$uuid\",\"requester\":\"$requester\",\"total_budget_usdc_units\":\"1000000\",\"funding\":\"credits\"}") +echo " plan.approved -> HTTP $code"; [ "$code" = 200 ] || { echo "FAIL: the gateway refused the plan" >&2; exit 1; } + +echo "4/6 locking one task for agent $agent (the receipt's own check, so the Φ hash matches)" +deadline=$(( $(date +%s) + 1800 )) +for attempt in $(seq 1 12); do + code=$(post_event task.dispatch_requested "{\"task_id\":\"$task_id\",\"plan_id\":\"$plan_id\",\"index\":0,\"attempt\":1,\"agent_id\":\"$agent\",\"amount_usdc_units\":\"1000000\",\"category\":\"test-repair\",\"phi_hash\":\"$phi_hash\",\"deadline_unix\":$deadline}") + echo " task.dispatch_requested -> HTTP $code" + [ "$code" = 200 ] && break + sleep 10 +done +[ "$code" = 200 ] || { echo "FAIL: the task was never locked" >&2; exit 1; } + +echo "5/6 uploading the bundle, then announcing the receipt, as a worker would" +curl -sS -o /dev/null -w ' PUT /v0/bundles -> HTTP %{http_code}\n' -X PUT "$store/v0/bundles" -H "Authorization: Bearer $store_token" \ + -H 'content-type: application/json' --data @"$out/bundle.json" +curl -sS -o /dev/null -w ' receipt.sealed -> HTTP %{http_code}\n' -X POST "$gateway/v0/events" -H "Authorization: Bearer $events_token" \ + -H "Idempotency-Key: $(cat /proc/sys/kernel/random/uuid)" -H 'content-type: application/json' --data @"$out/receipt-sealed.json" + +echo "6/6 waiting up to ${wait_s}s for the escrow to settle task $task_id" +states=(None Locked Released Returned); verdicts=(None Pass Fail Invalid) +end=$(( $(date +%s) + wait_s )) +while [ "$(date +%s)" -lt "$end" ]; do + read -r -d '' -a t < <(cast call "$escrow" 'taskOf(bytes32)(bytes32,uint256,address,uint256,uint64,uint8,uint8,uint64)' "$task_id" --rpc-url "$rpc" | awk '{print $1}'; printf '\0') || true + state=${t[5]:-0}; verdict=${t[6]:-0} + echo " escrow state=${states[$state]:-?} verdict=${verdicts[$verdict]:-?}" + if [ "$state" = 3 ]; then + if [ "$verdict" = 3 ]; then + echo "PASS: the false receipt was committed, judged Invalid, and the task was Returned." + echo "Record the transactions from BaseScan: https://sepolia.basescan.org/address/$escrow#events (filter by task $task_id)" + exit 0 + fi + echo "FAIL: the task was returned with verdict ${verdicts[$verdict]:-?}, not Invalid" >&2; exit 1 + fi + [ "$state" = 2 ] && { echo "FAIL: the task was RELEASED to the agent: the false receipt was paid" >&2; exit 1; } + sleep 15 +done +echo "FAIL: not settled in ${wait_s}s (is the validator running?)" >&2; exit 1 diff --git a/frontend/e2e/live/account.spec.ts b/frontend/e2e/live/account.spec.ts index ddaa184..5b0ecf9 100644 --- a/frontend/e2e/live/account.spec.ts +++ b/frontend/e2e/live/account.spec.ts @@ -229,6 +229,76 @@ test("the sign-in page tells a first-time visitor they can start free without a await expect(page.locator(".si-art")).not.toContainText(/PR #\d|0x30af/); }); +test("a dismissed choice stays closed on reload, and opens again on the next sign-in", async ({ page }) => { + test.setTimeout(90_000); // signing out plays a splash animation first, which a busy machine stretches + await open(page, { me: github, providers: everything }); + const choice = page.getByRole("dialog", { name: "Choose your plan" }); + await page.goto("/app"); + await expect(choice).toBeVisible(); + await page.getByRole("button", { name: "Look around first" }).click(); + await expect(choice).toBeHidden(); + + await page.reload(); + await expect(page.getByRole("heading", { name: "Overview" })).toBeVisible(); + await expect(choice).toBeHidden(); + await page.goto("/app/runs/new"); + await expect(choice).toBeHidden(); + + // Signing out and in again is a new sign-in: the choice is asked again. + await page.getByRole("button", { name: /^Account, signed in as/ }).click(); + await page.getByRole("menuitem", { name: "Sign out" }).click(); + await expect(page).toHaveURL("/", { timeout: 30_000 }); + await page.goto("/app"); + await expect(choice).toBeVisible(); +}); + +test("Escape leaves the choice for later, like Look around first", async ({ page }) => { + await open(page, { me: github, providers: everything }); + const choice = page.getByRole("dialog", { name: "Choose your plan" }); + await page.goto("/app"); + await expect(choice).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(choice).toBeHidden(); + await page.reload(); + await expect(page.getByRole("heading", { name: "Overview" })).toBeVisible(); + await expect(choice).toBeHidden(); +}); + +test("the choice is never laid over the Wallet page, where a wallet is connected", async ({ page }) => { + await open(page, { me: github, providers: everything }); + await page.goto("/app/wallet"); + await expect(page.getByText("No wallet connected", { exact: true })).toBeVisible(); + await expect(page.getByRole("dialog", { name: "Choose your plan" })).toHaveCount(0); + await page.getByRole("link", { name: "Overview" }).first().click(); + await expect(page.getByRole("dialog", { name: "Choose your plan" })).toBeVisible(); +}); + +test("a trial account with no wallet whose credits are spent is asked to connect one", async ({ page }) => { + await open(page, { + me: onTrial, + providers: everything, + credits: { granted: "5000000", spent: "5000000", remaining: "0", plans_today: 1 }, + }); + await page.goto("/app"); + const dialog = page.getByRole("dialog", { name: "Your free credits are used up" }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Free credits used" })).toBeDisabled(); + await expect(dialog.getByRole("button", { name: /Connect a wallet/ })).toBeEnabled(); + await page.getByRole("button", { name: "Look around first" }).click(); + await expect(dialog).toBeHidden(); +}); + +test("a trial account that still has credits is not interrupted", async ({ page }) => { + await open(page, { + me: onTrial, + providers: everything, + credits: { granted: "5000000", spent: "1000000", remaining: "4000000", plans_today: 1 }, + }); + await page.goto("/app"); + await expect(page.getByRole("heading", { name: "Overview" })).toBeVisible(); + await expect(page.getByRole("dialog")).toHaveCount(0); +}); + test("an emailed code signs in", async ({ page }) => { const b: Backend = { me: null, diff --git a/frontend/e2e/live/overview.spec.ts b/frontend/e2e/live/overview.spec.ts index efc8182..99ac0a2 100644 --- a/frontend/e2e/live/overview.spec.ts +++ b/frontend/e2e/live/overview.spec.ts @@ -54,3 +54,11 @@ test("the agents page lists the deployed agents with their on-chain operator", a await expect(rows).not.toHaveCount(0); await expect(rows.first()).toContainText("0x1111…1111"); }); + +test("the live header offers no control that is not wired up: no Search, no Notifications bell", async ({ page }) => { + await page.goto("/app"); + await expect(page.getByRole("link", { name: "Wallet and identity" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Search" })).toHaveCount(0); + await expect(page.getByRole("link", { name: /^Notifications/ })).toHaveCount(0); + await expect(page.locator("body")).not.toContainText("not wired up"); +}); diff --git a/frontend/public/.well-known/security.txt b/frontend/public/.well-known/security.txt new file mode 100644 index 0000000..2199104 --- /dev/null +++ b/frontend/public/.well-known/security.txt @@ -0,0 +1,4 @@ +Contact: mailto:hello@mergit.site +Expires: 2027-11-01T00:00:00.000Z +Preferred-Languages: en +Canonical: https://mergit.site/.well-known/security.txt diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..24cc48c --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / +Disallow: /app/ +Disallow: /v0/ diff --git a/frontend/src/auth/AuthProvider.tsx b/frontend/src/auth/AuthProvider.tsx index 3f4131b..e296a05 100644 --- a/frontend/src/auth/AuthProvider.tsx +++ b/frontend/src/auth/AuthProvider.tsx @@ -15,6 +15,7 @@ import { type Account, type AccountCheck, } from "./account"; +import { forgetDismissal } from "./planChoice"; import { demoSession, readSession, writeSession, type Session } from "./session"; import { SignOutSplash } from "./SignOutSplash"; @@ -53,11 +54,13 @@ export function AuthProvider({ children }: { children: ReactNode }) { ); const signIn = useCallback((next: Session) => { + forgetDismissal(); // a new sign-in is asked to choose a plan again writeSession(next); setSession(next); }, []); const endSession = useCallback(() => { + forgetDismissal(); writeSession(null); setSession(null); setAccount(null); diff --git a/frontend/src/auth/FundingChoice.tsx b/frontend/src/auth/FundingChoice.tsx index 083032b..ab5ba66 100644 --- a/frontend/src/auth/FundingChoice.tsx +++ b/frontend/src/auth/FundingChoice.tsx @@ -1,12 +1,15 @@ -import { useEffect, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useLocation } from "react-router-dom"; +import { paths } from "../app/paths"; +import { useCredits } from "../chain/useCredits"; import { Icon } from "../components/Icon"; import { MergitSymbol } from "../components/MergitLogo"; import { startTrial } from "./accountApi"; -import { fundingChoiceNeeded } from "./account"; import { useAuth } from "./context"; import { useConnectWallet } from "./useConnectWallet"; import { clearReopenRequest, reopenRequested } from "./walletGuide"; import { MergePrompt } from "./MergePrompt"; +import { choiceReason, dismissalKey, isDismissed, rememberDismissal } from "./planChoice"; import { WalletPicker } from "./WalletPicker"; const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); @@ -15,12 +18,46 @@ const message = (e: unknown) => (e instanceof Error ? e.message : String(e)); * Shown over the dashboard to an account that has neither started the free trial nor connected a wallet: a plan * choice. Free runs on the treasury's free credits (audited on every task, no reputation earned) and needs no wallet. * Pay as you go connects a wallet, which funds each plan from its own USDC and is where proofs settle. - * "Look around first" leaves the choice for later. + * "Look around first" (or Escape) leaves the choice for later: it stays closed for this tab and this sign-in, and + * opens again on the next sign-in, and when a trial account with no wallet has spent its free credits. It is never + * shown over the Wallet page, where a wallet is connected anyway. */ export function FundingChoice() { const { account, refreshAccount } = useAuth(); const wallet = useConnectWallet(); - const [dismissed, setDismissed] = useState(false); + const onWalletPage = useLocation().pathname === paths.wallet; + const trialNoWallet = account !== null && account.trialStartedAt !== null && account.wallets.length === 0; + const { credits } = useCredits(trialNoWallet && account.userId !== null); + const reason = choiceReason(account, credits?.remaining ?? null); + const key = reason && account?.userId ? dismissalKey(account.userId, reason) : null; + const [dismissedFor, setDismissedFor] = useState(null); + const dismissed = key !== null && (dismissedFor === key || isDismissed(key)); + const open = reason !== null && !dismissed && !onWalletPage; + const dialog = useRef(null); + const dismiss = () => { + if (key === null) return; + rememberDismissal(key); + setDismissedFor(key); + }; + // A layout effect, not a passive one: it runs before the dialog is painted, so Escape works from the first moment + // the dialog can be seen, even on a slow or busy machine where passive effects are delayed. + useLayoutEffect(() => { + if (!open) return; + dialog.current?.focus(); + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + if (key !== null) { + rememberDismissal(key); + setDismissedFor(key); + } + } + }; + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("keydown", onKey); + }; + }, [open, key]); const [starting, setStarting] = useState(false); const [walletOpen, setWalletOpen] = useState(reopenRequested); useEffect(() => { @@ -29,7 +66,8 @@ export function FundingChoice() { const [trialError, setTrialError] = useState(null); const busy = starting || wallet.busy; const error = trialError ?? wallet.error; - if (!fundingChoiceNeeded(account) || dismissed) return null; + if (!open) return null; + const exhausted = reason === "exhausted"; const trial = () => { setTrialError(null); @@ -47,6 +85,8 @@ export function FundingChoice() { return (

- Choose your plan + {exhausted ? "Your free credits are used up" : "Choose your plan"}

- {account?.name ? `Welcome, ${account.name}. ` : "Welcome. "} - Start free with test credits, or connect a wallet and pay as you go. You can switch later. + {exhausted + ? "Connect a wallet and pay as you go to keep running tasks. If someone gave you a redeem code, enter it on the Wallet page." + : `${account?.name ? `Welcome, ${account.name}. ` : "Welcome. "}Start free with test credits, or connect a wallet and pay as you go. You can switch later.`}

{error && (

@@ -92,8 +133,8 @@ export function FundingChoice() { No reputation is earned -

@@ -144,13 +185,7 @@ export function FundingChoice() { ))}
- diff --git a/frontend/src/auth/planChoice.test.ts b/frontend/src/auth/planChoice.test.ts new file mode 100644 index 0000000..16abb37 --- /dev/null +++ b/frontend/src/auth/planChoice.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Account } from "./account"; +import { choiceReason, dismissalKey, forgetDismissal, isDismissed, rememberDismissal } from "./planChoice"; + +const account = (over: Partial = {}): Account => ({ + userId: "u1", + address: "0x0", + name: null, + email: null, + avatarUrl: null, + identities: [], + wallets: [], + trialAddress: null, + trialStartedAt: null, + ...over, +}); +const onTrial = account({ trialStartedAt: "2026-11-01T00:00:00Z", trialAddress: "0x1" }); + +describe("when the plan choice is needed", () => { + it("is open for an account that has neither a wallet nor a trial", () => { + expect(choiceReason(account(), null)).toBe("new"); + expect(choiceReason(account(), 5_000_000n)).toBe("new"); + }); + + it("is open for a trial account with no wallet once its credits are spent", () => { + expect(choiceReason(onTrial, 0n)).toBe("exhausted"); + }); + + it("stays closed while credits remain, and while they are still unknown", () => { + expect(choiceReason(onTrial, 1n)).toBeNull(); + expect(choiceReason(onTrial, null)).toBeNull(); + }); + + it("stays closed for anyone with a wallet, however few credits are left", () => { + expect(choiceReason(account({ wallets: ["0xabc"] }), 0n)).toBeNull(); + expect(choiceReason(account({ trialStartedAt: "2026-11-01T00:00:00Z", wallets: ["0xabc"] }), 0n)).toBeNull(); + }); + + it("stays closed with no account, or one from before accounts existed", () => { + expect(choiceReason(null, 0n)).toBeNull(); + expect(choiceReason(account({ userId: null }), 0n)).toBeNull(); + }); +}); + +describe("remembering a dismissal", () => { + beforeEach(() => { + // The unit tests run in Node, which has no sessionStorage. + const store = new Map(); + vi.stubGlobal("sessionStorage", { + getItem: (k: string) => store.get(k) ?? null, + setItem: (k: string, v: string) => store.set(k, v), + removeItem: (k: string) => store.delete(k), + }); + }); + + it("holds for the same account and reason", () => { + const key = dismissalKey("u1", "new"); + expect(isDismissed(key)).toBe(false); + rememberDismissal(key); + expect(isDismissed(key)).toBe(true); + }); + + it("does not carry over to another account", () => { + rememberDismissal(dismissalKey("u1", "new")); + expect(isDismissed(dismissalKey("u2", "new"))).toBe(false); + }); + + it("is gone after signing out or in", () => { + const key = dismissalKey("u1", "new"); + rememberDismissal(key); + forgetDismissal(); + expect(isDismissed(key)).toBe(false); + }); + + it("does not hide running out of credits after dismissing the first choice", () => { + rememberDismissal(dismissalKey("u1", "new")); + expect(isDismissed(dismissalKey("u1", "exhausted"))).toBe(false); + }); +}); diff --git a/frontend/src/auth/planChoice.ts b/frontend/src/auth/planChoice.ts new file mode 100644 index 0000000..df13704 --- /dev/null +++ b/frontend/src/auth/planChoice.ts @@ -0,0 +1,50 @@ +import { fundingChoiceNeeded, type Account } from "./account"; + +/** Why the plan choice is open: the account has not chosen yet, or the free trial has run out of credits. */ +export type ChoiceReason = "new" | "exhausted"; + +/** + * Whether the account must choose a plan now. A new account has neither a wallet nor a trial. A trial account with + * no wallet whose free credits are all spent has to connect a wallet to go on; `remaining` is null until the credits + * are known, and then nothing is shown (a guess would flash the dialog at someone with credits left). + */ +export function choiceReason(account: Account | null, remaining: bigint | null): ChoiceReason | null { + if (fundingChoiceNeeded(account)) return "new"; + const trialWithoutWallet = + account !== null && account.userId !== null && account.trialStartedAt !== null && account.wallets.length === 0; + return trialWithoutWallet && remaining === 0n ? "exhausted" : null; +} + +const STORAGE_KEY = "mergit-plan-choice-dismissed"; + +/** + * One dismissal per account and reason: running out of credits later asks again. It is keyed on the account, not on + * the session record, because the record can be rebuilt while the page loads and a changed key would silently + * un-dismiss the dialog. Signing out or in clears it (`forgetDismissal`), which is what makes the next sign-in ask. + */ +export const dismissalKey = (userId: string, reason: ChoiceReason) => `${userId}:${reason}`; + +/** "Look around first" holds for this browser tab until the next sign-in or sign-out, so a reload does not bring the dialog back. */ +export function isDismissed(key: string): boolean { + try { + return sessionStorage.getItem(STORAGE_KEY) === key; + } catch { + return false; + } +} + +export function forgetDismissal(): void { + try { + sessionStorage.removeItem(STORAGE_KEY); + } catch { + // Private mode: nothing was kept. + } +} + +export function rememberDismissal(key: string): void { + try { + sessionStorage.setItem(STORAGE_KEY, key); + } catch { + // Private mode: the dismissal lasts until the page reloads. + } +} diff --git a/frontend/src/chain/readFailure.test.ts b/frontend/src/chain/readFailure.test.ts new file mode 100644 index 0000000..3e77e20 --- /dev/null +++ b/frontend/src/chain/readFailure.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { readFailure } from "./readFailure"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("what a failed chain read says", () => { + it("names the chain when the browser is online", () => { + vi.stubGlobal("navigator", { onLine: true }); + expect(readFailure("receipts", "Base Sepolia")).toBe( + "Could not read receipts from Base Sepolia. Try again in a moment.", + ); + }); + + it("says the browser is offline instead of blaming the chain", () => { + vi.stubGlobal("navigator", { onLine: false }); + expect(readFailure("receipts", "Base Sepolia")).toBe("You appear to be offline. Reconnect and try again."); + }); + + it("names the chain where there is no navigator to ask", () => { + vi.stubGlobal("navigator", undefined); + expect(readFailure("receipts", "Base Sepolia")).toContain("Base Sepolia"); + }); +}); diff --git a/frontend/src/chain/readFailure.ts b/frontend/src/chain/readFailure.ts new file mode 100644 index 0000000..a3cc0f3 --- /dev/null +++ b/frontend/src/chain/readFailure.ts @@ -0,0 +1,10 @@ +/** + * What to say when a read from the chain failed. If the browser itself is offline the chain was never the problem, + * so say that instead of blaming it (`what` is what could not be read, `where` the network it lives on). + */ +export function readFailure(what: string, where: string): string { + if (typeof navigator !== "undefined" && !navigator.onLine) { + return "You appear to be offline. Reconnect and try again."; + } + return `Could not read ${what} from ${where}. Try again in a moment.`; +} diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index d979ba6..22d88dc 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -26,6 +26,9 @@ export function TopNav() { const thumb = useSlidingThumb(tabsRef, ".tab.on", active); const { waiting } = useApprovals(); const notWired = useNotWired(); + // Search and the bell are only wired to the demo's mock data. On the live site they would do nothing (the bell + // counts mock approvals, and Search says "not wired up"), so they are not offered there until they are real. + const live = DATA_MODE === "live"; return (
@@ -59,17 +62,21 @@ export function TopNav() {
- - 0 ? `Notifications, ${waiting} waiting` : "Notifications"} - > - - {waiting > 0 && {waiting}} - + {!live && ( + + )} + {!live && ( + 0 ? `Notifications, ${waiting} waiting` : "Notifications"} + > + + {waiting > 0 && {waiting}} + + )}
{receiptsError - ? `Could not read the registry on ${LIVE.chainName} right now.` + ? readFailure("the registry", LIVE.chainName) : latest ? `Latest at block ${latest.blockNumber.toString()}, task ${latest.taskId.slice(0, 10)}…` : "No receipts committed yet. One appears here once an agent finishes a task."} diff --git a/frontend/src/screens/live/LiveProofLedger.tsx b/frontend/src/screens/live/LiveProofLedger.tsx index df5a388..0c6f7ed 100644 --- a/frontend/src/screens/live/LiveProofLedger.tsx +++ b/frontend/src/screens/live/LiveProofLedger.tsx @@ -7,6 +7,7 @@ import { PageHeader } from "../../components/PageHeader"; import { Spark } from "../../components/Spark"; import { listCommitments, type Commitment } from "../../chain/proofs"; import { LIVE } from "../../config/live"; +import { readFailure } from "../../chain/readFailure"; import { useCountUp } from "../../hooks/useCountUp"; import { costTrend, hourlyActivity, receiptsByAgent, receiptsPerBlock, runsPerDay, totalCost } from "./analytics"; import { AreaTrend, BlockBars, HourHeatmap, ShareLegend, ShareRing } from "./charts"; @@ -58,7 +59,7 @@ export function LiveProofLedger() { const [error, setError] = useState(); useEffect(() => { listCommitments().then(setRows, () => { - setError(`Could not read receipts from ${LIVE.chainName}. Try again in a moment.`); + setError(readFailure("receipts", LIVE.chainName)); }); }, []);