Release Notes
For the complete, commit-level history see CHANGELOG.md in the repository.
v3.6.1 — 2026-11-03
Admin-page release. The Sessions page shows when each session logged in and where it was, and no longer splits one session across several rows. Drop-in bump from 3.6.0.
Changed
- Login time — the "Last Activity" column is now "Date", the login time as
MM/dd/yy HH:mmin the server's time zone (named in the header), and rows are sorted newest login first. - Location — City, State, and Country are one comma-separated column, leaving out blank and
Unknownparts. - One row per session — the Remote Host, ISP, and User Agent columns are gone, and the query no longer groups by them, so a session whose IP or user agent changed is one row with its full request count. A session still shows as two rows if its IP moves to a different city.
- A week by default — the Sessions page covers the last 7 days, up from 2.
Dependencies
Resend 4.29.0 → 4.30.0
v3.6.0 — 2026-11-02
Security release. The server connects to PostgreSQL with the PostgreSQL JDBC driver (org.postgresql:postgresql) instead of pgjdbc-ng, whose final release (0.8.9, May 2021) bundles Netty 4.1.63 — one critical and seven high CVEs, never to be fixed there — and the database's TLS connection ran through it.
Upgrade
DBMS_URL changes form; keep the host, port, and database:
jdbc:pgsql://<host>:<port>/readingbat?ssl.mode=Require # before
jdbc:postgresql://<host>:<port>/readingbat?sslmode=require # after
Leave DBMS_DRIVER_CLASSNAME unset (the default is now org.postgresql.Driver), or remove it if it names com.impossibl.postgres.jdbc.PGDriver. A server still on the old settings stops at startup with a message naming the replacement. See Configuration.
Changed
- PostgreSQL JDBC driver — already on the runtime classpath for Cloud SQL and already what the database tests ran against. Exposed now picks
PostgreSQLDialectin place ofPostgreSQLNGDialect, which differ only in auto-commit aroundSchemaUtilscreate and drop calls the server does not make. - Old settings fail fast — the pgjdbc-ng driver class, a
jdbc:pgsql:URL, and a migrated URL that still saysssl.mode(which the new driver would ignore) are rejected before the connection pool is built. The messages never echo the URL, which can carry a password.
Security
- No more Netty 4.1.63 in the database path — pgjdbc-ng relocated it under
com.impossibl.shadow.io.netty, out of reach of the 4.2.x the server uses for HTTP, and Docker Scout reports CVE-2026-75595 (critical) and seven high CVEs against it. Itspom.propertiesalso hid the real 4.2.x from scanners in consumers' fat jars.
Dependencies
Removed com.impossibl.pgjdbc-ng:pgjdbc-ng-all 0.8.9
v3.5.1 — 2026-11-02
Memory-fix release. 3.5.0 moved Kotlin script evaluation to Kotlin 2.4.21, and from 2.4.20 its JSR-223 engine keeps every script state reachable — about 27 MB apiece — while the script pools start a new state for every evaluation. Heap grew with every Kotlin challenge a server evaluated, enough to exhaust a 2 GB heap over readingbat-java-content's Kotlin challenges. 3.5.1 pins the scripting runtime back to 2.4.10. No configuration or upgrade steps — a drop-in bump from 3.5.0.
Fixed
- Kotlin script evaluation no longer exhausts the heap —
kotlin-scripting-jsr223declaresstrictly 2.4.10, and the whole scripting stack resolves to 2.4.10 with it, while the project still builds with Kotlin 2.4.21. The strict version is published, so it also overrides the 2.4.21 that common-utils asks for, and consumers get the pin without declaring one of their own. On a 2 GB heap,readingbat-java-content's Kotlin sweep stays between 300 and 580 MB after GC, where 3.5.0 climbs to 2,043 MB and fails withOutOfMemoryError. - Request logging records logged-in users only — every visitor to the public pages carries a browser-session cookie, and every request that carried one used to be written to
server_requestsandbrowser_sessions, so crawlers and bots that keep cookies filled both tables. A request without a login now writes neither and skips the duration update, and a new anonymous visitor no longer costs an ipgeolocation.io lookup. Active-session counts, on the admin pages and in the Prometheus metrics, now count logged-in sessions only. Rows already in the tables are left alone.
Testing
- Testcontainers 2.x — PostgreSQL support is now published as
org.testcontainers:testcontainers-postgresql; the oldorg.testcontainers:postgresqlstops at 1.21.4, and neither Dependabot normake versionsoffers a move to a renamed module. Anything that depends on the old module alongsidereadingbat-kotestshould move too, since both carryorg.testcontainers.containers.PostgreSQLContainer. - Nothing to set up but a running Docker daemon — the test task no longer pins the Docker API version, which only restated Testcontainers' own default, and no longer disables Ryuk, so the PostgreSQL container is removed even if the test JVM dies. The test database now runs PostgreSQL 17; see Testing.
- CI no longer starts a database nothing uses — the test workflow's PostgreSQL service container and
DBMS_*variables fed only the production connection pool, which no test creates.
CI and tooling
make ciruns the GitHub checks locally — CSS freshness, the build and tests, zizmor, and the docs build that CI otherwise runs only after a merge to master.make -k cikeeps going past a failure.
Dependencies
Kotlin scripting runtime 2.4.21 → 2.4.10 · Gradle 9.8.1 → 9.8.2 · common-utils 5.1.1 → 5.2.0 · Ktor 3.6.0 → 3.6.1 · Exposed 1.5.1 → 1.6.0 · Flyway 13.10.0 → 13.12.0 · Prometheus Java client 1.9.0 → 1.10.0 · prometheus-proxy 4.3.0 → 4.4.1 · kotlinx-serialization 1.11.0 → 1.12.0 · Resend 4.28.0 → 4.29.0 · Cloud SQL socket factory 1.30.1 → 1.30.2 · Kotest 6.2.5 → 6.2.6 · Playwright 1.63.0 → 1.64.0 · Testcontainers 1.21.4 → 2.0.5 · versions plugin 0.66.0 → 0.67.0 · setup-java action 6.0.1 → 6.1.0 · setup-python action 7.0.0 → 7.1.0 · zizmor action 0.6.4 → 0.8.0 · zensical 0.0.70 → 0.0.74
v3.5.0 — 2026-10-12
Monitoring and maintenance release. Until now the only memory signal the server produced was the OOM itself, and afterwards there was nothing left to explain it. It now exports JVM metrics and says at startup whether an OOM will leave a heap dump behind. The CI workflows are hardened too: audited by zizmor, pinned to commit SHAs that Dependabot keeps current, and scanned by CodeQL's default setup. No configuration required, though enabling heap dumps in production is recommended. The public Metrics properties are now Prometheus 1.x types, which is source-breaking for anything that records to them from outside the library (labels(...) → labelValues(...)).
Monitoring
- JVM metrics are exported — heap, GC, thread, and class-loading collectors are scrapeable (on
:8083/metricsby default) alongside the application metrics. Heap that fails to fall back after a GC is the symptom to alert on:jvm_memory_used_bytes{area="heap"}. - Startup reports whether an OOM will be diagnosable — the server logs whether the JVM will write a heap dump on
OutOfMemoryError, and warns when it will not. Enable withJAVA_TOOL_OPTIONS="-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=<dir>", on a volume that outlives the process; see Configuration. Local runs (./gradlew run,make uber) now dump tobuild/. - Metrics use the Prometheus Java client 1.x — required by common-utils 5 and prometheus-proxy 4.2. The metrics endpoint now serves the 1.x
PrometheusRegistry, so a collector registered in the old 0.xCollectorRegistrycompiles, runs, and is never scraped.JvmMetricsTestchecks the JVM collectors against the registry that is actually served. JVM metrics carry their 1.x names (jvm_memory_used_bytes,jvm_classes_currently_loaded), not the 0.x names most published dashboards still use.
Changed
- File-system content paths are relative to the source root — since common-utils 4.x,
FileSystemSource.file(path)resolves againstpathPrefixitself, so prepending it as well doubled the prefix on the upgrade: invisible for a"./"root, fatal for"../". - The apparent Kotlin eval leak was the test harness — under
./gradlew test, heap climbed about 1 MB per Kotlin script eval because Kover's coverage agent holds every classloader it sees. Production and./gradlew rundo not leak (#128). - The docs site builds with zensical alone —
zensical.tomlnamed the emoji extension by its Material for MkDocs path, which zensical remaps only in YAML configs, so a localuv run zensical buildfailed withNo module named 'material'. CI had masked it by also installingmkdocs-material; the config now nameszensical.extensions.emoji, and the workflow installs zensical alone.
CI and tooling
- The CI workflows are audited and pinned — zizmor now audits every workflow, locally with
make zizmorand in CI, where its findings land in code scanning. The 27 issues it found are fixed: every action is pinned to a full commit SHA, checkouts no longer leave the job token in.git/config, and each workflow declares its own permissions. Dependabot bumps the pins weekly, as one grouped PR after a 7-day cooldown. CodeQL moves to code scanning's default setup, replacing a workflow that GitHub had disabled since 2023 and that pinned acodeql-actionrelease with an unpatched advisory. make versionsshows only what is behind — the dependencies with newer versions and any it could not check, where it used to list every up-to-date dependency first;make versions-allstill prints that full report. Both run the root project's check alone, which already covers every module, so the report no longer prints three times.
Dependencies
Gradle 9.7.1 → 9.8.1 · Kotlin 2.4.10 → 2.4.21 · common-utils 3.2.2 → 5.1.1 · prometheus-proxy 4.0.1 → 4.3.0 · Prometheus Java client 0.16.0 → 1.9.0 · Exposed 1.5.0 → 1.5.1 · Flyway 13.7.0 → 13.10.0 · PostgreSQL driver 42.7.13 → 42.7.14 · Resend 4.26.0 → 4.28.0 · Cloud SQL socket factory 1.30.0 → 1.30.1 · Kover 0.9.9 → 0.9.11 · kotlinter 5.6.0 → 5.7.0 · buildconfig 6.1.1 → 6.1.2 · versions plugin 0.64.0 → 0.66.0 · zensical 0.0.63 → 0.0.70
v3.4.0 — 2026-09-21
Rendering-correctness and maintenance release, with one breaking API change. The language tabs did not read as tabs in Safari, the rule beneath them stopped short of both screen edges, and the app depended on a CDN for images it already carried in its own jar. No configuration or upgrade steps for the app itself; Endpoints.STATIC_ROOT has been removed, which is source-breaking for anything compiled against the published artifact.
Rendering
- The selected tab is open in every browser — the folder-tab effect works by having the selected tab paint its white background over the 1px divider below the strip, nudged down with
top: 1px. That only lands if the tab's painted box ends exactly where the divider begins, andnav liwasdisplay: inline, so its height came from the font's ascent plus descent. Blink rounds that to whole pixels and covered the rule exactly; WebKit leaves it fractional — box bottom 159.64 against a divider at 159–160 — so 0.36px of black survived, about 0.7 device pixels at 2×. A line that looks thinner but never disappears. The tabs are now bottom-aligned inline-blocks, whose box ends at the line box bottom, which is where the divider starts, in every engine. - The rule beneath the tabs runs edge to edge — it is a block inside
<body>, whose 8px margin clipped it at both ends. It now carries a negative margin that cancels that gutter. A structural rule that stops short of the edge reads as a mistake rather than as a margin. - The page no longer scrolls sideways — the tab-strip container was
min-width: 100vw, and100vwcounts the scrollbar: 1665px inside a 1650px viewport, 23px of stray horizontal scroll. It also undid the fix above the moment you scrolled right. - The tab strip is inset from the page edge by 37px — one tab gap — so the first tab is spaced from the edge the way the tabs are spaced from each other.
Static Assets
- Served from the jar, not a CDN — all 29 assets were already packaged in the jar, but
Endpoints.STATIC_ROOTwas an absolute CDN URL andstaticResourcesmounts the tree at whatever that constant is, so the route has been unreachable since 1.3.0. Nothing noticed because a missing asset returns the not-found page with a 200 status. They now serve from/staticwith a one-year cache, an ETag, and a?v=<version>on every URL so a replaced image is never stranded behind a warm cache. - The CDN is now optional —
STATIC_URL_PREFIX(env var, orreadingbat.site.staticUrlPrefix) sets the prefix pages emit. Point it at a CDN origin to restore the old behavior with no rebuild; the app keeps serving the files itself either way. Endpoints.STATIC_ROOTis gone — it conflated the mount path with the emitted URL prefix, which is what made the route unreachable. Replaced byEndpoints.STATIC_PATHand the new property. Deleted rather than redefined, so call sites fail loudly instead of silently changing meaning.site.webmanifesticons fixed — root-relative srcs against files undericons/: 404 from the app, already 403 on the CDN. Now relative, resolving under any prefix.- Images are no longer compressed —
deflatedeclared its own condition, which under Ktor's rules opted it out of the default image/video/audio exclusions, and its priority put it ahead of gzip. Every response, images included, went through it.
Testing
- The suite now tests in two engines —
PlaywrightTabsTestasserts both promises the tab strip makes (the selected tab covers the divider; the divider spans the viewport with no overflow) in Chromium and WebKit. The hairline was invisible to Chromium at every font size probed, so a Chromium-only test could never have caught it. These are the first WebKit tests in the project.
Changed
- The design record covers the tab geometry —
DESIGN.mdnow states why the tabs must be bottom-aligned inline-blocks, why the rule runs full-bleed, and where the 37px inset comes from. - Line endings are normalized in the index —
.gitattributesgained* text=autoand dropped thebinaryattribute fromgradlewand*.bat, which had been suppressing both diffs and theeolconversion those same lines asked for. The generatedstatic/tailwind.cssand the vendoredstatic/prism/**are now marked so GitHub collapses them in diffs.
Dependencies
Gradle 9.6.1 → 9.7.1 · Ktor 3.5.2 → 3.6.0 · Exposed 1.3.1 → 1.5.0 · Flyway 13.1.0 → 13.7.0 · Kotest 6.2.3 → 6.2.5 · Playwright 1.61.0 → 1.63.0 · Cloud SQL socket factory 1.29.0 → 1.30.0 · Resend 4.13.0 → 4.26.0 · prometheus-proxy 4.0.0 → 4.0.1 · detekt alpha.5 → alpha.6 · buildconfig 6.0.10 → 6.1.1 · versions plugin 0.57.0 → 0.64.0 · zensical 0.0.52 → 0.0.63
v3.3.1 — 2026-08-01
Accessibility, performance, and maintenance release. The headline fix is that answer grading — the single most important thing the app tells a student — was communicated by fill color alone and was never announced to assistive technology. No configuration or upgrade steps — a drop-in bump from 3.3.0.
Accessibility
- Answer grading is perceivable without color or sight —
checkAnswerspainted each result cell green or red and did nothing else, so a screen-reader user received no result at all and a colorblind user was left comparing two fills measuring 1.36:1 against each other, far below the 3:1 WCAG 2.1 AA non-text minimum and a 1.4.1 (Use of Color) failure. Result cells now carry✓ correct/✗ try againtext, and the status cell is arole="status"/aria-live="polite"region announcingN of M correct. - Controls have accessible names — answer inputs gained
aria-label="Return value for <invocation>", the like/dislike buttons gained labels with their images marked decorative, and the sign-in modal's close control (aspanwith anonClick, unreachable by keyboard) became a realbutton. - Text colors pass AA — the pass/fail colors met contrast as fills but not as text, so
rb-correct-text(#3E862E) andrb-wrong-text(#ED0000) were added as separate text tokens andrb-headerwas darkened to#337E9C. Re-toning the tokens alone would have changed nothing:HEADER_COLORwas emitted as an inline style on the same elements, so the inline value won. That constant and all 13 call sites are gone. - Link hover no longer turns red, which collided with the wrongness signal — it is now an underline.
- Spinners that were never visible now work — the like/dislike and admin spinners used
fa-spin, but Font Awesome is not loaded on those pages. Replaced with a CSS.rb-spinnerthat honorsprefers-reduced-motion. - The page wordmark is now an
h1, the language nav carriesaria-label="Languages", and pages emit<meta charset>and a viewport tag.
Performance
- Images got much lighter — the four like/dislike PNGs were 1600px wide and render at 30px (345 KB → 12 KB), and
nervous/panicmoved to JPEG (~450 KB → ~72 KB). - All six
imgsites gained explicitwidth/height, removing layout shift, plusloadinghints.
Changed
- The generated stylesheet can no longer drift unnoticed —
static/tailwind.cssis checked in, but the Gradle wiring only regenerates it on macOS, so the accessibility pass left it stale, still carrying CSS for classes nothing emits. It has been rebuilt (80,540 → 78,905 bytes), and a newTailwind CSSworkflow now regenerates it in CI and fails if the committed artifact does not match. - A design-system record —
PRODUCT.md,DESIGN.md, and.impeccable/design.jsoncapture the product context and the design system the accessibility work was audited against. - Documentation accuracy —
make dbresetwas referenced in four places but has never existed; all now name the real Flyway targets (dbmigrate,dbclean,dbinfo,dbvalidate).CLAUDE.mdalso shed 57 lines of content derivable from the codebase itself. - The versions plugin is applied by its catalog id rather than a hardcoded legacy string, clearing a Gradle deprecation warning.
Dependencies
Ktor 3.5.1 → 3.5.2 · Flyway 13.0.0 → 13.1.0 · common-utils 3.2.1 → 3.2.2 · versions plugin 0.54.0 → 0.57.0 · zensical 3.10.2 → 3.10.3 · markdown 0.0.51 → 0.0.52
v3.3.0 — 2026-07-26
Modernization + maintenance release. Adopts Kotlin's experimental collection-literal syntax across the codebase, refreshes dependencies (with major bumps to Flyway 13, prometheus-proxy 4.0, and common-utils 3.x), and lands a handful of code-quality cleanups. No configuration or upgrade steps — a drop-in bump from 3.2.1.
Changed
- Kotlin collection literals — enabled the experimental
-Xcollection-literalscompiler flag and converted 191 call sites acrosssrcandtest:emptyList()→[],listOf(...)→[...], andmutableListOf(...)→[...], the last with an explicitMutableList<T>type on the declaration so mutability is never inferred away. The flag scopes to project sources only — the JSR-223 engine that evaluates content DSL files at runtime is unaffected, so DSL content keeps usinglistOf()/mutableListOf(). - Build-script structure — the Dokka configuration moved into a root-level
configureDokka()helper, andconfigureVersions()moved into anallprojects {}block. - Code-quality cleanups — removed redundant imports of symbols defined within a file's own object/companion (five files), and simplified an early-return conditional in
Intercepts.isBrowsableContentPath(if (x) return true; return y→return x || y, behavior-preserving).
Fixed
- Dokka is warning-free — the unresolved
[initProperties]KDoc link inContentDsl.ktreferenced aPropertycompanion member that was not in scope, so it rendered as plain text. It now uses the fully-qualified custom-link-text form.
Dependencies
Kotlin 2.4.0 → 2.4.10 · common-utils 2.9.3 → 3.2.1 · prometheus-proxy 3.2.0 → 4.0.0 · Flyway 12.10.0 → 13.0.0 · Kotest 6.2.1 → 6.2.3 · Logback 1.5.18 → 1.5.38 · PostgreSQL driver 42.7.12 → 42.7.13 · Cloud SQL socket factory 1.28.6 → 1.29.0 · Kotlinter 5.5.0 → 5.6.0 · Kover 0.9.8 → 0.9.9
v3.2.1 — 2026-07-03
Maintenance release: a Gradle 9.6.1 upgrade, a routine dependency refresh, and two small build/test polish items. No functional changes to the running server and no upgrade steps — a drop-in bump from 3.2.0.
Changed
- Gradle 9.6.1 — the wrapper moves from 9.5.1 to 9.6.1.
- Smarter pre-release version filter —
configureVersions()rejects a pre-release candidate only when the current dependency is stable, so libraries tracked on a pre-release line (e.g. the detekt 2.0 alpha) keep surfacing newer pre-releases. ThedependencyUpdatestask is also marked incompatible with the configuration cache. TestSupport.forEachAnswertakes asuspendblock — matching the suspendfunctionInfo()call site introduced in 3.2.0.
Dependencies
Gradle 9.5.1 → 9.6.1 · common-utils 2.9.2 → 2.9.3 · Ktor 3.5.0 → 3.5.1 · Exposed 1.3.0 → 1.3.1 · Kotest 6.2.0 → 6.2.1 · Flyway 12.8.1 → 12.10.0 · PostgreSQL driver 42.7.11 → 42.7.12 · Cloud SQL socket factory 1.28.4 → 1.28.6 · Playwright 1.60.0 → 1.61.0 · detekt 2.0.0-alpha.4 → 2.0.0-alpha.5 · maven-publish 0.36.0 → 0.37.0
v3.2.0 — 2026-06-15
Security-hardening release. A multi-agent security review surfaced 48 confirmed findings (7 high, 19 medium, 22 low); all 48 are addressed here, alongside a batch of WebSocket/caching reliability fixes and the 3.1.9-era build/tooling cleanup.
Upgrade note — SESSION_SECRET is now required in production
Session cookies are now signed and encrypted, so the server will not start
in production without a SESSION_SECRET. Generate one with
openssl rand -hex 32, set it via the SESSION_SECRET environment variable,
use the same value on every node, and note that rotating it invalidates
all existing sessions. See Configuration › Secrets.
Security
- Signed + encrypted session cookies (
SessionTransportTransformerEncrypt, AES-128 + HMAC-SHA256). The cookies were previously unsigned plaintext, allowing theuserIdto be forged for a trivial authentication bypass and admin takeover. - Server-side code injection (RCE) removed — answer checking no longer evaluates the user's response as a script; list/array answers are parsed and compared directly.
- Teacher IDOR closed — class-management actions verify class ownership.
- XSS fixes — student answers over WebSockets are HTML-escaped; user-controlled names in
confirm()/onSubmithandlers are escaped. - OAuth hardening — Google and GitHub logins require a verified email; login rotates the browser session to prevent fixation.
- Rate limiting enforced per-IP; secrets masked in logs; the operational logging WebSocket requires admin.
Reliability
- WebSockets — fixed pinger concurrent-modification crashes, removed a shared dispatcher that blocked all clients, collapsed N+1 blocking JDBC, and made the answer dashboard drop-oldest under backpressure.
- Caching — geo cache short-circuits the DB, drops its global mutex, stops caching failures permanently, and is size-bounded; a dir-contents cache key mismatch is fixed; per-user answer channels are bounded.
- Parsing & startup — nested-brace Kotlin script conversion, Java invocation ordering, quote-aware Python list parsing, a non-numeric env-var startup crash, and a bounded answer-check loop.
Challenge.functionInfo()is nowsuspend— the blocking script eval runs onDispatchers.IOinstead of bridging throughrunBlockinginside request/WS coroutines.
Dependencies
common-utils → 2.9.2 · Kotlin 2.4.0 · Ktor 3.5.0 · Exposed 1.3.0 · Kotest 6.2.0 · detekt 2.0.0-alpha.4 · HikariCP 7.1.0 · prometheus-proxy 3.2.0