PLATFORM UPDATES

CHANGELOG

Every update, fix, and improvement to TFCL Play, newest first. 100 releases, rolling since v0.116

August 2026

17 releases
LATEST
v0.196 2026-08-19 4 changes

Released: League admin panel — run your own league like RGL/UGC/ETF2L

  • The Seasonal Leagues system was redesigned into a "league-in-a-box" platform.
  • Every league now has a dedicated admin panel at /l/<league>/admin for owners and operators, with an Overview setup checklist, Settings (owner-only league rename — your /l/ links never break), Branding (banner, logo, custom CSS), Header & Footer (switch to fully league-owned page chrome with your own navigation and footer), Custom Pages (create public rules/announcements/contact pages), Staff management, Seasons, and an owner-only Danger Zone to archive or restore the league.
  • The public league page was rewritten as a clean visitor-facing page, the creation wizard gained one-click rules templates (competitive or casual) and now drops new owners straight into their admin panel with a welcome walkthrough.
  • Also fixed a bug that made league custom pages (/l/<league>/<page>) unreachable.
v0.192 2026-08-16 1 change

Opt-in league-owned chrome

League pages keep the shared TFCL layout by default. Operators can opt into league-owned header and footer chrome while retaining league branding, custom pages, links, and CSS controls.

v0.191 2026-08-16 1 change

League-owned page chrome

League owners and operators can replace the shared TFCL header and footer with sanitized custom HTML while required TFCL Play attribution remains visible.

v0.190 2026-08-16 1 change

League header and footer customization

Adds league-owned header title, logo, navigation, footer copy, links, layout, and colors while preserving the required TFCL Play attribution.

v0.194 2026-08-16 5 changes

Released: Sixes lobby visual refresh

  • Improved the Sixes lobby presentation and class selection visuals.
  • The homepage Sixes queue now uses the shorter "Queue up.
  • Play Sixes." headline without the previous eyebrow label, and the /pug/sixes class signup cards now use verified high-contrast TF2 class artwork for Scout, Soldier, Demoman, and Medic.
  • Map vote thumbnails remain available across the RGL Season 20 pool.
  • Also clarified the Rules-page ban policy so bans from other leagues are not automatically mirrored, while TFCL Prime bans continue through the separate verified integration.
v0.185 2026-08-06 1 change

League rules and season descriptions

Allows operators to publish league rules and edit descriptions for leagues and seasons.

v0.184 2026-08-06 1 change

League page branding

Allows league operators to customize the public league page with a header image and custom CSS.

v0.183 2026-08-06 1 change

Seasonal weekly maps

Allows league operators to assign a map to each regular-season week.

v0.182 2026-08-06 1 change

Relative seasonal roster lock

Allows roster changes during the regular season and locks them a configurable number of weeks before playoffs, defaulting to two weeks.

v0.181 2026-08-06 1 change

Persist league division names

Stores reusable division names at the league level so future seasons can reuse and edit the same divisions.

v0.180 2026-08-06 1 change

Separate round generation and match schedule timing

Allows operators to generate rounds on one recurring day while assigning matches to a separate default day and time.

v0.179 2026-08-06 1 change

Season match communications and private server information

Adds public match discussion threads and leader-only server connection details with a TFCL reservation link.

v0.192 2026-08-06 1 change

Configurable seasonal match and roster timing

Operators can set the first match due time, recurring weekly match day and time, and the roster lock deadline when creating a season.

v0.191 2026-08-06 1 change

Canonical seasonal league URLs

Seasonal league pages now use /l/<league-slug> and /l/<league-slug>/<season-slug>; the original league 1 uses the canonical slug /l/ultiduo.

v0.176 2026-08-06 1 change

Self-service seasonal TF2 leagues

Adds reusable leagues, seasons, up to five divisions, weekly Swiss-style team pairings with one to four matches per team, and configurable eight or sixteen team single-elimination playoffs.

v0.175 2026-08-06 1 change

Tournament Swiss brackets

Tournament creators can choose Swiss competition with 1-7 rounds. Pairings are generated round by round from standings, prefer equal-score opponents, avoid rematches, award deterministic byes for odd fields, and finalize standings after the last round.

v0.187 2026-08-01 1 change

Fixed: orphaned Vultr servers with no database record now get cleaned up automatically

A site-wide safety-net sweep now periodically cross-checks the actual list of running Vultr servers against every place TFCL Play tracks them, and destroys any real server that somehow has no record anywhere (e.g. if a creation call succeeded but the follow-up database write failed). Also broadened the existing expired-server cleanup so it runs from high-traffic pages instead of only the page that created the reservation.

July 2026

83 releases
v0.183 2026-07-31 3 changes

SDR everywhere: PUG lobbies + tournament servers now use fake-IP

  • Extends the Steam Datagram Relay (SDR) fake-IP DDoS protection rolled out in v0.182 to the entire server network.
  • Every player-facing connect string (self-service /servers page, PUG lobby connect box, tournament server connect info) now prefers the SDR fake-IP address once Steam has allocated one, falling back to the real server IP for the short window (roughly 1-2 minutes after boot) before allocation completes.
  • SDR is an official, Valve-documented feature built specifically for third-party dedicated server operators - see partner.steamgames.com/doc/features/multiplayer/steamdatagramrelay - so this is fully compliant with Valve's Terms of Service.
v0.175 2026-07-24 3 changes

