Daily Word: server-adjudicate guesses (answer no longer in the response)

Per Codex's v2 hardening. The GET /api/puzzle/word response no longer carries
the answer at all — guesses POST to /api/puzzle/word/guess and the server
returns the colour pattern, computed against the day's answer. The answer (and
the "why") are revealed only once solved or the guesses are spent. This removes
the "open DevTools, read the answer" issue without pretending to be a fortress
(a deliberate crafted request can still peek; there's no leaderboard or prize,
so that's fine). Client keeps local progress/stats; dict validation stays
client-side. Trade-off accepted: each guess needs the API (the site already
depends on it for today's content).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-10 18:48:47 -04:00
parent bccf03fb77
commit 1bc9925e40
4 changed files with 110 additions and 55 deletions
+18 -7
View File
@@ -284,16 +284,27 @@ def test_since_endpoint(tmp_path, monkeypatch):
def test_puzzle_endpoint(tmp_path, monkeypatch):
import base64
import os
import sqlite3
from goodnews import games
from goodnews.localtime import local_today
app, api = _make(tmp_path, monkeypatch)
tc = TestClient(app)
r = tc.get("/api/puzzle/word?variant=5").json()
assert r["game"] == "word" and r["variant"] == "5" and r["length"] == 5 and r["guesses"] == 6
assert len(base64.b64decode(r["answer"]).decode()) == 5
r6 = tc.get("/api/puzzle/word?variant=6").json()
assert len(base64.b64decode(r6["answer"]).decode()) == 6 and r6["guesses"] == 7
# deterministic for the same day
assert tc.get("/api/puzzle/word?variant=5").json()["answer"] == r["answer"]
# unknown variant / game → 404
assert "answer" not in r # the public puzzle response never carries the answer
assert tc.get("/api/puzzle/word?variant=6").json()["guesses"] == 7
assert tc.get("/api/puzzle/word?variant=9").status_code == 404
assert tc.get("/api/puzzle/wordsearch").status_code == 404
# server-adjudicated guessing (answer revealed only on solve / exhaustion)
c = sqlite3.connect(os.environ["GOODNEWS_DB"]); c.row_factory = sqlite3.Row
ans = games.generate_word_puzzle(c, local_today(), "5")["answer"]
mid = tc.post("/api/puzzle/word/guess", json={"variant": "5", "guess": "xxxxx", "n": 1}).json()
assert len(mid["colors"]) == 5 and mid["solved"] is False and mid["answer"] is None
win = tc.post("/api/puzzle/word/guess", json={"variant": "5", "guess": ans, "n": 2}).json()
assert win["solved"] is True and win["answer"] == ans and all(x == "correct" for x in win["colors"])
last = tc.post("/api/puzzle/word/guess", json={"variant": "5", "guess": "xxxxx", "n": 6}).json()
assert last["answer"] == ans # exhausting guesses reveals it even when wrong
# wrong length → 400
assert tc.post("/api/puzzle/word/guess", json={"variant": "5", "guess": "toolong", "n": 1}).status_code == 400