Skip to content

Guest voting, one language per visitor, and a deploy that can fail safely - #75

Merged
loewenmaehne merged 11 commits into
mainfrom
feat/guest-voting-and-hardening
Aug 20, 2026
Merged

loewenmaehne merged 11 commits into
mainfrom
feat/guest-voting-and-hardening

Conversation

@loewenmaehne

@loewenmaehne loewenmaehne commented Aug 20, 2026 •

Copy link
Copy Markdown
Owner

Works through the seven points from the review of what I would do differently. One commit per point, each independently reviewable.

What changed

1. Guests can vote and suggest (70c18cd) — the headline change. Voting and suggesting both refused anyone without a Google account, and there was no guest identity anywhere in the code. A guest who scanned the QR code had to accept cookies, open an OAuth popup and pick an account before their first thumbs-up.

A guest identity is now a random id the server signs (HMAC) and the client keeps in localStorage. Stateless by design: no row in users, no row in sessions, nothing personal stored, nothing to erase on request. Ids are namespaced guest: so they can never collide with a Google subject id.

Owners get a per-room "Login required" switch, off by default — so existing rooms open to guests on deploy. Turn it on and the room behaves exactly as it does today. Room.canParticipate is the single place that decides, so voting and suggesting cannot drift apart.

2. One language per visitor (ae18976) — translations.js held all 35 languages in one module: 114 KB gzipped on the critical path, more than React, the router and the OAuth client combined. Now one chunk per language. A German visitor loads 8.4 KB instead of 114.5 KB — 92% off the critical path.

3. Tests gate deploys (3951d4e) — the 87 server tests ran in no workflow, and update_server.sh restarted whatever it pulled, reporting success whether or not the process came back. Now: a test job in CI (plus cuevote-mcp, which had no CI at all), tests run before the restart, /health is polled after it, and a failure rolls back to the commit recorded before git reset --hard.

4. Real migrations (part of 70c18cd) — migrations 001–007 are all SELECT 1; the schema actually lived in db.js as CREATE TABLE IF NOT EXISTS plus try { ALTER TABLE } catch {}. 008 and 009 are the first real ones, migrations/README.md writes the rule down, and migrator.js now execs each file as one script instead of splitting on semicolons.

5. Dead TypeScript removed (5895ac8) — src/db.ts was a parallel port of db.js that nothing required, compiled by a CI job that type-checked nothing (checkJs: false). Measured before deciding: enabling checkJs yields 202 errors, mostly CommonJS/@types/ws interop — its own migration, not a step here. So the pretense goes, and the server gets the static analysis it never had: eslint was already a devDependency with no config and no script. First run found 13 real issues, all fixed.

6. Structure and comments (1bae25b, 5cfdd9e) — the queue ordering rule was re-implemented at three call sites, each re-deriving "never move index 0" (the invariant behind the silent-skip footgun). Now one tested function. Six identical ref-mirror effects in RoomBody collapse into useLatestRef, 39 effects down to 33 — which surfaced tRef, dead state written on every render and never read. Comments that recorded how someone arrived at the code were rewritten; one was a live question, so it got answered rather than reworded.

7. README (c1b4a33) — the tagline advertised "running a bar" while the in-app terms grant no licence for public performance, with the YouTube compliance review open.

Verification

  • 114 server tests pass, 27 of them new (guest access, guest tokens, queue ordering).
  • Live, over a real socket against a running server: a fresh guest is issued a signed token, votes (score 3 → 4), reconnects to the same identity with its vote remembered, toggles it back off, and a token with one character changed is rejected and replaced — while the voters map still never leaves the server. 10/10 checks.
  • In the browser: only en + one locale load on first paint; switching language at runtime fetches that one chunk and never the other 32. A fresh tab loads with no console errors.
  • Client lint, i18n (235 keys × 35 languages), SPDX and licence guardrails, and the MCP typecheck all pass.