Fixed "Generate Bracket" creating hundreds of fake placeholder teams instead of a real bracket

  • Generating a bracket sized it off the cup's registration cap (how many teams were ALLOWED to sign up) instead of how many teams actually registered - so a cup allowing up to 128 teams but with only a handful of real signups got padded out with over a hundred fake placeholder teams, making the bracket look broken or empty.
  • Fixed to size the bracket off the real team count.
  • The one cup that had already hit this (Ultiduo Cup #1) has been reset to a clean pre-bracket state so it can be regenerated correctly.
v0.174 2026-07-24 4 changes

Fixed cup bracket generation error (D1_ERROR: no such table cup_teams__legacy_teams_cap)

  • Generating a bracket for any cup (including Ultiduo cups) was failing with a database error.
  • Root cause: a schema-rebuild migration from several versions ago missed repairing one table's links after a table rename, leaving it pointing at names that no longer existed.
  • Fixed by rebuilding that table with the correct links.
  • No cup or bracket data was lost - the affected table had never successfully stored a row because of this bug.
v0.161 2026-07-20 1 change

Server Pool: renamed + plan type support + manual admin provisioning/destroy

- The persistent server pool (previously "Ultiduo Pool") now supports both ultiduo and sixes/highlander server plans. - Admin panel routes renamed from /ultiduo-pool to /pool with new plan_type parameter. - Admins can now manually provision a pool server of any plan type, and destroy a pool server at any time (even if busy). - Under the hood: the table is renamed from ultiduo_server_pool to server_pool and gains a plan_type column.

v0.159 2026-07-18 1 change

Ultiduo servers: instant availability + 24h auto-update rotation

- Ultiduo lobbies now provision their server the MOMENT the lobby is created, not after gathering/ready-check/voice-check finish - no more waiting on a cold Vultr boot right when you expect to connect. - One Ultiduo server now runs persistently 24/7 as an always-ready baseline. If it's already in use when a new lobby is created, a second on-demand server boots immediately so there's always at least one instantly available. - The persistent baseline server is automatically reinstalled (fresh Docker image pull) once every 24 hours, but only while idle - a live match is never interrupted.

v0.158.16 2026-07-18 4 changes

Hotfix: Roster-size banner showed "?" placeholders, and departed teammates still counted against the cup's roster cap

After v0.158.15 fixed the "Network error" crash, captains hitting the `team_too_large_for_cup` / `team_too_small_for_cup` 409 saw the inline banner render with garbage placeholders: *"this cup's ceiling is ?, your roster has ? ... Remove 0 players to register."* Separately, captains with a roster that correctly fit the cup's format were still rejected as "too large" — reported live on Ultiduo Cup #1 (cup #8) for a 2-player team against a 2-player cap.

Two independent bugs.

1. "?" placeholders. The register page's `call()` helper returned only `{ __error, __status }` when the server responded with non-2xx, silently dropping the `rescue` object (ceiling, roster size, surplus player ids) and the human-readable `message`. `showRosterAlert()` rendered with `rescue` fully `undefined`. Fix: `call()` now preserves the full server response body on error, spreading it before adding the `__error`/`__status` markers.

2. Departed members inflating roster count. The query that auto-fills a cup roster from a captain's `/teams` row never excluded team members who had since left the team — every historical member of the parent team was still counted toward the cup's player cap. A team with 2 active players but 1 former member showed up as a 3-player roster. Fix: all three roster auto-fill queries in `cups-db.ts` now filter `WHERE team_id = ? AND left_at IS NULL`, matching the pattern in `teams-db.ts`.

v0.158.15 2026-07-18 3 changes

Hotfix: "Network error" on register was actually a client-side crash hiding the real roster-size banner

Right after v0.158.14 shipped the correct `team_too_large_for_cup` / `team_too_small_for_cup` 409, captains hitting that 409 saw the register form report a generic "Network error" instead of the intended inline roster-size banner. Console showed `esc is not defined`.

Root cause. The register page's inline `<script>` calls `showRosterAlert()` when the server returns those 409 codes, and that function calls `esc(...)` to HTML-escape the cup format name. `esc()` is a server-side TypeScript helper defined at the top of `cups-pages.ts` for building the page HTML on the Worker — it does not exist in the browser's JS scope. The undefined-function reference threw inside the fetch handler, which the generic catch-all reported as "Network error" even though the server had responded correctly.

Fix. Added a small client-side `esc()` helper inside the register page's inline script so both call sites resolve correctly and the roster-size banner (with "remove/add N players" copy and surplus player ids) now renders as intended.

v0.158.14 2026-07-18 3 changes

Hotfix: Captains could never pay for a cup entry when their team roster didn't fit the format's cap/floor

Captains registering a `/teams` roster that didn't match the cup's format player limits (e.g. a 3-player team for a 2-player Ultiduo cup) got stuck in a loop: `/register` would 409 with `cup_has_unpaid_ghost_teams` and never let them pay, even after withdrawing every named registration and retrying.

Root cause. `registerTeamForCup` inserted the new `cup_teams` row BEFORE checking the roster against the cap/floor. When that check failed, the function threw — but the row it had just inserted was never cleaned up, leaving a permanent `status='registered'` row with zero players and no PayPal order. The newer ghost-row guard (v0.157.19) then treated that row as an unresolved pending registration on every subsequent attempt, masking the real roster-size error.

Fix. The roster ceiling/floor check now runs BEFORE the `cup_teams` row is inserted, so a captain whose roster doesn't fit the format sees the real `team_too_large_for_cup` / `team_too_small_for_cup` error immediately and no orphan row is ever created. Two pre-existing orphaned rows on Ultiduo Cup #1 (cup #8) were cleared from production.

v0.158.13 2026-07-18 3 changes

Hotfix: "Loading your teams" placeholder never resolved, plus full 4xx envelope in console for next 409 report

Two independent fixes:

1. "Loading your teams" never resolved. Captains registering with their existing `/teams` row for a league cup saw "Loading your teams…" in the dropdown and nothing ever replaced it — the captain's `/teams` row never appeared in the picker. `src/lib/cups-pages.ts` lines 1700/1703/1707 each had a single-quoted JS string containing an escaped apostrophe (`'cup\\'s'`). Vite/Rollup stripped the backslash at bundling time, so the browser-side script saw `'cup'` followed by literal `s` outside the string — a `SyntaxError` that aborted the entire `<script>` block before `loadTeamsForLeague()` could be called. The three buggy strings are written as `'cup&apos;s'` instead — the apostrophe is an HTML entity so it cannot terminate the JS string. Bonus: `loadTeamsForLeague` now has explicit fail/empty-array branches.

2. Full 4xx envelope in console. A single `console.error('[cup-register] server rejected', j)` line fires inside the fetch catch before the typed alert/banner handler runs, so the next captain-side 409 report includes the full `{ __error, __status, ...j.rescue }` envelope alongside the existing user-facing banner.

v0.158.12 2026-07-18 3 changes

Hotfix: Anonymous captains trying to register or create a cup hit a 404 instead of the Steam login page

Captains who weren't already signed in and clicked register or create-cup hit a `404 Not Found | TFCL Play` page instead of being routed through Steam OpenID back to where they were headed. They never reached the PayPal Smart Buttons on the post-register block.

Root cause. `src/routes/cups.ts` was redirecting unauthenticated captains to `/login?next=…` on three paths — `GET /cups/create` (line 133), `GET /cups/:slug/register` (line 272), and the cup-detail "Login to register" button. There is no `/login` page handler — the actual entry point is `/auth/steam?next=…`, which 302s to Steam OpenID. So every `next=` flag was pointing at a route that didn't exist.

Fix. All three call sites now redirect to `/auth/steam?next=…` instead. The two `/docs/api-keys` status cards are patched in the same commit so anonymous docs readers → "Sign in with Steam" reach the actual Steam OpenID flow.

v0.158.11 2026-07-18 6 changes

Hotfix: "Team too large for cup" tells the captain WHICH surplus player is over the cap on a tight-ceiling ultiduo (max=2)

A captain registering a roster that exceeded the cup's cap got a single-line `team_too_large_for_cup: roster of N exceeds cup cap 2 (format=ultiduo)` toast with no actionable info: they couldn't tell whether to drop a teammate from the cup roster or remove them from the parent `/teams` row, and didn't know which player_ids were the surplus.

Three-layer fix.

1. Lib (`src/lib/cups-db.ts`) — the existing `team_too_large_for_cup` / `team_too_small_for_cup` throws now attach a structured `err.rescuePayload = { cup_id, roster_size, roster_ceiling, floor, format, direction, surplus_player_user_ids: [...], missing_player_count, friendly }`. For too-small we report `missing_player_count` instead of surplus ids.

2. Route (`src/routes/cups.ts`) — catch-block forwards `rescue` to the 409/422 envelope; the `team_too_large_for_cup` arm is `startsWith()`-matched so lib-side string variants funnel to the same UI pathway.

3. UI (`src/lib/cups-pages.ts`) — new `<div id="roster-alert">` red banner under the pending banner. Builds copy from the typed payload: ceiling, current roster size, surplus ids rendered as a `<ul>` of `user_id <code>NNN</code> (surplus — remove from parent /teams row to fit the cap)`, plus a "For league cups, this usually means…" hint linking to `/teams`.

Community-cup form copy is now accurate: `Format ultiduo roster: 2–3 players (captain counts as one). Roster of exactly 2 is the smallest legal roster; anything below or above is rejected at submit.`

v0.158.10 2026-07-17 6 changes

Hotfix: "Both registered but neither works" — ghost-row guard widened to N rows + Withdraw-all

A captain could create TWO pending `cup_teams` rows under two different display-names without paying for either, then have NEITHER show up in the "you have a pending team to resume" rescue banner because the pre-flight query was `LIMIT 1` AND only matched by display_name. After re-trying they got an opaque "display name taken" error pointing at the *second* ghost with no way to know about the first, so they couldn't withdraw it.

Three-layer fix.

1. Lib (`src/lib/cups-db.ts`) — server-side ghost-row guard runs BEFORE the display-name dup check; covers ghost rows for DIFFERENT names (the Adrian Blud / Adrian Bludd scenario). Throws `Error('cup_has_unpaid_ghost_teams')` with `rescuePayload = { existing_ghost_teams: [...], requested_display_name, rescue_actions: [...] }` so a captain cannot create a third ghost.

2. Route (`src/routes/cups.ts`) — `GET /api/cups/:id/my-pending-team` widened from `LIMIT 1` to full set; new 409 mapping for the ghost arm added.

3. UI (`src/lib/cups-pages.ts`) — rescue banner renamed from singular `myPendingRow` to plural `myPendingRows`; lists ALL unpaid rows with four action buttons: Resume PayPal for the most recent team, Withdraw this team's name, Withdraw ALL unpaid teams (red `#dc2626` button — the killer feature), Change the name and retry.

Also relaxed CSP: `'unsafe-eval'` added to script-src so the PayPal SDK can `Function()`-evaluate its `react-is` session-stability check.

v0.158.9 2026-07-17 2 changes

Hotfix: /static/tfcl.css?v=0.158.7 cache shard was still poisoned against text/plain 500 — bump to v=0.158.9

v0.158.7's static-asset fix bumped the two HTML references to `?v=0.158.7`, but the URL itself had already been cached at the edge against the prior text/plain 500 with `cdn-cache-control: public, max-age=31536000, immutable`. Even though v0.158.8 added a Worker-side fallback that served the file correctly on a fresh cache key, the existing `?v=0.158.7` requests still returned the old 500 — so the 1-year immutable directive was winning over every deploy's intent.

Fix. Bump the cache-buster query string from `v=0.158.7` to `v=0.158.9` (`src/lib/layout.ts`) and rebuild `dist/_worker.js`. The new URL is a fresh cache key that the v0.158.8 Worker fallback handles correctly.

v0.158.8 2026-07-17 4 changes

Hotfix: Pages asset layer was returning 404 on /api-docs/* and 500 on /static/tfcl.css — both files now inlined in the Worker bundle

After v0.158.7 removed the `serveStatic('/static/*')` middleware, the Cloudflare Pages native asset handler took over `/static/*` — but `play.tfcleague.com`'s zone had cached `GET /static/tfcl.css` against the prior failure with `cf-cache-status: HIT, age: 77565`, and the immutable 1-year directive blocked revalidation. Separately, `/api-docs/*` (the developer docs SPA bundle, served from `public/api-docs/`) was returning 404 on `tfcl-pugs.pages.dev` and `play.tfcleague.com` because the Pages runtime had stopped honoring `dist/_routes.json` exclude rules for files derived from `dist/` at vite-build time.

Fix. Two `?raw-import` modules let `vite-rollup` inline the bytes straight into `dist/_worker.js`:

- `src/lib/api-docs-assets.ts` — pulls in the `public/api-docs/*` source files; Hono's RegExpRouter matches `/api-docs/*` and serves the inlined payload with the right MIME itself, bypassing the broken Pages exclude routing. - `src/lib/tfcl-css-asset.ts` — same pattern, but only for `tfcl.css` (the one file the failure reproduced for); other `/static/*` assets are untouched. Mounted as `app.use('/static/tfcl.css', serveTfclCss)`.

A `/static/tfcl.css` early-return in the response-wrap middleware prevents the 5-minute `Cache-Control` from being clobbered back to the long-lived immutable version.

v0.158.7 2026-07-16 3 changes

Released: Static-asset MIME bug from prior deploy flushed from CDN

After v0.158.6 shipped, every browser visiting `play.tfcleague.com/admin` saw two console errors: `GET https://play.tfcleague.com/static/tfcl.css` and `GET https://play.tfcleague.com/static/admin-approvals.js` both returned `HTTP/2 500` with `Content-Type: text/plain; charset=UTF-8`. The browser refused to apply the CSS or run the script, visually breaking the redesigned admin panel. The 500 was cached at the Cloudflare edge with `cdn-cache-control: public, max-age=31536000, immutable` — a one-year-stale record.

Fix. The original production deploy added an `app.use('/static/*', serveStatic({ root: './' }))` middleware in `src/index.tsx` BEFORE `_routes.json` exclude rule could hand off to the Pages asset layer. v0.158.7 removes that middleware so the Pages asset layer serves `/static/*` natively with correct MIME. Long-term prevention: the two HTML references now carry `?v=0.158.7` cache-busters so a future failure cannot pin to the same cache key. To flush existing stale cache, dev_mode was turned on for 10800 seconds (3 hours); `GET /zones/{id}/purge_cache` returned 401.

What to do. Hard refresh (`Ctrl+Shift+R` / `Cmd+Shift+R`) on `play.tfcleague.com/admin`. The page should now load with the v0.158.5 redesigned admin sidebar and the API KEYS mint modal working.

v0.158.6 2026-07-16 2 changes

Released: Footer + nav version badge shows the actual deployed version

v0.158.5 admin redesign was redeployed 5 times but on `play.tfcleague.com` the footer still read `v0.157.21`. The CHANGELOG and README were bumped to `v0.158.5` correctly, but neither is read by browsers — the visible version was hardcoded in `src/lib/layout.ts` (line 1511 nav badge, line 1973 footer) which was never updated.

Both hardcoded `v0.157.21` strings bumped to `v0.158.5` in `src/lib/layout.ts`. Visible footer/nav version now matches what is actually deployed. Lesson reinforced: bump the user-visible string first, not just README/CHANGELOG.

v0.158.5 2026-07-16 3 changes

Released: Admin sidebar rebuilt with inline-styled dynamic buttons

After three prior attempts (add `!important` overrides, replace collision with `class=hidden` + inline `display:flex`, remove collision entirely), the admin screenshot still showed sidebar buttons stretched with ~50-80px vertical gaps and the API KEYS card overlapping the nav.

What now works. Admin panel redesigned so layout cannot depend on class CSS rules: the entire `<style>` block (`.admin-layout`, `.admin-nav`, `.admin-nav-link`, `.admin-nav-badge`, and the responsive override) is removed. The shell and sidebar are bare HTML wrappers with inline `style="display:flex;..."` directly on the elements. The 15 sidebar buttons are no longer hardcoded in the template — they are built by a JavaScript IIFE from one data array, and every dynamically generated button has its own inline `style.cssText` with all sizing properties explicit. `switchTab` now applies hover/active visuals via inline-style mutations (`_tdApplySidebarVisuals`). The active tab gets a left-edge red shadow band so its position is unambiguous without external CSS.

Scope: admin panel only (`/admin` and `/admin/approvals`). Other pages untouched. Read-only confirmations: API key mint modal stays in the v0.158.4 dual-defense state. New diagnostics ship: `[TFCL ADMIN REDESIGN] sidebar mount point missing`, `sidebar built (N tabs)`, `build failed`, and `missing panel for tab`.

v0.157.21 2026-07-16 4 changes

Released: Captain add-teammate accepts Steam ID and /profile/<id> URLs

What broke. A captain trying to add a teammate on a cup team could paste either of two things from the teammate's profile page (the raw Steam64, or the full `/profile/<id>` URL) and neither one would take. The form gave a different error per format, so it looked broken in two unrelated ways.

What now works. The captain can paste any of three forms and the team will accept them: the internal user ID, the raw Steam64, or the `/profile/<id>` URL (absolute or relative). A new "Find player" button previews the matched username before adding, so the captain can confirm they picked the right person. If the identifier is unknown, the form now shows a clear "user not found, use Find player first" instead of a silent failure.

Why it broke. Three layers were quietly inconsistent: the form was treating the input as a numeric user ID (Steam64 parsed fine but is the wrong column; URLs gave `NaN`), the route was forwarding whatever number came in with no existence check, and the database write didn't verify the target row existed before inserting. A phantom insert would silently disappear from the roster view because that view joins on the real users table.

Scope. No database migration. The fix benefits every cup, not just the one where the bug was reported. Header and footer version badges bumped to `v0.157.21`.

v0.158.1 2026-07-16 2 changes

Hotfix: SyntaxError: Unexpected identifier 'Exo' on every v0.158 page

Playwright console capture against the just-deployed `https://play.tfcleague.com/docs/api-keys` flagged a fatal client-side JS error on both pages: 6 newly-added panel generators used the pattern `<span style="...font-family:\'Exo 2\',sans-serif;...">` — single-quoted JS strings whose CSS values used escaped apostrophes for the "Exo 2" font name. After SSR, the browser-side `<script>` block contained the unescaped form, and the apostrophe terminated the surrounding JS string at `\'Exo\'`, producing `SyntaxError: Unexpected identifier 'Exo'` and blocking every script on the page.

Fix (`src/lib/pages.ts`): swapped the inner CSS font-family value from `\'Exo 2\'` to `"Exo 2"` (double-quoted inside the single-quoted JS string) at all 6 sites — 5 on the docs page (key-prefix span, status-pill span, MINT KEY button, YOUR KEYS heading, Revoke button row) and 1 in the admin-SPA revoke button (`_apiKeyRow`). 278 single-quoted CSS-form usages elsewhere on `pages.ts` are unchanged because they live inside HTML template literals and never reach the browser as raw JS strings. Rebuilt `dist/_worker.js` (1,705.25 KB). Committed `99eee14`, deployed via wrangler pages byok to `https://00186ef9.tfcl-pugs.pages.dev`.

v0.158.0 2026-07-16 5 changes

Released: Admin and premium API-keys SPA surfaces — mint / list / revoke from the UI

Closes the missing-UI-affordance gap from v0.124: the `POST / GET / DELETE /api/admin/api-keys` endpoints have been minting admin `tfcl_ak_<43 chars>` keys for a year, but the only way for an admin to actually use them was DevTools `fetch()`. Same story on the public `/docs/api-keys` page.

Two new SPA panels in `src/lib/pages.ts`:

1. New `API KEYS` tab on `/admin` — sidebar button right after Gift Codes (red `#FFB800` accent to match premium-scope gold), with a Mint card (label input + MINT button → POST, one-shot raw-key modal with copy-to-clipboard + meta line showing prefix/scope/label), and the existing GET keys list rendered with scope pill / created / last-used timestamps / per-row REVOKE button → DELETE.

2. New `YOUR API KEYS — LIVE` card on `/docs/api-keys` — inline `<script>` self-probes GET `/api/premium/api-keys` on page load and renders ONE OF THREE states: 401 → "Sign in with Steam" link, 403 → "Subscribe on /premium" link, 200 → mint UI spread above the keys list with per-row revoke.

No schema migration, no auth changes, no route-handler changes — pure UI affordance wrapping existing endpoints. Build clean: 88 modules, 1,705.25 KB worker bundle. Committed `d8518d0` + `6b0678f`, pushed to `Sidular/TFCL-PLAY:main`.

v0.157.20 2026-07-16 3 changes

Released: Match-team and ELO now follow logs.tf, not the lobby pick-class

What broke. A finished match was awarding ELO and points to the wrong players. The match-record row said one team won, but the page grouped players by the column that was filled at lobby-pick time, which did not always agree with the actual team in the logs.tf log. Result: the players who actually won did not get the winner bonus, and the players who actually lost did. Leaderboards and standings drifted from the truth even when the match itself was solid.

What now works. When logs.tf settles a match (the daily-stable final iteration), the lib treats logs.tf as the canonical team source. It builds a per-player map from logs.tf first, then reverses and re-applies ELO using that map, updating the per-player team column to match logs.tf before any points are written. New matches cannot drift; the page always agrees with logs.tf.

Scope. Forward only. The historical match 12 row still shows the old wrong team labels until an operator runs the prepared reconcile SQL on the live database. The lib does not auto-correct old matches because correction touches the points ledger (referral bonuses, idempotency, audit trail), which is an operator call. No schema migration.

  • Files touched: `src/lib/logstf.ts` (`logTeamBySteamId` map added, `isCorrection` derivation uses logs.tf first, ELO apply path runs `UPDATE match_players SET team` when logs.tf disagrees), `migrations/_db_changelog_v0_157_20.sql` (new shard), `migrations/_correct_match_12_team_reversal.sql` (new - manual-run reconciliation SQL for the historical match #12 regression), `package.json` (`0.157.19 → 0.157.20`), `CHANGELOG.md` (prepended v0.157.20 entry), `README.md` (Current Version + new Last Updated paragraph).
  • Production deploy: `npx wrangler pages deploy dist --project-name tfcl-pugs`; D1 ops: apply changelog shard (`wrangler d1 execute tfcl-pugs-production --remote --file=./migrations/_db_changelog_v0_157_20.sql`). Match #12 reconcile SQL is delivered for manual execution - it is NOT auto-deployed because of point_transactions / referrals audit impact.
  • v0.157.19 2026-07-16 3 changes

    Released: Premium on the roster keeps captain controls open on a paid cup

    What broke. A captain whose own team had active TFCL Premium (so the team was comped into the paid cup at signup, no PayPal needed) still saw the lock banner: "Rename / add / kick are disabled because not all roster seats are paid." Clicking rename, add, or kick flashed "Team is locked - pay first to edit." Physically there was no PayPal order to pay against.

    What now works. The lock helper now runs the same roster-wide premium scan that the signup-time approval runs, so the two cannot disagree. If any active roster member carries premium, the lock is treated as open for rename, add, kick, and (already) withdraw. The captain just sees the buttons work.

    Scope. All paid cups. Behavior change is invisible except for captains of premium-comped teams, who now see an open control card instead of a "pay first" message. No schema migration.

  • Audit-grade signal unchanged. `team_comped_premium` audit rows from v0.157.14 still carry `premium_user_ids`, `captain_was_premium`, `roster_size`; the new entries on the lib side don't generate new audit rows (they read the same flag). The `withdrew_while_unpaid: false` stamp on a v0.157.17 withdraw of a premium-comped team is now correctly emitted because `isTeamLocked` returns `false`.
  • Files touched: `src/lib/cups-db.ts` (`getCupTeamRosterUserIds` helper added; `isTeamLocked` and `evaluateCupTeamPaymentStatus` updated with the premium short-circuit), `migrations/_db_changelog_v0_157_19.sql` (new shard), `package.json` (`0.157.18 → 0.157.19`), `CHANGELOG.md` (prepended v0.157.19 entry), `README.md` (Current Version + new Last Updated paragraph).
  • Production deploy: `npx wrangler pages deploy dist --project-name tfcl-pugs`; D1 ops: apply changelog shard (`wrangler d1 execute tfcl-pugs-production --remote --file=./migrations/_db_changelog_v0_157_19.sql`).
  • v0.157.18 2026-07-15 3 changes

    Released: Self-collision rescue for the captain's own unpaid team slot

    What broke. A captain whose PayPal capture never finished (closed the popup, sandbox error, abandoned the screen) returned to retry the same team name and hit an opaque "this name is taken" error, even though the row holding the name was their own unpaid in-progress registration. The unique-name index had no UI surface explaining that, and no path to resume PayPal or free the name short of an admin poking the database.

    What now works. The lib now distinguishes two failure modes: "someone else's active team holds this name" (real conflict, refuse), and "your own unpaid pending team holds this name" (your own stuck row, offer to fix it). The register page pre-flights on load, and if the captain has a pending unpaid matching their draft, it shows a panel with three clear actions: resume PayPal for the existing row, withdraw (which uses the v0.157.17 unlock so it works even without payment), or change the name. The names-clash alert is reserved for real conflicts only.

    Scope. All cups, all captains. No schema migration.

  • Routes file change (`src/routes/cups.ts`). `POST /api/cups/:id/register` and `POST /api/cups/:id/teams/:teamId/edit` catch blocks add the new 409 + `rescue` mapping before the generic fallback. New `GET /api/cups/:id/my-pending-team` endpoint. Route docblock header updated.
  • Lib file change (`src/lib/cups-db.ts`). `registerTeamForCup` and `editCupTeamByCaptain` dup SELECTs now return `captain_user_id`, `status`, `paypal_order_id`, `paid_player_count`. New self-collision branch throws `display_name_taken_by_own_pending_team` with `err.rescuePayload`.
  • UI file change (`src/lib/cups-pages.ts → renderCupRegisterPage`). Pending-alert panel + 3 buttons. Five new helpers in the existing inline `<script>`: `loadMyPending()`, `maybeShowPendingBanner()`, `doResumePaypal()`, `doWithdrawPending()`, `doRenameAndRetry()`. Submit handler branch reuses the same banner if the server returns the new error code.
  • Files touched: `src/lib/cups-db.ts`, `src/routes/cups.ts`, `src/lib/cups-pages.ts`, `migrations/_db_changelog_v0_157_18.sql` (new shard), `package.json` (`0.157.17 → 0.157.18`), `CHANGELOG.md` (prepended v0.157.18 entry), `README.md` (Current Version + Last Updated paragraph).
  • Production deploy: `npx wrangler pages deploy dist --project-name tfcl-pugs`; D1 ops: apply changelog shard via `wrangler d1 execute tfcl-pugs-production --remote --file=./migrations/_db_changelog_v0_157_18.sql`.
  • v0.157.17 2026-07-15 3 changes

    Released: Teams can withdraw from a cup without paying first

    What broke. A captain who registered but never finished PayPal (couldn't recruit teammates, paywall exceeded budget, the signup was an accident) was trapped: "pay to edit" was correctly required for rename / add / kick, but it had been rolled into "pay to leave" too, so the team stayed registered, held the display name, and counted against the cap indefinitely until the cup started. The admin-facing withdraw was never gated this way, so the captain-facing path was the strict one, which is the wrong way around.

    What now works. Withdrawing is the failure-mode escape hatch. A captain on an unpaid paid-cup team can now withdraw freely. Already-paid teammate seats are preserved (marked released) so those players can rejoin another team in the same cup without re-paying; the cup slot opens back up automatically. Rename / add / kick still require payment, because those change the team's shape, not just its seat count. The withdraw confirm dialog is worded differently on locked teams so the captain doesn't expect a PayPal refund that does not exist. Audit table records `withdrew_while_unpaid: true|false` so reporting can answer how often this rescue path fires.

    Scope. All cups, captain-facing withdraw path. No schema migration.

  • Routes file change. `src/routes/cups.ts → POST /api/cups/:id/teams/:teamId/withdraw` drops the `if (code === 'is_team_locked')` 409 mapping (the lib won't throw it on this path any more). `team_not_in_cup`, `cup_not_found`, `not_captain` error mappings stay.
  • Files touched: `src/lib/cups-db.ts` (docblock + signature: `withdrawCupTeamByCaptain` now returns `{ released_seats_paid, released_seats_unpaid, withdrew_while_unpaid }`; lib `is_team_locked` check removed; `isTeamLocked` evaluation still happens once for the audit stamp), `src/routes/cups.ts` (route docstring + 409 mapping cleanup; `withdrew_while_unpaid` propagates from lib call to JSON response), `src/lib/cups-pages.ts` (withdraw button handler no longer locks; confirm dialog different when locked; success status shows "(without paying)" tag; lock banner + footer copy updated), `package.json` (`0.157.16 → 0.157.17`), `CHANGELOG.md` (prepended v0.157.17 entry), `README.md` (`Current Version` + new Last Updated paragraph).
  • v0.157.16 2026-07-15 3 changes

    Released: Terminal-status teams no longer block the display name

    What broke. A captain trying to register (or rename) a team could be blocked by their own earlier `withdrawn` or `rejected` row from the same cup, or by anyone else's terminal-status row. The unique-name constraint treated every row the same regardless of status, so a name that had been released back into the wild was effectively dead until the cup itself was over.

    What now works. The unique-name index now only applies to active statuses. A team row that is `registered`, `approved`, or `paid` still blocks duplicate names; a `withdrawn`, `rejected`, or `disqualified` row is free for re-use. Real conflicts (someone active has the name) still get the same "this name is taken" error.

    Scope. All cups. Migrated the live database. Build-only change is a single partial unique index, applied once at deploy time.

    v0.157.15 2026-07-15 3 changes

    Hotfix: PayPal Smart Button no longer crashes on mount

    What broke. The moment PayPal tried to mount on a paid cup the user got a stylized "Network error: PAYPAL_BUTTON_STYLE is not defined." The constant was declared correctly at the top of the cup pages module, but inside the SSR'd `<script>` block three call sites reached it with a bare word (`style: PAYPAL_BUTTON_STYLE`) rather than a template interpolation. The literal text leaked into the browser-side script, where that identifier does not exist, so mounting crashed.

    What now works. All three PayPal mount sites now wrap the constant in `${JSON.stringify(…)}` so the TypeScript template emits a real JSON object. The PayPal SDK receives a normal style object and mounts cleanly.

    Scope. Three call sites in `cups-pages.ts`. No schema migration.

    v0.157.14 2026-07-15 1 change

    Released: Any premium on the roster comps the whole team in

    A premium member (captain or teammate) on a paid-cup team now marks the whole team as paid at signup. Before this, only a premium captain got through the PayPal wall; a non-premium captain with a premium teammate still got a stuck, unpaid team row. Now the signup-time scan reads the entire prospective roster and approves the team if any member carries active premium. Prize-pool math picks up the same comped teams with no further changes. No schema migration.

  • Operator-visible effect on cup 393: a captain with non-premium premium=off whose roster includes ONE premium teammate (and the rest non-premium) now lands `status='approved'` with a single `team_comped_premium` audit row showing `premium_user_ids: [teammate_id]` and `captain_was_premium: false`. Pool goes up by the captain's full per_player fee (Signups-vs-base breakdown picks up the comp'd-in roster size). Pre-v0.157.14, the same call landed `status='registered'` and demanded a PayPal capture that couldn't succeed with a non-premium captain.
  • v0.157.13 2026-07-15 1 change

    Released: Cup owners can seed the prize pool with a base amount

    Cup owners and admins can now set an owner-contributed base amount (in cents) on `paid_with_prize_pool` cups. The pool reads as `base + signup contributions`, clipped to the cap, so an admin who wants to guarantee a minimum payout can spool it up without depending on signups alone. Existing cups default to zero base (no behavior change). Owner-facing surfaces (create-cup form, edit-cup form, listing card, detail page, dynamic-pool banner) show the base-and-signups breakdown when a base exists. Cross-field validation rejects a base greater than the cap before it reaches the prize-pool ledger.

  • Operator-visible effect: on cup 393 (Ultiduo Cup #1 - currently `prize_pool_actual_cents=$6.00` from a single premium-comp 1-player roster), an admin PATCH with `prize_pool_base_cents=2000` now moves the visible total to `$26.00` (= base $20 + signups $6). The page shows `base $20.00 + signups $6.00` under the prize-pool row, and the dynamic-pool banner picks up a "the cup owner seeded the pool with $20.00 of base contribution" sentence. Reset `prize_pool_base_cents` back to 0 returns the readouts to their v0.157.12 shape.
  • No rebake SQL needed. Existing rows default to 0 and behavior is unchanged for every cup that does not set a base - the previous v0.157.12 pool math is exactly recovered at `base=0`.
  • v0.157.12 2026-07-15 1 change

    Released: Premium comps silently grow the real prize pool

    Premium-comp teams now contribute to the same `prize_pool_actual_cents` number that PayPal-paid teams do, with no separate "premium contributions" line anywhere. A premium comp is, after all, an entry that landed in the cup, and the pool should reflect that without a parallel row. The premium-only display surfaces from v0.157.11 (purple sub-line, listing-card prefix, banner sentence) are removed; the helper functions and intermediate `premium_pool_*` fields that drove them are removed too. The pool just reads as if every comp had been a normal paid entry.

  • Operator-visible effect: on a `paid_with_prize_pool, entry_unit='per_player', entry_fee_cents=750, platform_fee_bps=2000` cup whose only registered team is a premium comp of 1 seat, the visible prize pool goes from `$0.00` to `$6.00` (= 1 × $7.50 × 0.80 = $6.00 after the 20% platform fee). Nothing else on the page changes; the pool line reads as if the entry were PayPal-funded.
  • v0.157.11 2026-07-15 4 changes

    Released: Premium contributions shown on the prize pool (revised in v0.157.12)

    • A paid cup with premium-comped teams now shows that those teams are contributing, on top of the PayPal pool.
    • The detail page adds a purple "Premium contributions: $X.XX (N teams)" line next to the prize-pool row; the dynamic-pool banner adds a sibling sentence; the listing card hides a small purple prefix under the pool row.
    • The real `prize_pool_actual_cents` still reflects PayPal-captured dollars only.
    • (Superseded by v0.157.12, which folded the premium comp into the same pool number rather than running it as a parallel display value.)
    v0.157.10 2026-07-15 2 changes

    Released: Pay-to-enter cups must pay to enter, and the unpaid backlog is cleared

    What broke. A captain could register a paid cup, get an unpaid row published on the cup page, mint a PayPal order, then never come back to capture. The cup ended up with publicly listed ghost teams that paid nothing and counted against the bracket cap. Cup 393 ended up with 8 of these.

    What now works. Registration now refuses any case where PayPal is missing or the order failed - the team is auto-withdrawn in the same round-trip and a `team_paywall_blocked` row goes into audit. A red paywall banner sits above every paid cup's team list, and the submit button now reads "Submit & Pay $X.XX" so the commitment is explicit. Distinct error alerts for `paypal_not_configured` (admin must set the env var) versus `paypal_order_failed` (try again). The 8 ghost teams on cup 393 are discarded by a one-shot migration with full forensic audit rows, and the cup regenerates from real signed-up teams.

    v0.157.9 2026-07-15 5 changes

    Released: Post-login email prompt with force-logout-everyone

    • After login, every user with no verified email is routed through an email-prompt banner before reaching their intended page.
    • The deploy itself logs everyone out at migration time so the gate is uniform across the entire user base.
    • The prompt auto-clears once the user adds (and verifies) an email, and a "snooze 30 days" option is available.
    • The original destination is preserved through the chain so the user lands back where they were headed.
    • Users who already have a verified email see no change.
    v0.157.8 2026-07-15 3 changes

    Released: Admin notification rollout controls

    • A new admin panel on `/profile/settings` lets a single admin toggle all 11 notification categories, write per-row notes, fire test emails, and commit the whole rollout in one round-trip with a single audit row per category.
    • The previous workflow required 11 separate POSTs and produced 11 separate audit stamps; now an admin can stage the rollout visually, press Save once, and have one combined audit log.
    • The single-row endpoint stays in place for console use and future scripted rollouts.
    v0.157.7 2026-07-15 4 changes

    Hotfix: Save preferences on /profile/settings works again

    • Clicking "Save preferences" on the new settings grid silently 404'd, so category toggles were never persisted.
    • The form posted to `/api/notifications/preferences` but only the PUT handler was registered.
    • Fix: register the same persistence handler on both PUT and POST.
    • Both work now; the form's native POST succeeds, REST clients can still PUT.
    v0.157.6 2026-07-15 4 changes

    Released: Email-verify link works on first click and survives resubmits

    • The very first confirmation link sent after a user submitted "Add email" was already dead on arrival - clicking it produced a "bad_signature" error.
    • Two distinct bugs hit at once: the HMAC was being signed with one category but verified against another, and re-submitting the form clobbered the prior verify row.
    • Fix passes the right category into the signer so the HMAC matches, and the verify table now carries a per-row `pending_email` plus a `superseded_at` column so a fresh submit soft-supersedes the old link with a clear message instead of nuking it.
    • The first link works now, and re-submitting is safe in either direction.
    v0.157.5 2026-07-15 4 changes

    Hotfix: "Add email" no longer 500s on re-submit

    • Clicking "Add email" a second time (or any re-submit while an old verify row was still in the outbox) returned a 500 with the raw SQLite UNIQUE-constraint error.
    • The verify outbox row used a stable `dedupe_key` per (user, email), and the naive fix (flip prior status to cancelled) left the same key value bound, so the next INSERT collided regardless.
    • Fix: cancel-prior helper now rewrites BOTH status and a unique-key suffix so prior rows are preserved for audit but no longer occupy the key.
    • Latest-attempt-wins, and the regular enqueueOutbox path (which really wants true dedupe) is left alone.
    v0.157.4 2026-07-15 3 changes

    Released: Friendly category names + admin "send-all-as-test"

    • The email-preferences grid now shows human-readable labels with a short plain-English description under each (database slugs unchanged).
    • Admins get a single "send all 11 as test" panel on the same settings hub: pick a recipient, click the button, and the admin sees one SendGrid x-message-id per category.
    • Underlying `thing_thing` slugs are unchanged everywhere they need to be (D1 and SendGrid audit trails).
    v0.157.2 2026-07-15 1 change

    Hotfix: /points no longer blocked by the PayPal SDK script

    The PayPal SDK was being eagerly included as a parser-blocking script at the top of `/points`, so any slow CDN or blocked extension left the page frozen on "Loading…". The SDK is now injected on-demand only when the buy-points card actually renders for a signed-in user.

    v0.157.1 2026-07-15 1 change

    Hotfix: /profile/settings avatar shows and the inline bio+theme editor works

    The Section 1 avatar now renders (and pulls in every makeover-related field). The "Edit profile" link that 404'd is replaced with an inline editor for bio and accent-color theme, gated by the Profile Makeover reward the same way as before.

    v0.157 2026-07-15 4 changes

    Released: Profile settings hub (with admin email-reveal audit)

    • Account settings get a single home at `/profile/settings`.
    • Five surfaces rolled together: account identity, email + verification, per-category notification preferences, privacy and security, and (admin only) a user-lookup panel that can reveal a target user's email on demand.
    • Every admin reveal writes to a new audit table for review.
    • The legacy notifications page redirects to the new hub so existing links resolve.
    v0.156.4 2026-07-15 1 change

    Tweaked: Navbar logo size increased to 75px

    Logo bumped from 54px to 75px and the navbar height grown to 80px to fit. The logo now reads as the dominant visual anchor in the top bar.

    v0.156.3 2026-07-15 1 change

    Navbar logo is bigger

    The TFCL Sentry logo in the top navigation bar is bumped from 40px to 54px tall (~35% larger), and the bar itself grows from 56px to 64px to fit the bigger logo with the same breathing room on each side.

    v0.156.2 2026-07-15 1 change

    Tweaked: Home-page empty-state shows the frowning-face emoji

    The "no active PUGs" illustration went back to the emoji after briefly being ASCII `:(`; the ASCII variant needs monospace overrides that misalign the glyph, so the emoji wins. Still bobs in place.

    v0.156.1 2026-07-14 3 changes

    Hotfix: "No active PUGs yet" empty state is back

    • Home and `/pugs` were rendering the section heading with an empty grid when there were no user-created lobbies.
    • A legacy-row filter was silently stripping rows that fed the empty-state branch.
    • Restore the "No active PUGs right now - Create one!" message.
    v0.156 2026-07-14 8 changes

    Released: Email notifications (SendGrid transactional, 11 categories behind per-category feature flags)

    The platform's first real outbound email channel. Anything async (premium receipts, unfamiliar sign-ins, lobby invites while the tab is backgrounded) now has somewhere to land, with GDPR double-opt-in enforced before dispatch and per-category feature flags that let admins enable each category one at a time after preview lands in their own inbox.

    Capture. `/auth/email/add` stamps `users.email_unverified_pending` plus a random `email_verified_token`, fires a single SendGrid mail (bypassing the per-category flag gate because this IS the verification request) carrying a click-confirm link at `/auth/email/verify/:token`. Only on click do we flip `users.email` / `email_verified_at` / clear `email_unverified_pending`. Every other category refuses to dispatch to a user whose `email_verified_at IS NULL` - the `canSend()` predicate centralises the email-verified + per-category preference + global flag check into one decision.

    Outbox + log + dedupe. New `notification_email_outbox` table with a UNIQUE `dedupe_key` (so round-trip replays self-dedupe, e.g. `premium_receipt:<order_id>:<user_id>`), a sibling `notification_email_log` that records `sendgrid_msg_id` from SendGrid's 202 response plus HTTP status + retry count for forensics, and `notification_email_preferences` keyed by (user_id, category, channel) so future SMS/push channels slot in without a rewrite.

    Unsubscribe. Per (user, category) HMAC-signed tokens in `notification_email_unsubscribe_tokens` (30-day expiry, stored as sha256 of the raw token - matching the v0.137 PBKDF2 envelope pattern). One-click `GET /u/:token` flips per-category preference to disabled without sign-in (the HMAC IS the auth); `POST /api/notifications/preferences` lets the same user flip it back via the `/account/notifications` management page.

    Per-category feature flags + preview. `notification_email_feature_flags` table seeded with all 11 categories at enabled=0. Every `notify()` call short-circuits to no-op when its flag is off. Admin verifies `POST /api/notifications/preview` returns a real `sendgrid_msg_id` in their own inbox, then flips the category via `POST /api/admin/notifications/feature-flag`. Same staged-launch workflow as v0.122 to v0.141; a misbehaving category can be turned off in under a second without a deploy.

    Hook emitters. Four real-world actions gained fire-and-forget `notify()` calls inside `try { notify() } catch {}` envelopes so a SendGrid outage cannot block the user-side action: premium receipts on PayPal capture, API-key-created security alerts, lobby-invite shareable links, and ban-issue operational notices.

    UI. New `/account/notifications` page renders all 11 categories as per-category `<form method=POST>` toggles plus an `+ ADD EMAIL` / `VERIFY PENDING` block with a RESEND button. Endpoints page + 8 APIs mounted under the existing root in `src/index.tsx`.

    Backward compatibility. Migration 0147 only ADDs columns and tables - no removal/rename/rebuild. Pre-existing users keep their `users.email*` columns NULL until they opt in themselves; auth is unchanged everywhere except where token-self-authenticating is the documented design (`/auth/email/verify/:token`, `/u/:token`). The inline `INSERT INTO changelog` row was intentionally omitted from the numbered migration so it stays schema-only and replay-safe on partial DBs - the public `/changelog` body lives in the companion underscored shard `_db_changelog_v0.156.sql`.

    v0.155.1 2026-07-13 4 changes

    Hotfix: cup detail page 500 → 200

    • The v0.155 ship made `/cups/:id` and `/cups/<slug>` throw `ReferenceError: Cannot access 'isFree' before initialization` from inside the worker's V8 isolate, surfacing as 500.
    • The roster-card `.map()` callback reads `isFree` / `isPrize` for the payment pill, lock banner, and captain manage card, but v0.155 declared those consts after the map, creating a temporal dead zone.
    • Hoist above the map.
    • JSON endpoints were unaffected.
    v0.155 2026-07-13 7 changes

    Released: Hard pay-to-join + paid-seat portability + captain team management on cup page

    Before v0.155 a captain could register a paid cup with an empty wallet and sit on the row indefinitely; no UI to back-fill payment, no "manage my team" surface, no enforcement of "everyone pays before the team can change shape". v0.155 closes this from four angles:

    Lock predicate. New `isTeamLocked(cup, team)` helper returns true iff the cup is paid AND `paid_player_count < roster_size` (per_team: `paid_player_count == 0`). Every captain mutation (edit team, withdraw team, kick/add player) opens with that check and short-circuits to a typed `is_team_locked` error → 409. Pre-existing unpaid rows are LOCKED automatically; once paid in full via `/register` they unlock on next page load.

    Paid-seat portability. When the captain of a paid cup kicks a teammate who's already paid, the row is NOT deleted - marked `released_at = now()` with `paid_at` and `paypal_order_id` preserved. That released row becomes addressable by `rejoinFromReleasedIfAny`, so the same player can join another team in the same cup without hitting PayPal again. `addCupPlayerByCaptain` tries the recycle path first, falls back to a fresh INSERT.

    Schema. `migrations/0146_cup_players_released_at.sql` adds nullable `released_at` and rebuilds to relax UNIQUE(cup_team_id, user_id) to UNIQUE(cup_team_id, user_id, released_at). Hot-path index on (user_id, released_at, paid_at) keeps the recycle scan constant-time.

    Captain management card on `/cups/<slug>`. Each roster card the viewer is captain of now renders a per-team `PAID`/`PARTIAL`/`UNPAID` badge plus a Manage sub-card with: rename (display_name, lock-gated), add teammate (with "recycles paid seat if released" hint), kick non-captain (releases the row, decrements paid_player_count if applicable), withdraw (lock-gated). Every button on a locked team is toggled off client-side; the server is still source of truth. Five new endpoints sit under `/api/cups/:id/teams/:teamId/...`: GET manage, POST edit, POST withdraw, POST players/<id>/remove, POST players/add. All open with requireAuth + captain-actor + (where applicable) isTeamLocked short-circuit; locks deliver a friendlier "Pay your team in full before editing." instead of a bare error code.

    No backfill. Migration 0146 does NOT fill `paid_player_count`; existing unpaid rows stay unpaid. So immediately after deploy every roster with unpaid seats is locked.

    Files touched: `src/lib/cups-db.ts` (7 new exports), `src/routes/cups.ts` (5 new routes), `src/lib/cups-pages.ts` (per-team pill + Manage card + disabled-on-lock), `migrations/0146_cup_players_released_at.sql` (schema-only), companion `_db_changelog_v0.155.sql` carries the public `/changelog` body, `package.json` bumped to 0.155.

    v0.154 2026-07-13 3 changes

    League-cup rosters auto-fill from your team

    • When you register a league cup from an existing /teams page, every roster slot on the team populates the cup entry automatically — you no longer have to add each player by user-id one at a time.
    • A one-time sweep picked up existing cup entries that were registered against a team but had no per-seat rows, so older league-cup rosters are now intact too.
    • Side-effect: each paid teammate on a captain's league-cup entry now sees their own per-seat "Pay your seat" prompt light up on the cup page as soon as the auto-fill populates.
    security-2026-07-12b 2026-07-12 3 changes

    Security fix: RCON command-injection via custom_cfg / whitelist_id

    • A non-admin user could previously inject arbitrary TF2 server-console commands — including the RCON password itself, sv_cheats on, or mid-match player bans — by sneaking them through the custom config preset name on lobby creation or server provisioning.
    • Both fields are now restricted to the safe character set [a-zA-Z0-9_-] at the route boundary AND at the actual RCON sink, so the fix holds even if a future call site forgets to pre-validate.
    • This was never exploited in the wild that we can see, but it was discoverable in a routine follow-up audit, and we've rolled the SendGrid + matched API tokens fresh as a defence-in-depth carry-along.
    v0.149 2026-07-12 3 changes

    TFCL Premium members register to paid cups for free

    • If you're an active TFCL Premium member, registering your team to a paid cup skips the PayPal step entirely — the platform absorbs the entry fee on your behalf.
    • Audit trail: the cup registration record carries a premium-comp tag so paid-cup proceeds reporting and any future sponsorship deals can correctly attribute revenue vs. comped entries.
    • Free cups are unaffected; this only flips behaviour on paid cup registrations.
    v0.150 2026-07-12 3 changes

    Server pool: 30 concurrent reservations (10 free + 20 premium)

    • You can once again reserve a 6-hour premium server if you're a TFCL Premium member — the temporary pause is over. The premium pool runs alongside the free pool, capped at 20 premium + 10 free concurrently.
    • Free reservations stay at the 2-hour cap; premium stays at 6 hours. The free/premium split is enforced server-side and reflected on the /servers status banner.
    • The /api/servers/capacity endpoint now reports the live per-tier usage and cap so the /servers amber banner can update in real time.
    security-2026-07-12c 2026-07-12 3 changes

    Security fix: rcon_password / sv_password blocklist bypass

    • A lobby host or tournament creator could previously chain two console commands inside a single RCON message and the existing blocklist let it through with a false negative (the check only looked at the start of the message, not at command boundaries).
    • The blocklist now splits on the same delimiters the TF2 console itself uses (; and newlines) and checks every resulting sub-command, so chained / newline-separated attempts are rejected.
    • The fix is now a single shared helper used by both call sites (lobby hosts AND tournament hosts), so it can't drift between them in future updates.
    v0.148.1-hotfix 2026-07-11 2 changes

    Edit-cup form: numeric fields accept string-serialized values

    • Saving the edit-cup form now works consistently — previously the form sent values as JSON strings (e.g. {"max_teams":"16"}) but the validator expected numbers, so a perfectly valid edit could return a 422 error.
    • The PATCH handler now pre-coerces numeric fields from string to number before validation. Out-of-range and non-numeric values are still rejected as before.
    v0.148 2026-07-11 3 changes

    Cup max_teams: any whole number from 4 to 128

    • You can now register a cup with any whole-number max_teams from 4 up to 128 (was limited to a fixed menu of 4 / 8 / 16 / 32). The bracket math still handles every value correctly, including power-of-two edge cases.
    • Default for newly created cups stays at the format default, so this is opt-in for organisers who actually want a non-menu number.
    • A side-fix this release: the create-form entry fee and prize-pool number inputs no longer reject whole-cent values like 750¢ with a browser-side "nearest valid values" tooltip.
    v0.147 2026-07-11 3 changes

    Cup creator can set max_players_per_team

    • When you create a cup, you can now pin how many seats each team is allowed to bring (within the format min/max range). Defaults to the format ceiling so behaviour is unchanged unless you opt into a tighter cap.
    • The cap freezes once the cup is published so anyone mid-registration sees a stable target — no late-stage surprise rejections of a roster that already paid.
    • Existing rosters on existing cups are unaffected — the cap only applies to new registrations targeting that cup.
    v0.146.6 2026-07-11 3 changes

    Cup detail page: inline edit + last-edited audit

    • The cup detail page now has an inline "Edit cup" panel for the cup owner and admins (visible while the cup is draft, approved, or in-progress, with a slimmer field set for in-progress).
    • Every edit writes a row to the existing audit log so changes are diffable, and the page surfaces a small "last edited …" badge under the title with the editor and date.
    • Format and kind stay immutable once a cup is published — changing them after teams register would invalidate rosters. Funding mode and entry fee are gated behind a confirm dialog when changed because the impact on already-registered teams is material.
    v0.146 2026-07-11 2 changes

    Pretty cup URLs + an admin approvals queue

    • Cup URLs now use a human-readable slug instead of exposing the database row id (e.g. /cups/55-highlander-open). Old /cups/<id> URLs still work and 301-redirect, so existing links don't break.
    • The admin backend gets a single APPROVALS queue that surfaces every cup and tournament waiting for review, with inline Approve / Reject buttons wired to the existing endpoints.
    v0.142 2026-07-10 4 changes

    Tournaments are now on-demand leagues with invite codes

    • Every tournament now publishes a stable 8-character invite URL (/tournaments/join?code=ABCDEFGH) that never expires. Codes use a URL-safe alphabet (no 0/O/1/I/L), so a screenshot is enough to read it back.
    • Tournament visibility can be Public (appears in the public browse, anyone can join) or Private (hidden from browse, requires the invite URL and the join password). Existing tournaments default to Public.
    • Players join a tournament AS A TEAM, not as individuals — your /teams page becomes the thing that registers, and the team owner is automatically the tournament team captain.
    • Steam sign-in is required at every join gate so unverified visitors can't bypass with a cookie.
    v0.140 2026-07-09 7 changes

    Team roster size locked to 13

    • Every team roster is now capped at exactly 13 slots, full stop. The owner-tunable MAX ROSTER knob is gone from the create-team form so new captains no longer have to decide what "max roster" means.
    • Schema: teams.member_capacity is rebuilt from a 2-64 range (v0.136, migration 0130) into a CHECK (member_capacity = 13) equality constraint with DEFAULT 13. SQLite has no ALTER COLUMN so this migration does a table rebuild: shadow rename -> CREATE TABLE -> INSERT … SELECT with explicit 13 coercion -> drop shadow -> re-attach indexes. Wrapped in a transaction so a failure rolls back cleanly.
    • SQLite FK-side repair in the same migration: ALTER TABLE teams RENAME rewrote the auto-managed FOREIGN KEY in team_members to point at "teams__legacy_capacity", and the subsequent DROP TABLE on the shadow would have left team_members unable to insert rows. The migration duplicates the team_members rebuild so the FK rebinds to the new teams id instead. Identical columns + indexes, no data shape change.
    • Every existing row is backfilled to 13 (defensive - all v0.136-0133 rows sat at the v0.136 default of 6 today, but the rebuild INSERT explicitly coerces every row to 13 instead of trusting the column). The redundant "WHERE member_capacity != 13" UPDATE is purely a sanity assertion that fails loud if anything drifted off 13.
    • No more "X / 6 members" or "X / 9 members" badges - every list card and every detail page now shows "X / 13 members". The detail-page ROSTER header now says "ROSTER (X / 13)" identically across teams.
    • /api/teams/:id/join still returns the same team_full error (capacity: 13, current: N) once the 13th member is in place. /api/teams/ POST requests that hand an explicit member_capacity other than 13 are rejected by the server-side validator (CAPACITY_LOCKED error, 422), so legacy clients that still ship the field get a typed error rather than a silent fallback.
    • /api/teams/:id/* POST routes keep bare-integer :id - no URL change. /teams/<id> and /teams/<slug> page routes are unchanged. Migration is purely data-shape + UX cleanup.
    v0.139.1 2026-07-09 3 changes

    Refresh Envision Crew slug: 1-legacy -> 151-envision-crew

    • Team #1 (Envision Crew) now resolves at the canonical pretty URL https://play.tfcleague.com/teams/151-envision-crew instead of the migration-backfill /teams/1-legacy placeholder.
    • Hand-picked the disjoint prefix "151" to match the spec example brief (1-3 random digits, name-slugified).
    • The canonicalization 301 in /teams/:id will automatically redirect visitors who still have the /teams/1-legacy bookmark to the new URL - no cache flush needed because canonical redirects are honoured by all major browsers and search engines.
    v0.139 2026-07-09 5 changes

    Pretty team URLs (/teams/<slug>)

    • Team URLs no longer expose the raw AUTOINCREMENT id - they now route via /teams/<slug> instead of /teams/<id>.
    • The slug format is "<random-digits>-<name-slugified>", matching the brief example: team_id=1, name="Envision Crew" -> /teams/115-envision-crew.
    • The old /teams/<id> route continues to work as a backward-compat alias so existing bookmarks and search-engine cache entries do not 404.
    • Slugs are generated by the application layer (teamSlug() helper in src/lib/team-slug.ts) with retry-on-collision against the new UNIQUE slug index.
    • Existing teams are backfilled with a "<id>-legacy" slug on migration and regenerated on next edit/create from the team name.
    v0.137 2026-07-09 6 changes

    Team join password

    • Team owners can now optionally set a join password when creating a team or from the team detail page (v0.137 only exposes it at creation time - a settings UI for changing it later is on the v0.138 roadmap).
    • When a password is set, the team card shows a 🔒 badge so the rest of the league can tell at a glance that the team is gated.
    • Joining a password-protected team now opens a small inline modal that asks for the password; the join request POSTs {password} alongside the usual flow.
    • The API returns typed errors: "password_required" when the team is gated and no password was sent, and "wrong_password" when the supplied password does not match. Both are 409.
    • Passwords are hashed at rest with PBKDF2-SHA-256 + 16-byte random salt + 100,000 iterations (OWASP 2023 baseline). The raw password is never written to disk. The hashed blob is "pbkdf2_sha256$100000$<saltHex>$<hashHex>" so we can reroll the iteration count later without breaking stored teams.
    • Creating a team without a password keeps the v0.136 experience (open join).
    v0.129 2026-07-09 1 change

    Supporter Pack: Visual Overhaul

    - Supporter Pack now grants an animated gold glow ring on your avatar and an animated "Supporter" tag next to your name everywhere - profile page, profile card popup, lobby slots, lobby chat, leaderboard, and match detail pages - The previously-unused light-gold chat colour perk is now live: Supporters without a purchased Name Colour will see their name tinted gold in lobby chat and the points leaderboard - Dropped the "prioritised lobby matchmaking" line from the reward description - it never had a working mechanic behind it in the current draft-based lobby system - Existing Supporters have been backfilled with the light-gold chat colour so this applies retroactively, no need to re-purchase

    v0.136 2026-07-09 6 changes

    User-created teams (Create a Team in the sidebar)

    • New sidebar entry "Create Team" in the Quick Actions panel.
    • Authenticated users can create a team with a name (3-40 chars), tag (2-5 chars, uppercase), and optional avatar URL.
    • Both team name and tag are unique (case-insensitive) - first come, first served.
    • The creator is auto-enrolled as the team owner in team_members. v0.137+ will let owners add/remove members.
    • /teams lists all active teams, /teams/me lists teams you own or belong to, /teams/:id is the public detail page.
    • Teams do NOT yet integrate with tournament rosters or PUGs - this release is the minimum viable surface so the sidebar entry has real backing. The tournament_teams table (per-bracket rosters) is untouched by this migration.
    v0.128.4 2026-07-07 4 changes

    My Inventory is now the only place your perks live

    • The shop now only lists rewards you can still buy. Once you own a perk, it moves out of the catalogue and into My Inventory.
    • My Inventory shows every redeemed reward with chips: ACTIVE (perk is currently applied to your profile), OWNED (you have it on record but your profile is using something else), and UNLOCKED (perk has no toggle, e.g. Profile Makeover).
    • Cancellations are surfaced as a footnote under the inventory list.
    • One perk, one purchase: the API rejects re-buying a reward you already own; the catalogue hides it. (Repeatable rewards can opt in - none today.)
    v0.128.5 2026-07-07 3 changes

    Fix: Supporter Pack purchase wrongly reverted for two players

    • Migrations 0120/0122 (crash cleanup for the original v0.121 bug) ran after Koi and .glitch had already legitimately re-bought the Supporter Pack, and incorrectly cancelled that new purchase because the cleanup query matched on user+reward+status instead of a specific row.
    • Restored: both players' Supporter Pack redemption is now marked fulfilled and the Supporter badge/tag is active again. No further action needed on their end - refresh the page to see it.
    • Root cause fixed going forward: future cleanup/refund migrations must pin the exact redemption/transaction id, never just (user_id, reward_id, status).
    v0.128.3 2026-07-07 3 changes

    My Inventory panel on /points: see what you have and what you have not

    • The /points page now has a My Inventory panel that lists every reward you have already redeemed. Each item shows whether it is currently active on your profile, owned but a different perk is active, or otherwise.
    • Status chips: ACTIVE (gold) when the perk is applied right now, OWNED (green) when you have it on record but a different perk is active, UNTOGGLED (gray) for perks without an active toggle.
    • Cancelled or voided redemptions (such as the v0.128.2 supporter-pack refunds) appear in a quiet footnote so you can still see the audit context without it cluttering the main list.
    v0.128.2 2026-07-07 4 changes

    Supporter Pack redemption on /points now works

    • Buying the Supporter Pack reward on /points now completes end to end. Click Confirm, and your points are deducted, your Supporter status is set, and the modal closes cleanly.
    • The error message "user is not defined" that briefly flashed in red during a redemption attempt has been removed. The cause was a leftover defensive block in our redemption code that was missing a data fetch, not anything you did.
    • If you try to redeem a reward you have already bought, the modal now says "You've already redeemed <reward name>. Refresh to see your perks." instead of showing a raw database error.
    • Two players (Koi and .glitch) were refunded their 1,500 stranded points from the earliest Supporter Pack attempts that hit the bug. Each transaction history now shows the refund paired with the original deduction. .glitch also has a brief "reversal" entry in their history that pairs with an internal audit adjustment we caught during testing - net effect is one refund. Both players can now re-purchase the Supporter Pack normally - the "already redeemed" modal that briefly blocked them after the refund has been fixed.
    v0.128 2026-07-07 5 changes

    Buy Points on /points now works + reserve-servers list sorts by popularity + speed

    • Buying points on /points now works end to end. Click Buy, pay through PayPal, and your points land on your account.
    • The PayPal error message that used to say "Payment failed. Please try again." on a successful PayPal order has been removed; the cause was a missing column on our side, not anything you did.
    • The /servers region list now sorts the regions you actually use first. The top three regions get a small yellow POPULAR badge so you can spot them at a glance.
    • Each region card now also shows how long that region takes to be ready on average (in seconds), so you know what to expect before clicking Reserve.
    • The Tournament create-form on /tournaments/create reuses the same ordering, so the regions you pick there also surface popular + fast ones first.
    v0.127 2026-07-07 6 changes

    Per-player 20-minute ready timer + 5-min yellow Yes/No prompt (lobby NEVER resets)

    • Fixed a long-standing bug where lobbies would randomly reset back to gathering once they reached the ready check. That does not happen anymore, ever.
    • Each player in a lobby now gets their own 20-minute countdown. It starts when the lobby enters the ready check, or when you late-join after. A small mm:ss badge next to your slot shows time remaining.
    • When 5 minutes are left on your timer, a yellow popup asks "Are you still playing?" with YES and NO buttons.
    • YES keeps you in the lobby and resets your timer to a fresh 20 minutes. Other players' timers are not touched.
    • NO drops just you back to the player pool. No one else moves, no shuffle, no class reset, no lobby status change.
    • Players sitting in the pool without a slot or class for over an hour get cleaned up. Drafted players only get dropped after their personal timer runs out and they did not answer the yellow prompt.
    v0.126 2026-07-06 6 changes

    TFCL Premium gift codes (buy / mint / redeem)

    • You can now buy a TFCL Premium plan as a gift for someone else. Same prices as a self-purchase: $5 for 1 month, $20 for 6 months, $35 for 1 year.
    • After checkout you receive a one-time code. Send it however you want: Discord DM, email, gift card, hand them a note. The code stays valid for 2 years until redeemed.
    • The redeemer logs into their own account, opens /redeem, pastes the code, and the grant stacks on top of any premium time they already have. Buying early does not waste their unused time.
    • Admins can mint free codes directly from /admin for promotions, support credits, staff compensation, or tournament prizes.
    • Codes are single-shot: once redeemed they are done. Admins can revoke unused codes.
    • New "Buy as a gift" section on /premium with the same PayPal flow as a self-purchase, plus a new public /redeem page with a clean paste-and-go flow.
    v0.124 2026-07-06 5 changes

    Tournaments are now team-based with per-format roster caps

    • Tournaments are now organized around teams, not individuals. Each team has a captain (the first person to register under that team name) and a roster.
    • We split the cap into two clearer numbers: a max number of teams (2 to 64) and a per-team roster size set by your format. Standard roster sizes: Ultiduo = 3, 4v4 = 6, Sixes = 9, Highlander = 13.
    • Browse cards now show "X of Y teams" and "Z of W roster slots filled" so you can see at a glance how full the tournament is.
    • When you join, you pick a team name. The first person with that name becomes captain. The next people with the same name fill the open slots until the roster is full. The tournament still accepts more teams up to the max.
    • The old "MAX PARTICIPANTS" label is gone from the form. If a third-party tool was using the old name, the API still accepts it for back-compat.
    v0.125 2026-07-06 4 changes

    Tournament servers spin up automatically + logs.tf match reporting

    • Tournament servers now spin up automatically. 30 minutes before tournament day starts, we bring up enough servers across the regions you picked.
    • Those servers are reused across every match that day. We do not tear them down until every match on that day has been reported with a logs.tf link.
    • Reporting a match now asks for a logs.tf URL alongside the score. The tournament creator reports and verifies results, and there is a clear note on the create form so you know up front.
    • The bracket keeps a match-day count so multi-day tournaments know which day each match falls on. We track the per-day, per-region server pool behind the scenes.
    v0.124c 2026-07-06 6 changes

    Create form: split matches across days and pick your server regions

    • The old "EST. DURATION (MIN)" field on the create form is gone. It was a v0.122-era proxy for "how long will this run" and stopped making sense once tournaments got team schedules.
    • New: HOW MANY DAYS. Pick 1 to 7 days. Defaults to 1. If you pick more than 1, the next question appears below.
    • New: MATCHES PER DAY. Only shown when HOW MANY DAYS is greater than 1. We suggest a reasonable number, but you can pick uneven splits like 10 matches Saturday, 3 matches Sunday.
    • New: SERVER LOCATIONS. Pick one or more regions. The form shows a live preview of how many servers we will spin up (about 1 server per 4 matches per day, per region).
    • The legacy "minutes" estimate is preserved on the back end so older tournament views still see a sensible value.
    • We save the schedule details with your tournament so future match provisioning knows what to expect.
    v0.124b 2026-07-06 5 changes

    Create form: clear MAX TEAMS and ROSTER SIZE inputs (no more MAX PARTICIPANTS)

    • The create form no longer asks for "MAX PARTICIPANTS". That label was inherited from the old individual-signup days and is misleading now that tournaments are team-based.
    • New: MAX TEAMS. Hard cap on numbered teams in the bracket, from 2 to 64.
    • New: ROSTER SIZE. How many players per team. We pre-fill the standard for your format (Ultiduo = 3, 4v4 = 6, Sixes = 9, Highlander = 13). You can lower it for unusual rule sets but cannot go above the cap.
    • Roster minimum is 1 (a captain-only team). In practice the form still defaults to the format standard so the common case needs no override.
    • The back end rejects any oversize roster at create time, so a 14-player Highlander team fails to save rather than slipping through.
    v0.124 2026-07-06 5 changes

    Tournaments are now team-based with per-format roster caps

    • Tournaments are now organized around teams, not individuals. Each team has a captain (the first person to register under that team name) and a roster.
    • We split the cap into two clearer numbers: a max number of teams (2 to 64) and a per-team roster size set by your format. Standard roster sizes: Ultiduo = 3, 4v4 = 6, Sixes = 9, Highlander = 13.
    • Browse cards now show "X of Y teams" and "Z of W roster slots filled" so you can see at a glance how full the tournament is.
    • When you join, you pick a team name. The first person with that name becomes captain. The next people with the same name fill the open slots until the roster is full. The tournament still accepts more teams up to the max.
    • The old "MAX PARTICIPANTS" label is gone from the form. If a third-party tool was using the old name, the API still accepts it for back-compat.
    v0.123 2026-07-06 7 changes

    Tournaments v0.123: brackets, check-in, server provisioning, search, my-tournaments, profile stats

    • Brackets are now real: standing of a tournament renders an actual match tree (single-elim by default; round-robin for ultra-small ones; double-elim opt-in for admins).
    • Match results can be reported one at a time by the creator/admin via a new endpoint.
    • Check-in countdown widget on the detail page counts down the live window and shows X / N players checked in.
    • Optional auto-provision: when the creator toggles "auto provision a TFCL server", flipping to in_progress spins up a Vultr TFCL server from the same snapshot the Servers page uses; ip/password/rcon land on the tournament row.
    • Full-text search + format filter chips on /tournaments browse (use ?format=sixes&search=friday in the URL).
    • New /tournaments/me page lists every tournament you created or joined and shows aggregate stats (created/played/wins/podiums) at the top.
    • Profile stats card now includes TOURNAMENTS alongside MATCHES/WINS/LOSSES/WIN RATE.
    v0.121 2026-07-06 5 changes

    Points Store: Bundle Rewards + Buy Points

    • New "All-Class Frame Bundle" reward (1,800 pts): unlocks all 9 class profile frames at a 33% saving
    • New "Supporter Pack" reward (1,500 pts): persistent Supporter tag, light-gold chat colour, and +5% bonus on top of Premium's 2× earning (effective 2.1×)
    • Buy Points: a PayPal-funded $10 = 10,000 points top-up is now available on the Points Store page, gated to authenticated users
    • Premium re-priced: $5 / 1 month, $20 / 6 months (was $15), $35 / 1 year (was $25). 6m and 1y plans save 33% / 41%
    • TFCL Premium now grants 2× points on every gameplay reward (matches, logs, top scorer, map votes) and lets members schedule on-demand servers up to 48h ahead
    v0.122 2026-07-06 5 changes

    Tournaments (free, prizes and entry fees coming soon)

    • New Tournaments section at /tournaments - community-run brackets in 6s/4v4/Ultiduo formats
    • Free only at launch: every tournament created in v0.122 uses "free_open" mode (no entry fees, no prize pools, no PayPal involved)
    • Tournament creators publish a draft -> admin approves -> participants join -> check-in window -> bracket runs
    • "Coming soon" tag on the page makes it explicit that prize pools and entry fees are intentionally deferred
    • Tournament schema is forward-compatible: the same table will accept paid modes in a later release without a schema rebuild
    v0.120 2026-07-05 1 change

    Admins can now manage/destroy on-demand Servers reserved by other players

    - Admins can now destroy any on-demand Server from the admin panel's Servers tab, not just the ones they personally reserved - Adds a DESTROY button next to every active/provisioning reservation in that list - Destroying a server this way actually terminates the underlying game server instance, just like a player destroying their own server early - No change for regular players - you can still only manage your own reservations from the Servers page

    v0.119 2026-07-05 1 change

    Fixed logs.tf and demos.tf reporting "No API Key" on on-demand Servers

    - The in-game LogsTF and demos.tf plugins on our on-demand Servers need their own API keys to auto-upload a match's log/demo when it ends - neither had ever been configured, so both reported "No API Key" on every match - Both keys are now written into every newly-provisioned server automatically - Already-running servers self-heal the fix the next time their config is applied - no reboot needed - Scoped only to TFCL's own on-demand Servers - PUG lobby servers (rented from serveme.tf) already had their own working logs.tf/demos.tf integration and are unaffected

    v0.117 2026-07-05 1 change

    IP addresses are now hashed - not even admins can see a real IP

    - Previously, activity logs stored your raw IP address in plaintext, visible to site admins - Your IP is now immediately converted into a one-way cryptographic hash the moment it's received, and only that hash is ever stored - the real IP is never written to our database - This is irreversible: nobody, including our own admins with full database access, can recover your real IP from what's stored - The hash is still consistent per-IP, so we can still catch the same address being used across multiple accounts for abuse/ban-evasion detection, without ever seeing what that address actually is - Purged the small number of previously-stored raw IPs, since they predate this change - Updated the Privacy Policy's "Technical Logs" section to reflect this

    v0.116 2026-07-05 1 change

    Updated the Privacy Policy for TFCL Premium, on-demand Servers, and Map Uploads

    - The Privacy Policy hadn't been updated since these features shipped and was missing coverage of them - Added disclosure sections for TFCL Premium payment data (PayPal order info - we never receive your card or PayPal login details) - Added disclosure for on-demand Server data (region, map, config, and the connect info/RCON password needed for you to use your own server) - Added disclosure for custom map upload metadata - Clarified what data is and isn't shared publicly, and how long different types of data are retained