From 3b3f9296e3a807ff0c7a7b97d647fdcc90fcd33f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=BE=D0=B1=D0=BE=D1=82?= Date: Sun, 11 Oct 2026 22:39:33 +0200 Subject: [PATCH 1/2] feat(site): the hosted product is off; checkmydata.ai is a waitlist Operator decision (2026-10-11) to stop the running cost. The API, worker and databases were switched off out of band (0 dynos, add-ons and the Supabase project deleted after pg_dump backups); this change makes the repository match. - Every "Get Started" / "Log in" / "Try it now" CTA leads to a waitlist form at /#waitlist. POST /api/waitlist, served by the site's own Next.js server, mails one plain-text sign-up to contact@checkmydata.ai through Resend, with the visitor as Reply-To. No database: a sign-up that was not mailed is reported as a failure (502/503), never acknowledged. Honeypot, 5 per 10 min per address. - /app, /login, /pricing, /dashboard/... and the password/verification pages redirect (307) to /#waitlist. The code stays for a relaunch. - deploy.yml is workflow_dispatch only and ships the site alone; its post-deploy check posts an empty body to the waitlist and expects 422, so no deploy mails the inbox. - Scenarios: SCN-109 rewritten, SCN-153 added, SCN-110/112 retired. The stale verification ceiling rises 95 -> 105: re-stamping the block aged 21 scenarios that can no longer be audited against a running product. - Handoff: docs/handoffs/2026-10-11-hosted-product-off.md Brand-Case: product infra cost cut to a single site dyno; waitlist replaces sign-up Co-Authored-By: Claude Opus 5.5 (1M context) --- .github/workflows/deploy.yml | 194 ++++-------------- CHANGELOG.md | 13 ++ CLAUDE.md | 13 +- .../docs/test_ci_covers_every_pull_request.py | 29 ++- backend/tests/unit/docs/test_ux_scenarios.py | 9 +- .../unit/test_deploy_pipeline_can_fail.py | 50 +++-- .../handoffs/2026-10-11-hosted-product-off.md | 53 +++++ docs/ux/scenarios.md | 65 ++++-- frontend/next.config.ts | 21 ++ frontend/src/__tests__/waitlist.test.ts | 133 ++++++++++++ frontend/src/app/(marketing)/about/page.tsx | 4 +- frontend/src/app/(marketing)/layout.tsx | 13 +- frontend/src/app/(marketing)/page.tsx | 52 +++-- frontend/src/app/api/waitlist/route.ts | 114 ++++++++++ .../src/components/marketing/WaitlistForm.tsx | 138 +++++++++++++ frontend/src/components/ui/MobileMenu.tsx | 4 +- frontend/src/lib/waitlist.ts | 92 +++++++++ 17 files changed, 742 insertions(+), 255 deletions(-) create mode 100644 docs/handoffs/2026-10-11-hosted-product-off.md create mode 100644 frontend/src/__tests__/waitlist.test.ts create mode 100644 frontend/src/app/api/waitlist/route.ts create mode 100644 frontend/src/components/marketing/WaitlistForm.tsx create mode 100644 frontend/src/lib/waitlist.ts diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f9d003c1..f1adbd68 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,142 +1,72 @@ -name: Deploy to Heroku - +name: Deploy site to Heroku + +# Site only, and only by hand (operator, 2026-10-11). The hosted product was switched +# off: `checkmydata-api` runs no dynos and holds no database, so this workflow builds +# and releases the marketing site and nothing else. It used to fire on every green CI +# run on `main`; a merge no longer deploys — press "Run workflow" when the site should +# change. The backend half (images, release phase, worker check) is in git history at +# the commit before this one, for a relaunch. on: - workflow_run: - workflows: [CI] - types: [completed] - branches: [main] + workflow_dispatch: concurrency: group: heroku-deploy - # TEST-17: QUEUE, never cancel. A cancellation can land between the backend release and - # the frontend release, leaving the two on different commits until the next run's - # ten-minute build finishes — or between a release and its verification, marking an - # unverified production release merely "cancelled". Waiting costs one build; the other - # costs an incoherent production nobody was told about. cancel-in-progress: false env: HEROKU_API_KEY: ${{ secrets.HEROKU_API_KEY }} - BACKEND_APP: ${{ vars.BACKEND_APP || 'checkmydata-api' }} FRONTEND_APP: ${{ vars.FRONTEND_APP || 'checkmydata-web' }} - BACKEND_API_URL: ${{ vars.BACKEND_API_URL || 'https://api.checkmydata.ai/api' }} - BACKEND_WS_URL: ${{ vars.BACKEND_WS_URL || 'wss://api.checkmydata.ai/api/chat/ws' }} FRONTEND_URL: ${{ vars.FRONTEND_URL || 'https://checkmydata.ai' }} jobs: deploy: runs-on: ubuntu-latest - # `event == 'push'` is the half that is not obvious. `branches: [main]` above - # matches the HEAD branch of the CI run, and since CI now runs on every pull - # request, a PR opened *from* `main` would produce a run that satisfies it. - # Deploy follows a merge, never a proposal. - if: >- - ${{ github.event.workflow_run.conclusion == 'success' - && github.event.workflow_run.event == 'push' }} + # Only what is on `main` ships, whichever branch the button was pressed on. + if: ${{ github.ref == 'refs/heads/main' }} steps: - uses: actions/checkout@v5 - with: - ref: ${{ github.event.workflow_run.head_sha }} - name: Login to Heroku Container Registry run: echo "$HEROKU_API_KEY" | docker login -u _ --password-stdin registry.heroku.com - - name: Build backend image - # GIT_SHA is what Sentry's `release` becomes. It has to be the commit the image - # was built from, and it has to match the release the commits are attached to — - # `workflow_run.head_sha` is the same value this job checked out above. - run: | - docker build \ - --platform linux/amd64 \ - --build-arg GIT_SHA=${{ github.event.workflow_run.head_sha }} \ - -t registry.heroku.com/${{ env.BACKEND_APP }}/web \ - -f Dockerfile.backend . - - - name: Build worker image - run: | - docker build \ - --platform linux/amd64 \ - --build-arg BASE_IMAGE=registry.heroku.com/${{ env.BACKEND_APP }}/web \ - --build-arg GIT_SHA=${{ github.event.workflow_run.head_sha }} \ - -t registry.heroku.com/${{ env.BACKEND_APP }}/worker \ - -f Dockerfile.worker . - - - name: Build release-phase image - # Same image, one different CMD — the shape Dockerfile.worker already uses. This is - # what runs `alembic upgrade head` BEFORE the new release goes live (TEST-15). - run: | - docker build \ - --platform linux/amd64 \ - --build-arg BASE_IMAGE=registry.heroku.com/${{ env.BACKEND_APP }}/web \ - -t registry.heroku.com/${{ env.BACKEND_APP }}/release \ - -f Dockerfile.release . - - - name: Build frontend image + - name: Build site image run: | docker build \ --platform linux/amd64 \ - --build-arg NEXT_PUBLIC_API_URL=${{ env.BACKEND_API_URL }} \ - --build-arg NEXT_PUBLIC_WS_URL=${{ env.BACKEND_WS_URL }} \ - --build-arg NEXT_PUBLIC_GOOGLE_CLIENT_ID=${{ secrets.NEXT_PUBLIC_GOOGLE_CLIENT_ID }} \ --build-arg NEXT_PUBLIC_SENTRY_DSN=${{ vars.NEXT_PUBLIC_SENTRY_DSN }} \ --build-arg NEXT_PUBLIC_SENTRY_ENVIRONMENT=production \ -t registry.heroku.com/${{ env.FRONTEND_APP }}/web \ -f Dockerfile.frontend . - - name: Push backend image - run: docker push registry.heroku.com/${{ env.BACKEND_APP }}/web - - - name: Push worker image - run: docker push registry.heroku.com/${{ env.BACKEND_APP }}/worker - - - name: Push release-phase image - run: docker push registry.heroku.com/${{ env.BACKEND_APP }}/release - - - name: Push frontend image + - name: Push site image run: docker push registry.heroku.com/${{ env.FRONTEND_APP }}/web - name: Note the release version before deploying - # The whole verification rests on this: "a release exists and is healthy" is not the - # same statement as "MY release happened", and only a before/after comparison can - # tell them apart (TEST-01). + # `Range:` because the releases collection is paginated in ascending order; without + # it the newest version is never read and the check below waits on nothing. run: | set -euo pipefail - # `Range:` is load-bearing, not tidiness. The releases collection is paginated - # and returned in ASCENDING order, so a plain GET yields the OLDEST 200 releases - # and `[.[].version] | max` reads 200 on an app that is at 375 — the version then - # never appears to move and the verification below waits out its attempts on a - # release that already succeeded. Measured on production 2026-09-11, by this step - # failing its own first run. BEFORE_VERSION=$(curl --fail-with-body -sS \ -H "Accept: application/vnd.heroku+json; version=3" \ -H "Range: version ..; order=desc, max=1" \ -H "Authorization: Bearer ${{ secrets.HEROKU_API_KEY }}" \ - https://api.heroku.com/apps/${{ env.BACKEND_APP }}/releases | jq '.[0].version') + https://api.heroku.com/apps/${{ env.FRONTEND_APP }}/releases | jq '.[0].version') echo "BEFORE_VERSION=$BEFORE_VERSION" >> "$GITHUB_ENV" - echo "Backend is at release v$BEFORE_VERSION before this deploy" + echo "Site is at release v$BEFORE_VERSION before this deploy" - - name: Release backend + - name: Release site run: | set -euo pipefail - WEB_IMAGE_ID=$(docker inspect --format='{{.Id}}' registry.heroku.com/${{ env.BACKEND_APP }}/web) - WORKER_IMAGE_ID=$(docker inspect --format='{{.Id}}' registry.heroku.com/${{ env.BACKEND_APP }}/worker) - RELEASE_IMAGE_ID=$(docker inspect --format='{{.Id}}' registry.heroku.com/${{ env.BACKEND_APP }}/release) - # `--fail-with-body`: without it curl exits 0 on 401/403/404/422, so a rotated - # API key or a refused image id was a PASSING step and production silently - # stopped receiving deploys (TEST-01). `-w`/`-o` are not enough on their own — - # the exit code is what the runner reads. - curl --fail-with-body -sS -X PATCH https://api.heroku.com/apps/${{ env.BACKEND_APP }}/formation \ + IMAGE_ID=$(docker inspect --format='{{.Id}}' registry.heroku.com/${{ env.FRONTEND_APP }}/web) + curl --fail-with-body -sS -X PATCH https://api.heroku.com/apps/${{ env.FRONTEND_APP }}/formation \ -H "Content-Type: application/json" \ -H "Accept: application/vnd.heroku+json; version=3.docker-releases" \ -H "Authorization: Bearer ${{ secrets.HEROKU_API_KEY }}" \ - -d '{"updates":[{"type":"web","docker_image":"'"$WEB_IMAGE_ID"'"},{"type":"worker","docker_image":"'"$WORKER_IMAGE_ID"'"},{"type":"release","docker_image":"'"$RELEASE_IMAGE_ID"'"}]}' + -d '{"updates":[{"type":"web","docker_image":"'"$IMAGE_ID"'"}]}' - - name: Verify the backend release actually happened - # Reads the platform, not a public URL. The health endpoint answers from whatever is - # live, so when the release fails it is the OLD release that returns 200 — which is - # how a broken deploy reported itself healthy. A `failed` status here also covers - # the release phase: a migration that does not apply aborts the release. + - name: Verify the release actually happened + # The platform, not the public URL: when a release fails, the OLD one still answers 200. run: | set -euo pipefail for i in $(seq 1 20); do @@ -144,80 +74,36 @@ jobs: -H "Accept: application/vnd.heroku+json; version=3" \ -H "Range: version ..; order=desc, max=1" \ -H "Authorization: Bearer ${{ secrets.HEROKU_API_KEY }}" \ - https://api.heroku.com/apps/${{ env.BACKEND_APP }}/releases) + https://api.heroku.com/apps/${{ env.FRONTEND_APP }}/releases) VERSION=$(jq '.[0].version' <<<"$LATEST") STATUS=$(jq -r '.[0].status' <<<"$LATEST") - if [ "$VERSION" -le "$BEFORE_VERSION" ]; then - echo "Attempt $i: still at v$VERSION, waiting for a new release..." - elif [ "$STATUS" = "succeeded" ]; then - echo "Backend released: v$BEFORE_VERSION -> v$VERSION (status $STATUS)" - echo "RELEASE_VERSION=$VERSION" >> "$GITHUB_ENV" + if [ "$VERSION" -gt "$BEFORE_VERSION" ] && [ "$STATUS" = "succeeded" ]; then + echo "Site released: v$BEFORE_VERSION -> v$VERSION" exit 0 - elif [ "$STATUS" = "failed" ]; then - echo "Release v$VERSION FAILED — the release phase (alembic upgrade head) did not succeed" + elif [ "$VERSION" -gt "$BEFORE_VERSION" ] && [ "$STATUS" = "failed" ]; then + echo "Release v$VERSION FAILED" exit 1 - else - echo "Attempt $i: v$VERSION is $STATUS..." - fi - sleep 15 - done - echo "No successful release appeared after this deploy (still at v$BEFORE_VERSION)" - exit 1 - - - name: Verify the worker is running the new release - # TEST-16: both health checks speak HTTP and the worker serves none, so a worker - # image that dies on import deployed green — on the process type this repo's own - # incident history names as the recurring failure surface. - run: | - set -euo pipefail - for i in $(seq 1 20); do - DYNOS=$(curl --fail-with-body -sS \ - -H "Accept: application/vnd.heroku+json; version=3" \ - -H "Authorization: Bearer ${{ secrets.HEROKU_API_KEY }}" \ - https://api.heroku.com/apps/${{ env.BACKEND_APP }}/dynos) - UP=$(jq -r --argjson v "$RELEASE_VERSION" \ - '[.[] | select(.type == "worker" and .state == "up" and .release.version == $v)] | length' \ - <<<"$DYNOS") - if [ "$UP" -ge 1 ]; then - echo "Worker is up on release v$RELEASE_VERSION" - exit 0 fi - echo "Attempt $i: no worker dyno up on v$RELEASE_VERSION yet..." + echo "Attempt $i: v$VERSION is $STATUS, waiting..." sleep 15 done - echo "The worker never came up on release v$RELEASE_VERSION" + echo "No successful release appeared (still at v$BEFORE_VERSION)" exit 1 - - name: Release frontend + - name: Verify the site and the waitlist endpoint + # An empty sign-up must be refused with 422: that proves the route is deployed and + # validating, without mailing the team inbox on every deploy. run: | set -euo pipefail - IMAGE_ID=$(docker inspect --format='{{.Id}}' registry.heroku.com/${{ env.FRONTEND_APP }}/web) - curl --fail-with-body -sS -X PATCH https://api.heroku.com/apps/${{ env.FRONTEND_APP }}/formation \ - -H "Content-Type: application/json" \ - -H "Accept: application/vnd.heroku+json; version=3.docker-releases" \ - -H "Authorization: Bearer ${{ secrets.HEROKU_API_KEY }}" \ - -d '{"updates":[{"type":"web","docker_image":"'"$IMAGE_ID"'"}]}' - - - name: Verify backend health - run: | for i in 1 2 3 4 5; do - sleep 30 - STATUS=$(curl -s -o /dev/null -w "%{http_code}" ${{ env.BACKEND_API_URL }}/health) - if [ "$STATUS" = "200" ]; then - echo "Backend is healthy (HTTP $STATUS) on attempt $i" + sleep 20 + HOME_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "${{ env.FRONTEND_URL }}") + WAITLIST_STATUS=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ + -H "Content-Type: application/json" -d '{}' "${{ env.FRONTEND_URL }}/api/waitlist") + if [ "$HOME_STATUS" = "200" ] && [ "$WAITLIST_STATUS" = "422" ]; then + echo "Site 200, waitlist validates (422 on an empty body)" exit 0 fi - echo "Attempt $i: backend returned $STATUS, retrying..." + echo "Attempt $i: home $HOME_STATUS, waitlist $WAITLIST_STATUS" done - echo "Backend health check failed after 5 attempts (last status: $STATUS)" exit 1 - - - name: Verify frontend health - run: | - sleep 15 - STATUS=$(curl -s -o /dev/null -w "%{http_code}" ${{ env.FRONTEND_URL }}) - if [ "$STATUS" != "200" ]; then - echo "Frontend health check failed with status $STATUS" - exit 1 - fi - echo "Frontend is healthy (HTTP $STATUS)" diff --git a/CHANGELOG.md b/CHANGELOG.md index a851ee93..6d0afe8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Changed — the hosted product is switched off; checkmydata.ai is a waitlist (2026-10-11) + +- Operator decision, to stop the running cost. `checkmydata-api` runs no dynos and is in + maintenance mode; its Heroku Postgres and Redis add-ons and the Supabase project were + deleted after `pg_dump` backups (kept privately, outside Git). The deploy workflow no + longer fires on a green CI run: it builds and releases the site only, by hand. +- The site's "Get Started" / "Log in" / "Try it now" CTAs now lead to a waitlist form + (`#waitlist`). A sign-up is one email to `contact@checkmydata.ai` sent by the site's own + Next.js server through Resend (`POST /api/waitlist`) — no database. A sign-up that was not + mailed is reported to the visitor as a failure, never acknowledged. +- `/app`, `/login`, `/pricing`, `/dashboard/…` and the password/verification pages + redirect to `/#waitlist`. Their code stays for a relaunch; self-hosting is unchanged. + ### Fixed — a full rebuild interrupted part-way resumes, against its own tree (PRJ-06 T12a, #441) - A restart during a full rebuild used to pay for all of it again: the run created a fresh diff --git a/CLAUDE.md b/CLAUDE.md index b44bd5ec..f0221ce8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,6 +4,15 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Product snapshot +> **Hosting status (2026-10-11): the hosted product is OFF.** Operator decision to stop +> the running cost. Production is the marketing site alone (`checkmydata-web`, Heroku +> Basic), with a waitlist that mails `contact@checkmydata.ai` (`POST /api/waitlist`, +> Resend; SCN-153). `checkmydata-api` has 0 dynos, maintenance on, no add-ons; the +> Supabase project and the Heroku Postgres/Redis were deleted after `pg_dump` backups +> (private, outside Git — see `docs/handoffs/2026-10-11-hosted-product-off.md`). The +> deploy workflow is manual and site-only. Everything below describes the product as it +> stood when it ran, and as it still runs self-hosted. Relaunch steps are in the handoff. + - **What it is**: AI-powered database query agent — natural language → SQL, codebase Q&A, visualizations, team workspaces. - **Supported databases**: PostgreSQL, MySQL, ClickHouse, MongoDB (via `backend/app/connectors/`), plus **SQLite** — file-backed, added 2026-08-20 because the demo path has created `db_type="sqlite"` connections since it existed while `ADAPTER_REGISTRY` had no entry for it, so every demo connection raised `Unsupported adapter: sqlite` on the first question. Not offered in the connection UI; it is what the demo runs on (`app/services/demo_data.py`). - **Supported analytics sources**: `Connection.source_type` also accepts three analytics vendors (`backend/app/analytics/`, family defined once in `app/analytics/source_types.py`): **`ga4`** (Google Analytics 4 — the only one with a collector today), plus **`appstore`** and **`googleplay`**, reserved for m1/m2 (connection creation refuses them with 422 until their fact tables land). Runbook: `docs/ANALYTICS_SOURCES.md`. @@ -1153,8 +1162,8 @@ Read `vision.md` before any new feature. If a request conflicts with §7 invaria - `docs/ux/scenarios.md` is the source of truth for all user-facing behavior. - **The chain now has three layers, and they are not equally complete.** - `scenarios.md` covers the whole product (**154 scenarios; 142 `implemented`, 12 - `draft`** — measured 2026-09-18 by `make ux-status`, and asserted by + `scenarios.md` covers the whole product (**155 scenarios; 141 `implemented`, 12 + `draft`, 2 `retired`** — measured 2026-10-11 by `make ux-status`, and asserted by `test_the_ux_base_checks_all_three_documents.py`, because the previous figure here said 151/128/23 while the generated block in the document itself counted 153/141/12 and nothing compared the two). `flows.md` (FLW-01–05) and `screens.md` (SCR-01–10) were added diff --git a/backend/tests/unit/docs/test_ci_covers_every_pull_request.py b/backend/tests/unit/docs/test_ci_covers_every_pull_request.py index adb01edb..55101019 100644 --- a/backend/tests/unit/docs/test_ci_covers_every_pull_request.py +++ b/backend/tests/unit/docs/test_ci_covers_every_pull_request.py @@ -15,11 +15,10 @@ pull-request one, because the base branch of a PR says nothing about whether its diff deserves testing. -The second assertion guards the blast radius of the first. `deploy.yml` fires on a -completed CI run whose head branch is `main`; widening CI means PR-triggered runs now -exist that did not before, so the deploy job additionally requires the CI run to have -come from a `push`. A PR opened *from* `main` is the one shape that could otherwise -reach the deploy path through a pull request. +The second assertion guards the blast radius of the first. `deploy.yml` used to fire on +a completed CI run whose head branch was `main`, and had to exclude PR-triggered runs. +Since 2026-10-11 it is manual only, so the guard is simpler and stricter: no CI run of +any kind can reach it. """ from __future__ import annotations @@ -64,18 +63,18 @@ def test_push_stays_scoped_to_main() -> None: assert push.get("branches") == ["main"] -def test_deploy_only_follows_a_push_to_main() -> None: - """Widening CI creates PR-triggered runs that did not exist before. The deploy job - must not be reachable from one.""" +def test_deploy_is_never_triggered_by_ci() -> None: + """Since 2026-10-11 the deploy runs by hand only (the hosted product is off and the + workflow ships the site alone). So no CI run — from a push or from a pull request — + can reach it: the only trigger is `workflow_dispatch`, and the job refuses any ref + but `main`.""" deploy = _load("deploy.yml") - trigger = deploy["on"]["workflow_run"] - assert trigger["branches"] == ["main"] - + assert set(deploy["on"]) == {"workflow_dispatch"}, ( + f"deploy.yml must be manual only; it triggers on {sorted(deploy['on'])}" + ) condition = deploy["jobs"]["deploy"]["if"] - assert "conclusion == 'success'" in condition - assert "event == 'push'" in condition, ( - "a CI run triggered by a pull request must not reach the deploy job; the base " - "branch filter alone does not exclude a PR opened from `main`" + assert "refs/heads/main" in condition, ( + "a dispatch from a feature branch must not ship that branch to production" ) diff --git a/backend/tests/unit/docs/test_ux_scenarios.py b/backend/tests/unit/docs/test_ux_scenarios.py index bbefbdb4..b7d31936 100644 --- a/backend/tests/unit/docs/test_ux_scenarios.py +++ b/backend/tests/unit/docs/test_ux_scenarios.py @@ -288,7 +288,14 @@ def test_legacy_coverage_paths_do_not_regress() -> None: # Three batches in, the ratio is stable and worth stating: 15 scenarios re-audited, 15 # behaviourally correct, 22 stale references. The document's *claims* are holding; its # *citations* are not, and only the citations decay silently. -STALE_VERIFICATION_CEILING = 95 +# +# 95 → 105 on 2026-10-11, raised and not earned down. The hosted product was switched off +# that day (SCN-153; docs/handoffs/2026-10-11-hosted-product-off.md), and recording it +# moved the block's stamp from 2026-09-26 to 2026-10-11 — fifteen days that aged 21 +# scenarios across the line without any of them changing. Their re-audit needs a running +# product to audit against, and production no longer is one; the honest move is to say so +# here rather than date verdicts nobody measured. Lower it again with the next batch. +STALE_VERIFICATION_CEILING = 105 def _status_module(): diff --git a/backend/tests/unit/test_deploy_pipeline_can_fail.py b/backend/tests/unit/test_deploy_pipeline_can_fail.py index 36be880d..c9159050 100644 --- a/backend/tests/unit/test_deploy_pipeline_can_fail.py +++ b/backend/tests/unit/test_deploy_pipeline_can_fail.py @@ -28,6 +28,14 @@ the backend on the new sha and the frontend on the old one until the next run's ten-minute build finishes. +**2026-10-11: the workflow ships the site only, by hand.** The hosted product was switched +off (`docs/handoffs/2026-10-11-hosted-product-off.md`), so the backend half — its three +images, the release phase, the worker poll — left `deploy.yml`; it is in git history for a +relaunch. The guards for that half went with it (`Dockerfile.release` is still checked, +because it still exists), and one was added: the site's own check must prove the waitlist +endpoint is deployed without mailing the team inbox on every deploy. Everything that made +the release step able to fail still applies to the one release left. + This file reads the workflow as **YAML**, not as text. A guard that greps its own subject matches its own comments — the shape this audit files as TEST-04, met three times while closing these rows. @@ -83,7 +91,11 @@ def test_every_mutating_curl_fails_on_an_http_error(self, workflow: dict) -> Non body = step["run"] for line in body.splitlines(): if "curl" in line and ("-X PATCH" in line or "-X POST" in line): - if "--fail" not in body: + # A probe that captures the status and compares it reads the status, + # which is all `--fail` is for — the waitlist check expects a 422, + # which `--fail` would turn into an exit code. + reads_status = "%{http_code}" in line or "%{http_code}" in body + if "--fail" not in body and not reads_status: offenders.append(step.get("name", "")) assert not offenders, ( "these steps PATCH production and exit 0 on 401/403/404/422, because curl " @@ -143,25 +155,26 @@ def test_the_new_release_version_is_compared_with_the_old_one(self, workflow: di ) -class TestTheWorkerIsVerified: - def test_a_worker_dyno_is_polled_after_the_release(self, workflow: dict) -> None: - step = _step(workflow, "/dynos") - assert step is not None, ( - "the worker release is verified by nothing: both health checks speak HTTP and " - "the worker serves none (TEST-16)" +class TestOnlyTheSiteShips: + def test_no_backend_app_is_released(self, workflow: dict) -> None: + """A release of `checkmydata-api` would start dynos against a database that no + longer exists — and start billing for them.""" + joined = "\n".join(_code(s) for s in _run_steps(workflow)) + assert "BACKEND_APP" not in joined and "checkmydata-api" not in joined, ( + "deploy.yml releases the backend again; it was switched off on 2026-10-11" ) - assert "worker" in step["run"] - -class TestMigrationsRunInTheChannelThatDeploys: - def test_the_formation_patch_carries_a_release_process_type(self, workflow: dict) -> None: - step = _step(workflow, "formation") - assert step is not None - assert '"type":"release"' in step["run"].replace(" ", ""), ( - "the formation PATCH declares no release process type, so nothing runs " - "migrations on the channel that actually deploys (TEST-15)" + def test_the_waitlist_is_checked_without_sending_mail(self, workflow: dict) -> None: + step = _step(workflow, "/api/waitlist") + assert step is not None, "nothing checks that the waitlist endpoint was deployed" + code = _code(step) + assert "422" in code and "-d '{}'" in code, ( + "the check must post an EMPTY body and expect the validation refusal — a real " + "sign-up would mail contact@ on every deploy" ) + +class TestMigrationsRunInTheChannelThatDeploys: def test_a_release_image_exists_and_runs_alembic(self) -> None: dockerfile = ROOT / "Dockerfile.release" assert dockerfile.exists(), "no release image to run the migration" @@ -172,11 +185,6 @@ def test_a_release_image_exists_and_runs_alembic(self) -> None: "as Dockerfile.worker already is — a separately built one can drift from it" ) - def test_the_release_image_is_built_and_pushed(self, workflow: dict) -> None: - joined = "\n".join(s["run"] for s in _run_steps(workflow)) - assert "Dockerfile.release" in joined, "the release image is never built" - assert "/release" in joined, "the release image is never pushed to the registry" - class TestAReleaseIsNotCancelledHalfway: def test_the_deploy_queues_rather_than_cancels(self, workflow: dict) -> None: diff --git a/docs/handoffs/2026-10-11-hosted-product-off.md b/docs/handoffs/2026-10-11-hosted-product-off.md new file mode 100644 index 00000000..282e454e --- /dev/null +++ b/docs/handoffs/2026-10-11-hosted-product-off.md @@ -0,0 +1,53 @@ +# Handoff — hosted product switched off, site is a waitlist (2026-10-11) + +## Objective + +Operator instruction, 2026-10-11: stop every running cost except the website. Switch off +auto-deploy, the API, the worker and every database; replace the product entry points on +checkmydata.ai with a waitlist whose sign-ups reach `contact@checkmydata.ai`; make sure that +address has its own mailbox. + +## What was done + +| Area | State after | How it was checked | +|---|---|---| +| Backups | `pg_dump -Fc` of the Supabase production database and of the Heroku Postgres rollback copy, taken before deletion; kept privately by the operator, **outside Git** (they hold customer data) | `pg_restore --list` read both: 104 and 65 table-data entries | +| Secrets the dumps need | `MASTER_ENCRYPTION_KEY` (decrypts stored connection credentials), `JWT_SECRET`, `RESEND_API_KEY`, `GOOGLE_CLIENT_SECRET`, `SENTRY_DSN`, `GIT_WEBHOOK_SECRET` copied into the operator's secret store by name | store listing shows each slot | +| `checkmydata-api` (Heroku) | `web=0`, `worker=0` (were 2× Standard-2X), maintenance mode on; app shell and its config vars kept, so a relaunch needs no secret re-entry | `heroku ps` → "No dynos" | +| Heroku add-ons | `heroku-postgresql` (essential-1) and `heroku-redis` (mini) destroyed | `heroku addons` → none | +| Supabase | project `CheckMyData` deleted; its organization is on the free plan, so nothing is billed | Management API: `plan: free`, `projects: []` | +| Auto-deploy | `deploy.yml` is `workflow_dispatch` only and deploys the site alone | `gh workflow list` | +| Site (`checkmydata-web`, Heroku Basic) | waitlist form at `/#waitlist`; product routes redirect there; `RESEND_API_KEY` and `WAITLIST_TO` set on the dyno | `frontend/src/__tests__/waitlist.test.ts`; post-deploy check in `deploy.yml` | +| `contact@checkmydata.ai` | its own mailbox now (before: no mailbox, mail fell into the domain's catch-all); a dedicated Cloudflare Email Routing rule sends it to the inbox | routing check answers `verified, via rule` | + +Not touched, because they cost nothing idle: the Cloudflare zone and DNS, Resend (sending +domain verified), Sentry, GA4, the OpenRouter key (no traffic now that the API is off). +`api.checkmydata.ai` still points at the stopped app and answers with Heroku's maintenance page. + +## Decisions + +- **Product code stays in the repository.** The routes are redirected in + `frontend/next.config.ts`, not deleted: deleting them buys no saving and cascades through + ~10 tests, and a relaunch would have to restore them. Self-hosting is unchanged. +- **No waitlist database.** The email is the record. A sign-up that could not be mailed is + reported to the visitor as a failure (502/503), never acknowledged and dropped. +- **Redirects are temporary (307), not permanent**, so browsers do not cache them past a relaunch. +- **The API app is scaled to zero, not destroyed** — zero dynos cost nothing, and its config + vars are the cheapest relaunch path. + +## Relaunch (if ever) + +1. Create a Postgres (pgvector) database, restore the dump with `pg_restore --no-owner`, + point `DATABASE_URL` at it; add Redis and set `REDIS_URL`. +2. `heroku maintenance:off`, `heroku ps:scale web=1 worker=1 -a checkmydata-api`. +3. Restore the backend half of `deploy.yml` from the commit before this change, and remove + the redirects in `frontend/next.config.ts`. + +## Open work + +- Nothing blocking. Optional: the site still runs on a Basic dyno (~$7/month); a static host + would make it free but needs the waitlist endpoint moved to a serverless function. + +## Next task + +None owed. Watch `contact@checkmydata.ai` for sign-ups. diff --git a/docs/ux/scenarios.md b/docs/ux/scenarios.md index 0f64bc39..36c86ebb 100644 --- a/docs/ux/scenarios.md +++ b/docs/ux/scenarios.md @@ -9,10 +9,15 @@ found is recorded as a `draft` scenario with `Coverage: none yet`. Paths are relative to `frontend/src/` unless noted. All entries start `draft`; only a human review moves them to `validated`. +**Since 2026-10-11 the hosted deployment serves the marketing site only** — the API, +worker and databases were switched off and the product's routes redirect to the +waitlist (SCN-109, SCN-153). The product scenarios below describe the self-hosted build +from this repository, which is unchanged; they are not reachable on checkmydata.ai. + ### Implemented is not verified -Counted 2026-09-26 — regenerate with `make ux-status`. **Every number below is +Counted 2026-10-11 — regenerate with `make ux-status`. **Every number below is counted from the index table, never typed.** Ages are measured against the stamp above, not against the clock. A block that aged on @@ -21,13 +26,13 @@ without a cause is one people learn to ignore. It goes stale when the *table* ch | | | |---|---| -| Scenarios | **154** | -| Status | draft × 12, implemented × 142 | -| Last verdict | FAIL × 1, PARTIAL × 12, PASS × 129, no verdict × 12 | -| Verified when | 2026-07-19 × 72, 2026-08-19 × 9, 2026-08-20 × 1, 2026-08-21 × 2, 2026-08-31 × 10, 2026-09-03 × 1, 2026-09-07 × 4, 2026-09-08 × 8, 2026-09-09 × 1, 2026-09-17 × 5, 2026-09-18 × 2, 2026-09-23 × 14, 2026-09-26 × 13, undated × 12 | -| **Verified >30 days ago** | **84 of 154** (oldest 69 days) | -| Never verified (no date) | 12 | -| Referenced from code or tests | **52 of 154** | +| Scenarios | **155** | +| Status | draft × 12, implemented × 141, retired × 2 | +| Last verdict | FAIL × 1, PARTIAL × 12, PASS × 128, no verdict × 14 | +| Verified when | 2026-07-19 × 69, 2026-08-19 × 9, 2026-08-20 × 1, 2026-08-21 × 2, 2026-08-31 × 10, 2026-09-03 × 1, 2026-09-07 × 4, 2026-09-08 × 8, 2026-09-09 × 1, 2026-09-17 × 5, 2026-09-18 × 2, 2026-09-23 × 14, 2026-09-26 × 13, 2026-10-11 × 2, undated × 14 | +| **Verified >30 days ago** | **105 of 155** (oldest 84 days) | +| Never verified (no date) | 14 | +| Referenced from code or tests | **52 of 155** | *Implemented* says somebody built it. *Verified* says somebody checked it, on a date, and that date has an age. A reader shown only the first will believe the second — which @@ -151,10 +156,10 @@ it is what moves it. | SCN-106 | Request history & trace detail | logs | owner | implemented | 2026-08-31 PASS | | SCN-107 | Runs & Errors log tabs | logs | owner | implemented | 2026-09-17 PASS | | SCN-108 | Live activity log stream | logs | analyst | implemented | 2026-07-19 PASS | -| SCN-109 | Landing page → Get Started | marketing | visitor | implemented | 2026-07-19 PASS | -| SCN-110 | Pricing CTA (logged out) | marketing | visitor | implemented | 2026-07-19 PASS | +| SCN-109 | Landing page → Join the waitlist | marketing | visitor | implemented | 2026-10-11 PASS | +| SCN-110 | Pricing CTA (logged out) | marketing | visitor | retired | — | | SCN-111 | Support / Contact / Legal pages | marketing | visitor | implemented | 2026-07-19 PASS | -| SCN-112 | Logged-in visitor auto-redirect to /app | marketing | analyst | implemented | 2026-07-19 PASS | +| SCN-112 | Logged-in visitor auto-redirect to /app | marketing | analyst | retired | — | | SCN-113 | Add a Google Analytics 4 connection | analytics-sources | owner | implemented | 2026-08-19 PASS | | SCN-114 | Add / delete a vendor credential | analytics-sources | owner | implemented | 2026-08-19 PASS | | SCN-114a | Check whether a stored credential still works | analytics-sources | owner | implemented | 2026-09-18 PASS | @@ -196,6 +201,7 @@ it is what moves it. | SCN-150 | The sidebar surfaces what needs attention and routes to the fix | workspace | analyst | implemented | 2026-09-08 PASS | | SCN-151 | A panel link opens the panel it names | workspace | analyst | implemented | 2026-09-07 PASS | | SCN-152 | The rail says when a project's index outgrew its plan | billing | owner | implemented | 2026-09-09 PASS | +| SCN-153 | A waitlist sign-up reaches the team inbox | marketing | visitor | implemented | 2026-10-11 PASS | ## Personas @@ -2145,19 +2151,20 @@ Anonymous marketing-site visitor evaluating the product before signing up. ## marketing -### SCN-109: Landing page → Get Started +### SCN-109: Landing page → Join the waitlist - **Persona:** visitor - **Feature:** marketing -- **Entry point:** `/` (marketing home) +- **Entry point:** `/` (marketing home), or any retired product URL - **Preconditions:** anonymous visitor - **Steps:** - 1. Visitor reads the landing page and clicks "Get Started Free" -- **Expected result:** navigates to `/login` -- **UI elements:** hero/final "Get Started Free" CTAs, "View on GitHub", star count (hidden on fetch failure), FAQ accordion + 1. Visitor reads the landing page and clicks "Join the waitlist" (header, hero, open-source block, mobile menu) +- **Expected result:** scrolls to the `#waitlist` section with the sign-up form (SCN-153) +- **UI elements:** "Join the waitlist" CTAs, "View on GitHub", star count (hidden on fetch failure), FAQ accordion, waitlist section - **States covered:** success, empty(partial) -- **Errors & recovery:** GitHub stars fetch fails → star count silently hidden, no visible error (`page.tsx:227-229`) +- **Errors & recovery:** GitHub stars fetch fails → star count silently hidden, no visible error. A bookmarked product URL (`/app`, `/login`, `/pricing`, `/dashboard/…`, the password and verification pages) redirects to `/#waitlist` rather than a sign-in that cannot succeed +- **Changed 2026-10-11:** the hosted product was switched off (no API, no database). Every "Get Started" / "Log in" / "Try it now" CTA used to open `/login`; they now lead to the waitlist. The product routes stay in the tree for a relaunch and are redirected in `next.config.ts` - **Status:** implemented -- **Coverage:** app/(marketing)/page.tsx:429-434,976-981; components/marketing/FaqAccordion.tsx:34-60 +- **Coverage:** frontend/src/app/(marketing)/page.tsx; frontend/src/app/(marketing)/layout.tsx; frontend/src/components/ui/MobileMenu.tsx; frontend/next.config.ts; components/marketing/FaqAccordion.tsx ### SCN-110: Pricing CTA (logged out) - **Persona:** visitor @@ -2170,7 +2177,8 @@ Anonymous marketing-site visitor evaluating the product before signing up. - **UI elements:** per-plan CTA, static FAQ - **States covered:** loading, success - **Errors & recovery:** the `next=/pricing` intent is now honored — after login/register `/login` redirects to a safe same-origin `next` (`/pricing`), with an open-redirect guard rejecting `//host` / absolute-URL values (`login/page.tsx` `resolveRedirect`; test `frontend/src/__tests__/components/LoginPage.test.tsx`) -- **Status:** implemented +- **Status:** retired +- **Retired 2026-10-11:** the hosted product was switched off and `/pricing` redirects to `/#waitlist`; nothing can be bought. Kept for a relaunch. - **Coverage:** components/marketing/PricingTable.tsx:91-99; redirect honored in frontend/src/app/login/page.tsx ### SCN-111: Support / Contact / Legal pages @@ -2199,9 +2207,26 @@ Anonymous marketing-site visitor evaluating the product before signing up. - **UI elements:** (none — AuthRedirect returns null) - **States covered:** success - **Errors & recovery:** none -- **Status:** implemented +- **Retired 2026-10-11:** the landing page no longer mounts `AuthRedirect` — with the API switched off there is no session to restore, and the check would only fail. +- **Status:** retired - **Coverage:** components/auth/AuthRedirect.tsx:12-22 +### SCN-153: A waitlist sign-up reaches the team inbox +- **Persona:** visitor +- **Feature:** marketing +- **Entry point:** `/#waitlist` (SCN-109) +- **Preconditions:** anonymous visitor; `RESEND_API_KEY` set on the site's dyno +- **Steps:** + 1. Visitor enters a work email, optionally a name, company and what they would ask their database + 2. Visitor clicks "Join the waitlist" +- **Expected result:** the form is replaced by "You're on the list." and one plain-text email reaches `contact@checkmydata.ai`, with the visitor as Reply-To so the team answers them directly +- **UI elements:** email (required), name, company, note, "Join the waitlist" button ("Joining…" while sending), confirmation panel, inline error (`role="alert"`) +- **States covered:** idle, loading, success, error +- **Errors & recovery:** missing or malformed email → inline message, email field marked invalid, nothing sent (422); Resend refuses or is unreachable → "We couldn't save your sign-up. Try again, or email contact@checkmydata.ai." (502) — a sign-up that was not mailed is never acknowledged; key missing → "Sign-ups are briefly unavailable…" (503); more than five sign-ups from one address in ten minutes → 429 with a retry hint; a filled honeypot is answered as success and sends nothing +- **Why there is no database:** the product's backend and both databases were deleted on 2026-10-11 to stop their cost. The site's own Next.js server sends the email; the inbox is the record +- **Status:** implemented +- **Coverage:** frontend/src/components/marketing/WaitlistForm.tsx; frontend/src/app/api/waitlist/route.ts; frontend/src/lib/waitlist.ts; tests frontend/src/__tests__/waitlist.test.ts + ## analytics-sources