Decisions worth a second opinion

  • require_login defaults to 0, so every existing room opens to guests when this deploys. That is the point of the change, but it is a live behaviour change for rooms already out there — say the word and I will flip the default and make it opt-out instead.
  • GUEST_TOKEN_SECRET is optional. Unset, the server generates one and stores it in server_config, which is right for a single instance. Documented in DEPLOYMENT.md; set it explicitly before running a second process.
  • Decomposing RoomBody's effect graph is not in here. Playback recovery, socket sync and overlay state should not be pulled apart without a client test harness, and there is none. Worth its own piece of work.

Not verified

The in-room UI for a guest was checked at protocol level and through the lobby (which showed "Willkommen, Gast 9255", correctly translated), but not clicked through in the room itself — that sits behind the cookie consent dialog, and accepting terms on your behalf is not something I do unasked.


Review round (commits 70df9f1…e8d286c)

A high-effort review of this branch found 15 confirmed defects, all fixed here. One root cause produced six of them: ws.user started carrying two kinds of identity, but only Room.canParticipate told them apart — so every other !ws.user gate quietly changed meaning from "is signed in" to "has any identity", which every guest does.

Two were ship-blockers:

  • Ballot stuffing. GUEST_SESSION only refused to overwrite a signed-in user, so an existing guest could mint a fresh identity on demand and vote again with each one — defeating the single property the guest module exists to provide. Proved before the fix: one socket, one IP, never signed in, 21 identities, one track moved by 20. After: 22 requests, 1 identity, no inflation.
  • Nobody could sign in. Every affordance was user ? profile : <GoogleAuthButton>, and every visitor became a guest within a round-trip of page load — so the Google button stopped rendering anywhere, and a Require-Login room told guests to sign in with no control that could.

The rest: the YouTube Search-quota gate had silently opened to guests (its own comment explains why that matters); CREATE_ROOM, DELETE_ACCOUNT and the AI-DJ OAuth finalize all admitted guests; signing in mid-party let one person vote twice; set -e killed the deploy script before the rollback this branch added could ever run; the health check polled a port an inline .env comment had mangled; and five defects in the language split — a rejected chunk cached forever, English with no failure path, a render gate that never waited, localStorage moved where no ErrorBoundary could catch it, and a guardrail that stopped covering the module the app actually imports.

Each fix is verified rather than asserted: the exploit was re-run against the fixed server, the gates were exercised over a real socket, the sign-in button and the language switch were checked in the browser, both deploy defects were reproduced and re-tested in isolation, and the i18n guardrail was proved to catch the one-character break that used to ship green. 120 server tests (14 new), all six CI jobs pass.

The tagline advertised 'running a bar' while the in-app terms state the
service is designed for personal, private use and grant no licence for
public performance or commercial use. Marketing the excluded use case on
the public README is a contradiction we do not want on record while the
YouTube API compliance review is open.

Replaces it with private-gathering examples and states the private-use
scope explicitly, linking the terms.
… a success

The 87 server tests ran in no workflow, and update_server.sh restarted
whatever it pulled: it reported 'Update Completed Successfully' whether or
not the process came back, and 'git reset --hard origin/main' had already
destroyed the tree it could have rolled back to.

CI: add a server test job, and cover cuevote-mcp at all — its path was
missing from the filters, so OAuth and token-minting code merged untested.

Deploy: record the deployed commit before the reset, run the tests before
touching the running process, poll /health after the reload, and roll back
to the recorded commit when it does not come up. Worktree mode has no
rollback target and now says so instead of claiming success.

Verified /health answers 200 {"status":"ok"} against a locally started
server, and the port parser against the real .env plus the missing-file
fallback.
…that actually runs

src/db.ts and src/types.ts were a parallel port of db.js that nothing ever
required — one commit, untouched since, while db.js kept growing. tsc
compiled them (and transpiled the CommonJS into a dist/ no process runs)
with checkJs disabled, so the 'server — build' CI job type-checked exactly
nothing.

Measured before deciding: enabling checkJs across the server yields 202
errors, most of them the CommonJS/@types/ws interop making every socket
look like the module namespace. That is its own migration, not a step
here, so the pretense goes rather than being half-kept.

In its place the server gets the static analysis it never had: eslint was
already a devDependency with no config and no script. The first run found
13 real issues, all fixed — a Set built and never read in
checkVideoAvailability, an unused loop binding in the shutdown path,
unused parameters, unnecessary escapes inside regex character classes, and
two stale requires in dev scripts. Two rules are tuned with the reason in
the config: rest-sibling destructuring is how PII is stripped, and
defensive initialisers are deliberate.

