Stage 2.5 established the network and controller boundary. Stage 3 adds an isolated defensive engine, Stage 3.1 adds read-only hosted verification, and Stage 4A adds verified server-only production data, Stage 4B adds authoritative rounds, Stage 4C adds player-private submissions, and Stage 4D adds merged final round results. Stage 4E display-only QR joining is complete and merged; Stage 4F Touch/Trace entry is the current draft work. This document separates implemented controls from protections still required before public deployment.
- The server creates room codes, display IDs, player IDs, reconnect tokens, room state, and controller authority.
- Display creation accepts a strict empty object. It does not accept a room code, name, player ID, display ID, or controller claim.
- Player join accepts only room code and display name. It does not accept a controller flag or controller player ID.
- The first player receives controller authority from the server.
- Controller state is explicit:
nonehas no controller ID or connected player, andassignedreferences exactly one existing player. - Normal transfer accepts only
targetPlayerId, requires the currently connected controller socket, and requires a different connected player in the same room. - The display has no controller-assignment event. It cannot select, recover, or approve a game host.
- Controller succession is a server-owned lifecycle transition. It considers
only connected remaining players and sorts by
joinedAt, then player ID. - Transfer and succession never change display identity.
- The display session is never inserted into the player collection and never counts toward the eight-player maximum.
- Each connected socket is bound to at most one server-created role session:
displayorplayer. - Display and player reconnect tokens are stored in separate server indexes. Neither token can restore or impersonate the other role.
- Display or controller disconnect does not directly close the room.
- Public room state excludes socket IDs, token values, and private token indexes.
Stage 4C keeps paths, word decisions, and provisional points server-owned. Only the newest connected socket for a current participant can submit; displays, removed players, players waiting because the active roster is full, and stale replaced sockets are rejected. Stage 4D derives final scores, ranks, and winners only from the append-only participant roster and its server-retained private submission map.
- All lobby request payloads use shared strict Zod schemas at runtime.
- Unknown fields and malformed values are rejected.
- Socket.IO messages are capped at 16 KiB.
- Room codes use one canonical six-character format. Typed spaces and hyphens are removed, and letters are normalized to uppercase.
- Player display names normalize whitespace, allow 2–24 characters, and reject Unicode control and formatting characters.
- Duplicate player names are rejected within a room without regard to case.
- React renders names as text. Markup-like names are tested to remain plain content rather than executable HTML.
- Error responses use a fixed set of public codes and bounded messages.
- Express disables its identifying
X-Powered-Byresponse header. - Settings and start payloads are strict complete/empty objects. Clients cannot provide a board, seed, participant, timestamp, deadline, or round number.
- Successful action acknowledgements are schema-validated by the client, and a state version prevents a stale acknowledgement from replacing a newer broadcast.
Validation is not treated as authorization. The room store still decides whether a validated action is allowed for the socket and current role.
- Room codes use cryptographic randomness from a 32-character alphabet, giving 32⁶ possible values.
- Code allocation checks active-room collisions and stops after a bounded number of attempts.
- A room accepts at most eight phone players; the one display is separate.
- The process accepts at most 500 rooms by default, with a bounded configuration range.
- Create, join, and reconnect attempts are limited to 20 per 10 seconds for one socket.
- Socket.IO payloads are capped at 16 KiB.
- Room lifetime defaults to a sliding two hours.
- Disconnect grace defaults to five minutes.
- Cleanup runs every 30 seconds by default and removes expired rooms, ordinary players, token mappings, socket references, and expired-code tombstones.
- A room with no connected controller candidate is still bounded by the eight-player cap and room lifetime.
- Environment-provided numeric limits are range-checked and fall back to safe defaults when invalid.
- Board generation uses one cryptographic 48-bit sample per random value and the Stage 4A eight-attempt quality bound.
- One unreferenced 250 ms lifecycle interval scans bounded rooms. There is no unmanaged timer per room or client.
These controls reduce accidental exhaustion and simple abuse. They are not a complete public anti-abuse system.
Reconnect tokens contain 32 cryptographically random bytes encoded with a URL-safe alphabet. They are random secrets, not encoded claims.
The server issues different credential shapes:
- displays receive
displaySessionIdanddisplayReconnectToken; - players receive
playerIdandplayerReconnectToken.
Each token is indexed only in the matching role map and scoped to one room and session ID. Tokens are not placed in URLs or logged by application code.
A successful reconnect invalidates the presented token and issues a new one. A disconnect starts that role’s short grace period. Cleanup invalidates the credential after the deadline.
If a valid credential is reused while its previous socket exists, the new socket supersedes it. The old socket loses its room binding and receives a structured error. This prevents two active sockets from silently sharing one role during a refresh race. Stale-tab cleanup compares the failed token before removing shared browser storage, so it cannot delete the replacement tab’s newly rotated credential.
Browser storage is appropriate for the current temporary, account-free sessions, but it is accessible to JavaScript on the same origin. A future cross-site scripting flaw could expose it, so dependencies, text rendering, and future HTML features still require review.
The automatic root display flow stores a separate profile-local pointer containing only the display role, room code, and display session ID. It never contains the reconnect token. Root startup validates the referenced role-specific credential and reconnects it before creating a room. Invalid display state clears only that display credential; it cannot consume a player credential or affect another browser profile’s room.
Disconnect is presence loss, not room ownership loss:
- the display going offline does not remove players;
- the controller going offline does not close the room;
- the disconnected controller retains authority during reconnect grace;
- a disconnected display or player may restore only its own role during grace;
- an ordinary player is removed after grace;
- if controller grace expires, its token and player record are removed;
- the server promotes the earliest-joined connected remaining player, breaking equal join-time ties by player ID;
- if none is connected, state becomes
noneand the next join or reconnect becomes controller; - the old credential cannot reconnect after expiry, although the person can join again as an ordinary player;
- room TTL remains the final bound.
Controller absence is explicit rather than inferred. The display stays passive through disconnect, expiry, and succession; it cannot make itself or anyone else controller.
The room store authorizes and mutates each action synchronously against its current socket binding:
- after one transfer succeeds, a second request from the former controller is rejected;
- a controller reconnect processed at the grace deadline before cleanup preserves authority;
- if cleanup wins, it invalidates the expired token before choosing a successor;
- duplicate cleanup work and competing leave/transfer operations resolve to exactly one controller;
- stale cleanup cannot overwrite a newer manual controller assignment;
- if the selected successor disconnects, that player receives the same grace behavior before the next deterministic succession;
- a replaced stale socket cannot disconnect the newer valid socket.
Tests cover these boundaries with competing requests, refreshed sockets, offline targets, deterministic ties, repeat cleanup, and old-token reconnect attempts.
Socket.IO currently allows the two standard local Vite origins and the
configured public base URL. The Vite server proxies /api and /socket.io in
development.
Public deployment must provide HTTPS at <public origin>, verify
WebSocket forwarding, and narrow the production origin policy to actual
deployment needs. Reconnect tokens are application credentials and must never
be sent over unencrypted public HTTP.
Active-room snapshots disclose only bounded accepted-word counts in immutable round-participant order. Word identities, paths, provisional scores, acceptance times, private sequence/version data, and shared status remain absent until the existing finalized result projection permits the intended fields.
- Controlled startup loads the verified 79,370-word dictionary exactly once before listening or allowing room creation.
- The dictionary and provenance remain private server runtime state. Room snapshots, health output, logs, and client bundles contain no entries.
- Only the bound connected controller socket may update settings or start.
- Board generation and result validation finish before room mutation. Exhaustion leaves phase, prior round, settings, activity, and TTL unchanged.
- Round reconciliation is idempotent and records the official deadline as
endedAt; it does not extend TTL. - Disconnect, reconnect, leave, grace expiry, mid-round join, and controller transfer do not move the deadline or rewrite the participant snapshot.
- Returned boards and participants are copies, so caller mutation cannot alter internal room state.
- The client countdown uses the server snapshot plus
performance.now()only for display. It cannot change the authoritative phase.
There is no production Math.random(), client seed, audit PRNG, per-room timer,
manual end action, dictionary socket lookup, submission, scoring, or result
payload in Stage 4B.
- Active public room and round state contains no words, counts, or personal points.
- Reconnect returns only the bound player's current private state.
- Strict requests accept no identity, board, time, score, points, or verdict.
- The server uses
validateWordPath()with its board and private dictionary, discards paths, stores no rejection history, and never logs submitted words. - Personal duplicates, the 256-word cap, scoring, and the complete strict next state are checked before one atomic commit.
- A 20-per-1,000-ms per-socket submission limiter runs before parsing, bounds malformed and unauthenticated events, and clears on disconnect.
- The stricter 10-per-1,000-ms limiter is keyed by room/player, survives refresh, and remains separate from controller-action capacity.
- Exact-deadline processing publishes the ended transition even for malformed, rejected, or rate-limited submissions.
- No client event can request finalization or supply a result word, score, rank, winner, shared status, or final point value.
- Finalization requires exactly one valid private state for every immutable participant and none for a nonparticipant. Departed and grace-expired identities remain in the snapshot; mid-round joiners remain out.
- Pure bounded reconciliation counts canonical words across distinct player IDs, validates every word's length-based base points, and adds the fixed +1 or +2 bonus only to words held by one participant. It performs no I/O, randomness, dictionary lookup, rounding, or timestamp comparison.
- The complete ended round is strictly validated before one atomic commit.
Impossible internal failures expose only a bounded
INTERNAL_ERROR, publish no partial result, leave private state unchanged, and cannot stop other rooms in the lifecycle sweep. - The existing
room:statebroadcast is the only publication path. Finalization is idempotent, increments publicstateVersionexactly once, and does not updatelastActivityAtorexpiresAt. - Public results are bounded to eight participants and 256 words each. They contain canonical words, base points, shared status, exact quarter-point bonuses and finals, base/bonus/final totals, rank, and winner IDs only.
- Accepted timestamps, private sequence and submission versions, paths, rejected attempts, rate-limit windows, socket IDs, credentials, and dictionary data remain private.
- The public result is a detached immutable projection. The owner's unchanged private state remains reconnectable until the next round replaces both the old public result and private submission map.
- The display derives one public join URL from
window.location.originand the room's validated public code through the existing sharedbuildJoinUrlhelper. - The helper replaces stale paths, removes query parameters, fragments, and
URL userinfo, and emits only the current scheme, host, optional port, and
/join/<NORMALIZED_CODE>path. - The exact same completed string is passed to the QR renderer and rendered as the textual link. The QR component does not rebuild or append to it.
- QR markup contains no session ID, reconnect token, player or controller ID, socket ID, room snapshot, settings, board, result, accepted word, state version, dictionary value, analytics identifier, or authentication value.
qrcode.react4.2.0 runs synchronously in the client, renders a canvas, and has no runtime dependencies, network request, install script, native binary, filesystem access, telemetry, or remote code.- The published ISC licence notice is preserved in
THIRD_PARTY_NOTICES.md. Package files and bundled code were inspected before installation. - Rendering uses black modules on an opaque white background, error-correction level M with boosting disabled, and a four-module quiet zone. There is no image, logo, overlay, gradient, transparency, animation, or decorative module style.
- The canvas and wrapper are hidden from the accessibility tree. A semantic region supplies the heading, instructions, visible room code, and exact keyboard-focusable URL instead.
- A renderer exception is contained around only the visual QR. It cannot remove the textual join information, break gameplay or reconnect, delete a room, or start a retry loop.
- QR presentation is display-only. It grants no role, bypasses no name, capacity, expiration, or rate-limit check, and does not auto-join.
- Stage 4E adds no server endpoint, network event, state field, QR credential,
external service, untrusted HTML, or
dangerouslySetInnerHTML.
The public room code and QR are intentionally visible to people near the shared display. They are invitations to attempt an ordinary temporary-room join, not secrets or proof of authorization.
- Throttling is per socket, not per IP, subnet, device, or room code.
- A client can reconnect to obtain a new socket and a fresh request window.
- Room-not-found and recently expired responses are distinguishable, which may help code enumeration.
- There is no reverse-proxy request limit, network firewall policy, or production monitoring in this repository.
- Temporary credentials have no account identity, revocation interface, or durable audit record.
- The in-memory process is a single availability boundary; restarting it closes every room.
- The room/player submission limiter is not an IP-aware public edge limit.
- Physical QR scanning depends on display size, glare, focus, viewing distance, and the scanning device; the visible URL and room code remain required fallbacks.
Before public deployment, add layered IP-aware limits at a trusted boundary, review enumeration behavior, verify proxy IP handling, add safe operational metrics, and test the exact origin and TLS configuration.
- Canonical boards support only sizes 4, 5, and 6 and exactly
size × sizetiles. - Canonical tile tokens contain one to four uppercase ASCII letters.
- Successful board and path validation returns frozen snapshots rather than caller-owned arrays.
- Candidate words contain at most 64 ASCII letters after outer trimming and case normalization. Punctuation, internal whitespace, control and formatting characters, accents, and Unicode case expansions are rejected rather than silently removed.
- Paths must be non-empty, no longer than the board, and contain only unique in-range integer indexes. All entries are checked before tile access.
- Row/column adjacency prevents numeric row wrapping and accepts only one-cell horizontal, vertical, or diagonal moves.
- Path validation is linear and uses a Set for tile reuse. It has no recursion based on candidate data.
- The supplied path must reconstruct exactly the normalized submitted word before dictionary membership is queried.
- Dictionary lookup is injected, synchronous, filesystem-free, network-free, and Set-backed. Malformed input entries are reported.
- Weighted generation requires a validated injected random source. Non-finite or out-of-range values, non-finite weights, duplicate normalized tokens, and non-finite totals are rejected.
- Board-quality retry uses an iterative explicit limit from 1 through 1,000 and returns a structured exhaustion result.
- No production dictionary data, proprietary distribution, gameplay event, or dynamic code execution is included.
These controls protect pure engine calls. They do not authorize a socket, verify room phase, enforce a deadline, or rate-limit network submissions.
- The production dictionary source repository, release tag, direct and peeled commit, export arguments, counts, bytes, SHA-256, and metadata-free gzip size and SHA-256 are pinned.
- The complete applicable ESDB permission notice is committed beside the data and verified by an independently pinned full-file SHA-256. Conditional licence branches not selected by the size-60 American/Canadian export are not represented as though they applied.
- Reproduction fetches only the pinned tag at depth one from the fixed official
URL, checks out the pinned commit directly, invokes subprocesses without a
shell, accepts no output path, and rejects a source checkout whose remote,
tag, peeled commit,
HEAD, tracked state, untracked state, or source-path file type differs. - Generated dictionary output is length- and count-bounded, written in a same-directory temporary location, verified completely, gzip-measured, and atomically renamed. Symbolic-link output targets are rejected and temporary directories are removed on success and failure.
- Normal verification is offline. It checks regular file types, exact manifest fields, checksum, bytes, final newline, LF-only endings, BOM absence, ASCII format, per-line length, strict sort order, uniqueness, notice scope, and byte-identical regenerated distribution data.
- The runtime loader accepts only local file URLs, resolves production files relative to its module rather than the process working directory, bounds error detail, rejects symlinks and non-regular files, validates the exact schema and every pinned manifest field, verifies one read before constructing the dictionary, exposes no Set, and has no mutable global cache. The built JavaScript loader is smoke-tested from an unrelated working directory.
- Candidate derivation and board audits use fixed sample counts and a clearly non-production seeded generator. Production generation still requires an injected random source and can make at most eight attempts.
- The dictionary-derived profile has positive safe-integer weights, includes
QUinstead of standaloneQ, contains no proprietary table, and records zero manual adjustments. - Browser-conditioned package resolution is disabled. The client’s transitive workspace graph is checked for game-data dependencies, imports, aliases, relative paths, and re-exports; symbolic links are rejected throughout the scanned source and build boundaries; lint enforces the same source boundary; and post-build CI scans the emitted bundle for the dictionary hash and sentinel words. Neither application loads game data in Stage 4A.
- Scripts perform no dynamic code download, runtime external request, secret access, full-dictionary logging, persistence, or gameplay mutation.
The word list is under one megabyte. Loader memory is suitable for a controlled one-time startup load, but Stage 4B must not reload it per request, submission, player, or room.
The Stage 3.1 CI workflow has explicit workflow-level contents: read
permission and no secrets. Checkout credentials are not persisted. The
workflow cannot push commits, change pull requests, publish packages, create
releases, deploy software, upload source to an external service, or alter
repository settings.
Only actions/checkout and actions/setup-node are used. Both official actions
are pinned to full release commit SHAs rather than mutable branches or tags.
There is no third-party action, downloaded shell script, curl-to-shell
installer, dynamic code download, or write-capable token.
Pull requests use the ordinary pull_request event, never
pull_request_target. Repository scripts from the proposed revision execute
with read-only repository permission and without secrets or Git credentials.
Concurrency cancels superseded work for the same pull request or ref without
mixing unrelated runs.
Hosted verification supplements local review. Branch protection and repository Actions settings remain a separate settings task after the real check names have completed successfully.
Stages 4C and 4D implement the submission and single-round result parts of this boundary; later gameplay and production stages must continue to:
- permit only allowlisted grid sizes, durations, and scoring modes
- authorize settings and round starts against
controllerPlayerId - reject word submissions from display-bound sockets
- generate and retain the official board
- call the Stage 3 path and word engine only with the server-retained board
- load and retain the Stage 4A verified dictionary once during controlled startup
- enforce the server deadline and phase
- rate-limit submissions per player and room
- calculate every future score and duplicate rule from accepted server data
- add a regression test for each engine or authorization bug
No gameplay event may trust a client-provided score, time, controller role, board, dictionary result, settings object, or round result.
- Never commit passwords, API tokens, tunnel credentials, private keys,
registry tokens, personal server addresses, or real
.envfiles. - Supply future secrets through the deployment environment.
- Do not log reconnect tokens or future word/session credentials.
- Keep dependencies updated through reviewable changes.
- Run the future container as a non-root user with only required port and filesystem access.
- Record the license and attribution for every dictionary and bundled asset.