4 Commits
Author SHA1 Message Date
TehRiehlDeal da32e49b0b fix: push subscription payload; CSP hash computed from the shipped page (2.2.3)
frontend-ci / lint (push) Successful in 24s
frontend-ci / test (push) Successful in 32s
frontend-ci / secrets-scan (push) Successful in 4s
frontend-ci / sast (push) Successful in 6s
frontend-ci / fs-scan (push) Successful in 11s
frontend-ci / typecheck (push) Successful in 14s
frontend-ci / build (push) Successful in 38s
frontend-ci / push (push) Skipped
frontend-ci / test (pull_request) Successful in 34s
frontend-ci / secrets-scan (pull_request) Successful in 4s
frontend-ci / sast (pull_request) Successful in 6s
frontend-ci / fs-scan (pull_request) Successful in 13s
frontend-ci / typecheck (pull_request) Successful in 23s
frontend-ci / lint (pull_request) Successful in 26s
frontend-ci / build (pull_request) Successful in 48s
frontend-ci / push (pull_request) Skipped
Enabling push in production failed with "Validation failed": the
browser's PushSubscription.toJSON() carries expirationTime, and it was
sent verbatim to a schema that is strict about unknown fields. The
client now sends exactly endpoint + keys. The test doubles had been
shaped like the schema rather than like a browser, which is why 1,112
tests said nothing.

The CSP hash for index.html's inline theme script was hardcoded in the
container's start-up script behind a comment asking for it to be
regenerated by hand, and claiming a test asserted it; none did. The
bytes are what the browser hashes, so an edit or a checkout with
different line endings would have silently blocked the page's own
script. The hash is now emitted by `npm run build` from the index.html
that is actually shipped, copied into the image, and read at start; the
container refuses to start without it. Verified against the built
image: header hash equals served-script hash.

For the record: a CSP console message in production is what led here,
and the served page turned out to be correct - that message was a
browser extension's injected script, which the policy is right to
refuse.
2026-09-12 10:22:14 -07:00
TehRiehlDeal ca3862d5a7 harden(docker)!: run nginx unprivileged and stop dropping security headers
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.
2026-09-10 22:51:03 -07:00
TehRiehlDeal 92d7dfdef3 fix(frontend): repair session teardown and effect lifecycles
Closes WP-10 of the audit: M10-M16, M18, M19, M20 and the client half of
H11. Steps 5 and 7 of the package were already done in WP-1.

An expired token left the app half signed in: the interceptor only wrote to
the console, so the nav still showed the user's name while every request
failed. A 401 now clears the token and navigates to sign-in - a full
navigation rather than a router push, so in-memory state and open sockets go
with the page, and the API client does not have to reach into the stores and
join an import cycle with them. A 401 from /auth/login is excluded: there,
it is an answer.

Signing out disconnected the notifications socket and nothing else. Chat,
versus and game night stayed open holding a token that had just been removed,
and the friends and game stores kept their contents, so signing in as someone
else on the same tab showed the previous person's friend list. Teardown now
lives in one place, because the failure mode was precisely that adding a
socket did not mean remembering to close it.

All four connect() guards tested `.connected` rather than existence, so a
socket that was mid-reconnect was replaced without being closed and the
orphan went on handling every event a second time. disconnect() likewise
cleared only the socket, leaving matchId, result and score behind - the next
match opened showing the previous match's completion modal.

Effects depended on the whole `user` object, which loadUser() rebuilds on
every call, so they re-ran when nothing had changed. In AsyncVersusGame that
effect's cleanup calls resetGame(), so reloading a profile mid-match reset
the board. They depend on the id now. The same effect also reset the store
while its fetch was in flight, so init() then wrote the match back into what
had just been cleared; it aborts before resetting.

Page reads take an AbortSignal and their effects abort on unmount. Several
already had a `cancelled` flag, which stops the state write but lets the
request run to completion; the flag stays to guard the `finally` blocks.

Undo was fire-and-forget: rapid clicks put overlapping POSTs in flight
against a server that removes one link per call, and a failure was logged
while the local chain had already been shortened. Undos are serialised and
the server's chain - which it was already returning and the client was
discarding - is now authoritative.

The runtime API setting was substituted into a JS string literal by envsubst,
which does no escaping, so a value containing a quote ended the literal and
the remainder executed on every page load. It is escaped at render time
(verified under busybox with the production values and with an injection
payload, which comes out inert), and a malformed value is rejected on read.
Note this deliberately does not assert same origin, as the plan suggested:
the frontend and API are served from different subdomains in production, so
that assertion would break the deployment.

The Endless difficulty selector is removed rather than wired. Difficulty is
derived from cast analysis on the server and Endless builds its pairs in the
browser, so there was nothing to wire it to - it silently did nothing while
telling the player their choice mattered.

Verified in the running app: an invalid token clears and redirects to
/login?expired=1 without looping, while a wrong password shows "Invalid email
or password" on the form and stays put; sign-out clears the token, returns to
sign-in and leaves no trace of the previous user; Endless renders without the
control and keeps its real filters. No console errors beyond the 401s those
checks deliberately provoked.
2026-09-10 21:22:31 -07:00
TehRiehlDeal 7d0947d295 Add Keep-Me-Signed-In, movie release dates, and lint cleanup (#1)
frontend-ci / secrets-scan (push) Successful in 5s
frontend-ci / lint (push) Successful in 15s
frontend-ci / typecheck (push) Successful in 18s
frontend-ci / sast (push) Successful in 12s
frontend-ci / fs-scan (push) Successful in 13s
frontend-ci / build (push) Successful in 40s
frontend-ci / push (push) Failing after 34s
Reviewed-on: #1
2026-05-13 11:59:01 -07:00