Tests stay at 87/87.
…uire accounts

Voting and suggesting both refused anyone without a Google account, and
ws.user was only ever set by LOGIN or RESUME_SESSION — there was no guest
identity anywhere in the code. A guest who scanned the QR code at a party
had to accept cookies, open an OAuth popup and pick a Google account
before their first thumbs-up. That is the largest drop-off on the path the
product exists for.

A guest identity is now a random id the server signs (HMAC) and the client
keeps in localStorage. It is deliberately stateless: no row in users, no
row in sessions, nothing personal stored, nothing to erase on request. The
id is namespaced 'guest:' so it can never collide with a Google subject id
or be mistaken for a room owner. The secret behind it comes from
GUEST_TOKEN_SECRET, or is generated once and kept in server_config so a
default install needs no configuration — either way it survives a restart,
so a deploy mid-party does not sign everyone out.

Owners who want every participant tied to an account get a per-room
'Login required' switch, off by default. Room.canParticipate is the single
place that decides, so voting and suggesting cannot drift apart, and the
client mirrors it only to explain the rule instead of collecting a
rejection.

Guests have no account name, so the server sends a flag and a four-character
tag and the client renders the translated word — otherwise every guest in a
German room would read 'Guest'. All 35 languages carry the three new keys.

Schema: this is also the first change to follow the migration convention.
require_login and server_config land as real migrations (008, 009) rather
than another try/catch ALTER in db.js, migrations/README.md writes the rule
down, and migrator.js now execs each file as one script instead of
splitting it on semicolons.

Verified against a running server over a real socket: a fresh guest is
issued a signed token, votes (score 3 -> 4), reconnects to the same
identity with its vote remembered, toggles it back off, and a token with
one character changed is rejected and replaced — while the voters map still
never leaves the server. 104 unit tests pass, 17 of them new.
translations.js held every language in one module: 408 KB raw, 114 KB
gzipped — larger than React, the router and the OAuth client combined, and
on the critical path, because the render gate waits for it. A guest joining
a party over venue wifi downloaded 34 languages they cannot read to see the
one they can.

Each language is now its own module under translations/locales/, loaded
through import.meta.glob so the bundler emits one chunk per language. A
visitor fetches their own plus English, which stays loaded as the fallback
for any key a translation is missing. Detection no longer needs the strings
— only the list of codes — so it settles synchronously and the right chunk
starts downloading in the same tick the app boots. Switching language pulls
that chunk once and keeps it.

For a German visitor: 114.5 KB gzip becomes 8.4 KB (en 4.0 + de 4.6), 92%
off the critical path. An English visitor loads 4.0 KB.

These remain dynamic imports, so the TDZ crash fixed in 1b57d50 stays
fixed. check-i18n.mjs reassembles the locale files and enforces the same
five checks as before: 235 keys × 35 languages still pass.

While verifying this in the browser, the header greeted a guest as 'Guest
9255' in a German UI — the server's English fallback name leaking into the
one place it is read by a person. Added displayName(), so the greeting,
the profile heading and the avatar initial all translate the word:
'Willkommen, Gast 9255', 'Bienvenue, Invité 9255'.

Verified in the browser: only en+nl load on first paint, switching to
German fetches de.js alone, and switching to French at runtime fetches
fr.js alone — never the other 32.
… thinking-out-loud comments

Three call sites each re-implemented 'slice off the head, sort the rest,
put the head back' — in handleSuggestSong, handleVote and the playback
error path. Index 0 is the track that is playing (the client mirrors it as
currentTrack), so getting that wrong skips a song mid-play with nothing in
the logs to explain it. That is a footgun worth removing, not repeating
three times.

sortUpcoming() in queue-order.js is now the only place that knows the rule:
owner priority first, then score, ties keeping arrival order because the
sort is stable. It never mutates its input and never moves the head. Ten
tests pin that behaviour, including the head-stays-put invariant that the
duplication kept putting at risk.

