Track AF — The documentation site (maintainer request, 2026-09-24) — done, v3.0.3¶
- [x]
docs/published as a website athttps://<DOCS_DOMAIN>(productiondiplomacy-docs.xn--jalluthti-02a.fi):mkdocs.yml(Material for MkDocs 9.x, pinned to MkDocs 1.x -- 2.0 drops the plugin/theme system), a landing page (docs/index.md), navigation Play / Run it / Design / Project. Built with--strictindocker/docs.Dockerfile, served by thediplomacy_docsnginx container through Caddy (its own certificate and security headers;script-src 'unsafe-inline'for the docs site only, which Material's inline page scripts need; no Google Fonts). - [x] Caddy reads an empty site address as broken config and applies a
{$VAR:default}only when the variable is unset, so compose passeshttp://docs.invalidwhenDOCS_DOMAINis blank (a plain-HTTP name nobody uses; never a certificate request). - [x] Links out of
docs/(topyproject.toml, the frontend README, source directories) became GitHub URLs; two intesting_and_validation.mdhad pointed above the repository root since the flatten. - [x] The README and the bot's
/helplink to the site.
Evidence: strict build clean; image built and smoke-tested on the VPS (pages, 404),
caddy validate with and without DOCS_DOMAIN; tests/test_deployment_infrastructure.py.
Track AE — Play in a Telegram group; the new player guide (maintainer request, 2026-09-24) — done, v3.0.2¶
Before the beta announcement. The maintainer's picture ("add the bot to a group; orders
in private; the bot posts announcements and broadcasts") existed only in part, behind
/link_channel <game_id> <chat_id> with an id from a third-party bot, and with holes:
- [x] Orders could be entered in the group. Every command worked in any chat, so
/orderallin a group showed that player's order menu to everyone. Now a handler-group -1 guard (app.group_command_guard) answers private commands in a group with a link to a private chat; onlyGROUP_COMMANDSpass. Callback buttons pressed in a group only raise an alert. Group posts carry link buttons (t.me/<bot>?start=orders_N,payload.dm_start), never callbacks;/start <payload>(join_N,orders_N,game_N) continues in the private chat. - [x]
/newgame,/linkgroup,/unlinkgroup, sent in the group: create a game for the group (sender = creator, auto-process on) or attach/detach one, using the chat's own id. A group-scoped "/" menu (BotCommandScopeAllGroupChats). - [x] Only a group's members see its games (maintainer, same day):
GET /gameslists group games only to the bot (withchannel_id); the bot shows them only to members (bot.get_chat_member); joining one is refused unless through the bot (or by its creator), and the bot checks membership on every join path. - [x] Security holes in the channel routes: unlink, settings and the map / broadcast /
thread / timeline / dashboard / results posts had no auth; link needed any login.
All now need the bot secret, the admin token or a player seated in the game
(
require_game_player_or_bot); so does reading a game's group. - [x]
/channel_settingsnever worked (the bot POSTed, the route was PUT-only): the route accepts both./unlink_channelcalled the API without the bot secret:api_delete. - [x] Group announcements added: deadline changes, the 10-minute reminder, and "game
is full" (
api.shared.post_to_game_group), next to the existing turn results + map and broadcasts. - [x]
docs/NEW_USER_GUIDE.mdfor beta players, linked from the README and/help.
Evidence: tests/test_telegram_groups.py (guard, button guard, /newgame,
membership filtering and join refusal, deep links, link buttons),
tests/test_group_games_api.py (hidden listing, join via bot only, guarded routes,
deadline and turn posts to the group).
Track AD — Flat repository, README, and the license (maintainer request, 2026-09-24) — done, v3.0.1¶
- [x] Everything at the root.
new_implementation/moved up withgit mv(history follows); paths fixed in CI (test.yml), the deploy workflow (~/diplomacy), docs, CLAUDE.md,.gitignore,.vscode,.cursorrules. A stray empty rootpackage-lock.jsonremoved;VERSIONsaid 2.0.0, now 3.0.0. - [x] Production cutover without data loss. Compose had named the project (and so the
volumes) after the directory.
docker-compose.ymlnow pinsname: diplomacyand the volumes' old names (tested on the VPS: a project reuses a volume labelled for another);upgrade.shstops the oldnew_implementation-*containers first (two Postgres containers must never share the data volume); the deploy step moves the host.envup before anything can write a fresh one (a new.envwould mint a POSTGRES_PASSWORD the database doesn't know) -- covered by an executed deploy-step test. - [x] Root README.md rewritten for the flat layout: what it is, where to play, layout, development, deployment, docs, license.
- [x] License. Upstream diplomacy/diplomacy is AGPL-3.0-or-later (file headers: "either
version 3 ... or (at your option) any later version"). This project was a fork of it, and
its
maps/standard.map(201 of 212 lines identical to upstream's) andmaps/standard.svg(733 of 884) are adapted from upstream files, so it is a derivative work:LICENSE(upstream's AGPL-3.0 text) added,license = "AGPL-3.0-or-later"inpyproject.tomlandfrontend/package.json, attribution in the README. For section 13 (network use) the web footer and the bot's/helplink to the source. - [x] Missing tag
v2.7.68-- cited by CLAUDE.md and ~20 files asgit show v2.7.68:old_implementation/...-- created on8457eb0(the v2.7.68 release commit).
Track AC — Forgot password that actually delivers (maintainer request, 2026-09-24) — done, v2.7.111¶
The web app already had Forgot password? (login page → /forgot-password →
/reset-password) and both API routes, but in production it could never deliver: no SMTP
is configured on the VPS, so every reset link was created and silently dropped.
- [x] Telegram first, email as the fallback (maintainer's call): an account linked to Telegram gets the link from the bot through the durable outbox, and no email; email (SMTP) only for an account with no linked Telegram, or if the Telegram message could not be queued. With neither, the API logs a warning naming the address.
- [x] Rate limits where there were none (every request can message someone): 10 per IP per hour → 429; 3 links per address per hour, beyond which the reply is identical but nothing is sent (a 429 there would reveal that the address has an account).
- [x] The SMTP send's
except Exceptionnarrowed to(smtplib.SMTPException, OSError). - [x] Page copy says where the link goes.
Evidence: tests/test_password_reset_delivery.py (Telegram delivery and a full reset
with the delivered token, identical replies for unknown addresses, Telegram-not-email for
linked accounts, email for unlinked ones and when Telegram can't be queued, SMTP failure
not an error, both limits). Open: email for accounts without Telegram needs an SMTP
provider in the VPS .env (maintainer's choice).
Track AB — Server hardening (maintainer request, 2026-09-24) — done, v2.7.110¶
Right after the site went public (F4). An audit of the VPS and the public surface found a sound base -- key-only SSH, ufw deny-by-default, unattended security updates, API and bot running as non-root -- and these gaps, all closed:
- [x] Spoofable client IP (fixed in
v2.7.108, recorded here): nginx appended to a client's ownX-Forwarded-Forand uvicorn reads the first entry. - [x] Secrets compared with
==at ~20 sites (admin token, bot secret, and the idempotency middleware): all nowhmac.compare_digestviaapi.shared.is_admin_token/is_bot_secret. - [x] Swagger UI, ReDoc and the OpenAPI schema were public at
/api/docs,/api/redoc,/api/openapi.json: off in production (DIPLOMACY_API_DOCS=0). - [x] No browser security headers: Caddy now sends HSTS, a strict CSP (
script-src 'self'; the built SPA has no inline script and no third-party origin),nosniff,X-Frame-Options: DENY,Referrer-Policy,Permissions-Policy, and dropsServer/Via; nginxserver_tokens off. - [x] Containers:
no-new-privilegeson every service;cap_drop: ALLon the API and the bot. - [x] Host (
harden_host.sh, run byinstall.sh): sshd drop-in (X11 and agent forwarding off,MaxAuthTries 3,LoginGraceTime 30, idle sessions dropped; validated withsshd -tbefore reload),restricton the GitHub Actions deploy key, fail2ban for sshd (856 failed SSH attempts in the 24 hours before), and unattended-upgrades rebooting at 04:30 UTC when needed (a reboot had been pending).
Left as they are, deliberately: WireGuard wg0/UDP 33500 (the p2p bot on this host reaches
its agent through it); /api/dashboard's HTML page (every data route behind it needs the
admin token); /.env answering 200 (that is the SPA's index.html fallback, not a file).
Evidence: tests/test_deployment_infrastructure.py (hardening script contents,
no-new-privileges on all five services, headers, docs off); full suite 1774 passed.
Track AA — Telegram user flows, streamlined (maintainer request, 2026-09-24)¶
Why this track exists¶
Asked whether the Telegram flows were actually good, a review found that the parts worked but the paths between them were poor:
- You could not act from a notification. "Turn processed" was plain text; the player
had to remember
/orderall(the bot could already renderpayload.buttons, but only for channel posts, and the API never sent any). - A game id was needed as soon as you were in two games, on almost every command.
- Button menus dropped the game they were opened from. "🎯 Submit Interactive Orders"
knew the game id and called
selectunit(update, context)without it — an error for anyone in two games — and opened the one-unit picker, not the walk through every unit. - Two navigation systems that never met: an eight-key reply keyboard of global menus and 43 commands, neither organised around a game.
- Duplicated or misleading commands:
/ordervs/ordersdiffered only in whether the id was required;/wait(queue for a new game) sat next to/notready(wait flag);/deadlinehad five subcommands; any member could/processturn, turning everyone else's unsent orders into holds. - Onboarding: a separate Register step; a welcome advertising "New Features: convoy chain validation"; a private game's password typeable only as a command argument.
- The demo said something false: "other powers are AI-controlled (they won't move)" — six fake users who never ordered.
Found on the way: the queue buttons (join_waiting_list) called wait(), which only handled
a command's message — a button press did nothing at all.
AA1 — Game menu, current game, notification buttons, and the rest — done, v2.7.106¶
- [x] Notification buttons.
api.shared.game_buttons(game_id)→ 📝 Enter orders / 🗺 Map / 🎮 Game menu asg|{id}|{action}|n;notify_user/notify_playerstakebuttons=, stored aspayload.buttons. On turn processed (🗺 Final map when it ended), the 10-minute reminder, "you joined", "game is full" (both paths) and the queue's "game created". The bot renders DM buttons too (notifications._inline_keyboard). - [x] Game menu (
telegram_bot/hub.py)./gameslists games as buttons (straight to the menu with one game); the menu isstatus_textplus Order all units / One unit / My orders (Clear, History) / Map / Messages / Deadline / ready-or-wait (auto-process games) / Process turn now (creator only). Every button carries its game id.|nanswers in a new message so the notification stays readable. The old per-game callbacks (orders_menu_*,submit_orders_*,view_orders_*,clear_orders_*,order_history_*,view_messages_*,demo_orders_*) route into it, since they live on under old messages. - [x] Current game. Stored per player in the bot's SQLite (
current_gametable), set whenever a game is opened, named, joined or tapped from a notification.resolve_game_and_powerfalls back to it for a multi-game player;/game <id>switches. - [x] Typed replies. 💬 Messages → tap a power (or 📣 Everyone) → type. A private game's
power buttons ask for the password as the next message and delete it.
/cancelstops. Plain-text handling is private-chat only (the bot sits in linked channel groups). - [x] Commands.
/orders=/order(game id optional);/findgame,/leavequeue(/wait,/unwaitkept as aliases); deadline votes are buttons (the typed forms still work);/message,/broadcast,/messages,/viewmap,/processturn,/orderhistorytake an optional id. The "/" menu was re-curated. - [x] Early processing is the creator's.
POST /games/{id}/process_turnwith the bot secret and atelegram_id(what the bot now sends) is allowed only for the game's creator, 403 otherwise./users/{id}/gamesgainedis_creator. A bare bot secret, the admin token and seated Bearer users (the web) are unchanged — see the open question below. - [x] Onboarding.
/startregisters silently and shows three keys: 🎮 My games · 🎲 Find a game · ℹ️ Help (old labels still answered). Find a game = joinable games (not yours, not full, not demos, not finished), the queue, the demo. Joining and queueing register too. The queue commands work from buttons. - [x] Demo. The player creates it (so is its creator), the other six are civil-disorder
seats, auto-process is on, and
map_name="demo"makesGameService._demo_ai_ordersgive every order-less dummyengine.simple_aiorders (seeded by game and phase; checked to round-trip format → parse over 40 phases) that land inorder_history.
Evidence: tests/test_telegram_hub.py (16: current game, menu buttons per role, |n,
walk from a button, compose, deadline buttons, Find a game filtering, /start, DM buttons),
tests/test_telegram_flows_api.py (8: notification payload, creator-only processing,
is_creator, demo AI moving and recorded, determinism, non-demo dummies still hold), and a
password-join test in tests/test_private_games_bot.py. Full suite 1761 passed, 10 xfailed;
engine coverage 93.97%, total 75.02%.
Open question for the maintainer: the web client still let any seated player press "Process turn". Answered "update the website as well" — AA2.
AA2 — Early processing is the creator's on the web too — done, v2.7.107¶
- [x]
_authorize_process_turn: a Bearer user must be the game's creator (seated or not); a seated non-creator gets the same 403 as a Telegram player. Bot secret and admin token unchanged; an ownerless (waiting-list) game is admin-only. - [x] The view gains
created_by_user_id;GameViewshows the Process turn section only to the creator and tells other players when turns run instead.
Evidence: tests/test_telegram_flows_api.py::TestEndingATurnEarlyOnTheWeb (seated
non-creator 403 and the view names the creator; creator without a seat 200; ownerless game
admin-only), GameView.test.tsx (non-creator sees the explanation, not the button; the
creator's button still confirms before calling). Every existing web-processing test already
processed as the game's creator, so none changed behaviour.
Track Z — Telegram order entry (maintainer report, 2026-09-24)¶
Why this track exists¶
The maintainer played the demo game (game 1) as Germany through the bot, ordered all three units, and only one moved. Nothing was wrong with adjudication: the only German order ever stored was the last one entered.
Z1 — Orders sent one at a time overwrote each other — done, v2.7.104¶
- [x] Cause. Every bot path posts to
POST /games/set_orders—/selectunitone order per request,/orderone message's worth — andGameService.submit_ordersstoredpending[power] = <this request's orders>. Each submission wiped the previous ones, while the bot replied "Submit more orders with /selectunit". (The legacy text-commandserver.pyhad merged,existing + [order]; the HTTP path never did.) - [x] Fix.
set_orderstakesmerge(the bot always sendstrue; the web client, which sends the full set, keeps replace). Merging keys orders by their unit's province (a build by its site): a new valid order replaces that unit's old one, an invalid one never displaces a good one,WAIVEs append. - [x] W10 knock-on. Auto-processing counted a power as done once it had any order, so
one-at-a-time entry would have run the turn after a player's first unit. It now waits
until each power has ordered everything that must act (
GameService._orders_complete: every unit / every dislodged unit / as many builds-waives-disbands as owed);orders_statusgainsincomplete, shown in/status. Holding needs an explicitH. - Tests:
tests/test_order_merge.py(5, incl. the demo scenario end to end); W10's tests now order every unit.
Z2 — Two order-entry flows: all units, or one — done, v2.7.105¶
- [x] Maintainer's request: players usually order all their units, sometimes only some.
/orderall [game]walks every unit that must act this phase (retreats: every dislodged unit; adjustments: each build/disband slot, with already-chosen builds taken off the list), one screen per unit with its legal orders — supports and convoys through the same sub-menus as/selectunit— plus ⬅️ Back, ⏭ Skip and ❌ Cancel, then a summary to submit everything in one request (merged, Z1) or start over. A skipped unit keeps any earlier order, else holds. If the turn was processed while the player was choosing, nothing is sent and they are told./selectunitstays the one-order flow (sent at once) and now offers "📋 Order all units, one by one" at the top. - [x] Mechanics: the walk lives in
user_data["order_walk"][game_id]; while it exists, anord|pick is recorded for the current step instead of submitted (app.button_callback), which is what lets the existing support/convoy sub-menus serve both flows./orderallis in the "/" menu; help, demo help andTELEGRAM_BOT_COMMANDS.mdupdated. - Tests:
tests/test_order_all_flow.py(7, driven through the real callback router: full walk with skip/back, a support from the sub-menu, skipped units, turn moved on, cancel, adjustment slots, and/selectunitstill sending at once).