# Changelog Releases are repo-level annotated tags cut from `main` ([SemVer](https://semver.org/)); GitHub Releases carry the generated commit-level notes, and this file keeps the human summary. The version is repo-level — `apps/web/package.json` tracks the current tag; `scorer` and `sync` deliberately carry no version field. ## Unreleased - **Fixed: individual leaderboard rows now include team-unlocked Jeopardy story steps in their denominator (#689).** Reachability is now calculated for existing leaderboard contestants even when they have no personal Jeopardy solves, using their team's roster and shared unlock progress. Solve-less teammates therefore see the same reachable challenge total as their teammates, without changing points, rankings or leaderboard row creation. ## v1.1.0 — 2026-11-10 - **Fixed: `apps/web` pins minimatch to 11.0.1 for GHSA-g3hv-75wg-m249 (#734).** eslint's TypeScript parser pulled in minimatch 10.2.6, which has a denial-of-service advisory. The fix is a new major that no dependant's range reaches yet, so an exact override in `apps/web/pnpm-workspace.yaml` brings it in. Only lint loads it; the app never calls it at runtime. The `brace-expansion@5` pin is dropped: minimatch 11 already requires 5.0.12. - **Deprecated: the AI module (#726, #728).** It keeps working in v1.x, with security fixes only, and is removed in v2.0.0. Its switch in `/admin` (Event → Modules and the AI panel's header) now says so, and so does the confirmation when you enable it. For a new event, run AI security challenges as Jeopardy challenges with flags. ADR 69 records why; it supersedes ADR 53. - **Fixed: a fresh scorer image build fails at `apk add docker-cli` (#724).** Alpine moved `docker-cli` from 29.9.2-r0 to 29.9.3-r0 and keeps only the latest revision in its index, so the pinned 29.9.2-r0 could no longer be installed. The scorer Dockerfile now pins `29.9.3-r0`. An image you already built still works; this affects only a clean build. ## v1.0.3 — 2026-10-25 The second security patch on the v1.0.1 freeze, one day after the first: a zod denial of service that the public sign-in route reaches, carried in with the week's dependency bumps as one release. No breaking change, no configuration change. Redeploy the app; the scorer and sync images pick up the refreshed Node base on their next build. ### Changes - **Security: `zod` 4.5.4 → 4.6.7 (#711).** GHSA-mrhx-c7xc-m3w5 (high): an array schema without a length cap accumulates one issue object per failing element, so a large array of wrong-typed items exhausts memory. zod is transitive here, through `better-auth` and `better-call`, and the body of `POST /api/auth/sign-in/social` carries `scopes: z.array(z.string())` with no cap. That route is public on every box that signs in with GitHub, which is all of them, and nothing in front of the app limits a request body. One request could take the app container down; the 10 s health check (v1.0.2) brought it back, but the request repeats. - **Security: `better-auth` 1.7.7 → 1.7.12 (#711).** 1.7.12 runs the same issuer and nonce checks on OAuth callbacks completed through the OAuth proxy as on the standard callback, and rejects ID tokens without `exp` or `iat` or from a provider with no client ID. The kit uses neither the proxy nor ID-token sign-in; the bump is hygiene on the auth library, and it moves `better-call` to 1.4.4. - **Security: `happy-dom` 21.0.3 → 21.0.4 (#711).** GHSA-834v-8h66-3h79 (high), an incomplete fix for CVE-2026-33943. Test-only: happy-dom is the vitest DOM and never ships in the runtime image. - **Deps: `react` and `react-dom` 19.3.0 → 19.3.1, `vitest` 5.0.4 → 5.0.5, the `node:22-alpine` digest in all three Dockerfiles, `codeql-action` 4.39.1, `hashicorp/aws` 6.70.0 (#711).** The eight Dependabot PRs of the week (#695, #699, #701, #702, #707, #708, #709, #710) land as this one change so they get one CI run, one review and one deploy. `engines.node` in `apps/web` narrows from `>=22` to `^22.12.0`, the line CI, `.nvmrc` and the images actually run and the floor vitest 5 declares. ## v1.0.2 — 2026-10-24 A security patch for the v1.0.1 freeze: the Next.js advisories of October 22 and the Fly health-check interval. No breaking change, no configuration change, no scorer rebuild. Upgrade a public box now; the Server Actions denial of service reaches any App Router deployment with a server action, and this kit has one. ### Changes - **Security: `next` 16.4.0 → 16.4.2 (#703).** Closes three advisories published 2026-10-22 and patched in 16.4.1: GHSA-p3m5-59mp-jv5r (high, a denial of service through Server Actions; crafted requests pin the app's CPU, and this kit's `ai` module carries a server action whether or not the module is switched on), GHSA-2464-fhr4-w9xw (critical, remote code execution in Image Optimization with SVG input; the shipped lockfile already carried sharp 0.35.5, the advisory's workaround, and the image is musl-based) and GHSA-673q-mjm6-r5xr (critical, remote code execution in `use cache` functions; the kit has none). `eslint-config-next` moves with it. - **Fixed: an app-container crash on Fly costs ~30 seconds of errors (#691).** The container restarts in about two seconds, but Fly's proxy routes to it again only after the next passing health check, and `fly.toml` checked every 30 seconds. The check now runs every 10 seconds. It still watches `/health` (liveness) only. Takes effect on the next `deploy.sh` run. ## v1.0.1 — 2026-10-22 A fixes-only release: the build that runs the OWASP Global AppSec USA 2026 demo (November 5). No breaking changes and no configuration change. Rebuild and push the scorer image after upgrading: the VAmPI rubric fix (#661) and the probe-count fix (#643) ship inside it. ### Changes - **Changed: the Fly machine defaults to the minimum, `shared-cpu-1x` with 1 GB (was `shared-cpu-2x`, 2 GB).** The five containers hold about 665 MB idle, so 1 GB is the floor and the right size between events. An event sizes up with two new `.env.fly` keys, `FLY_VM_SIZE` and `FLY_VM_MEMORY`, which `deploy.sh` renders into the deployed copy of `fly.toml`, so a redeploy mid-event keeps the event size. **An existing Fly box shrinks to 1 GB on its next deploy unless both keys are set** (`FLY_VM_SIZE=shared-cpu-2x`, `FLY_VM_MEMORY=2048` restores the old size). A preset Fly does not know, or memory under 1024 MB, is refused before the deploy. - **Fixed: a fresh scorer image build fails at `apk add docker-cli` (#687).** Alpine moved `docker-cli` from 29.8.2-r1 to 29.9.2-r0 and keeps only the latest revision in its index, so the pinned `-r1` could no longer be installed. The scorer Dockerfile now pins `29.9.2-r0`. An image you already built still works; this affects only a clean build. - **Fixed: the Fly deploy fails on flyctl 0.4.120 with "restart policy `unless-stopped` is not supported by Fly Machines".** flyctl now reads the compose `restart:` key it used to ignore, and refuses the whole machine update. `deploy/fly/render-compose.sh` drops `restart:` from the rendered file, so every container inherits the machine's `on-failure` policy — the same behaviour as before, verified by the container-kill drill (#437). Docker Compose bring-ups keep their `restart:` policies unchanged. - **Fixed: `ctf-setup.sh`'s amd64 rebuild hint builds an image Fly accepts.** The hint now includes `--provenance=false --sbom=false`, as `docs/hosting.md` already does. Without them buildx pushes an OCI index carrying an attestation, which Fly's registry refuses with `400 Bad Request` when the deploy mirrors the scorer. - **Fixed: `apps/web` pins vite to 8.1.6 for the launch-editor advisories.** A `pnpm-workspace.yaml` override moves the test-only vite that vitest pulls in from 8.1.3 to 8.1.6, clearing Dependabot's three launch-editor alerts (one high). The runtime image never ships vite, so the box was not exposed. - **Fixed: the leaderboard could show stale scores due to an extra fetch cache (#598).** Normal scorer requests now bypass Next.js's 30-second fetch cache, while the leaderboard retains its 10-second fold memoization to avoid unnecessary recomputation. The Secure Development caption now says the board updates about every 10 seconds. - **Fixed: the scorer's unreachable-target early-abort test no longer flakes under CPU load (#643).** `runExec` now also returns `spawned`, the number of probes it actually launched — counted at the spawn decision, not by a marker the child writes after `node --test` boots. Under load a child can be reaped by its 300 ms safety kill before it runs a line, so the child-marker spawn-count assertion ("expected exactly 2 children spawned before abort armed") undercounted and failed intermittently; the abort gate itself was already deterministic. The elapsed-time assertion never failed and keeps its loose sanity bound; the test's behavioral assertions are unchanged, and a probe the gate skips is still never counted. - **Fixed: Jeopardy imports enforce the combined category limit.** A bundle that would take the board above 50 categories is rejected before any writes, preserving existing challenges, flags, hints, stories and attachments. Valid imports still keep existing category order and spelling. - **Fixed: AWS deploy reads `SCORE_IMAGE` from the repo-root `.env` (#593).** `--scorer-source` and non-empty exported `SCORE_IMAGE` take precedence. An unset or empty exported value falls back to the file, as in `ctf-setup.sh`. Stacks without Secure Development do not read the file; dry runs still make no Docker, AWS or Terraform calls. - **Fixed: a failed classic progress read no longer looks like an empty record (#642).** `getViewerClassic` now checks the per-command errors `upstashPipeline` returns instead of parsing them as empty solve and attempt hashes, so a `NOAUTH` or `WRONGTYPE` read fails loudly rather than telling the flags board a contestant has solved nothing and is outside every cooldown. - **Fixed: quiz and AI progress reads no longer turn a failed Redis reply into empty progress (#672).** `getViewerQuiz` and `getViewerAi` now check both pipeline replies for `.error` and throw, so a `NOAUTH` or `WRONGTYPE` read fails loudly instead of rendering "nothing answered/solved, no cooldown" on the board, `/profile` and `/api/board/items`. - **Fixed: repeated Redis SCAN keys no longer duplicate leaderboard teams.** The scorer and web app preserve the first occurrence of each team slug before reading its name and members, keeping one ranked row per team and one scorer history series. Individual points and team solve unions are unchanged. - **Fixed: an event-archive import preserves the AI launch keypair (#653).** Importing an archive replaces the challenge catalogue but no longer rotates `ctf:ai:launchkey`. Archive imports preserve the published key, as promised by ADR 53 and the `importEvent` comment. An ordinary master reset still rotates it. - **Fixed: VAmPI's Unauthorized Password Change could score on the stock app (#661).** VAmPI's `/createdb` commits its seed users one at a time, and a test that ran between `name1` and `name2` got `400 User Not Found` for the exploit, so the attacker's password failed to log in and the challenge read as fixed. The challenge now checks that `name2` exists before it tries the exploit, and the rubric's seed wait now covers every seeded user, not just `name1`. Scores on a real fix are unchanged. - **Fixed: the leaderboard chart ships only the top ten lines, and the load test reports measured p95 (#446).** `withModuleSeries` used to build a cumulative series for EVERY login and team on the board — re-expanding the scorer's and the mock source's own top-ten series — so a 200-contestant box put ~270 histories into every `/leaderboard` RSC payload while the chart (written for ten lines, ranks 9-10 folding into one muted "Other") drew them as an unreadable hairball. The stage now reads module events for, and emits series of, only the final top-ten entries and top-ten teams — ranked after hint penalties and organizer score adjustments — including a charted team's members who hold no entry of their own, whose items belong on their team's line, leaving every row, the `?display=1` surface and the viewer "you" highlight untouched. The fold also logs its own duration once per fold (not per request), so fold cost can be separated from request/render latency on a live box. `scripts/load-test.sh`'s results table gains a measured p95 column — the issue's own metric, which the autocannon CLI's fixed percentile list skips, so a small programmatic driver (`scripts/autocannon-phase.cjs`) adds it from the same latency histogram the summary percentiles come from — while the enforced bar stays on the stricter p97.5, which bounds p95. - **Fixed: deleting an open challenge in admin panels closes the editor (#666).** `useAdminResource.remove()` now clears the active editor when deleting the item currently open for editing, preventing a subsequent save from upserting the deleted row back into the store. In `ChallengeFrame`, flag visibility is preserved if an editor switch is cancelled, consecutive new challenge drafts receive distinct form keys to cleanly remount form state and re-trigger focus effects, and the Add button is guarded against clicks while categories are reordering, not yet loaded, or when the initial load failed. - **Changed: admin challenge lifecycle tests drive real DOM interactions (#676).** `apps/web` adds `@testing-library/react` and `happy-dom`, so deleting an open challenge, switching editors with a revealed flag, cancelling a draft switch and successive new-challenge drafts are covered through rendered components rather than static markup alone. ## v1.0.0 — 2026-10-16 The first stable release. It follows the kit's first real event (October 7–9, 2026: 28 teams, 41 players, 498 Secure Development scores ingested, none dropped). One breaking change, below. ### Breaking changes - **`LEADERBOARD_SOURCE=upstash` is removed (#502).** The mode read the legacy `ZADD leaderboard` / `HSET team:` schema that an external pusher writes — never this kit's scorer — through its own adapter. An event whose `.env` still names it now warns and serves the placeholder `mock` board instead of those rows. **After upgrading, set `LEADERBOARD_SOURCE=lambda` with `LEADERBOARD_API_URL` pointed at the scorer**, or leave `LEADERBOARD_SOURCE` unset for `mock`. ### Changes - **Fixed: a fresh scorer image build fails at `apk add docker-cli`.** Alpine replaced `docker-cli` 29.8.2-r0 with a rebuild, 29.8.2-r1, and keeps only the latest revision in its index, so the pinned `-r0` could no longer be installed. The scorer Dockerfile now pins `-r1`. An image you already built still works; this affects only a clean build. - **Changed: `apps/web` pins its dependencies to exact versions.** The eight caret ranges left in `apps/web/package.json` are now the exact versions `pnpm-lock.yaml` already resolved, so no installed version moves; every dependency in the app's manifest is now pinned. - **Changed: the kit has its own logo and favicon.** The built-in default logo is now the project's artwork — a fox, a server box and a flag beside the OWASP mark — on the landing hero, the leaderboard header, the "Built with OWASP CTF in a Box" credit, the README and the docs site, shown in its own colours. The favicon is the matching round badge, in the app and, for the first time, on the docs site. An organizer's uploaded logo and favicon still take precedence (ADR 65); nothing on a running box needs to change. - **Removed: the event Tagline setting (#648).** The Event tab's Tagline field was saved and carried in the event archive but shown on no page, so it is gone: the Event tab no longer offers it, `/admin` refuses an `eventTheme` write as an unknown setting, and the archive export no longer carries `event.theme`. An older archive that still has it imports as before, with the value ignored. A tagline already stored on a box stays in `ctf:admin:settings` unread; nothing needs cleaning up. - **Fixed: the demo quiz marked XSS as not an injection vulnerability (#649).** The `injection-types` question in the demo seed now counts Cross-Site Scripting as correct alongside SQL and command injection, as OWASP Top 10 2021 A03 does. - **Fixed: an event archive round trip deleted a switched-off module's content (#644).** The archive export left out the Jeopardy, Quiz or AI content of any module that was switched off, while the import clears all three before loading what the file carries. Exporting with AI off and importing that file emptied the AI catalogue. The export now carries all three modules' content whatever the toggles say; which modules are on still travels in the bundle's settings, so a module that was off comes back off, with its content. - **Fixed: the freeze and reset copy promised PR scores would wait (#646).** The Freeze switch's help line and both confirm dialogs said a Secure Development PR scored during a freeze was held and landed on the board when you unfreeze ("Nothing is dropped"). Since ADR 67 such a score is refused for good, and the contestant has to push again once scoring resumes. The master reset note said ingested points come back on the unfreeze unless the source PRs are closed, but the reset watermark skips every comment from before the reset. Both now say what happens, and so do `docs/aws.md` and `docs/troubleshooting.md`. A freeze set by hand on the settings hash, with no recorded start, still holds scores until it lifts; `docs/aws.md` says so next to the `HSET` line. - **Fixed: a module stayed off a leaderboard row until its first solve (#585).** Expanding a team with no Jeopardy solves showed only its Secure Development targets, so a new team could not tell from the board that Jeopardy counted. An expanded team or contestant row now lists every enabled module from the start, at `0 / N` and 0 pts; for Jeopardy with stories, `N` is what a row that has solved nothing can reach (every unstoried challenge and step 1 of each story). A module whose totals could not be read, or whose catalogue is empty, still stays off the row rather than reading `0 / 0`. Points, ranks and order are unchanged: a zero row adds nothing. - **Fixed: every image the judge pulls is pinned by digest.** The target bring-up scripts in `scorer/entrypoints/` ran seven third-party images by a moving tag: DVWA's `mariadb:10`, the `alpine` and `alpine/git` staging helpers, Security Shepherd's Maven and `docker:27-cli` builders, and WebGoat's `eclipse-temurin` JDK and `docker:27-cli` builder. A retagged upstream image could change a score or run untrusted code inside the judge. Each is now pinned to the digest its tag resolved to, so scoring is unchanged. `scripts/test/pins.bats` now scans the entrypoints too, and checks an image variable where it is set (`MAVEN_IMAGE="…"`), not only where `docker run` uses it. - **Added: hide a Jeopardy challenge, or schedule its release (#189).** Each challenge has a "Visible to contestants" switch and an optional "Release at" time in `/admin`, entered on the event's time zone and stored as UTC. The list shows a Live, Disabled or "Releases Oct 8, 10:00 GMT-3" chip per row. A scheduled challenge goes live at that instant on its own, with nothing running on the box: every read decides it, and grading and hint purchases decide it by Redis's clock. A hidden challenge does not exist for contestants: it is absent from the board, its page and the expanded leaderboard rows, its flag is refused like an unknown challenge's with no attempt spent, its hint and files are refused, and it is out of every denominator. A story closes up around a hidden step and reopens in order when it is released. Points already banked on it stay, and the profile names them in one "+N pts from challenges no longer on the board" line. An admin can still open a hidden challenge's page, marked "Hidden from contestants", where a flag or hint is a dry run that records nothing. Bundles carrying either field are version 4; an older bundle carrying one is refused. - **Fixed: setup and deploy script hygiene (#496).** `ctf-setup.sh`, `scripts/dev-stack` and both Fly scripts now read `.env` through one shared reader, `scripts/lib/dotenv.sh`. It reads a subset of docker compose's syntax exactly as compose does, and refuses the rest, stopping with the key and file named: interpolation (`$NAME`, `${...}`, `$$`), backslash escapes in double quotes (and `\'` in single quotes), an unterminated quote, and an env file that exists but cannot be read. Before, those read as empty or as literal text, which could switch Secure Development off or on by accident. The last assignment of a key wins even when it is empty. `SCORE_IMAGE=""` is now empty, so Secure Development is off, in every script. Before, `ctf-setup.sh` and `dev-stack` read it as the two-character string `""`, turned Secure Development on, and kept the quotes on any quoted value: `GITHUB_ORG="myorg"` forked into an org named `"myorg"`. `export KEY=`, `KEY = value` and `KEY: value` lines now count in those two as well. A tab before `#` no longer starts a comment, which matches compose. The `drop-old` provisioning check asks GitHub for `main` and `master` by name. It used to read the 30-branch first page of the branch list, so a fork with more branches could pass the check while `main` was still there. Only a 404 for both now counts as done, and any other error fails closed. The AWS deploy's `--dry-run` now redacts with the Fly deploy's rules from one shared `scripts/lib/redact.sh`, so it also hides `PRIVATE_KEY`, `CONNECTION_STRING`, `*_KEY` and `*AUTH` values. Both now mask credentials in any URL (`scheme://***@host`). - **Fixed: a Secure Development solve was timed, and judged, by when the box read it (#596).** It is now timed by when its score comment was posted on the PR, so the Actions run and the poll no longer cost a PR team minutes in a points tie, and a backlog read after a sync restart keeps each solve's own time. The scoring window and the freeze are judged at that time too: a PR scored while scoring was frozen no longer counts once the freeze lifts, and one scored before the freeze or before **Scoring closes** counts even if it is read later. To know when each freeze ran, the box now records it (`pausedSince` and a `ctf:admin:pauses` interval log, written with the switch; a master reset clears it). The poller no longer holds while scoring is frozen or closed. A refusal is logged as `refused ()` and is not counted as Dropped. A freeze set by hand with no recorded start holds Secure Development scores until it lifts, as before. The comment is edited on every run, so a solve first read from a later re-run takes the re-run's time. Either service can be upgraded first; see ADR 67. - **Added: first blood on the Jeopardy board (#188).** Each visible tile and open story step names the contestant who solved it first. It is display only and changes no score. It is read from the current team members' solve rows on every load, with nothing stored: the earliest solve wins, and an exact tie goes to the login that sorts first, ignoring case. A progress reset, a deleted contestant or a removal from a team passes their first bloods to the next solver. A locked story step shows none. - **Fixed: CI gates that could pass without proving anything (#498).** The vacuous-pass sweep (`scorer/tools/vacuous-sweep.mjs`) now exits 1 when a target's run aborted, got stuck behind a shared gate, or was not measured at all; before, only a vacuous pass failed it, so an unmeasured target read as clean. CI's path filter moved into `scripts/ci-changes.sh` with a bats suite: it no longer skips an area when the changed-file list passes 64 KiB (a SIGPIPE under `pipefail`), and it now runs the app suite for the rubric catalogues and `caddy/`, where the app job also runs `caddy validate`. The docker- and jq-dependent bats tests fail in CI instead of skipping, the window corpora carry a row-count floor, `secureDevTargets` has a shared corpus run by the app and the poller, every workflow action is pinned to a commit, and the acceptance scripts' Redis images are digest-pinned. - **Fixed: the Insights CSV garbled accented titles in Excel (#624).** "¿Qué dice señor?" opened as "¿Qué dice señor?": the file had no byte-order mark, so Excel read it in the system code page. The export now starts with one. A title a spreadsheet would run as a formula (leading `=`, `+`, `-`, `@`) is written as text, and a cell with a carriage return is quoted. - **Added: a Jeopardy challenge bundle can carry its files (#625).** Tick **Include files** next to **Export challenges** and the bundle embeds every uploaded file's bytes (base64, `attachmentFiles`, bundle version 3), so importing it on another box needs no re-upload. The import fills each upload the box is missing, skips one it already holds with the same sha256, and never deletes. A tampered file, or files that would push the event past its 50 MiB, are refused before anything is written. Imports now go to `/api/admin/classic/bundle`, outside the proxy's 10 MB body cut, and a bundle over 75 MB gets a `413` naming the limit. Without the option, or with it on a board holding no uploads, the export is the same metadata-only version 2 file as before, so a box on an older build still imports it, and so does the classic section of an event archive. Version 1 and 2 bundles import as before. - **Fixed: a hint could be charged a different price than the one confirmed (#557).** If an organizer changed the hint cost after a contestant's page loaded, Confirm (−10 pts) charged the new price. The purchase now carries the confirmed price and the charging script refuses a mismatch atomically, before any write; the button shows the new price and asks again. A hint already bought is still re-viewed free. - **Fixed: a regenerated or disbanded team could leave a stale join code working (#497).** Regenerate and disband read the code to retire before their script ran, so two regenerations at once (or one racing a disband) left older codes resolving to the team, or to a team that no longer existed. Both scripts now read the code they retire at the write. The captain-only team scripts, the organizer overrides, the admin-grant script and the rate limiter now run against a live Redis in CI. - **Changed: every attempt budget says it is per contestant (#494).** Quiz **Retry after** and the AI **Submission cooldown** now say, next to the field, what Max attempts and the Jeopardy cooldown already said: the budget is kept per contestant while points count per team, so a team of N gets N times it. Kept that way by decision; the AI docs say so too. - **Fixed: Insights' median time read 0s for almost every challenge (#616).** It measured from a player's first flag submission, and most players submit once they have the flag, so it was 0 by construction. The box now records when each player first opens a Jeopardy or AI challenge (`ctf:classic:opened:` / `ctf:ai:opened:`, once per challenge), and the median runs from that open to the solve. Rows with no open recorded fall back to the first submission and are starred; rows where every solver was right first time show —. The CSV gains `median_time_measure`. Master reset and a player's progress reset clear the new records. - **Fixed: leaving a team after registration closed stranded the player (#597).** Leave and the captain's Remove member now ask for confirmation once registration is closed, saying the player can't join any team again and their points leave the team; the server refuses an unconfirmed request (409). Remove member, refused outright after close until now, is allowed with that confirmation. `/admin → Support` gains **Add to team** / **Move to team** on a player's card, whatever the registration state: one atomic script, the team-size cap unless overridden, a captain of a team with other members refused, the player found by their signed-in spelling, written to the audit and activity logs. Reopening registration is no longer the only way back. - **Added: score adjustments for teams (#591).** `/admin → Support → Score adjustments` adds a bonus or penalty to a team with a required reason, and lists every one with who added it and a Remove. It is folded into the team's total last, after hint spend, on the board, display mode and the profile panel, marked "±N adjustment", floored at 0, never changing the tie-break time. Its own ledger (`ctf:adjustments`), audited on add and remove, cleared by the master reset, not carried by the event archive. It replaces deducting points by writing hint spend into Redis by hand. - **Fixed: admin Insights undercounted the event and disagreed with the leaderboard (#608).** Players whose GitHub login has a capital letter were read under the lowercased login and found empty; a player who left a team without scoring was never found; team points summed members' own totals instead of the board's. On the RTS event's snapshot "ever on a team" goes from 30 to 42, Jeopardy solves from 59 to 119, attempts from 225 to 339, and every team's points now equal the board. The solves chart keeps the quiet stretches as empty buckets on a real time axis across the full width, widening the bucket (10 min, 30 min, 1 h, …) on a long event. - **Added: search the admin Activity log on every field (#609).** One box matches each word against the login, the event, the detail (challenge, question or team id) and the time as shown, with `login:`, `type:`, `detail:` and `team:` to pin a word to one field; a time range (quick picks or from/to on the panel's clock) narrows it further, and each type chip shows its count. A filter now searches the whole log, not only the pages loaded, and the search is kept in the URL (`?q=`). Before, the box matched the login only. - **Fixed: team names could be invisible or reverse the text around them (#603).** Create and rename now refuse a name with nothing visible (a lone zero-width space saved a nameless team through rename) and any text-direction control character (a right-to-left override rendered a name reversed over its neighbours), and measure the 32-character limit after NFC. Every surface showing a team name isolates its direction, so a name stored before the check cannot reorder its row. - **Fixed: a solved story step could be carried to another team (#602).** A player who solved step N on one team and then joined another opened step N+1 there, because unlocks read who is on the team now. A teammate's solve now opens the next step only if they made it while on the team (solve time on or after their join time), decided inside the grading and hint scripts and applied the same way on the board, the challenge pages and `/api/board/items`. Points are unaffected: a team's total is still the union of its members' solves (ADR 60 amended). - **Added: solve counts on the Jeopardy and AI boards and in admin (#595).** Every visible tile, open story step and challenge page now says how many teams solved it (scoring is per team: two members on one flag count once, a teamless solver not at all), folded from current members' solves on read. A locked story step shows none. The admin Jeopardy list shows teams and players per challenge, "unsolved" when nobody has. Before, a count (of players) appeared only on a challenge's own page. - **Added: a QR code of the event's address on the projector board (#592).** Display mode shows it in a corner below the standings, with the address as text, so anyone in the room can join from a phone. Encoded on the server into an inline SVG (new dependency: `qrcode-generator` 2.0.4, pinned, encoding only, no dependencies of its own), from `EVENT_URL`. On by default; **QR code on the projector board** in `/admin → Event` turns it off. The regular leaderboard is unchanged. - **Fixed: the leaderboard's Individual view listed only contestants who had scored (#586).** Every team member now has a row; one with no points sits at 0 after everyone who scored, alphabetically, so a player who can find their team on the Teams view can find themselves too. On the RTS event's snapshot the view goes from 22 rows to all 41 team members. A signed-in player on no team still gets no row. - **Fixed: a points tie between teams ignored when they finished Jeopardy (#583).** Teams now rank like individuals: points, then items completed, then whoever earned their last points first, in any module. The scorer's order only knew Secure Development, and the app kept it on a tie, so at the RTS event three teams tied at 5,308 were ranked by their Secure Development finish; by the rule they finish Oxguardians, provart, Mortadela's, which the board now shows on the event's snapshot. The rules page and FAQ state the team rule, and Secure Development's rules say a PR counts from when the event receives it. - **Fixed: a numeric event setting could never be reset to its default (#600), and two messages misstated when points count (#599).** Emptying a number field in `/admin` (hint cost and gating, players per team, the retry gate, the cooldowns) now removes the override so the default applies again; the API takes `null` or `""` for the same, as the other optional settings already did. The profile's team card no longer says nothing counts without a team on a Secure Development event (PR scores are kept and count for the team the player joins), and every PR score report now says points count only while the event's scoring window is open. - **Changed: vitest 4.1.11 → 5.0.2 in `apps/web` (Dependabot #453).** No test or config changes were needed: the full suite, the live grading-Lua suites with `CTF_LUA_SUITES_REQUIRED=1`, lint, typecheck and the production build all pass unchanged. - **Changed: dependency batch (Dependabot #462, #563, #564, #565, #574, #575).** Next.js and `eslint-config-next` 16.3.6 → 16.4.0, better-auth 1.7.5 → 1.7.7, `@types/node` 26.6.2 → 26.6.4, sharp 0.35.4 → 0.35.5, source-map-js 1.2.1 → 1.2.2, and the compose `redis:8-alpine` digest. One lockfile regenerated against current `main` instead of six PRs whose lockfile hunks overlap. better-auth is now pinned exactly (1.7.7, one patch past Dependabot's target), as `next` already is; `@types/node` resolves one patch past its target inside the existing `^26` range. - **Fixed: an expanded leaderboard row listed the story steps its team had solved, but that the viewer has not reached, as unsolved and worth 0 (#584).** The steps stay redacted placeholders (ADR 60) and now read "Locked" with a dash instead of "Open" and "0pt"; one line under the list gives how many of them the team solved and their total ("5 hidden steps solved · 1,550 pts"). The route sends only that count, never which steps; the total is the row's own Jeopardy points minus the visible items, so no single step's value is sent or shown. - **Fixed: admin activity times read in UTC with no label, and to the minute (#587).** The Overview's Recent activity and the Activity tab now show each entry on the event's clock, to the second, with the offset named ("10-07 13:26:00 GMT-3"), with a "Show UTC" switch, remembered per browser, for matching a row against logs; at the RTS finish two solves 24 seconds apart both read "16:59". Insights and Support keep UTC on purpose, to match against logs, and now carry seconds too. - **Fixed: the leaderboard's score chart dropped every quiz, Jeopardy and AI point of a contestant whose GitHub login has an uppercase letter (#577).** The chart read each module's solves hash under the lowercased login, while the stores key it on the login as the session spelled it, so the read came back empty; a team whose only scorer history was one PR showed "Not enough score history yet to chart." with hundreds of points on the board. Scores, ranks and the module rows were never affected. - **Changed: classic story-lock reachable totals in profile and leaderboard (#570).** The classic module's denominator now counts only challenges reachable under the story-lock rules: step 1 of every story is always reachable; each later step unlocks only when its prerequisite was solved for that team — by a member who was on the team at the time (the `joinedAt` rule, ADR 60), so a solve carried in from another team keeps its points for the team but opens nothing after it. Locked steps are excluded from both the challenge count and the points ceiling — their titles and points are never exposed. A solo contestant is a team of one: their own solves unlock the next steps just like a team's. An unreadable story list now fails the request rather than degrading to "nothing is locked", which would put locked steps' titles and points on the board. The union with solve records is preserved, so challenges solved before deletion still count — and the profile takes its solve records and its solve-time eligibility set from ONE roster it already resolved (the same team-totals helper the leaderboard rows use), so a teammate's deleted solve counts in the ceiling exactly as the viewer's own. The profile shows "X of Y pts available" and a locked-step marker ("· 1 step locked", or "· N steps locked") when applicable, with a disclaimer: "Totals count unlocked challenges only — story steps add to the total as your team unlocks them." The leaderboard's "solved / total" column, its contestant rows and its team rows each use that row's own reachable denominator instead of the event-wide catalogue count, and the same disclaimer appears on the board while any row still has locked steps. This replaces the previous `visibleClassic` filtering and the event-wide `completable` denominator with a single shared helper in `lib/leaderboard/denominators.ts`. - **Changed: the maintainability follow-ups from the pre-v0.7.0 audit (#504).** Refactors with no behavior change: the demo seed and clear body moved out of `admin-store.ts` (43% of that file) into `lib/demo-seed.ts`, with the settings read it needs staying behind a two-line wrapper there so the two modules do not import each other in a loop; the four-module registry literal in `lib/modules.ts` (71% of it) split one file per module under `lib/module-defs/`, with the docs/playbook/scoring-branch URLs it linked to in `lib/module-urls.ts`; the attempt-row decode helpers and the Lua read of an attempt row shared by quiz, classic and ai through `lib/redis-decode.ts`; the admin controls' settings/nav/restamp logic split into hooks beside `admin-controls.tsx`; the classic and ai admin panels rebuilt on one shared `ChallengeFrame`; the acceptance gates sourcing their shared helpers from `scripts/lib/acceptance-lib.sh` instead of each carrying a copy (and `acceptance-target.sh` sourcing it too); and `ctf-setup.sh`'s doctor and setup wizard split into one helper per check. Each has a regression test that fails if the duplication grows back. - **Added: the event logo and the sponsor strip on the regular leaderboard (#571, ADR 66).** The leaderboard is the page an event keeps on screen, and it now carries the event's identity there too, not only on the projector view: the uploaded event logo sits beside the "Leaderboard" title (hidden if it fails to load; the default OWASP mark when none is uploaded, as on the landing page), and the landing page's sponsor strip runs under the header, above the board — same order, same `/admin` logo-size setting, nothing when there are no sponsors. Both reads fail open, so a Redis blip costs the decoration, never the standings. The footer's text sponsor line steps aside on this page so sponsors are credited once, and a wide logo wraps above the title on a phone. `PageHeader` gains an optional `logo` slot; every other page renders exactly as before. - **Changed: a comment policy, and the audit's top 20 files trimmed to it (#505).** AGENTS.md now says what a comment is for — the present: why this shape, what must stay true, what breaks if it changes, where a security or fail-closed boundary sits — and what it is not: **history** ("previously", "used to", "renamed from", "no longer", a bare `(issue #N)`), which belongs to the commit that made the change and, once it is a decision, to an ADR in `docs/decisions.md`; a **change restatement** narrating a diff the reader can see; and **narration** of what the next line plainly does. Directive comments (`// eslint-disable…`, `# shellcheck disable…`, `@ts-expect-error`, shebangs) are not comments and are never deleted. The pre-v0.7.0 audit's top 20 files by comment count were walked to that rule — offending comments removed or rewritten, every rationale, invariant, fail direction, key layout and security boundary left standing — and `scripts/check-comment-policy.mjs` (run by `bats scripts/test/`) holds them there: it fails on a history, restatement or provenance comment in any of those files, on the policy going missing from AGENTS.md, or on an audited file dropping out of its manifest. No code changed with any of it. - **Fixed: a hint can no longer be bought while scoring is closed (#566).** A paid reveal lowers the buyer's net, so it now closes with scoring the way a flag or quiz submit does: while scoring is frozen the reveal is refused with "Scoring is paused right now — hints can't be bought until it resumes", and once the scheduled end has passed with "Scoring has closed — the event has ended, so hints can no longer be bought" (`403`, from the same gate the other refusals come from, before the time, progress and affordability checks, and re-checked inside the atomic charge script against Redis's own clock and the live freeze flag, so a reveal that passed the gate a moment before the end cannot charge after it). Before, a contestant could buy a hint after the event ended and move the final standings. Re-viewing a hint already bought stays free, and the admin preview is unaffected. - **Fixed: after the scheduled end, a refused submission says the event has ended instead of "Scoring is paused right now. Try again later" (#567).** The classic, quiz and AI gates now answer `ended` (not `paused`) once `Scoring closes` has passed — a freeze toggled on after the close still reads as the end — and both refusal messages say "Scoring has closed — the event has ended." The external AI site receives `403 {"error": "ended"}` for the same case (see docs/ai-module.md's error table); `paused` keeps its meaning for the manual freeze, a start still ahead, and not launched. - **Fixed: the paid-hint "−N pts spent · M pts left" acknowledgement stays on the page after the reveal's refresh (#560).** A successful reveal calls `router.refresh()`, and both challenge pages then rendered the now-owned hint as a separate server-side `