Also rewrote the comments that recorded how someone arrived at the code
instead of what it does — 'Let's stick to strict Title check as
requested', 'I suspect index.js logic forwards it blindly', 'maybe wait?'.
One of them was a live question, so it got answered rather than reworded:
DELETE_ACCOUNT is handled in the index.js switch, which returns before any
room routing, so the case in Room.js is unreachable and now says so.

114 tests pass.
…n RoomBody

RoomBody carried 39 useEffects. Six of them were the same three lines:
mirror a piece of state into a ref so the YouTube player's event handlers —
bound once at construction, closing over whatever the state was then — read
current values instead of stale ones. Identical, easy to get subtly wrong
(a mismatched dependency silently freezes the ref), and crowding out the
effects that actually do something.

useLatestRef() replaces all six. 39 effects down to 33, with no change to
what any of them do.

Removing the boilerplate surfaced dead state: tRef was written on every
change of the translation function and never read anywhere. The linter
could only see it once the mirror effect stopped referencing it. Removed.

The linter proves useRef() is stable but cannot prove that of a custom
hook's return, so the four dependency arrays that read these refs now list
them. A stable value never re-triggers an effect, so this is bookkeeping,
not behaviour.

This is the safe part of decomposing this component. The effect graph
itself — playback recovery, socket sync, overlay state — should not be
pulled apart without a client test harness to catch what moves, and there
is none yet.

Verified in a fresh browser tab: the app loads with no console errors.
Review of the guest-voting change found one root cause behind six defects:
ws.user now carries two kinds of identity, but only Room.canParticipate
told them apart. Every other `!ws.user` gate silently changed meaning from
"is signed in" to "has any identity", which every guest does.

guest.hasAccount(ws) is now the question those gates ask:

  CREATE_ROOM       rooms.owner_id is a foreign key into users, so a guest
                    owner threw SQLITE_CONSTRAINT_FOREIGNKEY — after
                    bcrypt.hashSync had already blocked the thread ~100ms
                    for an unauthenticated caller.
  DELETE_ACCOUNT    ran a full room_state scan plus a WAL checkpoint and a
                    broadcast to every client in every loaded room, per
                    message, for an id that was never in users.
  MCP_AUTHORIZE     bound an AI-DJ OAuth grant to a browser-local random
                    number and burned the single-use handle.
  FETCH_SUGGESTIONS the most expensive YouTube call there is (~100 of
                    10.000 daily units on a cache miss). Its own comment
                    said every quota-spending action requires a login;
                    suggesting and voting were opened to guests on
                    purpose, this was not.

The identity itself was also mintable without limit. GUEST_SESSION only
refused to overwrite a signed-in user, so a socket that was already a
guest could ask for a fresh identity and vote again with each one —
defeating the single property the module exists to provide. Proved before
the fix: one socket, one IP, never signed in, 21 identities, one track
moved by 20. A socket now gets exactly one identity and re-sends are
answered with it; minting a new one costs a new connection, and
MAX_GUEST_MINTS_PER_IP (60 per 10 min, generous for a party behind one
NAT) bounds churning them. Retested after the fix: 22 requests, 1
identity, no inflation.

Signing in mid-party no longer counts one person twice. Room.mergeIdentity
re-keys the guest's votes and attributions onto the account, and takes the
guest vote back out of the score when the account had already voted from
another device.

canParticipate stopped conflating its two refusals: a socket with no
identity yet was being told the channel requires a signed-in account, in
rooms where that setting is off. NO_IDENTITY and LOGIN_REQUIRED now carry
their own accurate messages.

Tests: 120 pass, 6 new. The suite also stopped hanging after its last
assertion — the async write worker is a live handle and nothing closed it,
which surfaced once several runs overlapped.
Every sign-in affordance was written as `user ? profile : <GoogleAuthButton>`
back when `user` could only mean an account. Handing every visitor a guest
identity made that condition true within a round-trip of page load, so the
Google button stopped rendering anywhere: Header, both Lobby variants and
the AI-connect page. Nobody who was not already signed in could sign in,
signing out re-guested immediately so no account switch was possible, and
a room with the new Require-Login toggle told guests to sign in with no
control anywhere that could.

