diff --git a/CHANGELOG.md b/CHANGELOG.md index 7052638..429207f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,15 @@ All notable changes to this project are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and the project uses semantic versioning. +## [Unreleased] + +### Added + +- `GET /version.json` on `play.`: `{"version": ""}` with + `Content-Type: application/json` and `Cache-Control: no-store`, so a deploy can be verified by the + version it reports. It is answered on the API host only, never on a game's host, so it cannot + shadow a static game's own `/version.json`. + ## [0.6.0] - 2026-10-03 ### Added diff --git a/README.md b/README.md index 326579c..d86573d 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ out for a phone first, and is sent with `Cache-Control: public, max-age=60`. Set | `PUT` | `/api/games//versions/` | Body: a `.tar` (optionally compressed) or `.zip` holding exactly `index.html`, `game.zip`, `version.txt`. It is validated before anything is written, then stored and made current unless `?activate=false`. Responses: **201**; **409** if the version exists (versions are immutable); **401/403** on a missing or wrong token; **411** without a `Content-Length`; **413** over the size cap; **400** for a missing or extra file, a `version.txt` that disagrees with ``, or a `game.zip` without tak's four assets. | | `POST` | `/api/games//current` | `{"version": "…"}`: repoints the game to a stored version. This is rollback. **404** for a version not on disk. | | `GET` | `/api/games`, `/api/games/` | slug, title, repo, url, aliases, kind, isolation, current, versions, **plays** (page loads by people; crawlers and scripts are excluded by User-Agent). Needs no token, never shows hashes, and is readable cross-origin (`Access-Control-Allow-Origin: *`) so the portal can show play counts. | +| `GET` | `/version.json` | `{"version": ""}`: the version of arcade itself that is running (`arcade.__version__`), with `Cache-Control: no-store`, so a deploy can be verified by the version it reports. Needs no token. Answered on `play.` only: on a game's host `/version.json` is the game's own path. | The token is `Authorization: Bearer `, and each game's token deploys only that game. An unknown slug and a wrong token both get 403. The last `ARCADE_KEEP_VERSIONS` (5) versions per game diff --git a/src/arcade/server.py b/src/arcade/server.py index de49483..8296bcf 100644 --- a/src/arcade/server.py +++ b/src/arcade/server.py @@ -5,7 +5,8 @@ play. the upload API, and / a page listing every game (or, with ARCADE_LANDING_MODE=redirect, a 302 to - the portal) + the portal), and /version.json: the running + arcade's own version, {"version": ""} .play. a game, served the way tak.web.serve serves one a game's old hostname (registry `aliases`) @@ -394,6 +395,9 @@ def _api(self, path, read): if read and path in ("/", "/index.html"): self._landing() return + if read and path == "/version.json": + self._version() + return if read and path == "/api/games": registry = arcade.registry.registry self._json(200, {"games": [self._describe(game) for game in registry]}, public=True) @@ -416,6 +420,14 @@ def _api(self, path, read): return self._json(404, {"error": "not found"}) + def _version(self): + # The version of the code this process is running, so a deploy can + # be verified by the version it reports. Only on the API host: on a + # game's host /version.json belongs to the game (a static site may + # ship its own), so it is never answered there. + body = json.dumps({"version": __version__}).encode("utf-8") + self._send(200, body, "application/json", headers=(("Cache-Control", "no-store"),)) + def _landing(self): if config.landingMode == "redirect": self._send(302, b"", headers=(("Location", config.landingUrl),)) diff --git a/tests/test_server.py b/tests/test_server.py index 6d02788..2363b0a 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -5,6 +5,7 @@ import pytest +from arcade import __version__ as arcade_version from arcade.server import ISOLATION_HEADERS, Arcade, Config, makeServer from helpers import ASSETS, OTHER_SHA, OTHER_TOKEN, TOKEN, bundleFiles, game, registryText, tarBundle @@ -84,6 +85,28 @@ def test_health_on_any_host(arcade): assert (response.status, data) == (200, b"ok\n") +def test_the_api_host_reports_the_running_version(arcade): + for method in ("GET", "HEAD"): + response, data = request(arcade, method, API, "/version.json") + assert response.status == 200 + assert response.getheader("Content-Type") == "application/json" + assert response.getheader("Cache-Control") == "no-store" + if method == "GET": + assert json.loads(data) == {"version": arcade_version} + else: + assert data == b"" + + +def test_version_json_on_a_game_host_belongs_to_the_game(arcade): + site = dict(_site("0.1"), **{"version.json": b'{"game": "rps"}'}) + assert upload(arcade, slug="rps", version="0.1", body=tarBundle(site))[0].status == 201 + response, data = request(arcade, "GET", "rps." + DOMAIN, "/version.json") + assert (response.status, data) == (200, b'{"game": "rps"}') + upload(arcade) + for host in (TIDEWATER, "tidewater.example.org", "nope." + DOMAIN, "localhost"): + assert request(arcade, "GET", host, "/version.json")[0].status == 404, host + + def test_unknown_hosts_are_404_and_still_isolated(arcade): for host in ("example.com", "nope." + DOMAIN, "a.b." + DOMAIN, ""): response, _ = request(arcade, "GET", host, "/")