` — an element swap that unmounted the reveal control and destroyed the acknowledgement it held, so the ack vanished in under a second while the hint text stayed. The pages now pass the owned text into the same control as a prop, which renders it both before and after the refresh. An unowned viewer still never receives the text, and a hint loaded already-owned shows no acknowledgement (nothing was charged on that page load). - **Fixed: `ctf-setup.sh org` no longer fails when GitHub's free plan refuses branch protection on a private repo (#601).** That refusal — the state of every fork between `org` and `launch` (#465) — is now recorded as **pending until launch**: every remaining step and repo still runs, the summary lists each pending `org/repo:ctf`, and it is never reported as completed; any other protection error still fails the run. `launch` applies the same idempotent protection as each fork goes public and verifies it (one approving review required, force-pushes and deletions disabled) before reporting success, and `doctor` shows it pending (⏳) on a private fork before launch, clearing to ✅ only once protection is verified. - **Fixed: a hint can no longer be bought with points the contestant does not have (#553).** The reveal is refused — `403`, "Not enough points: this hint costs N and you have M" — when the contestant's leaderboard score (every module, net of hints already bought) does not cover the price — checked once at the gate and again atomically inside the charge script, so two simultaneous purchases cannot both squeeze through on the same balance — and a refused purchase never reads the hint text, which the script fetches only after every check that can refuse has passed. The gate folds the score fresh rather than reading the leaderboard's ~10 s memo (the memo is per app instance, and the AWS module runs two); fresh reads also bypass the Lambda source's 30 s fetch cache. The charge is stamped with a shared score revision, and every score-lowering admin operation is bracketed by a shared in-progress marker (raised before its first write, lowered after its last, with a stuck-guard) — a reset or a module switched off on another instance while the fold ran makes the charge come back `stale` and retry, and while one is still running the purchase is refused with "Scores are being updated. Try again in a moment" — so nobody can buy against points that no longer count. Those operations (the master and per-player resets, a contestant delete, the demo clear, any settings write) also drop the memo, on their failure paths too, so the board itself stops showing wiped scores at once. Before, the board floored a net score at 0 and the shortfall was quietly forgiven, so a hint was cheaper for whoever had the least to lose. Re-viewing a hint already bought stays free whatever the balance, a free hint (cost 0) skips the check, and an unreadable balance refuses rather than reveals — with "Couldn't check your score right now. Try again", not an invented figure. After a purchase the challenge page now also says "N pts left" next to the "−N pts spent" acknowledgement (the follow-up deferred from #550) — the balance at the moment of the charge; a solve landing in the same instant shows on the refresh that follows. Internally the hint policy reads the leaderboard's penalty fold needs moved to `hint-config.ts`; `hint-store.ts` re-exports them, so nothing outside the leaderboard changes its imports. - **Fixed: a master reset no longer resurrects Secure Development scores on the next poll (#551).** The reset already bumped a `resetAt` epoch that the `sync` poller honoured by dropping its cursor — but dropping the cursor also cleared the seen-set, so the next poll re-read every bot comment on the still-open PRs and re-banked the pre-reset scores within a minute of the wipe. The epoch is now also the ingestion **watermark**: a score comment last edited before the reset is marked seen and skipped (reported once in the poll log as `N preReset`), while a PR re-scored after the reset edits its comment past the epoch and lands as normal. An epoch written as ISO by an older state file is read too, and an unparseable one skips nothing — losing every score on a junk value would be the worse failure. Organizers no longer need to delete the source PR comments for a wipe to hold. - **Changed: a paid hint confirms before it charges, and acknowledges the cost afterward (#550).** The challenge page's **Reveal hint** button now opens a **Confirm / Cancel** step on the first press instead of charging immediately — an accidental click no longer spends points — and once the hint is revealed the block shows "−N pts spent" so the deduction is acknowledged in place rather than discovered later on the leaderboard. The in-row Secure Development hint chip already worked this way; this brings the single-challenge control in line. - **Changed: the leaderboard explains its ~1-minute score cadence (#552).** On an event that runs Secure Development, a one-line note tells contestants that SD points and team totals land on the next scoring sweep (about once a minute), so a solve — or a teammate who just joined — can take a moment to appear, rather than reading as a broken board. Events that don't run Secure Development don't show it — every other module (classic, quiz, ai) folds app-side and updates live. - **Changed: the default quiz retry cooldown is now 1 minute, down from 5 (#549).** A wrong answer used to lock a question for five minutes (with a three-attempt cap); the default is now one minute, which still deters rapid guessing on few-option questions without a long lockout after an honest wrong guess. Organizers can still set any value in /admin → Quiz; only the baked default changed. - **Changed: an event names its time zone, and every date and time follows it (#547).** A new **Timezone** field in /admin → Event → Identity (`eventTimeZone`, an IANA zone; blank = UTC) sets the clock for the landing page's dates line, the phase line ("scoring opens Oct 3, 9:00 AM GMT-3"), the score chart's time axis and the four /admin schedule inputs. Those inputs now read the event's clock, not the organizer's browser — **on a box with no zone set they are UTC**, where they used to be the browser's local time. Bounds are still stored as ISO instants, so scoring is unchanged; countdowns and the admin ops stamps (Insights, Support) are as before. The event archive carries the zone like the location. - **Changed: the landing page's event logo is larger and can link to the event's own site (#545).** From `sm` up the hero logo's box grows to 224 px tall and 32 rem wide (phones keep 144 px). A new **Logo link** field in /admin → Event → Identity (`eventLogoUrl`, https only, no credentials, ≤2048 characters) makes the logo open that page in a new tab; blank leaves it unlinked, as before. The OWASP credit keeps its own link, and the event archive carries the logo link with the logo. - **Changed: the projector board shows the event's logo and a clock, and the landing page's main button is back above the fold (#543).** The projector view (`/leaderboard?display=1`) puts the uploaded event logo beside the name and adds a clock read from the scoring window: "starts in …" before scoring opens, "ends in …" while it is open, "final" after it closes, and "not launched" until a start is set. On the landing page the sponsor strip moves below the primary button, and the hero has less padding on top. - **Security: the app, sync and scorer images move to a patched Alpine base (#539).** All three pinned a `node:22-alpine` digest whose OpenSSL (`libssl3`/`libcrypto3` 3.5.7-r0) carries 2 critical and 7 high CVEs, as reported by ECR's scan of a live deploy. The pin moves to a digest with 3.5.8-r0 (Alpine 3.24.2, Node 22.23.3), still pinned by digest. Rebuild and redeploy to pick it up: on AWS `./deploy.sh` and `terraform apply`, on Fly `deploy/fly/deploy.sh`. On a Secure Development event, also rebuild the scorer image (`docker build --platform linux/amd64 -t scorer/`) and push it; the deploy then mirrors the new digest. - **Changed: an uploaded event logo leads the hero at full size (#538).** It was held to a fixed 80 px height, so a portrait or square badge rendered as an icon (a 482×603 logo showed at 64×80). It now fills a box up to 192 px tall and 24 rem wide (144 px on phones), whatever its shape. The OWASP mark beside it is smaller, captioned "Built with OWASP CTF in a Box" and linked to the project. With no logo uploaded, the hero is unchanged. - **Security: brace-expansion patched in the app's dev toolchain (GHSA-q2hr-2g5m-vwhr, Dependabot alerts #20 and #21).** Both copies the lockfile resolves are bumped: 1.1.18 → 1.1.21, and 5.0.9 → 5.0.12 through a scoped `pnpm-workspace.yaml` override (pinned exactly, `5.0.12`), since pnpm keeps 5.0.9 within minimatch's own range. Lint-time only (eslint → minimatch); nothing in the served app changes. Supersedes Dependabot #517, which bumped only the 1.x copy. - **Added: an event logo and favicon, set in `/admin` → Event (#529).** Organizers can upload a logo (PNG, JPEG or WebP, up to 128 KB) that leads the landing page's hero, with a smaller OWASP mark kept beside it, and a favicon (a square PNG, 32–512 px). **Restore default** puts the built-in ones back. The files' real bytes are checked and SVG is refused; both are served from the box itself, kept by a master reset, and carried by the event archive. No rebuild is needed to brand an event any more. - **Fixed: the AWS module's first `terraform apply` no longer comes up with no route to Redis (#530).** Six security group rule descriptions were written `app -> srh`, and EC2 refuses `>` in a rule description. Those rules failed to create, including the only ingress to ElastiCache, so srh could not reach Redis and the ECS deployment stopped with "No rollback candidate". They now read `app to srh`. A new check in the Terraform workflow fails any security group or rule description EC2 would refuse, since `validate` and the mocked `terraform test` never call EC2. - **Added: srh, scorer and sync task sizes are AWS Terraform variables (#531).** `srh_cpu`, `srh_memory`, `srh_desired_count`, `scorer_cpu`, `scorer_memory`, `sync_cpu` and `sync_memory` join the app's existing three. The defaults are the sizes every deploy already runs, so nothing changes unless you set them. A CPU/memory pair Fargate does not run (for the app too) is refused at plan, and `srh_desired_count` must stay at 2 or more. The module README has a sizing note for a bigger event. - **Fixed: a Redis error reply no longer reads as an empty answer on six more paths (#499).** `upstashPipeline` returns a per-command error instead of throwing, and these reads ignored it. A contestant's team check (`hasTeam`) refused live submissions on a Redis fault even though it is meant to let them through. Leaving a team reported success when nothing happened. A valid join code was called "invalid or expired", both on the join page and when joining. `/admin` and `/health/deep` showed "no sync heartbeat", and the stored-admins list and the Secure Development board read as empty. Each one now throws or says the read failed, so the caller's documented fail direction applies and the log names the read. - **Fixed: three `ctf-setup.sh` answers organizers read before launch (#496).** `doctor` (and `launch`'s pre-flight) no longer reports a fork's scorer-image grant as verified when its newest scoring run could not be read and only an older run had pulled the image. `doctor` no longer tells you to run `private` for a public fork that contestants have already forked, which `private` skips on purpose. "1 forks" now reads "1 fork". And `doctor --dry-run` makes no `gh` calls at all, as every `--dry-run` promises: it lists what it would check instead. A grant GitHub would not answer for is now a ❌ that fails `doctor`'s exit, not a quiet "unverified", and a `GITHUB_APP_ID` that is not a positive number is reported before any call. - **Fixed: a failed Redis read no longer zeroes quiz, Jeopardy or AI points in silence (#523).** The per-contestant totals, and the hint-penalty total, read their hashes without checking the reply's error, so a `WRONGTYPE`, `NOAUTH` or timeout read as "nobody has points" (or "nobody bought a hint"): the board re-ranked on the missing points and nothing was logged. They now throw, so the leaderboard logs which read failed and leaves that module (or the penalties) off the board, as it already did for a read that threw. - **Changed: the individual leaderboard ranks by points first (#522).** Organizers, note this before an event: the order is now points, then items completed across modules (a tiebreak only), then whoever got there first. Until now items came first, so a contestant with many cheap solves outranked one with more points, and the individual view disagreed with the team view and the scorer's own board, which already rank by points. The board's sort chips are now `points` (the default, the standing order) and `solved`; the `rank` chip is gone because it repeated `points`. Rules and FAQ now state the order. - **Fixed: "whoever got there first" now holds for quiz, Jeopardy and AI (#522).** The leaderboard breaks a points-and-items tie on each contestant's latest award time, but only Secure Development solves carried one: the quiz, Jeopardy and AI totals had no time at all, so a tie between two contestants who scored there fell back to an arbitrary order, and a later quiz answer never counted as activity. Each module's grading script now records the award time in `ctf:quiz:lastAt` / `ctf:classic:lastAt` / `ctf:ai:lastAt` in the same call that updates the totals, and the event reset, a contestant's progress reset and the demo seed and clear keep it in step. Points scored before the upgrade have no time until that contestant's next award. A failed read of the new hash logs and drops only the tiebreak, never the points. `scripts/load-seed.mjs` seeds the new hashes and `scripts/score-audit.mjs` reads them, so its tiebreak model matches the board's. - **Fixed: team totals counted quiz, Jeopardy and AI points twice (#520).** On any board whose source reports its own teams (the scorer, i.e. every event running Secure Development), each team's total added its quiz, Jeopardy and AI points once in `withModuleContributions` and again in `withTeamStandings`. A team at 58 Secure Development points plus 2050 app-side points was served 4158 instead of 2108. The module chips on an expanded team row were right (each pass wrote the same value), and contestant rows were unaffected. Because the error scaled with each team's app-side points, a team strong in Quiz/Jeopardy/AI could rank above a team that had more points: the **team ranking was wrong, not just the numbers**, on the public board, the display board and the profile's Team progress panel. Membership-only teams (not known to the scorer) were counted correctly. Present since v0.6.0 (#414). Teams now get their app-side points in `withTeamStandings` alone, also when the team store is empty or cannot be read. The same fix gives a team's Jeopardy chip the right denominator: challenges solved before they were deleted now count in it, as they already did for Quiz and AI (#350). - **Added: a read-only score auditor.** `scripts/score-audit.sh --app ` runs `scripts/score-audit.mjs` inside the app container: it recomputes every contestant's and every team's score, per module and overall, with ranks, from the raw Redis rows — a second implementation of the documented rules, importing none of the app's fold code — and diffs it against the `data` the `/leaderboard` page actually serves (its RSC flight payload). It sends only read-only Redis commands (anything else is refused before it is sent), fails loudly on a payload shape it does not recognise, refuses a vacuous comparison, and exits 0 only on zero mismatches. See [Checking score consistency](docs/operations.md#checking-score-consistency-scriptsscore-auditsh). - **Fixed: the load-test seeder read which modules are live from a field nothing writes.** `scripts/load-seed.mjs` parsed a JSON `enabledModuleIds`, but the app stores `enabledModules` as a comma list, so every module always read as live and a seed attached points to boards that were switched off. It now reads the real field with the app's default (absent = Secure Development iff `SCORE_IMAGE`, empty = none) and refuses to seed on a list holding anything but known module ids. Its test suite now runs in CI. - **Changed: `scripts/load-test.sh` deletes the seeder it uploaded** to the container's `/tmp` on every exit path, without changing the exit status. - **Breaking: the `LEADERBOARD_SOURCE=upstash` mode and its adapter are removed (#502).** The adapter served a direct read of the legacy `ZADD leaderboard` / `HSET team:` schema, written by an external push-era action rather than this kit's scorer — poll has been the one score transport since v0.6 ([ADR 56](docs/decisions.md)), which writes `ctf:solves:` and reaches the board through `lambda`. With the adapter gone, an `.env` that still says `upstash` is an unknown value: the app warns once — `unknown LEADERBOARD_SOURCE "upstash" — falling back to "mock"` — and serves placeholder data where those rows used to render, so any event that did rely on that schema loses its real board until it switches. **Set `LEADERBOARD_SOURCE=lambda` with `LEADERBOARD_API_URL` pointed at the scorer**, or leave the key unset for `mock`. - **Changed: the pre-v0.7.0 audit's dead-code findings (#502).** Deletions and de-exports with no behavior change: the orphaned `AppChallengeList` and `HintNotice` components (neither has a caller), `getHintAvailability()` (it always returned `{}`, and no code path in this kit writes `hints:`), and the `serializeBundle` helpers in `sponsors-io.ts` and `ai-io.ts` (no export button uses them — only the classic and quiz bundles have one). `sync`'s config drops its always-`"app"` `authMode` field, an unused local in the seed test is gone, and 128 exports that nothing outside their own file consumes lost the `export` keyword — audited symbol by symbol, keeping every framework-required export, package entry point and real consumer. The audit's one behavior change, the `upstash` mode removal, is the breaking entry above. ## v0.7.0 — 2026-09-30 ### Breaking changes Six of them. Each has a full entry below with the reasoning and the steps; this is the index an upgrader reads first. - **Every event needs an official launch (#464).** An empty Scoring opens now means *not launched*: nothing scores, and every module page and API is locked for non-admins until the event is launched. **After upgrading, press Launch in `/admin` → Event** (or set Scoring opens), or the box stays closed. - **The pre-event password gate is removed (#464).** The launch lock replaces it, and its `CHALLENGES_GATE_*` keys are no longer read; see the next item. - **The v0.7 migration shims are gone (#503).** Delete, by hand, any leftover `SCORE_INGEST`, `CHALLENGES_GATE_ENABLED` and `CHALLENGES_GATE_PASSWORD` lines in `.env` / `.env.fly`, and the `LEADERBOARD_URL` / `LEADERBOARD_TOKEN` org Actions secrets: `doctor` no longer reports them. - **Event bundles are version 2 (#463, #186).** An export from this release carries `stories` (and `attachments` when a challenge has files); a box on v0.6.0 or earlier refuses it. Upgrade the destination box first. - **Switching a module off stops its grading (#495).** Quiz, Jeopardy and AI grading routes answer `403 {"error": "unavailable"}` while the module is off, including the external AI site's `POST /api/ai/submit` and `POST /api/ai/event` (see `docs/ai-module.md` §7). - **The AWS module's inputs changed (#476).** An existing `terraform.tfvars` needs `github_client_id` (and `github_app_id` with Secure Development), new SSM secrets, and the image placeholders from `terraform.tfvars.example`; `docs/aws.md` has the commands. ### Changes - **An import's "bundle version is newer" error no longer repeats the submitted version.** It states the rule (the supported range) like every other import error since #515. - **Breaking: the v0.7 migration shims are removed (#503).** v0.6 kept a few one-release aids for keys and secrets that push ingest (#377) and the password gate (#464) left behind. They are gone: - `ctf-setup.sh doctor` no longer names a leftover `SCORE_INGEST` line or `CHALLENGES_GATE_ENABLED` / `CHALLENGES_GATE_PASSWORD` keys in `.env`, and no longer reads the org's Actions secrets to look for `LEADERBOARD_URL` / `LEADERBOARD_TOKEN`. It makes no `orgs//actions/secrets` call at all now. - The Secure Development setup checklist in `/admin` drops its "Nothing to configure for the transport" step. - `scripts/acceptance-scorer.sh` no longer runs the judge a second time with `SCORE_API`/`SCORE_TOKEN` set to prove them inert. - Test-only: `apps/web/src/test/enabled-modules-baked.ts`, which mimicked the deleted `event.yaml` bake by reading the module set off a fake `isModuleEnabled` export, is replaced by `enabled-modules-mock.ts`. Each of the 56 suites now states its live module set in its own mock. Nothing reads any of those keys or secrets, and nothing did in v0.6 either: a box still boots with them. What changes is that nobody tells you they are there. **Upgrading from v0.5 or earlier straight to this release:** delete any `SCORE_INGEST`, `CHALLENGES_GATE_ENABLED` and `CHALLENGES_GATE_PASSWORD` line from `.env` (and `.env.fly`), and delete the `LEADERBOARD_URL` / `LEADERBOARD_TOKEN` Actions secrets from the event org (Settings → Secrets and variables → Actions). Those two secrets are readable by every run a contestant's pull request triggers. The event is locked until you press **Launch** in `/admin` → Event; the password gate those keys set is not coming back. - **The remaining log and import-error echoes name the label and the position, never the value (#500 follow-up).** The server-side `catch` sites #510 left alone (the leaderboard overlays, the challenge catalogue fetch, the activity log, the launch lock and AI preview check, the rate limiter, the country counter, the attachment download and six `/api/admin/*` routes) now log the shared `errorLabel(err)` instead of the caught value, so a driver-decorated error or a thrown string cannot carry a request into the log. The quiz, Jeopardy, AI, sponsors and event-archive import validators no longer repeat a submitted value or key name in their messages: an error names the indexed position and the rule, e.g. `questions[3].correct[1]`, "must be one of this question's choice ids", and a duplicate names the earlier position it collides with. Admins see different import-error text; no import that passed before is refused, and none that failed now passes. - **Team join codes draw every symbol uniformly (#435).** A join code's six symbols were `randomBytes` values reduced `% 31`, and 256 is not a multiple of 31, so the first eight symbols of the alphabet came up 9/256 of the time and the other 23 at 8/256 (CodeQL `js/biased-cryptographic-random`, high). The loss was about 0.012 bits over a whole code, but the alert stayed open on every PR. Each symbol is now `randomInt(0, 31)` from `node:crypto`, which rejection-samples. The alphabet, length and format are unchanged, and existing codes keep working. - **sync's cursor now lives in Redis (`ctf:sync:state`).** Before, it lived only in `state.json` on sync's disk, so a restart on Fargate (or any sync container recreated without its volume) re-read every score comment. Nothing was double-counted, but Secure Development solves removed by a per-contestant reset came back, and `/admin`'s ingested/dropped counters went back to 0. - The cursor, seen cache, counters and reset epoch are read at startup and written after every tick. A restart resumes where it stopped. - If Redis cannot be read at startup, sync **holds**: it logs `cannot load poll state from Redis (ctf:sync:state) … not polling until it is readable` and retries each poll interval, instead of starting from an empty cursor. - **Upgrade:** the first boot copies an existing `STATE_PATH` file into Redis and renames it `state.json.migrated`. No action needed. The compose `sync-state` volume and the Fly `STATE_PATH` stay, for that migration and for a sync run with no Redis client. - See ADR 64. - **The empty board says nobody has scored yet (#482).** Searching a board with no scored contestants used to answer with the spelling nudge, which is wrong when there is nobody to check the spelling against. A typed query now reads "Nobody has scored yet" instead, and only the empty search box still draws the "board is wide open" podium. Covered by regression tests on the board-state helpers. - **Switching a module off stops its grading (#495).** Turning Quiz, Jeopardy or AI off in `/admin` hid the board but left its grading routes live: `POST /api/quiz/answer`, `POST /api/classic/submit`, and the AI module's token-authenticated `POST /api/ai/submit` and `POST /api/ai/event`. An answer from an already-open tab, a curl, or the external AI challenge site still banked points that reappeared on re-enable. All four now refuse with `403 {"error": "unavailable"}` as their first check, before any session, token, signature or key is read, the same gate the AI module's in-box submit action already had. An admin preview or an AI `dryRun` is refused too. External AI integrators: the new 403 is in the error table, `docs/ai-module.md` §7. - **Dependency refresh: Next.js 16.3.6, `@types/node` 26.6.2, Caddy image digest.** `next` and `eslint-config-next` move 16.3.5 → 16.3.6, the fix for GHSA-vcvr-r3jv-pc5j (remote code execution in `next/og`'s `ImageResponse`). The app does not use `next/og` or `ImageResponse`, so it was not exploitable here; the bump is hygiene. `@types/node` moves to 26.6.2 in the lockfile, and `docker-compose.yml` pins the current `caddy:2-alpine` digest (Caddy 2.11.4). - **Security headers on every deployment (audit S1).** The app now sends `X-Frame-Options: DENY`, `Content-Security-Policy: frame-ancestors 'none'`, `nosniff`, `Referrer-Policy: strict-origin-when-cross-origin` and HSTS on every response, and no longer sends `X-Powered-By`. Before this only Caddy set them, so AWS and Fly boxes, which run no Caddy, sent none. Caddy keeps the same values as a deferred defence-in-depth block, and a test fails if the two drift apart. - **Event-archive imports over 10 MB work (audit S2).** The Next proxy cut every request body at 10 MB, so an archive with more than about 7 MB of attachments failed with a bare "invalid request payload". The import route is now outside the proxy matcher and runs the proxy's cross-origin check itself. It accepts up to its documented cap of about 75 MB and refuses a larger body with a `413` that names the cap. See ADR 40. - **Contribution gates (#483).** A PR that changes `apps/`, `sync/`, `scorer/`, `setup/` or `deploy/` now fails the `changelog` check without a CHANGELOG entry (test-only and `.md` paths are exempt; the `no-changelog` label skips it). PRs are labeled by path automatically, and a new CodeRabbit check warns on a behavior change that ships no test. CONTRIBUTING.md lists what a PR needs to pass. - **Challenge files and links (#186).** A classic challenge can carry uploaded files (up to 5 MiB each, 50 MiB per event, 10 attachments per challenge) and external links. - Uploads are stored in Redis and served only to whoever can see the challenge, through the same visibility check as the challenge page. They are always served as a download, never rendered. - Links are marked as public to anyone with the URL. - Challenge bundles carry attachment metadata; the event archive carries the bytes, sha256-checked before an import replaces anything. A bundle that names a file the box lacks records it as missing until it is re-uploaded. - **Breaking for older boxes:** a bundle exported from a challenge with files carries an `attachments` list, which a box on an earlier release refuses (v0.6.0 and earlier already refuse any version 2 file, see stories below). Upgrade the destination box to this release or later before importing; a bundle with no files carries no `attachments` key. - The demo's two forensics challenges now ship a real synthetic pcap and JPEG. See ADR 61. - **Classic stories (#463, part 1).** A story is an ordered chain of classic challenges that a team unlocks one step at a time. - Step N+1 opens once any teammate solves step N. - The board shows a lane per story above the categories, and a locked step appears only as "??? — step N of M". - Grading and hint reveal enforce the lock inside their Lua scripts, before reading any secret. A locked step costs nothing and is answered exactly like an unknown challenge. - A locked step's page, metadata and board-items entry reveal nothing. - See ADR 60. - **Authoring stories, and bundle v2 (#463, part 2).** - A **Stories** block on the Jeopardy tab creates stories, orders their steps with ↑ / ↓, and saves the whole list in one request. The server refuses a step that names a missing challenge, and a challenge placed in two stories. - Challenge bundles are now **version 2** and carry a `stories` list. Import merges the stories by id, and a merge that would put one challenge in two stories refuses the whole file. Version 1 files still import unchanged. - **Breaking for older boxes:** an export from this version is stamped `"version": 2`, which a box running an earlier release refuses (it accepts only version 1). Export from the newer box only into a box on this release or later; a hand-made version 1 file (no `stories`) still imports everywhere. - **Forks stay private until launch; `ctf-setup.sh launch` (#465).** `org` (and the new `private` subcommand) sets each detached Secure Development fork private while the event is not launched. On launch day, `ctf-setup.sh launch` checks every fork is detached and the scorer package private, waits for Launch in `/admin`, and only then makes every fork public. `doctor` warns about a fork that is public before launch or private after. - **A Launch block in `/admin`.** The Event tab now shows **Not launched**, **Scheduled for …** or **Live since …**. - **Launch now** (with a confirmation) writes `scoringStartsAt: "now"`, and the server resolves that on its own clock. - **Un-launch** (a confirmed, dangerous action) clears the start. Solves already banked are kept. - **A master reset now returns the event to not launched.** Its script clears the scoring start in the same atomic step that freezes and audits. - **`/health/deep` reports `launched`**, and `ctf-setup.sh doctor` warns while the box is not launched. - This is the last PR of #464. - **Admins preview the event before launch, with dry-run grading.** An admin who opens a module page before launch sees a **Preview — event not launched** banner, with a link to view the event as a contestant. - Their flags, quiz answers, AI flags and events, and hint reveals are graded by the same Lua scripts as a real submission. Each script takes a new trailing dry-run argument, compares as usual, and writes nothing: no solve, points, attempt, cooldown, solve count, hint charge or activity-log line. - The answer carries `dryRun: true`, and the UI says "preview only, nothing was recorded". - The AI module's launch token gains a signed `ctf.preview` claim for this case. The admin Send test mints its token that way before launch, so it can now reach **Would award**. - Part of #464. - **Breaking: every event needs an official launch — an empty scoring start now means "not launched".** A blank `scoringStartsAt` used to mean "no bound, always open"; it now means nothing scores. That covers the app's flag, quiz and AI graders, and the scorer and `sync`, which hold Secure Development ingestion. Paid hint reveals are not gated by this change; #464's page lock covers them. The three readers change together through a new `outsideScoringWindow`, pinned by a shared corpus (`test/fixtures/scoring-window-corpus.json`). The registration window keeps its old meaning, so a blank bound there is still open. **After upgrading, set Scoring opens in `/admin` → Event**, or scoring stays closed. This is PR 1 of #464. The page lock, admin preview and a Launch button follow, and ADR 59 records the decision. CI now also runs all three suites when only a window corpus changes; before this, such an edit ran none of them. - **Breaking: the pre-event password gate is removed; the launch lock replaces it.** Gone: the `CHALLENGES_GATE_ENABLED`/`CHALLENGES_GATE_PASSWORD` keys, the `/gate` page and `POST /api/gate`, the HMAC-signed unlock cookie, and the per-IP attempt throttle — the site now stores no IP address at all. In its place, until the event is launched (a Scoring opens that has passed) every module page — `/challenges`, `/flags`, `/quiz`, `/ai`, their detail pages, and `/leaderboard` — redirects a non-admin to `/` before loading any content, and the module APIs (`POST /api/classic/submit`, `/api/quiz/answer`, `/api/hints/reveal`, `GET /api/board/items`, and the ai module's in-box flag action, which also guards the launch-token mint) answer `403 { "error": "not-launched" }`, a refusal the classic, quiz and hint UIs name. Unlike the gate, it covers the APIs, it follows the one clock instead of a second switch, and admins browse every page as a preview. It fails closed: a settings or admin-check error counts as not launched. The landing page counts down to a scheduled start, or says "Launching soon." with none, and points signed-out visitors at forming a team while registration is open. `launch-guard-coverage.test.ts` fails if a module page or route lacks the guard. **After upgrading, delete any `CHALLENGES_GATE_*` lines from `.env`** — nothing reads them, and `ctf-setup.sh doctor` names them. Part of #464. - **Every screenshot and the demo GIF now show the current brand.** The rename in #456 reached every string a `grep` can see and none of the pictures: all fourteen raster assets under `docs/assets/` predated it, and the audit of them turned up two that were wrong rather than merely stale. `wizard.jpg` spelled the retired name in its ASCII banner and its title line **and** still showed a `3/9 Event config (event.yaml)` step — a file ADR 55 retired — so it was wrong about the architecture too; `admin-event.jpg` showed the retired name as the default event name. Both are recaptured, the wizard from the script's real dry-run output via `vhs`. The other eleven contestant and admin shots, and the seven-frame `demo.gif`, are recaptured from the live box on the default event name, so the header wordmark reads `$ OWASP CTF in a Box` throughout — the old ones read `$ owasp-ctf`, which was that box's custom event name at the time, not a hardcoded string, but looked exactly like the retired slug. Three captions moved to match what their image now shows: `docs/hosting.md`'s wizard caption (the dry run ticks secrets and event basics, and the scorer-image check is the first pending step), `docs/operations.md`'s flag-board caption (it described a challenge detail page with a submission box, which no board shot has ever shown), and the README's `demo.gif` caption (the team view no longer expands a target down to its challenge rows; the GIF ends on the team's open flags instead). Untouched on purpose: `doctor.jpg`, which carries no brand string, and `ai-board.jpg`, a signed-out shot whose footer shows the hardcoded `$ owasp-ctf` wordmark — that string is a code change, not a recapture, and gets its own PR. - **The footer's wordmark is the runtime event name, like the header's.** `site-footer.tsx` rendered a hardcoded `$ owasp-ctf` under every page while the header rendered `$ ` — so an organizer's rename reached one end of the page and not the other, and after the September 2026 rename the retired slug sat under the new brand on every route. The header already had a test forbidding that slug (`site-header.test.tsx`); nothing forbade it in the footer. The footer now reads `event.name` from the same `getSite()` it already awaited, and `site-footer-wordmark.test.tsx` pins it, scoped to the prompt so the repo link's legitimate `owasp-ctf-in-a-box` href does not trip it. The 404 and error pages' terminal flourishes drop the slug too — `$ owasp-ctf goto …` and `$ owasp-ctf render …` become `$ ctf goto …` and `$ ctf render …`. Those lines are pretend CLI *commands*, so the event name is the wrong thing to put there (a program with spaces in its name reads as garbage); `ctf` is the identifier family the glossary already keeps, carries no brand, and is what `DESIGN_SYSTEM.md` §7 now uses as its example. The glossary's `ctf-*` row records it, so the next sweep does not rediscover it as an inconsistency. - **The wizard banner is one line again.** The two-line logotype #457 introduced (`OWASP CTF` over `IN A BOX`, ten rows) is replaced by the whole name on one line in figlet's `small` font — 76 columns, four rows, one row shorter than the original `OWASP CTF` banner, in the brand's own casing. All caps in that font lands on exactly 80 columns, one wrap away from garbage on the terminal the wizard targets; `in a Box` is what fits. `docs/assets/wizard.jpg` is recaptured from the new output. - **The AWS module's provider lock file is now committed.** It had been gitignored since the module was written, grouped with `*.tfstate` and `*.tfplan` under "never commit these" — but a dependency lock is not state, it is this module's `pnpm-lock.yaml`. The consequence was that `versions.tf`'s `~> 6.0` / `~> 3.6` constraints floated: every `terraform init`, including each CI run, resolved whatever provider build the registry served that day, with nothing verifying the download. `.terraform.lock.hcl` now pins `hashicorp/aws` and `hashicorp/random` to a version and a checksum, with `h1:` hashes for the four platforms that run `init` against this module — `linux_amd64` (CI) plus `linux_arm64`, `darwin_amd64` and `darwin_arm64` (operators). That platform list is about review and reproducibility, not availability: a missing platform hash does **not** break `terraform init`, because the `zh:` hashes in a registry-sourced lock cover every platform and a writable `init` appends the `h1:` it needs. What the list avoids is every operator's first `init` rewriting the lock under them. `deploy/aws-terraform/README.md` spells out both that and the Dependabot gotcha: it re-locks for its own platform only, so regenerate the full set with `terraform providers lock -platform=...` on any provider-bump PR. - **The rename's leftovers: the setup wizard, the AWS stack and the names table.** A sweep for the retired brand across the whole tree found six live strings the rename had not reached, all outside the areas its own diff touched. The biggest is the **wizard's ASCII banner**, the first thing an organizer sees — it still spelled `OWASP CTF`, having been rebranded *to* that name in #368. It is now a two-line logotype (the whole name in one run of that font is ~128 columns; the wizard has to read at 80), with the second word right-aligned under the first so the block stays 54 columns wide. Also renamed: the **OAuth App name the wizard dictates** — organizers were being told to register a fresh app under the retired brand — the wizard's `… setup wizard` line and its bats assertion, the App-creation page's ``, the script's header comment, and the two `description` strings in `deploy/aws-terraform` (`elasticache.tf`, `kms.tf`) whose own README had been renamed without them. Those are in-place attribute updates; no AWS resource is replaced. **No identifier moved** — see below for why, and note that the `<title>` and event-name assertions in `scripts/acceptance-app.sh` and `scripts/acceptance-quiz-only.sh` were already correct and are untouched. - **`docs/glossary.md`'s names table now explains the split it asserts.** The table said two strings keep the old brand "because they name things that already exist in somebody's GitHub account". That is true of the `OWASP CTF sync` GitHub App and only of it; it was never true of the wizard banner, which names nothing GitHub stores — so the banner was being held back by a reason that did not apply to it, and the entry did not say which of the wizard's two banners it meant. The paragraph now claims the exception for the one string it fits and says explicitly that the banner and the per-event OAuth App name follow the brand instead. The table also gains a `ctf-*` row for the service identifiers that are *also* runtime log prefixes — `ctf-sync` and `ctf-score-engine` are each both a `package.json` name and the string every line that service logs begins with, which is why renaming them is a worse trade than the inconsistency — and the `owasp-ctf` row now names the Fly app as its third use. - **Renamed: the kit is now called OWASP CTF in a Box.** This is the name the OWASP Foundation approved when the project was transferred into the org; the repo had been carrying the pre-transfer name throughout. The rename reaches the brand — the README, the docs site title, the ADR index, `CONTRIBUTING.md`, `SECURITY.md`, the design system, the `LICENSE` copyright line — **and the default event name**, which the glossary has always kept deliberately equal to the brand. One behaviour change follows from that: a box whose organizer never set an event name now shows "OWASP CTF in a Box" where it showed "OWASP CTF" (`DEFAULT_EVENT_IDENTITY.eventName`, and with it the page `<title>`, the header and the leaderboard). That is a rename of the default, not of anything stored — a box with a name set in `/admin` → Event → Identity is untouched. What deliberately did **not** move: the `owasp-ctf` image namespace and repo directory (renaming it breaks every `SCORE_IMAGE` already sitting in a `.env`), the `OWASP-CTF` fork org, `ghcr.io/owasp-ctf/score`, and the `OWASP CTF sync` GitHub App, whose name belongs to an app already installed in someone's account. `docs/glossary.md`'s names table is the authority on that split; ADR 14 carries an amendment for the changed default; and the entries below that use the old name are history, left as written. - **The repo now meets the OWASP Project Policy's requirements for a project under the Foundation.** The kit was transferred into the OWASP org, and the governance files still described an unaffiliated project. Five things changed. The README, `docs/index.md` and `docs/glossary.md` said "not affiliated with or endorsed by the OWASP Foundation" — the policy requires the opposite ("Projects must identify as an OWASP project in their branding"), so they now identify the project and link its home page at `owasp.org/projects/ctf-in-a-box`, and carry the Branding Guidelines' own Statement of Non-Endorsement instead, which is what that sentence was actually reaching for. `CONTRIBUTING.md` gains a DCO section: the policy names the Developer Certificate of Origin as the contributor agreement every OWASP project must use, so every commit needs `git commit -s`. Documentation is relicensed **CC BY-SA 4.0** (`docs/LICENSE`) — the policy splits the two, OSI-approved for source and Creative Commons for documentation; code stays MIT and the root `LICENSE` is untouched. `CODE_OF_CONDUCT.md` names the OWASP Code of Conduct as authoritative over its Contributor Covenant text and adds the Foundation escalation path (compliance@owasp.org, the conflict-resolution and whistleblower policies, the Project Committee) for a report that concerns the project's own leaders. And the app footer now carries the OWASP attribution — links to owasp.org, the project page and the repo, plus the trademark and non-endorsement notices — because the policy asks for that branding on any domain the project maintains, and an event box runs on the organizer's own hostname. - **One footer, not four (#474).** The site footer stacked four bands, each with its own divider, and read as four footers. It is now a main block (wordmark and dates, the site nav, the policy links, the OWASP attribution) plus one bottom bar (the trademark notice and the sponsor credit), with the bar's divider the only one inside it. The phone column follows source order, so a screen reader hears what the eye sees. Every footer link is padded to meet WCAG 2.5.8, the main nav is labelled "Site", and the landing page no longer credits its sponsors twice. - **The AWS module runs a current event (#476).** Its task definitions had not kept up with poll scoring (#377) or with sync's move to a GitHub App, so an applied stack could not sign anyone in, served the mock leaderboard, refused team writes, and started neither sync nor the scorer. - The app now gets `GITHUB_CLIENT_ID`, `LEADERBOARD_SOURCE=lambda`, `TEAM_WRITES_ENABLED=true` and, on a Secure Development event, `LEADERBOARD_API_URL`. - sync gets the GitHub App's id, installation id and private key, the scorer's URL and its bearer token. The scorer gets that token too. - The scorer has a Cloud Map name and its own security group, reachable on `:4000` from the app and sync only. - On every deployment, not only AWS: when `GITHUB_APP_INSTALLATION_ID` is unset, sync now asks GitHub for the App's installation on `GITHUB_ORG`. It used to take the first installation the App listed, which polls nothing when that is another org, and it now refuses an App not installed there. A non-numeric `GITHUB_APP_INSTALLATION_ID` is refused at start-up, and the Terraform variables check both App ids at plan time. - Traffic between the stack's own services stays HTTP with a bearer token, with the network as the boundary (ADR 63). TLS on those hops is the post-event follow-up #484. - `stack.tftest.hcl` now reads `docker-compose.yml` and fails if any environment key compose gives the app, the scorer or sync is missing from its ECS task. - **Breaking for an existing `terraform.tfvars`:** `github_client_id` is now required, and so is `github_app_id` when `enable_secure_development` is true. In SSM, `GITHUB_TOKEN` is no longer read; store `GITHUB_APP_PRIVATE_KEY` (base64 of the `.pem`) and `SCORER_TOKEN` instead. `docs/aws.md` and the module README list the commands. - **Fixed: hint errors are logged as a label, and archive-import errors no longer echo the file's values (#500).** - Four hint-store failure logs (the hint gate's solve lookup, a failed hint reveal, and the classic and ai hint-list reads) passed the caught error object itself to `console.error`. They now log `errorLabel(err)`, the name and message only, like every other store. - Importing an event archive refused a bad attachment file with a message that repeated its `sha256`, its `item` and any unknown key names. Those messages now give the file's position and the rule it broke, and a `sha256` that is not 64 lowercase hex digits gets its own error. - `ai-http.ts` and `admin-store.ts` had their own copies of `errorLabel`. Both now use the one in `error-label.ts` (`adminErrorLabel` is kept as an alias), and a test fails if another copy appears in `apps/web/src`. - **The AWS stack comes up (#476, pre-event bring-up fixes).** The stack had never been applied, and a rehearsal would have stopped at the first image build. - **ECS Exec is on by default** (`enable_ecs_exec`), so `aws ecs execute-command` gives an operator a shell in a running task on event day. - **srh's health check could never pass.** It called `GET /ping`, which the pinned srh answers with a 404, so srh never went healthy and the first apply hung. It now POSTs `["PING"]` as JSON and requires `PONG`. The command lives in `srh-healthcheck.sh`, which `terraform.yml` runs inside the pinned srh image against a real Redis. - **The app image could not be built.** `deploy.sh` used `apps/web` as the build context, but the Dockerfile needs the repo root. It now builds the way compose and the Fly deploy do. - **Everything is built for `linux/amd64`**, and every task definition says X86_64. An Apple Silicon laptop used to push arm64 images. - **The scorer and sync now live in the stack's own ECR.** Fargate could pull neither: the scorer package is private and sync is published nowhere. `deploy.sh` builds sync, mirrors the scorer from `--scorer-source` (default `$SCORE_IMAGE`) and writes all three image refs. The execution role pulls those three repositories by name, in place of the managed policy's `"*"`. - Every service rolls back a deployment that never goes healthy. srh runs two tasks, with a health check that rides out an ElastiCache failover. - `backend.tf.example` and the README's Remote state section set up the S3 state a real event needs. The bootstrap docs, the tfvars example and the `next_steps` output now agree. - **Breaking for an existing `terraform.tfvars`:** a `ghcr.io/<org>/score:latest` `scorer_image`, or a hand-pushed `sync_image`, is refused at plan time. Replace both with the placeholder from `terraform.tfvars.example`, re-run the bootstrap apply to create the two new repositories, then run `./deploy.sh --scorer-source <SCORE_IMAGE>`, which writes both. `docker login` to the scorer's registry first. - **On a stack that is already running an event:** do not take this upgrade mid-event. The first apply after it replaces the scorer and sync task definitions (new image refs) and the execution role's policy, and brings a second srh task up, so every service rolls once. Take it before the event or after it, never during one; `/admin` → Freeze first if you must. - **Docs: an AWS event-day runbook, and the gaps an organizer should know about.** `docs/aws.md` now covers running the event on ECS: watching the services, a shell with ECS Exec, freezing scoring, what an app task loss, an srh restart, an ElastiCache failover and a sync restart each look like, rolling back a bad image, running the load pass by hand (the load-test harness is Fly-only), and a tear-down that exports first and deletes the hand-made SSM parameters. `docs/troubleshooting.md` gains the matching ECS recipes. `docs/operations.md` and the `/admin` help text now say that the quiz attempt cap, the quiz retry cooldown and the classic cooldown count per contestant while points count per team, so a team of N gets N times the budget. The pre-event checks now dispatch `stock-scores-zero` and `patched-scores-right` on the release commit. The `ctf-setup.sh` header now describes `launch` in the order it runs: wait for Launch, then make the forks public. - **Docs drift from the pre-v0.7.0 audit (#501).** `docker-compose.yml` no longer passes `DEMO_MODE` to the app. Nothing has read it since #419 (demo seed/clear are admin-gated, ADR 58), so a `.env` that still sets it boots exactly as before; delete the line at leisure. A `.nvmrc` pins Node 22 for `nvm use`. The ADR index lists 54, 57 and 58, and ADR 57's heading no longer wraps (its anchor had lost half its title). The Makefile, AGENTS.md and CONTRIBUTING.md now name the AWS `deploy.sh` shellcheck and bats suite CI runs, and CONTRIBUTING describes the AWS module as ECS, not an EC2 box. A new `scripts/check-docs-drift.mjs`, run by the `shell` job's bats suite, fails a PR whose ADR index, local shell commands or Node pin drift from the source they copy. Stale comments, test counts and push-era wording are corrected. ## v0.6.0 — 2026-09-20 ### Breaking changes Five of them, and one chore that is not optional. Each has a full entry below with the reasoning; this is the index an upgrader reads first. - **`event.yaml` is deleted** — `.env` bootstraps the box and `/admin` runs the event ([ADR 55](docs/decisions.md)). Migration steps are under *Migrating a running event* in the configuration v2 entry. - **Push score ingest is removed** — poll is the one score transport ([ADR 56](docs/decisions.md)). A hand-rolled `--profile push` bring-up now starts the app with **no scorer and no poller**; use `--profile secdev --profile app`. - **The kit is renamed OWASP CTF** — new repo, docs and image paths. - **The repo moved to the OWASP org** — `OWASP/owasp-ctf-in-a-box`. Git remotes and `github.com` links redirect, so a clone keeps working. **The docs site does not redirect**: `dcotelo.github.io/owasp-ctf/` is gone and the site is now at `owasp.github.io/owasp-ctf-in-a-box/`. Update any bookmark or link of your own that points at the old Pages host. - **The AWS module is ECS Fargate + ElastiCache + ALB** — an existing EC2 deploy does not upgrade in place. - **Revoke the old leaderboard credentials.** If your event org still carries `LEADERBOARD_URL` and `LEADERBOARD_TOKEN` from a push-era setup, delete them now. Nothing reads them any more and they authorize nothing — but they are readable by the Actions run that a contestant's own pull request triggers, which is the worst place for a credential to sit. `ctf-setup.sh doctor` reports them fail-closed: a `gh` error reads "not verified", never "absent". - **Changed: the kit lives in the OWASP org now (#454).** The repository is `OWASP/owasp-ctf-in-a-box` and the documentation site is `owasp.github.io/owasp-ctf-in-a-box/`. Every reference the kit ships moved with it: the README and its CI badge, `SECURITY.md`, the issue-template config, every `docs/` page, the setup wizard's closing links, the `sync` GitHub App manifest, and the two the app serves at runtime — the landing page's repo button and `DOCS_URL` in `modules.ts`, which every module's setup checklist links through. GitHub 301-redirects the repo itself, so an existing clone, a `gh` command and any `github.com/dcotelo/owasp-ctf` link all keep working. **GitHub Pages does not redirect after a transfer** — the old docs host returns 404, which is why this is a fix rather than a rename: a box running an earlier build links its contestants and organizers to a dead site. - **Docs: the Cloudflare rate-limiting rule in front of the box (#438, edge layer).** The reference domain has always been proxied by Cloudflare and the repo never said so. `docs/hosting.md` gains a section on why the edge is the only place a per-IP control can live, the one Free-plan rule to create (`/api/*`, 100 requests per 10 s per IP, block 10 s, zone-level not account-level) and its verification; the pre-event checklist in `docs/operations.md` grows from three checks to four. - **Fixed: the leaderboard fold runs once per 10 s, not once per viewer (#444).** Composing the board — the scorer read, every module's points, team standings, the chart series and hint penalties — cost roughly 500 Redis commands per page view at 200 contestants, and the result was the same for everyone (the "you" highlight is applied on the client). The load test (#439) measured 2.7 req/s served against 10 demanded after the payload fix (#434). `/leaderboard`, `?display=1` and the landing page's live strip now read one memoized fold, refreshed every 10 s; concurrent viewers share the fold in flight, and a fold that throws is never cached. An app-side solve (quiz, flag, AI) reaches the board within 10 s; a Secure Development score within about 40 s, since the scorer read underneath was already cached for 30 s; a contestant's own pages still read live. - **Fixed: the leaderboard no longer ships a copy of the Secure Development catalogue in every row (#434).** Each contestant and team row carried the full per-challenge list — name, points, OWASP code — when only which ids were solved differed between rows. The load test (#439) measured it: at 200 contestants the page was 13 MB and served 0.3 requests a second against 10 demanded. The catalogue now travels once, on `LeaderboardData.catalog`; rows carry `apps[].solvedIds` (narrowed to ids the catalogue still holds); `AppBreakdown` joins the two at render, for the one row that is open. `/profile` and `/challenges` read the same shape. The scorer's own response was already built this way — the expansion happened in the app. - **Added: a load-test harness (#439).** `scripts/load-seed.mjs` writes N synthetic contestants on teams — the demo seed's exact key families, attached to the box's own catalogue — from inside the Fly machine, failing closed when it cannot tell which modules are live or when a row it is about to write already exists outside its own manifest; `--clean` removes exactly what that manifest lists, with no catalogue, no `--count` and no name pattern. `scripts/load-test.sh` ships it there, drives the two public hot pages with autocannon at fixed rates, samples machine memory (and fails the run if it could not) and writes one report on autocannon's p97.5. Its first run on the live box at 200 contestants measured `/leaderboard` at 13 MB and 0.3 req/s against 10 demanded, which is what promoted #434 to pre-event work. - **Added: `/health/deep` and a Fly machine check (#437).** The box had no health check and no monitor, and every read fails open by design — so a dead Redis or scorer left the site rendering with nothing scoring, and the first person to notice would have been a contestant. `/health` stays liveness-only and is now Fly's `http_service` check; the new public `GET /health/deep` probes Redis through srh and (when `SCORE_IMAGE` is set) the scorer's `/healthz`, answering 503 with each dependency reported as exactly `"ok"` or `"down"` and nothing more, reporting the poller's last poll age without failing on it, and caching results for 10 s so an unauthenticated URL cannot become a probe storm. `docs/hosting.md` gains a Monitoring section for pointing a free uptime service at it. - **Fixed: Insights team points now include Secure Development (#432).** The Teams table on `/admin/insights` summed each member's Quiz, Jeopardy and AI points and nothing else, so on a secure-development event every team read lower than the leaderboard by exactly its SD points — under a caveat that promised SD "contributes to participation and points". The per-login SD total is read from the leaderboard source (the scorer's own `points`, before the module overlays add theirs); a scorer that cannot be reached costs the SD share and says so in the caveats, and `mock` mode's placeholder scores are left out with the reason printed rather than folded in as if real. - **Added: recognition-only sponsor support (#417, #421, #423, #425, #426, #427, #428, #429, #431).** An event can credit the organizations funding it without any of that touching scoring: a `/sponsors` page, a strip on the landing page, and a row on the projector board, all driven from an `/admin` Sponsors tab built around the list rather than a single editor. Logos are uploaded as PNG, WebP or JPEG and served from the box, never hotlinked. How large they render is one organizer setting (`sponsorLogoSize`, small/medium/ large) applied to both the strip and the board — the board's logos were a fixed 2.2vh, which read as an illegible fleck from the back of a room, which is the whole point of that surface. A sponsor's name renders beside its logo and stands in for it when there is none. The `/sponsors` page carries a standing notice that sponsors fund the event and have no influence over challenge content, scoring or results. - **Changed: Classic CTF is now called Jeopardy (#424).** The module keeps its id (`classic`) and every Redis key it has ever written — this is the label contestants and organizers read, nothing else. The "CTF" and "Challenges" suffixes are dropped from the other module names for the same reason: the nav said "Classic CTF Challenges" inside a CTF. - **Changed: demo data is an admin control, not a build-time gate (#419, #430).** `DEMO_MODE` is gone. **Seed demo data** and the new **Clear demo data** both live in the Event tab's danger zone, behind the same type-to-confirm every destructive control there uses. A rehearsal can now be set up and torn down from the panel without a redeploy — which is what made it possible to rehearse on the box at all. - **Added: the leaderboard chart plots every enabled module (#415).** It charted Secure Development alone, so a quiz-only or Jeopardy event watched a flat line sit under a visibly rising board. **Fixed (#418):** on mobile the same chart read stale or flat when the time domain was mostly dead time — the series was there, compressed into the last few pixels. - **Fixed: a leaderboard source's teams no longer hide the ones contestants created (#413).** **Fixed: the profile trigger opens a menu, so it now looks like one (#411)** — it was styled as a plain link, so nobody found sign-out. - **Fixed: four findings from the live e2e audit (#379, #380, #383, #384).** The progress sliver, the profile page's ceiling, the OAuth error copy, and the `ctf` branch instructions on `/challenges`. - **Fixed: two deploy failures (#365, #422).** Redis is handed its data dir before it drops privileges, so a fresh volume no longer comes up read-only. Fly build attestations are disabled — they raced the registry and failed the push, intermittently and with an error that named neither cause. - **Docs and internals.** Every diagram is one animated-SVG system sharing a palette, a reduced-motion block and a `<desc>` that is the embedding page's alt text (#361). The demo seed's AI challenges, and the fact that event mode has no in-box fallback, are documented (#356). The stale references the ECS revamp and the rebrand left behind are gone (#366). `AGENTS.md` records how to talk to CodeRabbit through PR comments (#388). - **Added: the setup wizard offers an optional fly.io deploy as its closing step (#371).** It used to end at the local `docker compose` bring-up, leaving an organizer to find `deploy/fly/deploy.sh init --from .env`, the hostname and the second OAuth callback out of [docs/fly.md](docs/fly.md) for themselves. It now asks *"Deploy to fly.io now?"* — default **no**, so a run that only wants the box is unchanged — and on yes prepares `.env.fly`, writes the public hostname there as `EVENT_URL` (never into `.env`, which stays the compose box's), prints the OAuth callback that hostname needs, previews the deploy and asks before running it, then hands off `fly certs add` for a custom domain. It skips itself with instructions when `flyctl` is missing or signed out, for an app-only event, and a failed or abandoned deploy never takes the run down. - **BREAKING: push score ingest is removed; poll is the score transport (#377, [ADR 56](docs/decisions.md)).** A fork's Action writes its score comment on the PR and the `sync` poller reads it — that is now the only way a secure-development score reaches the box, and there is no setting to choose it. Gone: `caddy/Caddyfile.push` (compose mounts a constant `caddy/Caddyfile.poll`), the `push` compose profile and its bring-up notice service, the `SCORE_INGEST` key itself — from `.env.example`, the wizard, `scripts/dev-stack`, `deploy/fly/deploy.sh` and both AWS task definitions — the AWS module's `score_ingest` variable, the judge's `SCORE_API`/`SCORE_TOKEN` leaderboard POST, and with it the `<!-- ctf-score:not-recorded -->` marker that POST was the only writer of. The rendered consumer workflow no longer passes those two secrets to the scorer. **Upgrading:** nothing to do for a poll event, which is every event this kit has ever set up. A box whose `.env` still says `SCORE_INGEST=push` comes up exactly as before — nothing reads the key — but it is now polling, and `ctf-setup.sh doctor` says so once and invites you to delete the line (a leftover `poll` is silent: it already agrees with the behaviour). The one thing that does change under you is a hand-rolled bring-up: `docker compose --profile push --profile app up` now starts the app with **no scorer and no poller**, because that profile no longer exists on any service. Bring the stack up with `--profile secdev --profile app` — which is what the wizard and `scripts/dev-stack` have always printed for a poll event. `doctor` also still reports leftover `LEADERBOARD_URL`/`LEADERBOARD_TOKEN` org secrets, fail-closed — a `gh` error or an empty reply reads "not verified", never "absent" — and those are worth deleting now more than before: they are readable by the runs a contestant's PR triggers and authorize nothing at all. Push was deprecated and removed inside the same unreleased version on purpose; ADR 56 records why, under *Alternatives rejected*. - **Fixed: a Fly deploy could ship the previous event org's credentials without saying so (#381).** `.env.fly` and `.env` were never compared, so a re-created org — new OAuth app, new sync App — deployed silently against the old one: every sign-in bounced with `?error=application_suspended` and `sync` logged `GitHub 401 minting installation token` on every poll, while the deploy, `/health` and `doctor` (which reads `.env`) all looked fine. A deploy now names every external-system key the two files disagree on — before the build and again in the closing summary, key names only, never a value — and warns rather than refuses, since per-environment OAuth apps are legitimate. `init --refresh` gained three fixes of its own: an explicitly blank `GITHUB_APP_INSTALLATION_ID=` line in `.env` now **clears** the pinned id (that blank means "let `sync` auto-discover the installation"), while an explicit blank of any other key keeps the deployed value and says why rather than locking everyone out of `/admin`, and a key absent from `.env` altogether keeps the deployed value for every key including the installation id; the source is read in every form `docker compose` accepts (`KEY = value`, `KEY: value`, quoted, `export`-prefixed) instead of `KEY=value` alone; and a line in any of those forms is now replaced in place rather than having a second assignment appended. Both `init` and a deploy warn when the env file assigns a key twice, naming it and stating that the last assignment wins. - **Fixed: the challenge browser's OWASP category filter offered codes from targets the organizer had unticked (#391).** The scorer's catalogue carries every target in its rubric, and the filter was built from the whole of it rather than from the runtime target list, so narrowing targets on **Secure Development → Targets** left the filter offering categories that matched nothing on the board. It now follows the enabled targets, as the page's totals already did. - **BREAKING: configuration v2 — `event.yaml` is deleted; `.env` bootstraps the box and `/admin` runs the event (#386, [ADR 55](docs/decisions.md)).** One change, shipped over four parts, that replaces two overlapping config planes with two separate ones. Nothing is baked into an image any more, and no image takes a configuration build-arg. **Removed.** `event.yaml` and `event.yaml.example`; the `EVENT_CONFIG_B64` build-arg and the `EVENT_CONFIG` env var; `apps/web/Dockerfile`'s config `ARG` and the compose `build.args` entry that fed it; `apps/web/scripts/generate-event-config.mjs` with its generated `event-config.generated.ts` and the `prebuild`/`predev`/`pretest` hooks that ran it; `sync`'s yaml reader and its bind-mounted `/config/event.yaml`; `setup/test/corpus` and the cross-reader corpus suites that pinned the three yaml parsers against each other; the wizard's `--config` flag, its yaml writer, its `KNOWN_MODULES` mirror, its `--targets` flag and its "which modules" question; Fly's `--config` flag; and AWS's `event_yaml_b64` variable. **New in `.env`, read at container start.** `GITHUB_ORG` — the event org for fork links and for the repos `sync` polls; empty renders bare repo names in the app, and makes `sync` refuse to start naming the key. `ADMIN_LOGINS` — a comma-separated list of GitHub logins allowed into `/admin`, matched case-insensitively; empty or unset **locks everyone out**, including organizers who used to be in the `admins` list, so set it and restart (no rebuild). `SCORE_IMAGE` gains a meaning: **non-empty is how a box says it runs Secure Development** — it picks the compose profile, it is the default module set on a first boot, and it gates the module's admin toggle. **Moved to `/admin`, live, with no restart.** Which modules run: before an organizer touches the panel, Secure Development is the only board on — and only with a `SCORE_IMAGE`; Quiz, Classic and AI start off, and switching the last board off is allowed (the landing page then says "No boards are open yet"). Which Secure Development targets run: `ctf-setup.sh org` now forks and provisions **all six** of `setup/targets.tsv` for every event — six forks, six scoring workflows, six package Read grants — and **Secure Development → Targets** picks the live subset, defaulting to all six, at least one required, taking effect on the next page load and the next poll tick. The event's identity — name, tagline, location, contact e-mail, Discord invite — on the Event tab's Identity section, defaulting to "OWASP CTF" and empty. The event's dates and countdown now derive from the **Scoring opens** / **Scoring closes** schedule rather than a separate field. Event archives carry `secureDevTargets` and name/theme/location and restore them on import (bundle format v2; a v1 archive still imports, leaving the stored target list untouched); contact e-mail and Discord invite are deliberately never exported. **Compose profile renamed `poll` → `secdev`.** `scorer` carries `["secdev", "push"]` and `sync` carries `["secdev"]`. Whoever brings the stack up adds `--profile secdev` **iff `SCORE_IMAGE` is non-empty** — `scripts/dev-stack` and `deploy/fly/render-compose.sh` do it for you. The `push` profile is unchanged here (its deprecation is #377). **Deploy paths.** Fly and AWS bake nothing and carry `GITHUB_ORG` and `ADMIN_LOGINS` as runtime environment instead: Fly's `deploy.sh init --refresh` now refreshes both alongside the other external credentials and falls through to the top-up prompts rather than exiting (#381), and the AWS app task definition gained `GITHUB_ORG`/`ADMIN_LOGINS` in place of the config bake. **Migrating a running event.** There is no compatibility shim and no migration step — `event.yaml` is simply not read by anything. On a box: add `GITHUB_ORG` and `ADMIN_LOGINS` to `.env`, then bring the stack up with `--profile secdev --profile app` (drop `secdev` if you have no `SCORE_IMAGE`); delete `event.yaml`. On Fly: `deploy/fly/deploy.sh init --refresh` copies both keys into `.env.fly`, then deploy as usual. On AWS: set the new `github_org` and `admin_logins` Terraform variables and drop `event_yaml_b64`. Everything else the file used to say — which modules run, which targets run, the event's identity, the dates — is now set in `/admin` on the running box, and the module content, teams, scores and hint spend in Redis are untouched by any of this. - **README and docs screenshots caught up with the rename.** The wizard and `doctor` terminal shots still showed the CTF-in-a-box banner, the old eight-step numbering and the `ctf-in-a-box-test` org; the challenge browser linked the old org's forks; the admin Event tab named the old test event. All four are recaptured from the current script and the live box. The README's heading now carries the OWASP logo the app itself shows (a light/dark pair, since the mark is black on transparent), and its two references to the AWS module as "one EC2 box" now describe the ECS Fargate stack that replaced it. - **The Fly module now refuses push mode instead of deploying a box that scores nothing (#373).** `docs/fly.md` said push "works"; it never did there. In compose, caddy routes `POST /score` to `scorer:4000`; a Fly machine has no caddy and `fly.toml` exposes only the app on port 3000, so a fork's Action would POST every score into a 404 — silently, since that step does not fail the workflow. `deploy.sh` now exits with the reason and the fix when `.env.fly` says `SCORE_INGEST=push` (dry-run included), and the docs say poll-only. Routing `/score` on Fly, if ever wanted, stays tracked in #373. - **The wizard's "Score ingest" answer now reaches `.env` (#372).** It was written to `event.yaml` only, while `SCORE_INGEST` in `.env` — the switch `docker-compose.yml`, the Caddy profile and the wizard's own bring-up step actually read — kept its `poll` template value. An organizer who answered `push` got a push label on a poll deployment with no warning. The wizard now writes both from the one answer, and `doctor` and the bring-up step warn, naming both files and both values, whenever the two disagree. - **The wizard now verifies last, after you have done the UI-only steps (#370).** It used to run `doctor` the instant the org was provisioned and *then* tell you to detach the forks and grant the package — so every first run ended on a table of ⚠️ for steps you had not been given the chance to do. It now prints that checklist, pauses for you (skipped under `--dry-run`), brings the containers up, and runs `doctor` as a closing ninth step, so a clean table on the last screen means the event is ready. - **sync's poll cursor now survives a Fly restart (#364).** The single-volume layout puts it at `/data/sync/state.json`, but sync runs as `node` and a fresh Fly volume is root-owned, so pointing `STATE_PATH` there failed with EACCES — and the live `.env.fly`, written before `init` added the knob, never pointed there at all, so the cursor sat on ephemeral disk and every suspend/resume re-read every fork. The sync image now has an entrypoint that creates and `chown`s the state directory as root and drops to `node` (via `su-exec`) before starting the poller, mirroring what redis's command does for its own directory. `deploy.sh` warns when an env file lacks `REDIS_DIR`/`STATE_PATH` and names the two lines to add. - **BREAKING: the kit is now called OWASP CTF.** Repo at `github.com/dcotelo/owasp-ctf`, docs at `dcotelo.github.io/owasp-ctf`, Fly app `owasp-ctf`, and the Terraform defaults `name = "owasp-ctf"` / `ssm_prefix = "/owasp-ctf"`. Every resource the AWS module names from those defaults is named differently from here on; there is no migration from the old names. The scorer image path `ghcr.io/owasp-ctf/score` predates the rebrand and is unchanged. The brand and the neutral default event name are now the same string, so a build that lost `EVENT_CONFIG_B64` no longer gives itself away by its name — check for an empty `admins` list and a 403 on `/admin` instead. This project remains unaffiliated with, and unendorsed by, the OWASP Foundation; OWASP® is a registered trademark of the OWASP Foundation. - **A teamless organizer is now told before they submit, not after (#357).** Scoring is per team and every submit route refuses a teamless login, but organizers are exempt from the two redirects that steer contestants to team setup — an organizer opening a module page to check their content renders is not playing. The exemption covered the *information* as well as the redirect: the form rendered, the route refused the submission, and the rule arrived attached to a solve that did not count. The population most likely to be testing a board was the one guaranteed to discover the requirement by losing a submission to it. A notice now sits above the form on `/flags/<id>`, `/quiz` and `/ai/<id>` for a signed-in viewer with no team, and it names **Play solo** — the one-click team of one that already existed on the profile and that none of the no-team copy mentioned. The refusal messages name it too, so the cheapest exit is visible at both moments. The exemption itself is unchanged; what changed is that it no longer exempts anyone from knowing. Costs one extra `hasTeam` read per admin module page view, which is what buys the notice. - **BREAKING: the AWS module is now ECS Fargate + ElastiCache + ALB, replacing the single EC2 box.** An existing EC2 deploy does **not** upgrade with an `apply` — that would destroy the instance and build the new stack around a database that never existed. Migrate instead: export the event archive, stand the new stack up beside the old one, import, move DNS, then destroy the old (steps in the module README). The driver was durability, not fashion. On the box, Redis was a container writing an append-only file to an EBS volume — our fsync policy, our volume, our restore procedure, and no backups unless the operator built them. Poll mode survives "the box died"; nothing there survived "the box died and the quiz answers, the classic flags and every team went with it". ElastiCache makes that AWS's problem, and once Redis is managed the ALB (health checks, a task swap without dropping the event) and Fargate (no instance to patch) follow nearly for free. ADR 54 records the alternatives, including the two it rejects: EKS, and keeping EC2 with backups bolted on. **The app did not change.** It, the scorer and sync speak only the Upstash REST API and never raw Redis, so ElastiCache changed exactly one thing — what `srh` connects *to*. `srh` stays; everything above it is the code compose runs. ADR 41's boundary also stays in the security groups: the ALB alone reaches the app, the app and workers alone reach `srh`, `srh` alone reaches ElastiCache, and the app has no route to Redis at all. Tasks sit in public subnets with no permitted inbound, because a NAT gateway costs about what the whole instance did, per AZ, before a byte moves. Three things are worse on purpose and are written down rather than left to be discovered: it costs roughly **four times** the EC2 bill at the defaults (itemised, with the two dials that bring it down); **Terraform state now contains a secret**, the generated ElastiCache AUTH token, so an encrypted remote backend stops being advice; and **durability is snapshots, not AOF**, so a restore loses up to a day rather than up to a second — which is why the event archive export remains the backup that matters for authored content. **Every event secret is encrypted with a KMS key the stack creates**, and `aws ssm put-parameter --key-id` is required rather than optional: the task execution role's `kms:Decrypt` names that one key, where it previously held `"*"` narrowed only by a `kms:ViaService` condition — enough to reach any SecureString in the account that delegates to IAM. The bootstrap apply now targets the key alongside ECR, so the secrets step lands between the two applies rather than before them, and a parameter stored under a different key fails at task start with an `AccessDeniedException`. About a dollar a month. The image bake moved off the instance into `deploy/aws-terraform/deploy.sh`, because Terraform cannot build an image and `event.yaml` is baked at build time — an image built without `EVENT_CONFIG_B64` ships an empty `admins` list and 403s every organizer. Tags are content-addressed (revision + config hash) into an immutable repository, so a redeploy with nothing changed is a no-op instead of an error, and `--dry-run` prints every command while running none of them, with the config redacted. The ALB health-checks `/health`, not `/`. A page that reads Redis is the wrong probe for something wired to task replacement: a blip would deregister every app task, and replacing them cannot fix Redis. It also proved nothing — the app streams its shell with HTTP 200 and puts render failures in the body, which is exactly how #312 stayed invisible to status-code checks. Verified with **no AWS account**: `srh` speaks TLS + AUTH to a cache configured as ElastiCache presents itself, and `EVAL`/`EVALSHA` survive the hop, with a wrong token rejected as the control. Worth knowing when a handshake fails — `srh` verifies against CAStore's embedded bundle, not the OS trust store, so the fix is a newer `srh_image` and never a mounted CA. - **Fixed: the demo board shipped a challenge nobody could solve (#355).** **Seed demo data** wrote `Jailbreak Arena` as an `event`-mode AI challenge worth 400 points. That mode is graded *only* by an external arena POSTing a signed solve event — it renders no flag form by design — and the fixture's launch URL points at `ai-demo.example.org`, a reserved documentation domain with no DNS record. So the demo showed a challenge whose own description promised the external side would report the solve, with a Launch button that dead-ended and no other way to clear it. The seeded solve rows made the board look like two contestants already had. It is now flag-gradable (`mode: "both"` — the panel labels that "Either — flag or external event") with a flag and a rewritten description that says what a real event-graded challenge would do instead. The demo board plays end to end; what event mode looks like stays documented in `docs/ai-module.md` §5, which is the honest place for it, since demonstrating it needs a second system. The seed test now asserts **no** demo challenge is `event`-mode, so reintroducing one fails before it reaches a board. - **Fixed: a team could vanish from the leaderboard while its own profile page still showed it (#358).** Found on a live board — the Teams view listed four teams holding seven members while Insights, reading the same function, counted eight people on a team. The missing one had created a team minutes earlier and was competing; from the board's perspective they were not there. Every SCAN walk in the app read a failed page through a `["0", []]` fallback, and `"0"` is the cursor value meaning ITERATION COMPLETE. `upstashPipeline` reports a per-command failure as `{ error }` rather than throwing, so a failed page did not retry, did not throw and did not return empty — it ended the walk and handed back the pages gathered so far, indistinguishable from a full sweep. Because it depends on which page fails, it is intermittent, and two readers of the same data disagreed. **Five walks shared the shape, and the leaderboard was the least severe.** The master reset returned a `cleared` count for a sweep that had stopped early, so an organizer could be told the event was wiped and open a "fresh" one still holding the previous event's solves; clearing one contestant's progress could report success having removed part of it; and two counters (Insights participation, the pre-delete confirmation count) silently undercounted. All five now parse a page through one helper that throws, and each caller applies its own documented fail direction — the leaderboard degrades to the team-less view it already had a `catch` for, Insights records a caveat and keeps the figures it can still stand behind, and the reset and the clear fail loudly rather than claim to have finished. Re-running a failed reset is safe: deleting an absent key is a no-op. The regression tests fail the *partial* case specifically — first page good, second page failing. Asserting only that an all-failing walk returns nothing would have passed against the bug, since a first-page failure returned empty under the old code too. - **Dependencies.** `next` and `eslint-config-next` 16.3.4 → 16.3.5 (#450, backported fixes only, including CSP nonces on `loading`/`template` script tags and two `next/image` disk-cache corrections); `better-auth` 1.7.2 → 1.7.5 (#401, #452); `@types/node` 26.4.1 → 26.6.1 (#400, #451); `@types/react-dom` and the react group (#399, #409); `yaml` 2.9.0 → 2.9.1 in `scorer` (#449). Dependabot now keeps the node base image on its current line, patches only, rather than proposing a major it cannot test (#408). ## v0.5.0 — 2026-09-07 The admin panel every URL of which had stopped loading, a security bump, and a long run of numbers that finally agree with each other. - **Fixed: every `/admin` URL returned the error boundary instead of the panel (#312, fixed by #324).** For the whole window between #297 and #324, an organizer could not freeze scoring, edit a question or a flag, reset the event, manage admins, or read Activity or Insights — the entire control plane was unreachable in production, on every URL shape, and a signed-out visitor never even got the "Forbidden — Organizer access only" wall, because the throw happened in the route file before the panel ran. `resolveAdminTab`, `adminTabHref` and `tabFromLocation` lived in `admin-controls.tsx`, which is `"use client"`. A function exported from a Client Component is a client *reference*, not a callable, so the two route files that CALL `resolveAdminTab` threw at request time: *"Attempted to call resolveAdminTab() from the server but resolveAdminTab is on the client."* They now live in `admin-tabs.ts`, which carries no marker, and `admin-controls.tsx` re-exports them for its own client callers — the shape `team-limits.ts` and `admin-admins.ts` already use. **Nothing in CI could see it**, which is the part worth carrying forward: `"use client"` is inert under vitest, so the call simply succeeds there; `next build` compiles it without complaint because the error is raised per request; and `acceptance-app.sh` never requested `/admin`. Full CI was green on `main` with the panel dead. Two guards were added with the fix — a static check that neither route imports the helpers from the client module, and an `acceptance-app.sh` assertion that requests `/admin` and `/admin/overview` against a real image and asserts the rendered copy. It asserts on **copy, not status**: the shell streams with HTTP 200 and the failure arrives inside the body, so a status check returns 200 on a fully dead panel. If you are running an event on a build from that window, redeploy. - **Deleting a solved challenge no longer tells a contestant they finished the module (#330, #343).** The numerator on `/profile` counts solve records, which survive deletion on purpose — the admin dialog promises it — while the denominator counted the live catalogue. So an organizer who deleted two solved AI challenges mid-event handed every affected contestant "5 / 5 cleared 870 / 850 pts", a bar filled past its own end, above a board showing 3 / 3. Both halves now count the **union** by identity: the live catalogue plus any solve whose challenge is gone, valued at what the solve record banked, which is the only figure a deleted challenge still has. Clamping was the first attempt and is not the same thing — `max(live, solved)` stops the ratio exceeding one but reports 5 / 5 where the truth is 5 / 7, calling a module finished with two challenges still open on it. Secure Development keeps a clamp, having no per-item identity to union over: its catalogue is baked from the rubrics, so it can only lose a whole target from under banked points. The follow-up is the part worth carrying forward: the first pass moved the module rows and the header ceiling onto the union and **left the footer behind**, so one page read "500 / 700 pts" in a row and "0 pts still on the board" beneath it — the same wrong claim in a second voice. When a denominator changes, every reader of it changes with it. - **The leaderboard and the profile now agree on how much of a module is left (#348).** Expanding a team on `/leaderboard` read "AI Challenges 5 / 5 cleared" while `/profile` read "5 / 7 cleared" for the same contestant in the same minute. The board said the module was finished; the profile said five of seven, and the board's is the more authoritative-looking of the two. The denominator has to be the live catalogue **unioned** with items solved whose challenge an organizer has since deleted — solve records survive deletion on purpose, which is what splits the two counts. That rule reached the profile in #330 and #343 but not the board, because it lived inside `profile/module-blocks.ts`, a module the leaderboard cannot import. It now lives in `leaderboard/denominators.ts` and both surfaces draw from it, with a test that computes the same fixture both ways and fails if they diverge. No new Redis reads: the team fold already dedupes members' solves by item id (that is how a flag two teammates both solved counts once), so the ids the union needs were in hand and simply were not carried out of it. Rows built from the per-login aggregate counters still clamp, because those counters are running totals with no memory of which items produced them — and clamping is documented as the fallback it is, not a second spelling of the union. - **Seeding demo data no longer deletes the categories an organizer authored (#344).** The seed wrote both module category lists with an absolute `SET`, so **Seed demo data** replaced them with the fixture's. The challenges themselves survived — they are written per-field, keyed by id — and the admin panel kept listing them, but the contestant board renders only categories present in the list. Three authored AI challenges and 850 points of content silently left `/ai`, together with the viewer's three solves of them, while the panel two clicks away read "1 category · 5 challenges" like a healthy setup. A master reset is no way back: it preserves authored categories on purpose, so the list it preserves is the seeded one. Both lists are now **unioned** — the organizer's order kept verbatim, then any demo category not already present appended, matching case-insensitively the way `setCategories` and classic's `importBundle` already do. Each seeded challenge is written under whichever spelling the union kept, so seeding "AI" onto a board that spells it "ai" no longer stores rows the board's exact-match filter cannot see. A union that would exceed the 50-category cap refuses outright, writing nothing, since trimming it would orphan the demo challenges and storing it would block every later category edit. The union and those challenge writes happen in **one Lua script**, not a read followed by a write. Upstash's `/pipeline` is not transactional, so a `GET` and a later `SET` leave a window in which an organizer's own category edit is read, ignored and overwritten — and because the challenge rows have to name a category the list actually holds, a rename landing inside that window would orphan every row the seed just wrote, which is #344 again by another route. Redis runs the script atomically, so the union is computed against the list as it is at that instant. Two things say so now, because an organizer can still reach that state by hand or through an import that spells a category differently: each module's admin panel warns, in amber, when challenges sit in a category absent from the list — with the count on the status line, which no longer reads a green "setup complete" above it — and the seed's confirmation names the authored content it touches instead of only "contestants, teams, and solves". The seed remains `DEMO_MODE`-only and cannot be reached in a real event. - **`demo.gif` shows the leaderboard as it is now (#321).** The walkthrough on the README and the docs home still showed the pre-#294 team row — a `PTS` header over flat module chips above a flat target list — which is the shape #348 and #350 have since changed twice more. It is re-recorded against the running app: the score-over-time sweep reading each team's total at that instant, then the leading team opening into its members, one progress row per module, its per-target breakdown, and a target's own flags with their OWASP category badges and open/patched status. Nine frames at the original's pacing, 1456x821 to match the stills beside it. - **The admin screenshots in the docs show the admin panel that exists (#321).** Four of them predated the redesign entirely: an outer `CONTROLS` frame with seven flat horizontal tabs, no AI tab at all, module toggles as checkboxes, categories as full-width rows with Move up / Move down / Remove, the Hardest-first table naming every challenge by generated id, and a standalone STATUS card that Overview absorbed. `leaderboard-team.jpg` showed the pre-#294 team row — a `PTS` header over flat module chips above a flat target list — rather than `net pts` over one progress row per module. All five are recaptured against the running app, and two alt texts that described replaced controls are corrected with them: the category chips now carry rename (#306) as well as move and remove, and a challenge row's delete lives in its row menu rather than beside Edit. `admin-support.jpg` now shows the state its own alt text has always described — the panel after a contestant lookup, not the empty form above it. `demo.gif` is still the pre-#294 row and is not re-recorded here: its value is a rising score-over-time sweep, which needs a demo-seeded event rather than the one this was captured on. Tracked on #321. - **Security: two unauthenticated RCE advisories in Next.js are closed (#238).** `next` moves 16.3.2 → **16.3.4**, which the release notes list as carrying fixes for [GHSA-2xp9-vwfh-vxw4](https://github.com/vercel/next.js/security/advisories/GHSA-2xp9-vwfh-vxw4) — unauthenticated remote code execution in the Image Optimization API when AVIF files are used — and [GHSA-p293-qw3h-jr36](https://github.com/vercel/next.js/security/advisories/GHSA-p293-qw3h-jr36), unauthenticated RCE on Windows-hosted servers. The bump also carries `@img/sharp-libvips` 1.3.2 → 1.3.3 (`sharp` stays 0.35.4). **The first one reaches this app.** `apps/web/next.config.ts` allows `avatars.githubusercontent.com` under `images.remotePatterns`, so `/_next/image` is a live, unauthenticated route on every deployment of this kit. The second does not: the app ships from `node:22-alpine`, and the kit has no Windows host anywhere in it — recorded rather than dropped so nobody has to re-derive that it was considered. Nothing in the kit's own code changed. It is here because this file is the only place a self-hosting organizer learns that upgrading is security-relevant rather than optional — fixes land on `main` and ride the next tag, and nothing is backported ([SECURITY.md](SECURITY.md)). An event already running should redeploy. - **The CI workflow pins every action to a commit SHA, and stops handing the checkout token to build steps (#299).** `ci.yml`'s actions moved from floating tags to 40-character SHAs, and all thirteen of its checkouts now carry `persist-credentials: false`, so a compromised action release cannot become a compromised run of this repo's CI and a build step cannot reach the credential the checkout used. The kit's own CI is in [SECURITY.md](SECURITY.md)'s scope, which is why it is worth an entry. Scoped to `ci.yml`. `codeql.yml`, `pages.yml`, `terraform.yml`, `stock-scores-zero.yml` and `patched-scores-right.yml` still use floating `@v` tags and do not set `persist-credentials`; finishing them is tracked separately rather than quietly implied here. - **Dependencies, CI and internals, in one place.** These would otherwise be silent — a Dependabot PR prompts nobody to write a changelog entry, which is how the two security-relevant bumps above were nearly missed: - `better-auth` 1.7.1 → 1.7.2 (#273). Sessions are stateless JWE cookies signed by `BETTER_AUTH_SECRET`, and `auth.ts`'s `disabledPaths` denylist is version-sensitive by construction, so auth bumps are treated as security changes and `auth.test.ts` gates them. - A **required CI gate** now fails when the duplicated KNOWN_MODULES and target lists disagree across `sync`, `setup`, the app and the scorer (#286, #288, #289) — the drift ADR 10 accepts by duplicating them. - A root `Makefile` wraps the commands CI runs, so `make help` lists them rather than each contributor rediscovering them in `AGENTS.md` (#287). - The quiz, classic and ai admin panels share one implementation of each flow instead of three copies (#278) — the refactor the module-shaped bugs fixed later in this release all landed once because of. - `architecture.md`'s flow diagrams are animated SVGs (#276). - An ai solve submitted through the in-box form logs its activity row, which only the API path was doing (#255). - Five documentation corrections against the code: the CI job count, the admin panel's real tab shell, ai's archive-bundle status, the srh caveat the live Lua suites closed, the CodeRabbit pre-merge check modes, and the three `event.yaml` keys the example omitted (#307-#311). - **`GET /health` says which build is running.** "Did my fix reach the box?" had no answer from outside the container. `fly status` counts deploys, not commits, and needs Fly credentials; the alternative was re-testing the bug and inferring — which is how issue #312 sat fixed on `main` and still broken in production, with nothing to poll that would have said so. The endpoint is public, unauthenticated and liveness-only: `status`, the release `version`, the `revision` the image was built from, and `builtAt`. The last two are the ones that matter — `version` only moves on a release, so it cannot detect a deploy of an unreleased commit, and `builtAt` separates two deploys of the same commit, which a redeploy after a config change produces. Both are baked from build args that `deploy/fly/deploy.sh` and `scripts/dev-stack` fill in; a build that passes neither reports `"unknown"` and `null` rather than failing, because a health endpoint that can 500 is not a health endpoint. `deploy.sh` reports `unknown` from a dirty tree on purpose — including for untracked files, since the build context is the working tree and an untracked file is baked in just as surely as a modified one, so the sha would not describe the image. Nothing here touches Redis: a dependency check would report the app unhealthy when the app is fine, which is backwards for something a restart policy acts on, and that answer already exists on the admin Overview behind the organizer gate. The values are validated rather than echoed — only a hex sha and a parseable instant get through, because they arrive from a deploy shell and land in a world-readable body — and a test pins the response to exactly four fields, since the real risk to a public health endpoint is not that it breaks but that it quietly grows. - **The FAQ no longer promises that case never matters.** Asked "Does case or extra spacing matter?", it answered "No. Matching trims leading and trailing whitespace and ignores case" — flatly, with no exception. A classic challenge can be marked case-sensitive, in which case `flagComparisonForm` compares the flag verbatim and the board badges the card `CASE-SENSITIVE`, so a contestant who read the FAQ, typed the flag in whatever casing came naturally and was told "Not quite." had been misled by the site itself — and spent the challenge's cooldown finding out. Every other string carrying this claim already qualifies it; the registry comment beside them says the qualifier is mandatory, and notes that stating it unconditionally shipped once before, in v0.3.0. The FAQ answer now names the exception and points at the card, and the classic-only FAQ test asserts the **answer** rather than only that the question rendered — which is how the wrong one survived. - **Secure Development has no hints, and the app stops implying otherwise.** Two changes in this window, and the second reversed the first's premise, so they are recorded as one arc. With hints switched on, no 💡 ever appeared on a target's challenge rows and the banner stated, as fact, that "no challenge is offering one yet". It could not know that: `getHintAvailability` hand-rolled Upstash's path-style `GET /hkeys/<key>` so the read could ride Next's ISR cache, and **srh does not serve that route** — it answers `404 SRH: Endpoint not found`, and srh is what every deployment of this kit runs in front of Redis. The read failed on every render, the `catch` turned it into `{}`, and "the read failed" became "there are no hints". That transport bug was real and is fixed (#313): the read went through `upstashPipeline`, which srh does serve, and a per-command error now throws so the fail direction applies rather than being reported positionally and ignored. Fixing it revealed there had never been a **producer**. Nothing in this kit writes a `hints:<app>` field — not the scorer (which has no concept of a hint, despite three comments here calling those hashes "scorer-owned"), not the admin panel (Secure Development's tab has no hint field), not the rubrics. So a working read could only ever come back empty while `/challenges` reported that to contestants as news, and the Hints tab told organizers each module "holds the hint text on its own tab" — true of Classic and AI, false there, sending them to a tab with no such field. So Secure Development is out of the availability read (#334): `getHintAvailability` returns `{}` and reads nothing, `/challenges` shows no hint banner and no 💡, and the Hints tab names Secure Development alongside quiz as having none. It is kept as a function returning the same shape, so reviving it is one edit if secure-development hint text ever gains an author — the three candidate designs are recorded in #334. **Classic and AI hints are unaffected throughout.** Both are authored through their own admin tabs into `ctf:classic:hints` / `ctf:ai:hints`, both read through `upstashPipeline`, and the four hint settings, the pricing, the gates, the penalty column and the reveal machinery all keep working for them. - **Classic and AI now tell contestants that a hint costs points.** Secure Development's rules and terms have always said "Revealing a hint deducts points from your total"; classic's and ai's never did, though all three sell hints through the same gate and the same four settings. On a classic-only or ai-only event the price of a hint reached a contestant only from the reveal button itself. Both modules' `rules` and `terms` scoring copy now carry the sentence, mirroring secure-development's two variants. Quiz is deliberately untouched — it sells no hints, so the sentence would be a lie there — and a registry-level test now asserts exactly that split. - **A category can be renamed, and its challenges come with it.** Categories could be added and removed but never renamed, and removal is refused while any challenge still files under one — so fixing a typo in a category ten challenges already used meant editing all ten and then deleting the old name. Each chip on the Classic and AI tabs now has a **Rename** control that edits the name in place and rewrites every challenge in that category in the same operation. Renaming onto a name another category already holds is refused rather than merged (merging is a different, lossier request); changing only the capitalisation of the same category is a rename, not a clash. The rename travels as its own request shape rather than through the category array — replacing the whole list is precisely what cannot express a rename, since a renamed entry is indistinguishable from one removed plus one added, and that is how the challenges lost their association with it. The challenges are written first and the list last, so an interrupted rename is finished by simply running the same rename again, and no challenge disappears from the panel while one is half-applied. - **The number beside a challenge is where it actually sits, and a team's slug is never retyped.** Each module list printed the row's stored `order` field, which is not a position in anything on screen: on a board seeded per category it repeats — four rows read `#1`, four read `#2` — under a sentence promising contestants see them in that order. The list now numbers rows itself, from where they sit in the group being displayed, so the numbering restarts per category exactly as the reader's eye does; the stored field is untouched and still drives the real ordering. On **Support**, the contestant card knew the team's slug and the team actions below still made you type it, which is how the commonest ticket on that tab started; the card now offers a control that fills the field and puts the cursor in it. Disband and Transfer keep their confirmations where they are — a card whose subject is a person is not the place for a second trigger on a team-wide action. That card's `joined` timestamp also now says **UTC**, which the line directly beneath it had been saying all along. - **Nothing in the admin panel destroys work without asking.** Opening a different question or challenge — or clicking Add — replaced a half-written draft in silence: the module forms sit *below* the list, so every list control stays live while you write, and text typed into one prompt vanished the moment Edit was clicked on the next row. All three panels now ask before discarding, through one guard in the shared editor hook; a form with nothing typed into it still opens straight away, and a save in flight is left alone. On the **Admins** tab, removing a colleague fired immediately on the click, with no gate at all, while removing *yourself* used `window.confirm` — the one native dialog in a panel where every other destructive action has a focus-managed, styled one. Both now go through that dialog, each with the consequence stated, and neither asks you to type a phrase: losing panel access is recoverable by any other admin, and reserving type-to-confirm for what genuinely cannot be undone is what keeps organizers reading it. - **The admin panel calls things by the names an organizer uses.** Insights' Hardest-first table and its CSV named every challenge by generated id (`case-probe-control-xf1ob0`), never by title — unreadable out loud at a closing ceremony, and the quiz and challenge lists two tabs away had shown titles all along. Both now carry the **title** (a quiz question's prompt, a challenge's title) with the id kept beside it, since the id is what a support question and a store key name; a challenge deleted since it was solved keeps its metrics and falls back to its id, and a catalogue read that fails costs the labels, never the numbers. Elsewhere in the panel one thing had four names: a classic solve was a "flag solve" in Activity under a tab called Classic CTF, an ai solve was "ai solve", and Insights said "Sec-dev" and printed raw module ids in a column headed Module — all of them now use the module's own name, pinned to the registry by a test. The AI **Send test** verdicts (`no-team`, `wrong-mode`, `paused`, …) were raw strings whose decoder ring lived only in `docs/operations.md`; each now carries a sentence saying what it means, and the three that are *not* integration faults say so first. And an event with no Secure Development no longer opens its admin page with "Sync not running." forever — there are no forks to poll, so the section is simply absent, while an event that does serve the module still gets the warning, now saying what it costs. - **The Event tab stops understating what its three biggest controls do.** **Freeze scoring** said only "Pause new submissions from being scored", so the one thing an organizer has to relay to a room — *your PR score is real, the board is on hold* — was nowhere on the switch. It now says which submissions are refused, that fork Actions keep judging and commenting and that those scores land on unfreeze, and it quotes the sentence contestants actually read ("Scoring is paused right now. Try again later.") so a help desk recognises it. Overview's **Scoring** switch is the same setting and now shares that copy from one module instead of a drifting duplicate. **Master reset** listed what it destroys and never what it keeps, which is most of an organizer's evening: it now says authored questions, challenges, flags, hints, categories and every setting survive, that the AI launch key is rotated, and it explains the poll-mode caveat instead of assuming the reader knows their `SCORE_INGEST`. The **Event archive** moves out of the red Danger zone into its own section with a visible disclosure arrow — Export writes nothing and is what you run *before* something risky, and painting it like a wipe taught the opposite — and its description says "Classic, Quiz and AI", which is what the bundle has always carried. - **Four admin editor bugs that each cost an organizer a save.** Renaming a quiz choice's id left the old id in the answer key: the panel said the question was saveable and the store answered 400 (#280). A title or prompt whose first word ran past the 48-character confirmation cut could end in half an emoji — a phrase no keyboard can type, so that item could not be deleted at all (#281). The classic and ai forms opened with the cursor nowhere and the quiz form opened it in a choice-id box rather than the prompt (#282). And a file the browser could not read was dropped in silence, leaving the textarea unchanged with no explanation — on the quiz/classic importers and on the event archive alike (#284). - **Every admin destination has its own URL.** `/admin/overview`, `/admin/activity`, `/admin/insights`, `/admin/support`, `/admin/event`, `/admin/hints`, `/admin/admins`, and one per enabled module (`/admin/quiz`, `/admin/ai`, …). The sidebar links to those paths, so a link is safe to bookmark, paste into a runbook, or read out over a call — and switching tabs now updates the address bar (`pushState`, no page load), with Back walking the destinations, so the URL always names the screen in front of you. Previously the panel lived at `/admin` alone: every tab click was intercepted client-side, and the address bar kept saying `/admin` whatever was on screen. The older `/admin?tab=<id>` form still works and still means the same thing, both shapes render the same shell — one gate, one set of reads — and an unrecognised tab in either form still falls back to **Overview** rather than 404ing. - **Every AI endpoint now demonstrates itself.** The panel shipped a ready-to-run curl for one route — `/api/ai/event`, on each challenge's own row — and left the other two as a URL and nothing else: an organizer could copy `/api/ai/submit` and still not know whether it wanted a header, what came back, or what a wrong flag looks like next to a refusal. Each of the three endpoint URLs now carries a collapsed demo answering **send / receive / expect**: a runnable request, the `200` it returns, and the refusals worth designing for (a wrong flag and a cooldown for Submit; `invalid-signature`, `stale-request` and `replay` for Event; an expired token and the 120/min budget for State). **State** is marked *read-only* — the one route of the three that writes nothing, so it can be tried against a live event with no consequence. Every value is a placeholder; the real signing key and the one-click dry run stay on the per-challenge row, and a test asserts no key- or token-shaped string reaches the demos. - **The AI panel says what the external site has to do, and the token handshake has a diagram.** The panel handed an organizer the endpoint URLs, a per-challenge signing key and a Send test button, then told them to "stand up the external challenge site against the integration contract" — fine for whoever writes that site, no help to the organizer standing between them and it. A new **Wiring the external site** drawer, collapsed above the challenge list, gives the handshake in five steps (take the token from `{token}`, verify it with the public launch key, re-read State for live progress, report the solve signed over `"<timestamp>.<raw body>"` within ±300s, expect one award per `jti`) and sets the two keys side by side — the **launch key** is public, one per event, and fetched; the **signing key** is secret, one per challenge, and pasted — because conflating them produces a signature failure that looks exactly like a wrong key. The AI module's setup checklist now names those four external-side requirements instead of deferring all of them to a link. `docs/ai-module.md` opens with a new animated diagram of the whole handshake, and the operations guide's AI section carries it too, above a checklist of what an operator configures on the far end. No store, key or API change; no secret is rendered by the new drawer. - **The profile and the leaderboard show progress the same way, and a module's ceiling adds up.** `/profile`'s Secure Development header read "6 / 321 patched 8 / 0 pts" — earned 8, available 0 — above target rows whose own ceilings summed to 668: the lambda source returned a hardcoded `maxPoints: 0` for a profile while computing a real one per target. It now sums its targets', as the mock source always has. Every level of both screens — module, target, and a module with no targets — is one shared row (`components/progress/`): its bar measures points (what the board ranks on, and the only measure that means the same thing at each level), with a minimum visible fill so early progress is not a dot on a grey line; its count carries each module's own word (`patched` / `answered` / `solved` / `cleared`, and `flags` as classic's verb), so a team card can no longer read "3 answered · 3 solved · 3 solved" for two different modules; and its points sit in a fixed right-aligned column, ending the run-together "1 / 38 patched2 / 141 pts". An expanded target groups its challenges by OWASP category with the most winnable group first, sinks the done rows, and collapses them behind a **Show patched (n)** toggle past ten rows. The profile gains one line of genuinely new information — how many points are still on the board and which module holds most of them — and an expanded leaderboard team renders that same tree read-only, replacing a chip row that gave points with no denominator and a target list that gave counts with no points; a team's hint spend is no longer shown to rivals, so its total is labelled `net pts`. No store, key or API change. - **Module screens are content screens (admin redesign, PR 3 of 3).** Each module's admin screen opens with a sticky header — its name and an **Enabled** switch, the same control as Event's Modules row — and a setup status line ("Setup complete · 4 categories · 12 challenges") that opens into the checklist only while a verifiable step is still to do; steps done outside the panel are no longer repeated, and the safe / not-safe mid-event lists move into their own drawer. One compact **Settings** card holds the title, blurb, the module's knobs and a link to Hints. Categories are a row of inline chips (move left/right, remove on hover or focus). Challenge lists are grouped by category with a count per heading, and each row keeps Edit on the row with Move up / Move down / Delete in a **⋯** menu; the AI board renders through the same list with its integration disclosure under each row. Danger red is reserved for what cannot be undone: Delete and Remove are neutral until their confirmation, Rotate is amber, and a **solved** or **would-award** Send test is green. The admin's type floor rises: nothing under 12 px, explanatory text at 14 px, dense tables and eyebrows keep 12 px. Stored keys, API routes and validation are unchanged. - **The admin's live views refresh themselves, and every switch says whether it saved (admin redesign, PR 2 of 3).** Overview, Activity and Insights load when opened — never on page load, so reaching Support still costs no Redis read for the O(contestants) metrics fold — and, while the event phase is live, refresh every 15 s (Insights every 30 s), each with an "updated Ns ago · refreshes every 15 s" stamp that turns into "auto-refresh paused while the event is not live" before scoring opens or after a freeze; a hidden browser tab never polls, and Activity's timed refresh re-reads as many rows as were paged in rather than snapping back to page one. The Refresh buttons become secondary and share the timer's code path. A new `AdminSwitch` replaces every native checkbox in the panel (module switches, Freeze scoring, Team registration, Overview's Scoring and Registration, Hints enabled) with a real `role="switch"` that reports "Saving… / Saved / <the refusal>" beside the row, through the same status line the numeric fields use — the numeric fields, in turn, lose the native spinner that clipped five-figure values. The settings audit line ("last changed by …") no longer appears under Activity or Insights, and the Insights sparkline gets a time axis. Stored keys, API routes and validation are unchanged. - **The admin panel has a sidebar, an Overview, and a compact header (admin redesign, PR 1 of 3).** The nine flat tabs are now a left sidebar in three groups — Run (Overview, Activity, Insights, Support), Content (one per enabled module), Setup (Event, Hints, Admins) — collapsing to a drawer on narrow screens. Deep links stay `?tab=<id>`; an unknown id falls back to the new **Overview** instead of Event. Overview answers "is scoring on, how many teams, is anything stuck" in one screen: phase and time remaining, Scoring and Registration as switches, the four funnel figures (Stuck first when non-zero), the sync health line folded in from the old Status card, the five most recent activity rows, and a setup-status line per module — read-only apart from the two switches, and a snapshot for now (the 15 s refresh is PR 2). The header is one row (`Admin · event · phase · until date`) reusing the public phase strip's vocabulary; the "Organizer / Admin" block and the outer Controls frame are gone. The hint policy moved out of Event onto its own **Hints** destination — stored keys and validation unchanged, as with its earlier moves. Every existing tab renders unchanged inside the new shell. - **Every fail-direction gate in the pause/schedule contract is now pinned by a test (#232).** `hint-store.ts` `revealHint` documents and pins fail-CLOSED on a settings-read error (never charge on uncertainty); `team-store.ts` `isRegistrationClosed` now catches a transport failure the same way it already tolerated a per-command error, failing OPEN on both (a Redis blip must not itself block registration — the join/create Lua script still validates every real invariant atomically) — matching `resolveTeamMaxMembers`'s existing reasoning right above it. A new shared differential corpus, `test/fixtures/window-corpus.json`, is run verbatim by all three `outsideWindow` readers (`apps/web`, `scorer`, `sync`) so a `<`→`<=` flip at the exact scheduled-window boundary in any one of them fails CI even if that reader's own hand-written cases miss it. - **Every numeric setting says whether it saved.** The nine numeric knobs and the four schedule fields now report beside the field: "Saving…" while the write is in flight, "Saved" for a moment after, or the reason it was refused — junk, a fraction, a negative or a blanked field snaps back to the stored value *with* that reason, and a server rejection is rewritten through the field's label ("Hint cost must be a whole number between 0 and 100,000.") instead of landing as `hintCost must be an integer in [0, 100000]` under the whole panel while the rejected text stayed in the box (admin UX audit F2). The refusal is announced (`role="alert"`) and tied to its input (`aria-invalid`, `aria-describedby`). One shared component, `components/admin-number-field.tsx`, replaces the hand-written pair on every tab; stored keys and server validation are unchanged. - **The AI tab is a list again.** The three module-wide endpoint URLs render once above the challenge list instead of inside every row, and each row's integration panel (signing key, test curl, Send test) is collapsed until opened — three challenges had made the tab 2,253 px tall, 542 px a row (F5). A flag-only row's summary says the panel is not needed for it. - **Hint policy moved to the Event tab.** The four hint knobs (enabled, cost, solves required, unlock after) govern Secure Development, Classic and AI hints alike, but rendered only on the Secure Development tab — so a classic-only or ai-only event sold hints at the default price with no switch anywhere in the panel (admin UX audit F1). They now sit in a **Hints** section on Event, under the schedule, and the unlock-after help names the **Scoring opens** field instead of pointing "below" at a tab that no longer held it (F6). Secure Development keeps its re-run cooldown. Stored keys and server-side validation are unchanged. - **The blurb help tells the truth.** The module-identity blurb's help text said it was "not shown on any page"; it is the lede under the title on the quiz, flags and AI boards and those pages' meta description. The help now says so (admin UX audit F3). - **Support shows AI progress.** The contestant lookup reads the AI solves, attempts and points alongside quiz and classic, the card shows them, the attempts total includes them, and the reset-progress confirm names "classic and AI solves" and sums all three modules' points — the total the reset actually removes (F4). "Sec-dev solves" is spelled out as Secure Development. - **Every module tab opens with a setup checklist.** A new registry contract, `ModuleDef.setup` (module contract §5.9): what contestants experience, the minimum to make the module playable in dependency order with each step marked in-panel or outside, what is safe to change mid-event, and a link to the module's operations guide. Rendered by one shared component ahead of the identity editor; where the panel holds the count (questions, challenges, categories) the step shows it live, and says "Checking…" until it does. - **`pnpm lint` is green and CI runs it.** The app's lint had sat red (4 errors, 5 warnings) with nothing running it — hygiene audit T1. Each finding is fixed in the code rather than excused: the three `set-state-in-effect` errors by parking the nav dropdown's focus request in a ref and moving the admin panels' mount-time fetch to a module-level function whose result the effect applies in a callback; the render-time `Date.now()` in the Event tab's schedule readout by stamping "now" in the handlers that apply settings; the rest by deleting the dead imports, the dead `hasSecureDev`, and a `next/image` mock nothing under its subject rendered. No `eslint-disable` added, no rule downgraded. The `app` CI job now runs `corepack pnpm lint` right after install, and AGENTS.md, CONTRIBUTING and the README's command lines carry the same step. Dead code the audit proved dead goes with it: the never-wired `score-check.tsx` (and the `check-land` keyframe only it used), the unused `tsx` devDependency, the whole-catalogue totals in `apps.ts`, the dead re-exports in `ai-keys` and `admin-store`, the exported-but-in-file-only `toCatalogChallenge`, `enabledModuleRoutes` (the proxy gates the registry's full route list on purpose — see the comment on `GATED_ROUTES`), and the single-team `getTeamQuizTotals` wrapper whose only caller was its test. All internal to `apps/web`; no behaviour changes. - **The profile page names the team hash through `teamKey`, and the judge's network comments say what the network is.** `profile/page.tsx` still open-coded `ctf:team:<slug>` twice — the reader ADR 48 moved the builders into `team-keys.ts` for — behind a comment excusing it; it now imports `teamKey` like every other reader, and a source-scan test keeps the literal from coming back. `scorer/entrypoint.sh` and `scorer/entrypoints/webgoat.sh` described `$NETWORK` as `--internal`; it is a plain `docker network create` bridge (as `docs/scorer.md` already said) on which the app under test publishes no host ports. Comments only — no `docker network create` line changed. - **Every live Redis suite runs in CI now, not just the grading Lua.** The `hint-store` and `team-store` `.upstash` suites had rotted (#235): the reveal path grew an anti-burner gate that refused every purchase the suite made (it seeded a hint but no solve), and a populated team's captain can no longer simply leave. Both are repaired against the current rules — the hint suite seeds its policy through `updateAdminSettings` and earns the gate with a solve, asserting on the way that the refusal charges nothing; the team suite asserts the captain refusal is a no-op, then transfers and leaves — and each still goes red when the store it covers is mutated by one line. The three older suites gate through `live-redis.ts` like the Lua ones, so `CTF_LUA_SUITES_REQUIRED=1` covers them too, and the CI step runs every `*.upstash.test.ts` file (`vitest run upstash --no-file-parallelism`, serial because two suites share `ctf:admin:settings`). No runtime behaviour changes. - **Four HIGH Dependabot alerts in the app lockfile cleared.** `browserslist` (two advisories), `js-yaml` and `brace-expansion` — all dev/build-side transitives of `next` and `eslint-config-next` — re-resolved to patched versions. `browserslist` needed an `overrides:` floor in `apps/web/pnpm-workspace.yaml` because pnpm would not move a package that is also a peer of `update-browserslist-db`; the comment there says when to drop it. pnpm is now pinned for corepack via `packageManager` (`pnpm@11.25.0`, the version CI was already resolving), so the settings file's semantics no longer depend on whichever pnpm corepack fetched that day. No runtime behaviour changes. - **Store `catch` blocks log a redacted label, never the exception object, and a bulk import refuses to write after a failed read.** The classic and quiz stores logged the raw caught value at six sites, three of them the `catch` around the grading call whose arguments are the submitted flag or answer — hardening, not a reported leak: no error shape reachable today carries them, but a driver that attached its failed request would have put the event's flags in the log. The ai store's `errorLabel` (#241) is now a shared `lib/error-label.ts` used by all three (#244). Classic's and quiz's `importBundle` also inspected neither reply of their membership read, so a transient `GET ctf:classic:categories` failure became an empty category list that the write pipeline then made permanent; both now throw before any write, as the ai store already did (#261). - **Docs reconciled with the code the hygiene audit compared them against.** The review guideline's public-surface list names all five unauthenticated `/api` routes (it said three), the same-origin carve-out and rate-limit lists include the ai module's routes, and the team-required boundary names `/api/ai/submit`; CONTRIBUTING counts ten CI jobs and four modules and lists `acceptance-ai-only.sh`; the README's copy-pasteable scorer test line runs `acceptance-scorer.sh` from the repo root, where it lives; the pre-event gate's scope is stated once and correctly (`apps/web/.env.example` said the module APIs were not behind it — they are). Two shipped planning files (`docs/DOCS-PLAN.md`, `docs/DOCS-CHANGELOG.md`) and the `github.oauth_client_id` key in `event.yaml.example`, which no reader ever read, are removed. - **The grading Lua is executed by tests now, against a real Redis.** Classic's `SUBMIT_SCRIPT`, quiz's `GRADE_SCRIPT` and ai's `AWARD_SCRIPT` — the scripts that decide points — had never been run by any test; the mocked suites pinned only the arguments handed to them. Three `*.lua.upstash.test.ts` suites now run the real scripts against redis + srh (skipped locally without the env, required in CI), and each of the six one-line Lua mutations the August review found survivable now fails a test. The three script constants are exported for that purpose; nothing else about them changed. - **The event archive now carries the AI catalogue** (#250, #155's ai half). Export writes an `ai` section — challenges with their mode, launch URL template, flag, hint, categories and per-challenge signing key — and import clears and replaces the AI board like the classic and quiz ones, so an archived event no longer loses its AI challenges and an external site configured against a signing key keeps working after a restore. The module's launch keypair is deliberately not in a bundle and an import leaves the box's own pair alone. Bundles exported before this change still import unchanged (the section is optional); a bundle with an `ai` section imports into an older box only after removing it. - **`ctf-setup.sh --dry-run` is dry again, and `--out` is honoured everywhere.** The wizard's org step probed the org with `gh api` and ran a full `doctor` sweep even under `--dry-run`, and step 1 probed `gh auth status` / `docker compose version`; all are narrated instead. `secrets` writes its env file owner-only (`0600`) regardless of the caller's umask. `org` read `SCORE_IMAGE` from a hardcoded `.env` even when `--out` named another file. A value-taking flag left without a value now fails with the script's own message rather than bash's "unbound variable". - **Acceptance scripts fail loudly, not silently.** The bare `grep -q` assertions in `acceptance-app.sh` and `acceptance-quiz-only.sh` died under `set -e` with no output; each now names what was missing. `acceptance-patched.sh`'s "no challenges found" guard was unreachable for the same reason. `scripts/dev-stack` (no `.sh` suffix) is now in CI's shellcheck list. - **Redis reads that fail now say so, everywhere.** `sync`'s pipeline client used to swallow a per-command error reply (`WRONGTYPE`, `NOAUTH`, an unsupported command) as `undefined`, which its callers read as "not paused", "no reset" and "status written" without a log line; it now throws like the scorer's client, so every caller's documented fail-open direction still applies but is visible. The scorer's own pause read logged nothing on failure; it does now. - **A hung Redis proxy can no longer stall the poller or a score POST.** All three Upstash/SRH pipeline clients (`apps/web`, `scorer`, `sync`) abort a round trip after 10 s instead of waiting forever. - **Numeric knobs are validated instead of silently misbehaving.** A non-numeric `POLL_INTERVAL_MS` used to poll GitHub in a tight loop (`setTimeout(NaN)` fires immediately — and so does any value past `setTimeout`'s 2^31−1 ms cap, so the accepted range is now 1 to 1 789 569 705 ms) and a non-numeric or blank scorer `PORT` bound a random port (`PORT` must now be an integer 0–65535; the default stays 4000). Both refuse to start with a clear message; a running event is unaffected unless it already carried an invalid value, which never worked. An installation token whose `expires_at` is missing, unparseable or already past is rejected instead of being re-minted on every call. ## v0.4.0 — 2026-09-01 A playable Classic CTF, an event you can carry somewhere else, and a front end that tells the truth. - **Classic CTF** became a board rather than a form: a category-grouped tile grid with a dedicated page per challenge (#208), and **paid hints** sold through the same gate, price and penalty machinery as secure-development (#190). The hint penalty nets the FINAL total as the scoring pipeline's last stage, so a hint bought against one module can never be discounted by another module's points. - **Event archive**: export a whole event — catalogues, teams, solves, settings — to a JSON bundle and import it back (#155). An event is now portable between boxes, and a finished one can be kept without keeping its infrastructure. - **Teams first.** Team setup is the first step after sign-in (#219), rather than something a contestant discovers after their first solve banks into no team total. - **Admin activity log**: login timestamps plus a filterable event stream on a new Activity tab (#213) — the mid-event question "did anyone sign in yet?" answered without a Redis console. - **Visual identity**: the original navy/blue terminal look enhanced rather than replaced, with progress displays that read at a glance (#207). - **Accessibility and resilience**: per-route loading states, error boundaries that keep a failure inside the segment that caused it, a skip link, focus handling that survives a control being replaced, and mobile fixes (#240). - **A contestant-facing copy/UX truth pass** (#200, tiers 1–4): honest claims, state-aware affordances, an effective-state readout, and every module accounted for on the leaderboard and profile. - **Audit and correctness fixes**: quiz freeze reads fail open like classic (#215); hint penalties and roster rows match case-insensitively (#216); signing out of a session-gated page redirects home (#214); classic carries `caseSensitive` back out of the store, so a case-sensitive challenge stays badged and exports correctly (#196); a challenge page's 404 now attaches to the right boundary and says which of its two causes fired (#208 follow-ups). - **Sign-in and navigation fixes**: post-signin redirects are relative rather than derived from `request.url`, fixing a localhost bounce (#227); the avatar menu survives session revalidation (#228); the user menu closes reliably and its links work in Brave (#223). - **Documentation overhaul** (#218): README rewritten (status above the fold, a fair comparison, a working no-GitHub quickstart), stale-doc drift fixed across the set, ADR and section anchors made renderer-stable, a new troubleshooting runbook and glossary, and an explanation of how Insights computes each figure (#198). The landing copy's false "each app is an OWASP project" claim is corrected and the baked "OWASP CTF area" strings now follow `event.name`; the OWASP-CTF default branding is kept. - **Review and CI**: a tuned CodeRabbit configuration carrying this repo's own invariants as pre-merge checks (#220, #225, #236), with the breaking-change documentation check demoted to a warning after it blocked a PR on a stale snapshot of its own description (#242); cross-area CI path-filter edges closed so a touched area can no longer skip its jobs (#236); every fork-repo-name reader pinned to `setup/targets.tsv` (#199); a reference patch for Security Shepherd's `Challenge-10-IDOR-2` (#221). - **Dependencies**: Redis 8-alpine, the Next.js group, and `github/codeql-action` v4. No breaking changes: no `event.yaml` key, `ctf:*` Redis key, scorer payload or `ctf-setup.sh` flag changed shape. An event running v0.3.0 upgrades by redeploying — which also moves Redis from 7-alpine to 8-alpine. Redis 8 reads a 7 AOF dataset, and the compose file keeps it on the named `redis-data` volume, so scores survive the container being replaced. Pause the event from `/admin` before redeploying a live one, and do not bring the stack down with `-v` — that removes the volume, which is the one action here that loses data. ## v0.3.0 — 2026-08-23 Three modules, runtime admin controls, zero vacuous passes. - **Quiz** and **Classic CTF** shipped as full modules — authored from `/admin` (single and bulk JSON-bundle authoring), graded in the app, each able to run an event alone with no scorer or GitHub org. - The admin panel became the runtime control plane: grant/revoke admins, switch modules on and off mid-event, set the team cap, scoring cooldown, scheduled scoring and registration windows, per-module titles — all without a rebuild. Support actions (reset/delete a contestant, take over a team) and engagement metrics (Insights) landed alongside. - Teams: required to score, one-click solo play, shareable `/join/<code>` links. - Security hardening: Redis authenticated and cut off from the app tier, same-origin assertions on mutating routes, rate limits on join/reveal, HTTPS enforced for production events. - The vacuous-pass war: a sweep that points every rubric at an up-but-useless stub reached **0 of 321** and became a CI gate. - Deploys: the whole stack as one Fly machine running the repo's own compose file; workflow version-stamping with a per-fork `upgrade` path; `doctor` verifies the package Read grant by observation. ## v0.2.0 — 2026-08-16 Guided wizard, AWS deploy, verifying doctor. - `ctf-setup.sh` became a resumable guided wizard that prompts for every value inline and does each automatable step. - Single-shot AWS deploy: a Terraform module for one ephemeral EC2 box. - `doctor` grew into the per-fork provisioning status matrix. ## v0.1.0 — 2026-08-15 First tagged release: the full offline-tested kit — compose stack, poll pipeline, six vendored target rubrics. - **Security (critical):** closed the score-comment forge — the scoring workflow could be made to post a contestant's own forged `<!-- ctf-score: -->` marker as `github-actions[bot]`. The judge's report now lives outside the PR checkout (`CTF_OUT_DIR`) and is posted only when the scorer step succeeded. - Hardening: baseline security headers in both Caddyfiles; the srh proxy image pinned by digest.