isSignedIn(user) is the client's counterpart to guest.hasAccount on the
server, and now gates all four, plus the create-channel tile, which offered
guests an enabled button whose insert always failed on the owner_id
foreign key.

Server refusals also stopped being English-only. The server sends a code
with each one; the client maps it to a translated string and falls back to
the server text when it does not recognise the code. The two local gates
no longer reuse the suggest copy for votes, and they distinguish "this
channel wants an account" from "your session is not ready yet" — the
second used to claim the first, in rooms where that setting is off.

Four new keys in all 35 languages (239 × 35 pass check:i18n).

Verified in the browser: with a guest token present and no session token,
the lobby renders "Se connecter avec Google" and the create tile takes the
not-signed-in path.
Five defects from the review, all in the machinery the split introduced.

A rejected load was cached forever. loadLanguage stored the promise before
it settled and never evicted it, so one dropped request pinned that
language to an error for the whole session — selecting it again returned
the same rejection without another attempt. Failures are now evicted, so
picking the language again is a real retry, and a module without a usable
default export is rejected rather than stored as undefined (which the
consumer's truthiness guard would have turned into a render loop).

English had no failure path at all. It was the one leg of the Promise.all
without a .catch, and the .then that consumed it had no rejection handler,
so a single 404 — a visitor on a cached index.html after a deploy — left
the app on a text-free spinner forever with an unhandled rejection and
nothing logged. English is retried once, the result never rejects, and if
it still fails the app offers a reload control instead of spinning.

The render gate did not do what it said. Guarding on
"translations && (translations[language] || translations.en)" is the same
as guarding on "translations", because English is always present, so
switching language repainted the entire UI in English for the length of
the fetch. It now waits for the chosen language, and stops waiting when
that language is known to have failed.

localStorage moved back out of harm's way. Reading it at module scope
meant a browser with site data blocked — or a storage-partitioned frame —
threw during evaluation of a chunk main.jsx imports before createRoot, so
the page went blank with no ErrorBoundary. Reads and writes are guarded.

The guardrail stopped covering the loader. check-i18n.mjs validated the
locale directory but no longer touched the module the app imports:
changing the glob by one character printed "i18n OK" while the build
emitted zero locale chunks. It now checks the glob and the prefix
codeFromPath strips — verified by reproducing both breaks and watching the
check fail.

Also drops the manualChunks rule for the deleted translations.js, which
matched nothing, and updates docs/TDZ-FIXES.md, which still described both
it and the old dynamic import as active.
…ver uses

Two defects from the review, both in the safety net added earlier on this
branch, and both of which made a healthy or recoverable deploy worse.

set -e killed the script before the rollback. "pm2 reload || pm2 restart"
is a list, not a condition, so when BOTH fail — a wedged or out-of-memory
pm2, exactly what the rollback exists for — set -e exits right there. The
health check and the whole rollback path never ran, and the working tree
had already been reset onto the new commit. Verified: the old shape exits
1 without reaching the next line; the new one handles it and continues.

The health check polled the wrong port. server_port kept everything after
the first "=", so PORT=8080 with a trailing comment became 8080#httpport,
which curl reads as a fragment and turns into a request for / — answered
with 404, failing all 15 attempts and rolling back a deploy that was fine.
It also ignored an exported PORT, which the server itself honours because
dotenv does not override an existing variable and pm2 --update-env passes
the environment through. Resolution now matches the server's, and inline
comments, quotes, export and spaces around = all parse; anything that is
not a plain number falls back to 8080 instead of being polled.

While fixing that: the helpers resolved paths relative to the working
directory while do_update walks between package directories, so the
rollback introduced on this branch would have run npm ci in the wrong
place. Everything is anchored to the script's own directory now, and git
operations name the repo explicitly.

Exit codes also stopped conflating the two bad outcomes: 1 means the
deploy failed but the previous version is serving again, 2 means the site
needs a human. A rollback restores the server and not the branch, so the
script now says so — the next run would otherwise redeploy the same bad
commit, tests included, since they passed on it.

DEPLOYMENT.md documents the codes and MAX_GUEST_MINTS_PER_IP.
@loewenmaehne
loewenmaehne merged commit 046efea into main Aug 20, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant