CHANGELOG.md
Rama main · réplica de lectura del repositorio original.
# 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:<login>` 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 (<reason>)` 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:<login>`, 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 `<p>` — 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 <SCORE_IMAGE> 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
<fly-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:<login>` 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:<target>` 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:<app>`),
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/<org>/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 `$ <event name>` — 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 `<title>`, 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.