Skip to content

Commit 78669a9

Browse files
Close the direct run.app door with a shared-secret origin gate (#11208)
## Summary - **`api/origin_gate.py` requires the header the Cloudflare edge stamps**, and refuses anything else with `403` before the request costs anything. The API stands on Cloud Run with `ingress=all`, so it answers on two addresses — `api.anyplot.ai` behind Cloudflare, and the raw `*.run.app` URL in front of nothing. Every edge measure (bot challenge, WAF, the cache that makes the `max-age=300` reads free) was one URL away from being bypassed; `api/request_context.py` already documented callers doing it. - **Unset means off.** Nothing changes until `ORIGIN_SECRET` is set on the service, which is also the rollback. Local dev and the test suite never see the gate, so this can merge and deploy long before the Cloudflare rule or the secret exist. - **`/health` reports `origin_gate`** — `off` · `off-seen` · `ok` · `missing` · `mismatch` — for the request it was asked with, never the value. That is what turns the rollout into a measurement instead of a leap. - **The apex Worker's source moves into `infra/cloudflare/`**, because a Worker subrequest to a host in the same zone bypasses that zone's Transform Rules. It now stamps the header itself, deleting any inbound one first. - Transferred from the sibling repo kurrentschrift (PRs #493, #495), where this shipped and was measured live. - **Known residual, deliberately out of scope:** the app service also stands with `ingress=all`, and its nginx relays a crawler user agent through `@seo_proxy` to `api.anyplot.ai`, where the edge stamps the header legitimately — so the prerendered render stays reachable via the *app*'s raw `run.app` URL. That is a second door on a second service (the request the API sees really did pass the edge), and closing it means gating `anyplot-app` or refusing to proxy for `run.app` hosts, which `bot-serving-check.yml` probes nightly. Named in `api/origin_gate.py` and `docs/reference/api.md`; own PR. ## What is exempt, and why each one has to be Exact paths, no prefixes: | Path | Why | |---|---| | `/health` | the deploy's pre-traffic smoke probes the candidate revision on its `run.app` tag URL, which by definition never passes the edge — gating it makes every deploy fail closed | | `/debug/cache/invalidate` | **anyplot-specific.** `sync-postgres.yml` posts here from a GitHub runner over the direct `*.run.app` URL *on purpose* — Cloudflare's bot challenge answers an unauthenticated curl POST against `api.anyplot.ai` with a 403 HTML page. The endpoint carries its own shared secret (`CACHE_INVALIDATE_TOKEN`, constant-time compared, 503 when unconfigured), so it is gated, just by a different lock | | `OPTIONS` | a browser cannot attach a custom header to a preflight | **`/seo-proxy/…` is deliberately NOT exempt**, though the sibling repo exempts it belt-and-braces. Copilot was right that it would be a real hole: those handlers query `SpecRepository`/`ImplRepository` on a cache miss or an unknown id, and any request with a recognized crawler user agent schedules an outbound Plausible event — so an exemption would leave the API's most expensive reads open on the direct URL, which is the cost this gate exists to refuse. The site's nginx already fetches those pages over `https://api.anyplot.ai`, so the path carries the header; step (b) of the rollout validates it end to end with a crawler user agent before anything is armed, and `bot-serving-check.yml` runs daily, so being wrong here is loud rather than silent. **The direct paths I checked** (this is the part that does not transfer, and had to be re-derived for this repo): - `api.anyplot.ai` — SPA (`VITE_API_URL`), MCP clients, OG cards embedded cross-origin, and the site's nginx for `@seo_proxy`, `@seo_proxy_python`, `/llms-full.txt`, `/sitemap.xml`. All through the edge → the Transform Rule stamps them. - `anyplot.ai/api/*` — the Worker. Same-zone subrequest, so it must stamp for itself. Its `/api/event` Plausible passthrough is preserved untouched. - `anyplot-api-…run.app` — the Cloud Build smoke (now sends the header) and `sync-postgres.yml` (exempt path, above). - `anyplot-app-…run.app` — `bot-serving-check.yml` hits the *app* origin, whose nginx then goes out through `api.anyplot.ai`. Unaffected. ## Every header secret goes through one byte-wise comparator `secrets.compare_digest` raises `TypeError` when either `str` holds a non-ASCII character, and a header value reaches the application latin-1-decoded straight from the wire. Comparing strings handed any unauthenticated caller a one-byte way to turn a cheap 401 or 403 into an unhandled, logged 500 (Copilot). That was true of the gate — and of `X-Admin-Token` and `X-Cache-Token`, which matters more: `/debug/cache/invalidate` is *exempt from the gate on the grounds that it has its own lock*, and it is the one endpoint reachable on the direct `run.app` URL. So the fix is one comparator in `api/secret_compare.py`, used by all three call sites, rather than three separate patches: a comparator that is correct in two places out of three is exactly what nobody notices. It also refuses when either side is missing, so an unconfigured secret can never be satisfied by an absent header. Pinned by tests including one that asserts the `str` comparison this replaced *does* raise on the same input, so the others cannot quietly stop measuring anything. ## Two changes beyond the gate itself **The deploy step configures the revision additively — `--update-secrets` and `--update-env-vars`.** Both `--set-` forms replace their whole set, so anything attached to the service out of band is stripped from every revision the pipeline creates. `ORIGIN_SECRET` is exactly that kind of binding — attached by hand to arm, removed by hand to roll back — and a secret-backed variable lives in the same revision environment as a literal one, so *either* flag was a way to silently disarm the gate on the next deploy (the second half found by Copilot, after the first fix). It cannot simply be listed in the flags instead: Cloud Run refuses a deploy naming a secret that does not exist, which would break every build until step (c) below. Two flags in the deploy step; everything else in `api/cloudbuild.yaml` is confined to the smoke step. **The analytics middleware moves inside `CORSMiddleware`.** The gate has to be inside CORS (so its 403 carries the headers a browser needs to read it as a 403 rather than as an opaque network error) *and* outside the bot counter (so a refused request can never fire an outbound Plausible event — `track_asset_fetch` fires per request for anything with a crawler user agent, so a caller on the direct URL could otherwise turn each of its own refusals into one, unthrottled, at a third-party endpoint). In this repo the counter sat outside CORS, which makes those two mutually exclusive; moving it in resolves it. The cache-header middleware stays outside CORS, where its `setdefault` for the `/og/` cards depends on being. `api/main.py` now carries the stack order and the reason for each position. The only behavioural consequence of the move: a CORS preflight no longer reaches the counter. Preflights carry the browser's user agent, and both tracking functions return early unless `detect_ai_agent` classifies the UA, so nothing that was being counted stops being counted. ## Rollout — in this order, and measured at each step **(a) Merge and deploy with the check off.** Nothing to configure; `ORIGIN_SECRET` does not exist yet, `gate_is_armed()` is false, every path behaves exactly as today. Confirm with: ```bash curl -s https://api.anyplot.ai/health # expect "origin_gate":"off" ``` **(b) Put the Transform Rule live and give the Worker its binding — then measure `off-seen` on EVERY path.** Cloudflare dashboard: Rules → Transform Rules → Modify Request Header → *set static* `X-Origin-Secret` for `http.host eq "api.anyplot.ai"`. Then deploy `infra/cloudflare/anyplot-api-proxy.js` with the `ORIGIN_SECRET` secret binding (procedure in `infra/cloudflare/README.md`). The gate is still off, so nothing can break; what this step buys is the evidence: ```bash curl -s https://api.anyplot.ai/health # must read "off-seen" curl -s https://anyplot.ai/api/health # must read "off-seen" ← the Worker; this is the one that reads "off" if the binding is missing curl -s https://<api-run-url>/health # must stay "off" — that is the door being closed # nginx rides on the first line's verdict; probe it end to end: curl -s -A 'Mozilla/5.0 (compatible; Googlebot/2.1)' https://anyplot.ai/scatter-basic | head -5 curl -sI https://anyplot.ai/llms-full.txt ``` **Do not proceed while any path that must keep working still reads `off`.** **(c) Create the secret and arm the service.** Both the Cloud Run runtime and the Cloud Build trigger use the same identity, `239660669828-compute@developer.gserviceaccount.com`, so one grant covers the service *and* the smoke step's read. ```bash # Never `echo` — it appends a newline. (The config strips whitespace as a # second net, but the value should be right in the first place.) gcloud secrets create ORIGIN_SECRET --project=anyplot --replication-policy=automatic printf %s "<value>" | gcloud secrets versions add ORIGIN_SECRET --project=anyplot --data-file=- gcloud secrets add-iam-policy-binding ORIGIN_SECRET --project=anyplot \ --member="serviceAccount:239660669828-compute@developer.gserviceaccount.com" \ --role="roles/secretmanager.secretAccessor" # Same value into the Cloudflare Transform Rule and the Worker binding. # Same value into the Cloudflare Transform Rule and the Worker binding. ``` Then arm the service. **The full block is in `docs/reference/api.md` § Origin gate** — it lives in the repository rather than in this description, because a procedure that exists only in a PR body is one nobody finds at 2 a.m. Three things in it are not obvious, each from a Copilot round: 1. **Refuse to act while a candidate revision is in flight.** `services update` clones the service's *latest* template, not the serving one, and the pipeline deliberately leaves each build's smoked-but-unpromoted candidate as latest — so arming during a deploy would ship that build's image along with the gate, and naming the new revision precisely does not change which image it inherits. The block asserts `latestReadyRevisionName == the revision serving 100%` and stops otherwise. 2. **Pin the secret to a version number, never `:latest`.** Cloud Run resolves a secret-backed variable when each instance starts, so with `:latest` a new secret version reaches new instances while older ones keep the old value — and since the edge stamps exactly one value, that shows up as intermittent 403s *inside a single revision*. 3. **Promote by name, never `--to-latest`** — the same hazard as (1), and the reason `api/cloudbuild.yaml` refuses that flag. Rotation gets its own paragraph there: the gate accepts exactly one value, so there is no overlap window. Roll back, rotate both sides, arm again on the new version number. **(d) Verify.** ```bash curl -s https://api.anyplot.ai/health # "ok" curl -s https://anyplot.ai/api/health # "ok" curl -s https://<api-run-url>/health # "missing" ← the door is now shut curl -s -o /dev/null -w '%{http_code}\n' https://<api-run-url>/libraries # 403 curl -s -o /dev/null -w '%{http_code}\n' https://api.anyplot.ai/libraries # 200 ``` Then walk the site once (gallery, a spec page, the stats page), fetch an OG card cross-origin, and let one `sync-postgres` run finish — its cache flush must still return 200. **(e) Rollback** — one variable, no code change: The same block as arming, with `--remove-secrets=ORIGIN_SECRET` in place of `--update-secrets` and a `disarm-` suffix — including the in-flight-candidate guard, which matters more here than when arming. Removing the Worker binding is **not** a rollback: while the service is armed, that takes `anyplot.ai/api/*` down instead of freeing it. Roll back on the API side, always. ## Follow-up this PR deliberately leaves open `/debug/cache/invalidate` is exempt because `sync-postgres.yml` has no front door. The cleaner end state is for that workflow to send `X-Origin-Secret` from a repository secret, at which point the exemption can go. That needs a GitHub Actions secret plus a change to `.github/workflows/sync-postgres.yml`, which is out of this PR's scope — noted here so it is not lost. ## Test plan - [x] `tests/unit/api/test_origin_gate.py` — 57 tests: dormant by default (including with a wrong header), the armed gate across five header shapes and six methods, the exemption list both as live requests and as assertions on the list itself (including that `/seo-proxy/…` is refused), preflight and CORS-headers-on-the-403, the analytics middleware never firing on a refusal — for an asset path *and* a crawler page — all five `/health` verdicts, the non-ASCII header on all three secrets, and the trailing-newline strip on `ORIGIN_SECRET` *and* the other Secret-Manager-backed values. - [x] `uv run pytest tests/unit` — 1818 passed, 1 skipped (pre-existing local skip: MonoLisa italic not cached). - [x] `uv run ruff check .` / `ruff format --check .` — clean. - [x] `uv run --extra typecheck mypy api core` — no issues in 37 source files. - [x] `api/cloudbuild.yaml` parses; step ids unchanged (`build-image`, `push-image`, `push-latest`, `deploy`, `smoke`, `promote`, `get-url`). - Not verifiable before merge: the Cloud Build smoke's new lines and the Cloudflare side. The smoke is written so that a missing secret or a missing permission yields an empty value and bare probes — correct while the gate is off, loud at `/libraries` once it is on — and it accepts `off`/`off-seen`, so it cannot take the deploy pipeline down during the rollout or after a rollback. ## Checklist - [x] `CHANGELOG.md` updated under `[Unreleased]` — one `### Added` entry for the gate, two `### Changed` entries for the deploy flag and the middleware order. - [x] Docs updated: `docs/reference/api.md` (new "Origin gate" section, `/health` response), `docs/development.md` (env table), `docs/reference/repository.md` and `agentic/docs/project-guide.md` (both repository maps get `infra/`), `.env.example`, and `infra/cloudflare/README.md` for the Worker and the measuring procedure. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01PBQdMbboxo59sSThGSbfke --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 9c85763 commit 78669a9

16 files changed

Lines changed: 1233 additions & 33 deletions

File tree

.env.example

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,13 @@ PORT=8000
5757
# requests with a valid JWT but an unlisted email return 403.
5858
# ADMIN_ALLOWED_EMAILS=alice@example.com,bob@example.com
5959

60+
# Shared secret a Cloudflare Transform Rule stamps as X-Origin-Secret on every
61+
# request it proxies for api.anyplot.ai; api/origin_gate.py refuses anything
62+
# without it, which closes the direct *.run.app door. LEAVE THIS UNSET locally
63+
# and in tests — unset means the gate is off, and that is also the production
64+
# rollback. Set only on the Cloud Run service, from Secret Manager.
65+
# ORIGIN_SECRET=
66+
6067
# ============================================================================
6168
# AI Services (optional)
6269
# ============================================================================

CHANGELOG.md

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,41 @@ aggregate instead: an italic *Catalog* line at the end of the version section an
2828

2929
### Added
3030

31+
- **A shared-secret origin gate closes the direct `*.run.app` door, and the apex Worker's
32+
source moves into the repository** — the API runs on Cloud Run with `ingress=all`, so it
33+
answers on two addresses: `api.anyplot.ai`, which Cloudflare proxies, and the raw
34+
`*.run.app` URL, which it does not. Everything the edge enforces — the bot challenge, the
35+
WAF, the cache that makes the `max-age=300` reads free — was one URL away from being
36+
bypassed, and `api/request_context.py` already documented callers doing it. A Cloudflare
37+
Transform Rule stamps `X-Origin-Secret` on everything it proxies for the API host, and
38+
`api/origin_gate.py` refuses anything without it with 403 before the request costs
39+
anything. **Unset means off**, which is what makes the rollback a single variable and
40+
keeps local development and the test suite untouched: the code can ship long before the
41+
rule and the secret exist. `/health` reports `origin_gate` (`off` · `off-seen` · `ok` ·
42+
`missing` · `mismatch`) for the request it was asked with — never the value — so every
43+
route into the service can be measured *before* the switch is thrown; `off-seen` is the
44+
state every path that must keep working has to reach first. Exempt, as exact paths with no
45+
prefixes: `/health` (the deploy smoke reaches the candidate on its `run.app` tag URL, which
46+
never passes the edge) and `/debug/cache/invalidate` (`sync-postgres.yml` posts to the
47+
direct URL by design, because Cloudflare's bot challenge answers an unauthenticated curl
48+
POST with a 403 HTML page; that endpoint carries its own constant-time token), plus
49+
`OPTIONS`, which a browser cannot attach a custom header to. `/seo-proxy/…` is deliberately
50+
**not** exempt although the sibling repo exempts it: the site's nginx fetches those pages
51+
over `api.anyplot.ai` and so carries the header, while an exemption would leave the API's
52+
most expensive reads open on the direct URL — a cache miss or an unknown id queries the
53+
repositories, and a crawler user agent schedules an outbound Plausible event per request.
54+
Every header secret is now compared through one byte-wise comparator
55+
(`api/secret_compare.py`, used by the gate and by both `/debug/*` locks), because
56+
`secrets.compare_digest` raises `TypeError` on a non-ASCII `str` while a header arrives
57+
latin-1-decoded from the wire: comparing strings handed any caller a one-byte way to turn a
58+
cheap 401 or 403 into an unhandled, logged 500 — including on `/debug/cache/invalidate`,
59+
which is exempt from the gate precisely because it has its own lock. The Cloudflare Worker
60+
behind `anyplot.ai/api/*` now has
61+
its source in `infra/cloudflare/`, because a Worker subrequest to a host in the same zone
62+
bypasses that zone's Transform Rules — so the Worker stamps the header itself, deleting
63+
any inbound one first so a caller cannot supply it. The pre-traffic smoke reads the secret
64+
at run time and sends it, accepting `off`/`off-seen` so the pipeline keeps working before
65+
the gate is armed and after a rollback. (#11208)
3166
- **The agent instructions are pinned by a test, and the drift it found is fixed**`CLAUDE.md`
3267
and `.github/copilot-instructions.md` both open with the claim that they stay in sync, and both
3368
are read as binding shorthand, but nothing checked either claim. `tests/unit/test_agent_instructions.py`
@@ -73,7 +108,6 @@ aggregate instead: an italic *Catalog* line at the end of the version section an
73108
branch, so a dispatch from a feature branch can never raise or close a production
74109
incident. Timeout recomputed to 62 min by the file's own formula (36 checks x 90 s + five
75110
non-retried probes). (#11209)
76-
77111
- **The API image is built and its container smoke-tested before merge, not after** — the
78112
first build attempt of a changed Dockerfile used to happen in Cloud Build, once the PR was
79113
already on `main`; that is how the deploy-api trigger sat red from 2026-08-30 until #10821
@@ -256,6 +290,25 @@ aggregate instead: an italic *Catalog* line at the end of the version section an
256290
idle window — so the instance is in practice never reclaimed and visitors keep the
257291
same time to first byte. `anyplot-api` keeps `min-instances=1`: its cold start is
258292
~11.6 s and its traffic does leave gaps over 15 minutes. (#10812)
293+
- **The API deploy configures the revision additively — `--update-secrets` and
294+
`--update-env-vars`, not the `--set-` forms** — both `--set-` flags replace their whole set,
295+
so anything attached to the service out of band is stripped from every revision the pipeline
296+
creates. `ORIGIN_SECRET` is exactly such a binding — attached by hand to arm the origin gate,
297+
removed by hand to roll back — and a secret-backed variable lives in the same revision
298+
environment as a literal one, so either flag was a way to silently disarm the gate on the
299+
next deploy. It cannot simply be listed in the flag instead: Cloud Run refuses a deploy
300+
naming a secret that does not exist, which would break every build until the rollout creates
301+
it. The cost is that a variable dropped from either line is no longer removed automatically.
302+
(#11208)
303+
- **The analytics middleware moves inside `CORSMiddleware`** — a consequence of where the
304+
origin gate has to sit. The gate belongs inside CORS, so its 403 still carries the headers
305+
a browser needs to read it as a 403 rather than as an opaque network error, and outside
306+
the bot counter, so a refused request can never fire an outbound Plausible event —
307+
`track_asset_fetch` fires per request for anything with a crawler user agent, so a caller
308+
on the direct URL could otherwise turn each of its own refusals into one. Those two are
309+
only simultaneously possible with the counter inside CORS. The cache-header middleware
310+
stays outside CORS, where its `setdefault` for the /og/ cards depends on being. `api/main.py`
311+
now carries the stack order and the reason for each position. (#11208)
259312
- **The frontend declares the Node version it is actually built with, and something
260313
enforces it**`app/package.json` asked for `node >=20` while the image that produces
261314
the deployed bundle builds on Node 22 and CI tests on Node 24, so the only version the

agentic/docs/project-guide.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,8 @@ Example: `plots/scatter-basic/` contains everything for the basic scatter plot.
202202
- **`agentic/workflows/`**: Click CLI scripts (plan, build, test, review + orchestrators)
203203
- **`agentic/commands/`**: Markdown prompt templates
204204
- **`automation/`**: CI/CD helper scripts (workflow_cli, label_manager, sync_to_postgres)
205+
- **`infra/`**: Infrastructure that would otherwise live only in a dashboard
206+
- **`infra/cloudflare/`**: Source of the apex `anyplot.ai/api/*` Worker, plus the origin gate's rollout and measuring procedure
205207
- **`tests/`**: Unit, integration, and e2e tests mirroring source structure
206208
- **`docs/`**: Architecture and workflow documentation
207209

api/cloudbuild.yaml

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -68,10 +68,31 @@ steps:
6868
# /insights/visitors on the public stats page). The Secret Manager
6969
# entry must exist before the first deploy that includes this line —
7070
# create it with: gcloud secrets create PLAUSIBLE_API_KEY --data-file=-
71-
- "--set-secrets=DATABASE_URL=DATABASE_URL:latest,CACHE_INVALIDATE_TOKEN=CACHE_INVALIDATE_TOKEN:latest,ADMIN_TOKEN=ADMIN_TOKEN:latest,PLAUSIBLE_API_KEY=PLAUSIBLE_API_KEY:latest"
71+
#
72+
# `--update-secrets`, NOT `--set-secrets`: the latter replaces the whole
73+
# binding set, so it would strip any secret attached out of band from
74+
# every revision this pipeline creates. ORIGIN_SECRET (api/origin_gate.py)
75+
# is exactly such a binding — it is attached by hand when the gate is
76+
# armed and removed by hand to roll back, and `--set-secrets` would
77+
# silently disarm the gate on the next deploy. It cannot be listed here
78+
# instead: Cloud Run refuses a deploy that names a secret which does not
79+
# exist, which would break every build until the rollout reaches the step
80+
# that creates it. The cost of `--update-secrets` is that a binding
81+
# dropped from this line is no longer removed automatically — worth it
82+
# against a gate that turns itself off.
83+
- "--update-secrets=DATABASE_URL=DATABASE_URL:latest,CACHE_INVALIDATE_TOKEN=CACHE_INVALIDATE_TOKEN:latest,ADMIN_TOKEN=ADMIN_TOKEN:latest,PLAUSIBLE_API_KEY=PLAUSIBLE_API_KEY:latest"
7284
- "--execution-environment=gen2"
7385
# ^|^ alt delimiter: values contain @ (emails) and may contain , (multi-email lists)
74-
- "--set-env-vars=^|^ENVIRONMENT=production|GOOGLE_CLOUD_PROJECT=$PROJECT_ID|GCS_BUCKET=anyplot-images|CF_ACCESS_TEAM_DOMAIN=${_CF_ACCESS_TEAM_DOMAIN}|CF_ACCESS_AUD=${_CF_ACCESS_AUD}|ADMIN_ALLOWED_EMAILS=${_ADMIN_ALLOWED_EMAILS}"
86+
#
87+
# `--update-env-vars` for the same reason as `--update-secrets` above, and
88+
# belt and braces on top of it: a secret-backed variable lives in the same
89+
# revision environment as a literal one, so a destructive `--set-env-vars`
90+
# is a second way this pipeline could drop the hand-attached
91+
# ORIGIN_SECRET and silently disarm the gate (Copilot review). Additive on
92+
# both flags means one deploy cannot undo an out-of-band change; the cost
93+
# is that a variable dropped from this line is no longer removed by the
94+
# next deploy, which is the trade already accepted for the secrets.
95+
- "--update-env-vars=^|^ENVIRONMENT=production|GOOGLE_CLOUD_PROJECT=$PROJECT_ID|GCS_BUCKET=anyplot-images|CF_ACCESS_TEAM_DOMAIN=${_CF_ACCESS_TEAM_DOMAIN}|CF_ACCESS_AUD=${_CF_ACCESS_AUD}|ADMIN_ALLOWED_EMAILS=${_ADMIN_ALLOWED_EMAILS}"
7596
- "--cpu-throttling"
7697
- "--concurrency=15"
7798
- "--timeout=600"
@@ -112,18 +133,51 @@ steps:
112133
# _MIN_INSTANCES says, so the first call that touches the DB may be the
113134
# first real DB request of that container's life and can fail once.
114135
RETRY="--retry 5 --retry-delay 5 --retry-all-errors"
136+
# The candidate is probed on its `run.app` tag URL, which by definition
137+
# never passes the Cloudflare edge — so once ORIGIN_SECRET is set on the
138+
# service (api/origin_gate.py) every probe but /health needs the header
139+
# the edge would have stamped. Read here rather than through
140+
# `availableSecrets` on purpose: that resolves at build start and would
141+
# fail every build until the secret exists, which is precisely the first
142+
# step of the rollout. Missing secret or missing permission => empty =>
143+
# the probes run bare, which is correct while the gate is off and fails
144+
# loudly at /libraries once it is on. The value is captured, never
145+
# echoed; the step runs without `set -x`.
146+
ORIGIN_SECRET=$$(gcloud secrets versions access latest --secret=ORIGIN_SECRET 2>/dev/null || true)
147+
HDR=()
148+
if [ -n "$$ORIGIN_SECRET" ]; then HDR=(-H "X-Origin-Secret: $$ORIGIN_SECRET"); fi
149+
# /health stays bare: it is exempt from the gate, and that is what makes
150+
# it the probe that always reaches a cold candidate.
115151
curl -fsS $$RETRY "$$URL/health" | grep -q '"healthy"'
152+
# …and that the secret this BUILD can read is the one the SERVICE was
153+
# given. /health reports the verdict for the request it was asked with
154+
# (never the value), so a rotation applied to only one of the two shows
155+
# up here instead of as a mysterious 403 after the promote.
156+
# `off`/`off-seen` are ACCEPTED, not failures: they are the gate before
157+
# it is armed and after a rollback, and a build that refused to run then
158+
# would take the deploy pipeline down exactly when it is needed most.
159+
# Only `mismatch` is a real disagreement.
160+
if [ -n "$$ORIGIN_SECRET" ]; then
161+
gate=$$(curl -fsS $$RETRY "$${HDR[@]}" "$$URL/health" | python3 -c "import json,sys; print(json.load(sys.stdin).get('origin_gate'))")
162+
case "$$gate" in
163+
ok) echo "origin gate: armed, and this build's secret matches" ;;
164+
off|off-seen) echo "origin gate: $$gate (not armed on this revision)" ;;
165+
*) echo "origin gate says '$$gate' for this build's secret — service and build disagree"; exit 1 ;;
166+
esac
167+
fi
116168
# /libraries and /languages fall back to static metadata when the DB is
117169
# unreachable (optional_db), so they prove the app serves but not the
118170
# database. /plots/filter takes require_db — it is the probe that fails
119171
# when the Cloud SQL connection is broken.
120-
curl -fsS $$RETRY "$$URL/libraries" | grep -q '"libraries"'
121-
curl -fsS $$RETRY "$$URL/languages" | grep -q '"languages"'
122-
curl -fsS $$RETRY "$$URL/plots/filter" >/dev/null
172+
curl -fsS $$RETRY "$${HDR[@]}" "$$URL/libraries" | grep -q '"libraries"'
173+
curl -fsS $$RETRY "$${HDR[@]}" "$$URL/languages" | grep -q '"languages"'
174+
curl -fsS $$RETRY "$${HDR[@]}" "$$URL/plots/filter" >/dev/null
123175
# Fail-closed admin gate. 401 is the answer with ADMIN_TOKEN present and
124176
# no header sent; a 503 here would mean the secret never arrived, which
125-
# is exactly the misconfiguration worth failing the build over.
126-
code=$$(curl -s $$RETRY -o /dev/null -w '%{http_code}' "$$URL/debug/status")
177+
# is exactly the misconfiguration worth failing the build over. With the
178+
# gate armed the origin header has to be sent too, or this reads 403 and
179+
# says "admin gate" about something that never reached it.
180+
code=$$(curl -s $$RETRY "$${HDR[@]}" -o /dev/null -w '%{http_code}' "$$URL/debug/status")
127181
test "$$code" = "401" || { echo "admin gate expected 401, got $$code"; exit 1; }
128182
echo "smoke OK"
129183
id: "smoke"

api/main.py

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
http_exception_handler,
2626
)
2727
from api.mcp.server import mcp_server # noqa: E402
28+
from api.origin_gate import OriginSecretMiddleware # noqa: E402
2829
from api.routers import ( # noqa: E402
2930
debug_router,
3031
download_router,
@@ -161,26 +162,35 @@ async def lifespan(app: FastAPI):
161162
app.add_exception_handler(HTTPException, http_exception_handler)
162163
app.add_exception_handler(Exception, generic_exception_handler)
163164

165+
# The middleware stack, written innermost-first because `add_middleware` and
166+
# `@app.middleware` both wrap what is already there — so reading this file from
167+
# here down gives the order a request actually travels, in reverse:
168+
#
169+
# cache headers → CORS → origin gate → bot counter → gzip → router
170+
#
171+
# (`HeadAsGetMiddleware` and `MCPTrailingSlashMiddleware` wrap the whole app
172+
# further out still; both only rewrite the scope.)
173+
#
174+
# Two of those positions are load-bearing:
175+
#
176+
# * The origin gate directly inside CORS, so a 403 from it still carries the
177+
# headers a browser needs to read it as a 403 rather than as an opaque
178+
# network error — and OUTSIDE the bot counter, so a refused request can never
179+
# fire an outbound Plausible event. That second one is why the counter moved
180+
# in here from outside CORS: `track_asset_fetch` fires per request for
181+
# anything with a crawler user agent, so a caller on the direct `run.app` URL
182+
# could otherwise turn each of its own refusals into one.
183+
# * The cache-header middleware stays OUTSIDE CORS, because its `setdefault`
184+
# for the /og/ cards is what keeps CORSMiddleware's own header when the
185+
# request came from an allowlisted origin — it has to run after CORS on the
186+
# way out.
187+
164188
# Enable GZip compression for responses > 500 bytes
165189
# This significantly reduces payload size for JSON API responses
166190
# (e.g., /plots/filter: 301KB -> ~40KB with gzip)
167191
# Note: GZip must be added before CORS so compression happens before CORS headers are added
168192
app.add_middleware(GZipMiddleware, minimum_size=500)
169193

170-
# Configure CORS. Origins come from settings.cors_origins (single source of
171-
# truth — a hardcoded list here previously left https://www.anyplot.ai out
172-
# even though config promised it); the regex additionally allows any
173-
# localhost port for local dev servers.
174-
app.add_middleware(
175-
CORSMiddleware,
176-
allow_origins=settings.cors_origins,
177-
allow_origin_regex=r"http://localhost:\d+",
178-
allow_credentials=True,
179-
allow_methods=["*"],
180-
allow_headers=["*"],
181-
expose_headers=["Mcp-Session-Id"], # MCP session tracking
182-
)
183-
184194

185195
# Record which AI or search agent requested which catalogue page.
186196
#
@@ -214,6 +224,26 @@ async def record_bot_fetch(request: Request, call_next):
214224
return response
215225

216226

227+
# Close the direct `*.run.app` door: require the header the Cloudflare edge
228+
# stamps. Dormant until ORIGIN_SECRET is set on the service, which is both the
229+
# rollout order and the rollback (api/origin_gate.py).
230+
app.add_middleware(OriginSecretMiddleware)
231+
232+
# Configure CORS. Origins come from settings.cors_origins (single source of
233+
# truth — a hardcoded list here previously left https://www.anyplot.ai out
234+
# even though config promised it); the regex additionally allows any
235+
# localhost port for local dev servers.
236+
app.add_middleware(
237+
CORSMiddleware,
238+
allow_origins=settings.cors_origins,
239+
allow_origin_regex=r"http://localhost:\d+",
240+
allow_credentials=True,
241+
allow_methods=["*"],
242+
allow_headers=["*"],
243+
expose_headers=["Mcp-Session-Id"], # MCP session tracking
244+
)
245+
246+
217247
# Add cache headers middleware
218248
@app.middleware("http")
219249
async def add_cache_headers(request: Request, call_next):

0 commit comments

Comments
 (0)