BREAKING CHANGE: the production container listens on 8080 instead of 80. nginx now runs as an unprivileged user and cannot bind a privileged port without CAP_NET_BIND_SERVICE, which compose drops. Anything routing to this container must be repointed - in the homelab Traefik config, traefik.http.services.movieloop.loadbalancer.server.port 80 -> 8080. nginx's add_header is not additive across levels: a block declaring any add_header of its own discards every header inherited from its parent. Both /assets/ and /config.js declared one, so X-Frame-Options, X-Content-Type-Options and Referrer-Policy were silently absent from every JavaScript and CSS response. The headers now come from a snippet included in each location - verified 6/6 on /, on a hashed asset bundle, and on /config.js. Adds a CSP, HSTS and Permissions-Policy. connect-src is derived at container start from API_URL, because the API is deliberately not same-origin in production; asserting same-origin would have broken the deployment outright. The inline theme script is allowed by SHA-256 hash rather than unsafe-inline, which would have bought almost nothing - editing that script means regenerating the hash. config.js is also no longer rendered with envsubst, which does no escaping: an API_URL containing a double quote closed the JS string literal and the remainder executed on every page load. Addresses M31, M34.
9.3 KiB
9.3 KiB
Changelog
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
History prior to 1.1.0 has not yet been backfilled from git.
[Unreleased]
[2.0.0] - 2026-09-10
Container and response-header hardening (WP-13: M31, M34).
⚠ Breaking
- The production container now listens on 8080, not 80. nginx runs as an
unprivileged user, and a non-root process cannot bind a privileged port without
CAP_NET_BIND_SERVICE, which compose drops. Anything routing to this container must be repointed: in the homelab Traefik config that meanstraefik.http.services.movieloop.loadbalancer.server.portchanges from80to8080. The published mapping indocker-compose.prod.ymlis updated here, but the Traefik label lives outside this repo and must be changed by hand.
Fixed
- Security headers are no longer dropped from most responses. nginx's
add_headeris not additive across levels: a block declaring anyadd_headerof its own discards every header inherited from its parent.location /assets/andlocation = /config.jseach declared one, soX-Frame-Options,X-Content-Type-OptionsandReferrer-Policywere silently absent from every JavaScript and CSS response - which is most of what a browser loads. The headers now come from a snippet included in each location. Verified against the built image: 6 of 6 headers present on/, on a hashed/assets/*.jsbundle, and on/config.js. config.jsno longer interpolatesAPI_URLunescaped. This usedenvsubst, which does no escaping, so a value containing a double quote closed the JavaScript string literal and everything after it became executable script on every page load. The value is now escaped when rendered. Operator-supplied, so not a likely attack - but the page hands its auth token to whatever origin that value resolves to, and a malformed value is now inert rather than executable.
Added
- A Content-Security-Policy, plus
Strict-Transport-SecurityandPermissions-Policy. With the JWT inlocalStorage, CSP is the main compensating control against injected script and there was none.connect-srcis derived at container start fromAPI_URL, because the API is not same-origin in production - the app is served from one subdomain and the API from another, so naming it explicitly is required or every request and websocket would be blocked. The inline theme script inindex.htmlruns before first paint to avoid a flash of the wrong theme and so cannot move to a file; it is allowed by SHA-256 hash rather than byunsafe-inline, which would have bought almost nothing. If that script is edited its hash must be regenerated indocker/40-render-config.shor the page will not apply its theme.
Changed
- The production image is based on
nginxinc/nginx-unprivileged:alpineand runs as uid 101 rather than root. It serves static files and never needed root. Verified in the built image:uid=101(nginx). envsubstis no longer installed, since the entrypoint rendersconfig.jsitself in order to escape the value.
[1.3.0] - 2026-09-10
Session handling and effect correctness.
Fixed
- An expired or revoked session no longer leaves the app half signed in. The response interceptor only wrote to the console, so the nav still showed your name while every request quietly failed. A 401 now clears the token and returns you to sign-in. Signing in with the wrong password is unaffected - that is an answer, not an expired session.
- Signing out ends the whole session. It disconnected the notifications socket and nothing else, leaving the chat, versus and game night sockets open with a token that had just been removed, and the friends and game stores populated - so signing in as someone else on the same tab showed the previous person's friend list and pending-request badge.
- Leaving a match no longer carries its result into the next one. Disconnecting cleared only the socket, so the match id, result and score survived and the next match opened showing the previous match's completion modal.
- Reconnecting no longer duplicates everything. All four sockets tested whether the existing connection was connected rather than whether it existed, so one that was mid-reconnect got replaced without being closed; the abandoned socket kept its own handlers and delivered every event a second time.
- Game night spectators are detected again. The check decoded the session token
with
atob, which rejects the base64url encoding tokens actually use, so it threw inside a socket handler and the spectator flag was never set. - Reloading your profile no longer discards a game in progress. Effects depended on the whole user object, which is rebuilt on every profile load, so they re-ran when nothing had changed - and in an async match the re-run reset the board mid-game.
- Undo is applied in order and no longer drifts from the server. Rapid undos fired overlapping requests at a server that removes one link per call, and a failure was only logged while the local chain had already been shortened.
- Leaving a page cancels its in-flight requests instead of letting them land afterwards.
- The friends page no longer removes other components' socket listeners when it unmounts.
- Copying a result no longer leaves a timer running after the page closes.
- Share links point at the deployment that produced them, rather than always at production.
Security
- The runtime API setting is escaped when written into the page. It was substituted raw, so a value containing a quote ended the string and the rest became executable JavaScript on every page load - and the page hands its auth token to whatever that value resolves to. A malformed value is now also rejected on read, falling back to the default.
Removed
- The difficulty selector in Endless mode. It was never applied to anything: difficulty is derived from cast analysis on the server, and Endless builds its pairs in the browser, so the control silently did nothing. The genre and decade filters are unaffected.
[1.2.3] - 2026-09-10
Fixed
- The async challenge leaderboard no longer shows an empty heading to anyone who has not played that match. The backend now withholds the movie pair from non-participants - the pair is the puzzle - so the two titles arrive absent rather than empty, and the page rendered a bare separator between nothing. It now says the movies are hidden until you play, and the type reflects that they are optional.
[1.2.2] - 2026-09-09
Fixed
- Shell scripts and Dockerfiles are pinned to LF so a Windows checkout no longer
copies CRLF line endings into the container image.
docker/40-render-config.shwas affected; nginx sources it rather than executing it, so nothing was visibly broken, but the file was wrong in the image.
[1.2.1] - 2026-09-09
Fixed
- All high-severity dependency advisories cleared (
npm audit: 23 -> 0), entirely within existing semver ranges. - Five further
set-state-in-effectviolations caught by the updated react-hooks plugin, including search state that was cleared on every keystroke below the search threshold, and an async submit guard that re-ran its own effect.
Changed
- In-range dependency updates, notably react 19.2 -> 19.3 and
@base-ui/react1.2 -> 1.8. shadcn,tailwindcss,@tailwindcss/viteandtw-animate-cssmoved to devDependencies. They are build-time only -shadcnis a scaffolding CLI with no runtime imports - but as runtime dependencies they pulledmsw,@babel/coreandts-morphinto the production dependency tree that vulnerability scanning reads.
[1.2.0] - 2026-09-09
Changed
- Game completion, hints and all versus submissions follow the server's authoritative scoring contract: completion posts no payload, hints go through the server (which also records them), and match submissions send a session id rather than a score.
Fixed
- Stale hints are cleared when the chain changes; the previous movie's cast used to stay on screen after picking an actor another way.
[1.1.0] - 2026-09-09
Fixed
- Versus, Game Night and Async Versus can complete a match again. None of the three ever created a backend game session, so no score was produced, nothing was submitted, the opponent was never notified, and the completion modal had no way out.
- Chain links may be reused. Repeating an actor or movie was rejected outright instead of being allowed and penalised, which could strand a player mid-chain. Reuse now warns that it costs points, and the penalty appears in the score breakdown.
- A failed async score submission is no longer retried on every render.
- Confetti no longer re-randomises mid-flight.
- Page data fetches are cancelled on unmount, so a late response cannot write into a view the player has left.
- Profile stats and history load independently, fixing a guard that could stop the stats request from ever re-running.
Changed
- The dev container starts the Vite server correctly and now picks up source edits, so hot reload works.
- The lint CI job fails on warnings, and the two React hook rules that had been demoted to warnings are back at error.