From 64dca85f76463ef2d8250c8269f284baba78ca51 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Fri, 10 Jul 2026 08:24:41 -0700 Subject: [PATCH 01/56] Preserve room sessions across deploys Delay disconnect cleanup while clients reconnect and reconcile membership through the shared Redis adapter. Deploy application services sequentially without recreating stateful dependencies. --- .env.example | 2 + README_DEPLOYMENT.md | 25 ++++ compose.yml | 1 + scripts/deploy.sh | 40 +++++- server/config/default.json | 3 +- server/index.js | 24 ---- server/runtimeConfig.js | 9 ++ server/socket/lib/reconnectGrace.js | 93 ++++++++++++++ server/socket/lib/reconnectGrace.test.js | 157 +++++++++++++++++++++++ server/socket/namespaces/rooms.js | 103 +++++++++------ 10 files changed, 393 insertions(+), 64 deletions(-) create mode 100644 server/socket/lib/reconnectGrace.js create mode 100644 server/socket/lib/reconnectGrace.test.js diff --git a/.env.example b/.env.example index 2ef5254..bc02498 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,8 @@ NODE_ENV=prod PORT=8080 SOCKETIO_PORT=9000 +# Preserve room membership while clients reconnect after a socket deploy. +ROOM_RECONNECT_GRACE_MS=60000 APP_IMAGE_TAG=local # Public URLs used by the client build diff --git a/README_DEPLOYMENT.md b/README_DEPLOYMENT.md index eba0432..0174b6c 100644 --- a/README_DEPLOYMENT.md +++ b/README_DEPLOYMENT.md @@ -87,6 +87,31 @@ Deploy latest code from the server checkout: APP_DIR=/opt/letscube scripts/deploy.sh ``` +The deploy script builds the new application image and applies PostgreSQL +migrations before replacing either application process. It then replaces and +health-checks `api` and `socket` one at a time. MongoDB, PostgreSQL, and Redis +are started without recreating existing containers. nginx is gracefully +reloaded after each application replacement so it resolves the new container +without dropping unrelated connections. + +Socket clients may briefly reconnect while the `socket` container is replaced. +Room membership, the current scramble, waiting/competing state, and room admin +are preserved during `ROOM_RECONNECT_GRACE_MS` (60 seconds by default). A +client that reconnects within that window resumes the existing room instead of +leaving and rejoining it. Only users with no socket on any Socket.IO instance +after the grace window are removed from the room. + +Set the grace window in `.env.prod` if production deploys or client reconnects +need more time: + +```bash +ROOM_RECONNECT_GRACE_MS=90000 +``` + +Keep the grace shorter than the time in which an abandoned room should appear +active. Explicit leaves, kicks, and bans remain immediate and do not use the +grace window. + ## Backups And Restore Run a MongoDB backup: diff --git a/compose.yml b/compose.yml index 574303d..f2009cb 100644 --- a/compose.yml +++ b/compose.yml @@ -4,6 +4,7 @@ x-app-environment: &app-environment NODE_ENV: ${NODE_ENV:-prod} PORT: ${PORT:-8080} SOCKETIO_PORT: ${SOCKETIO_PORT:-9000} + ROOM_RECONNECT_GRACE_MS: ${ROOM_RECONNECT_GRACE_MS:-60000} MONGO_URL: ${MONGO_URL:-mongodb://mongo:27017/letscube} REDIS_URL: ${REDIS_URL:-redis://redis:6379} POSTGRES_ENABLED: ${POSTGRES_ENABLED:-true} diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 4f292e4..f13f378 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -36,8 +36,44 @@ if [ "$previous_commit" = "$current_commit" ] && [ "$DEPLOY_FORCE" != "true" ]; exit 0 fi -if ! "${COMPOSE[@]}" up -d --build; then - echo "Deployment failed. Recent service logs:" >&2 +if ! "${COMPOSE[@]}" build api; then + echo "Application image build failed." >&2 + exit 1 +fi + +if ! "${COMPOSE[@]}" up -d --no-recreate mongo postgres redis; then + echo "Failed to start backing services." >&2 + exit 1 +fi + +if ! "${COMPOSE[@]}" run --rm --no-deps migrate; then + echo "PostgreSQL migration failed; application containers were not replaced." >&2 + exit 1 +fi + +# Replace one application process at a time. API sessions live in MongoDB, and +# socket room membership is preserved during the configured reconnect grace. +if ! "${COMPOSE[@]}" up -d --no-deps --wait api; then + echo "API deployment failed. Recent API logs:" >&2 + "${COMPOSE[@]}" logs --tail=120 api >&2 || true + exit 1 +fi + +if ! "${COMPOSE[@]}" up -d --no-deps --wait nginx \ + || ! "${COMPOSE[@]}" exec -T nginx nginx -s reload; then + echo "Failed to reload nginx after the API deployment." >&2 + exit 1 +fi + +if ! "${COMPOSE[@]}" up -d --no-deps --wait socket; then + echo "Socket deployment failed. Recent socket logs:" >&2 + "${COMPOSE[@]}" logs --tail=120 socket >&2 || true + exit 1 +fi + +if ! "${COMPOSE[@]}" up -d --no-deps --wait nginx \ + || ! "${COMPOSE[@]}" exec -T nginx nginx -s reload; then + echo "Failed to reload nginx after the socket deployment. Recent service logs:" >&2 "${COMPOSE[@]}" logs --tail=120 api socket nginx >&2 || true exit 1 fi diff --git a/server/config/default.json b/server/config/default.json index 8c3bbd8..463f612 100644 --- a/server/config/default.json +++ b/server/config/default.json @@ -3,7 +3,8 @@ "port": 8080 }, "socketio": { - "port": 9000 + "port": 9000, + "reconnectGraceMs": 60000 }, "mongodb": "mongodb://127.0.0.1/letscube", "postgres": { diff --git a/server/index.js b/server/index.js index 3293ac0..0516e33 100644 --- a/server/index.js +++ b/server/index.js @@ -12,7 +12,6 @@ const session = require('./middlewares/session'); const logger = require('./logger'); const auth = require('./auth'); const api = require('./api'); -const { Room } = require('./models'); Error.stackTraceLimit = 100; @@ -101,26 +100,3 @@ try { } catch (e) { logger.error(e); } - -/* eslint-disable no-await-in-loop */ -async function asyncForEach(array, callback) { - for (let index = 0; index < array.length; index += 1) { - await callback(array[index], index, array); - } -} - -process.on('SIGINT', () => { - Room.find().then(async (rooms) => { - try { - await asyncForEach(rooms, async (room) => { - await asyncForEach(room.users, async (user) => { - await room.dropUser(user); - }); - }); - } catch (e) { - logger.error(e); - } - - process.exit(0); - }); -}); diff --git a/server/runtimeConfig.js b/server/runtimeConfig.js index 382b628..296228f 100644 --- a/server/runtimeConfig.js +++ b/server/runtimeConfig.js @@ -18,6 +18,11 @@ const parsePositiveInteger = (value, fallback) => { return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; }; +const parseNonNegativeInteger = (value, fallback) => { + const parsed = Number.parseInt(value, 10); + return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback; +}; + const authSecret = process.env.AUTH_SECRET || process.env.SESSION_SECRET || baseConfig.auth.secret; @@ -58,6 +63,10 @@ module.exports = { socketio: { ...baseConfig.socketio, port: parsePort(process.env.SOCKETIO_PORT, baseConfig.socketio.port), + reconnectGraceMs: parseNonNegativeInteger( + process.env.ROOM_RECONNECT_GRACE_MS, + baseConfig.socketio.reconnectGraceMs, + ), }, mongodb: process.env.MONGO_URL || process.env.MONGODB_URI || baseConfig.mongodb, redis, diff --git a/server/socket/lib/reconnectGrace.js b/server/socket/lib/reconnectGrace.js new file mode 100644 index 0000000..a755364 --- /dev/null +++ b/server/socket/lib/reconnectGrace.js @@ -0,0 +1,93 @@ +const timerKey = (roomId, userId) => `${roomId}:${userId}`; + +const createReconnectGrace = ({ + graceMs, + hasActiveSockets, + finalizeDeparture, + listActiveUsers, + logger, + setTimer = setTimeout, + clearTimer = clearTimeout, +}) => { + const timers = new Map(); + const finalizationQueues = new Map(); + + const cancel = (roomId, userId) => { + const key = timerKey(roomId, userId); + const timer = timers.get(key); + if (timer) { + clearTimer(timer); + timers.delete(key); + } + }; + + const enqueueFinalization = (departure, requireInactive) => { + const roomKey = departure.roomId.toString(); + const previous = finalizationQueues.get(roomKey) || Promise.resolve(); + const current = previous.catch(() => {}).then(async () => { + if (requireInactive + && await hasActiveSockets(departure.userId, departure.roomId)) { + return false; + } + + return finalizeDeparture(departure); + }); + + finalizationQueues.set(roomKey, current); + current.finally(() => { + if (finalizationQueues.get(roomKey) === current) { + finalizationQueues.delete(roomKey); + } + }).catch(() => {}); + + return current; + }; + + const finalizeIfInactive = (departure) => enqueueFinalization(departure, true); + const finalize = (departure) => enqueueFinalization(departure, false); + + const schedule = (departure) => { + cancel(departure.roomId, departure.userId); + + const key = timerKey(departure.roomId, departure.userId); + const timer = setTimer(() => { + timers.delete(key); + finalizeIfInactive(departure).catch((err) => logger.error(err)); + }, graceMs); + + if (timer && timer.unref) { + timer.unref(); + } + + timers.set(key, timer); + }; + + const reconcile = async () => { + const activeUsers = await listActiveUsers(); + await Promise.all(activeUsers.map((departure) => finalizeIfInactive(departure))); + }; + + const startReconciliation = () => { + const timer = setTimer(() => { + reconcile().catch((err) => logger.error(err)); + }, graceMs); + + if (timer && timer.unref) { + timer.unref(); + } + + return timer; + }; + + return { + cancel, + finalize, + reconcile, + schedule, + startReconciliation, + }; +}; + +module.exports = { + createReconnectGrace, +}; diff --git a/server/socket/lib/reconnectGrace.test.js b/server/socket/lib/reconnectGrace.test.js new file mode 100644 index 0000000..2cb2b27 --- /dev/null +++ b/server/socket/lib/reconnectGrace.test.js @@ -0,0 +1,157 @@ +/* eslint-env jest */ + +const { createReconnectGrace } = require('./reconnectGrace'); + +describe('reconnect grace', () => { + const departure = { + roomId: 'room-123', + userId: 101, + connectionId: 'socket-123', + leaveReason: 'disconnect', + }; + let hasActiveSockets; + let finalizeDeparture; + let listActiveUsers; + let logger; + + const createManager = (graceMs = 30000) => createReconnectGrace({ + graceMs, + hasActiveSockets, + finalizeDeparture, + listActiveUsers, + logger, + }); + + beforeEach(() => { + jest.useFakeTimers(); + hasActiveSockets = jest.fn().mockResolvedValue(false); + finalizeDeparture = jest.fn().mockResolvedValue(true); + listActiveUsers = jest.fn().mockResolvedValue([]); + logger = { error: jest.fn() }; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it('keeps the user in the room until the reconnect grace expires', async () => { + let markFinalized; + const finalized = new Promise((resolve) => { + markFinalized = resolve; + }); + finalizeDeparture.mockImplementationOnce(async (value) => { + markFinalized(value); + return true; + }); + const manager = createManager(); + + manager.schedule(departure); + jest.advanceTimersByTime(29999); + await Promise.resolve(); + + expect(finalizeDeparture).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); + await finalized; + + expect(hasActiveSockets).toHaveBeenCalledWith(101, 'room-123'); + expect(finalizeDeparture).toHaveBeenCalledWith(departure); + }); + + it('cancels pending cleanup when the user reconnects', async () => { + const manager = createManager(); + + manager.schedule(departure); + manager.cancel(departure.roomId, departure.userId); + jest.runAllTimers(); + await Promise.resolve(); + + expect(hasActiveSockets).not.toHaveBeenCalled(); + expect(finalizeDeparture).not.toHaveBeenCalled(); + }); + + it('finalizes an explicit departure without waiting for socket inactivity', async () => { + const manager = createManager(); + + await manager.finalize({ ...departure, leaveReason: 'explicit' }); + + expect(hasActiveSockets).not.toHaveBeenCalled(); + expect(finalizeDeparture).toHaveBeenCalledWith({ + ...departure, + leaveReason: 'explicit', + }); + }); + + it('does not remove a user who has another active socket', async () => { + let markChecked; + const checked = new Promise((resolve) => { + markChecked = resolve; + }); + hasActiveSockets.mockImplementationOnce(async () => { + markChecked(); + return true; + }); + const manager = createManager(); + + manager.schedule(departure); + jest.runAllTimers(); + await checked; + + expect(finalizeDeparture).not.toHaveBeenCalled(); + }); + + it('reconciles users left active by a replaced socket process', async () => { + const connected = { ...departure, userId: 202 }; + listActiveUsers.mockResolvedValue([departure, connected]); + hasActiveSockets.mockImplementation(async (userId) => userId === 202); + const manager = createManager(); + + await manager.reconcile(); + + expect(finalizeDeparture).toHaveBeenCalledTimes(1); + expect(finalizeDeparture).toHaveBeenCalledWith(departure); + }); + + it('serializes cleanup for users in the same room', async () => { + let finishFirstDeparture; + let markFirstStarted; + const firstStarted = new Promise((resolve) => { + markFirstStarted = resolve; + }); + const secondDeparture = { ...departure, userId: 202 }; + listActiveUsers.mockResolvedValue([departure, secondDeparture]); + finalizeDeparture + .mockImplementationOnce(() => new Promise((resolve) => { + markFirstStarted(); + finishFirstDeparture = resolve; + })) + .mockResolvedValueOnce(true); + const manager = createManager(); + + const reconciliation = manager.reconcile(); + await firstStarted; + + expect(finalizeDeparture).toHaveBeenCalledTimes(1); + + finishFirstDeparture(true); + await reconciliation; + + expect(finalizeDeparture).toHaveBeenCalledTimes(2); + expect(finalizeDeparture).toHaveBeenLastCalledWith(secondDeparture); + }); + + it('delays startup reconciliation by the same grace window', async () => { + listActiveUsers.mockResolvedValue([departure]); + const manager = createManager(45000); + + manager.startReconciliation(); + jest.advanceTimersByTime(44999); + expect(listActiveUsers).not.toHaveBeenCalled(); + + jest.advanceTimersByTime(1); + await Promise.resolve(); + await Promise.resolve(); + + expect(listActiveUsers).toHaveBeenCalledTimes(1); + }); +}); diff --git a/server/socket/namespaces/rooms.js b/server/socket/namespaces/rooms.js index 862d26e..0047820 100644 --- a/server/socket/namespaces/rooms.js +++ b/server/socket/namespaces/rooms.js @@ -3,6 +3,7 @@ const bcrypt = require('bcrypt'); const Protocol = require('../../../client/src/lib/protocol.json'); const ChatMessage = require('../lib/ChatMessage'); const { parseCommand } = require('../lib/commands'); +const config = require('../../runtimeConfig'); const logger = require('../../logger'); const metrics = require('../../metrics'); const { markRoomDeleted } = require('../../postgres/dualWrite'); @@ -11,6 +12,7 @@ const { encodeUserRoom } = require('../utils'); const roomMap = require('../lib/roomMap'); const { canAccessRoom, canDeleteRoom } = require('../lib/roomAuthorization'); const { removeUserFromRoomSockets } = require('../lib/roomSockets'); +const { createReconnectGrace } = require('../lib/reconnectGrace'); const { createSafeSocketHandler, optionalAcknowledgment, @@ -61,31 +63,23 @@ module.exports = (io, middlewares) => { return [...sockets].some((socketId) => socketId !== currentSocketId); }; - async function markNormalRoomsStaleOnStartup() { - const rooms = await Room.find({ type: 'normal' }) + const hasActiveSocketsForUserRoom = async (userId, roomId) => ( + (await socketsForUserRoom(userId, roomId)).size > 0 + ); + + async function listActiveRoomUsers() { + const rooms = await Room.find() .populate('users') .populate('admin') .populate('owner'); - await Promise.all(rooms.map(async (room) => { - room.users.forEach((user) => { - room.inRoom.set(user.id.toString(), false); - room.waitingFor.set(user.id.toString(), false); - }); - - room.admin = null; - await room.updateStale(true); - })); - - logger.info('Marked normal rooms stale on socket startup', { - rooms: rooms.length, - }); + return rooms.flatMap((room) => room.usersInRoom.map((user) => ({ + roomId: room._id, + userId: user.id, + leaveReason: 'disconnect', + }))); } - const normalRoomsReady = markNormalRoomsStaleOnStartup().catch((err) => { - logger.error(err); - }); - function sendNewScramble(room) { return room.newAttempt().then((r) => { logger.debug('Sending new scramble to room', { roomId: room.id }); @@ -100,6 +94,49 @@ module.exports = (io, middlewares) => { }); } + async function finalizeUserDeparture({ + roomId, userId, connectionId, leaveReason, + }) { + const room = await fetchRoom(roomId); + const userKey = userId.toString(); + + if (!room || !room.inRoom.get(userKey)) { + return false; + } + + const updatedRoom = await room.dropUser({ id: userId }, (_room) => { + ns().in(room.accessCode).emit(Protocol.UPDATE_ADMIN, _room.admin); + }); + + await metrics.endRoomVisit({ + room: updatedRoom, + userId, + connectionId, + leaveReason, + activeUserCount: updatedRoom.usersLength, + }); + + ns().in(updatedRoom.accessCode).emit(Protocol.USER_LEFT, userId); + ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(updatedRoom)); + + if (updatedRoom.doneWithScramble()) { + logger.debug('everyone done, sending new scramble'); + sendNewScramble(updatedRoom); + } + + return true; + } + + const reconnectGrace = createReconnectGrace({ + graceMs: config.socketio.reconnectGraceMs, + hasActiveSockets: hasActiveSocketsForUserRoom, + finalizeDeparture: finalizeUserDeparture, + listActiveUsers: listActiveRoomUsers, + logger, + }); + + reconnectGrace.startReconciliation(); + const interval = 60 * 1000; // 30 seconds function startTimer(room) { @@ -183,8 +220,6 @@ module.exports = (io, middlewares) => { } ns().on('connection', async (socket) => { - await normalRoomsReady; - const on = createSafeSocketHandler(socket, logger, Protocol.ERROR); logger.debug(`socket ${socket.id} connected to rooms; logged in as ${socket.user ? socket.user.name : 'Anonymous'}`); @@ -284,25 +319,13 @@ module.exports = (io, middlewares) => { return; } - const room = await socket.room.dropUser(socket.user, (_room) => { - ns().in(socket.room.accessCode).emit(Protocol.UPDATE_ADMIN, _room.admin); - }); - - await metrics.endRoomVisit({ - room, + reconnectGrace.cancel(socket.roomId, socket.userId); + await reconnectGrace.finalize({ + roomId: socket.roomId, userId: socket.userId, connectionId: socket.id, leaveReason, - activeUserCount: room.usersLength, }); - - broadcast(Protocol.USER_LEFT, socket.user.id); - ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(room)); - - if (room.doneWithScramble()) { - logger.debug('everyone done, sending new scramble'); - sendNewScramble(room); - } } catch (e) { logger.error(e); } @@ -338,6 +361,7 @@ module.exports = (io, middlewares) => { } socket.join(encodeUserRoom(socket.userId, room._id)); + reconnectGrace.cancel(room._id, socket.userId); const r = await room.addUser(socket.user, spectating, (_room) => { ns().in(_room.accessCode).emit(Protocol.UPDATE_ADMIN, _room.admin); @@ -838,7 +862,12 @@ module.exports = (io, middlewares) => { logger.info(`socket ${socket.id} disconnected; Left room: ${socket.room ? socket.room.name : 'Null'}`, { roomId: socket.roomId }); if (socket.user && socket.room) { - await leaveRoom('disconnect'); + reconnectGrace.schedule({ + roomId: socket.roomId, + userId: socket.userId, + connectionId: socket.id, + leaveReason: 'disconnect', + }); } else if (socket.room) { await metrics.endRoomVisit({ room: socket.room, From 516cc090e872f1ea3ba7b2a01d46cab191d79b31 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Fri, 10 Jul 2026 08:43:32 -0700 Subject: [PATCH 02/56] Add API and socket health checks Expose dependency-aware HTTP readiness endpoints and a Socket.IO health round trip. Point Compose and nginx at the explicit health routes for deployment verification. --- README_DEPLOYMENT.md | 16 +++++ client/src/lib/protocol.json | 2 + compose.yml | 4 +- nginx.conf | 4 ++ server/health.js | 56 +++++++++++++++++ server/health.test.js | 79 ++++++++++++++++++++++++ server/index.js | 18 +++++- server/socket/index.js | 27 +++++++- server/socket/namespaces/default.js | 11 +++- server/socket/namespaces/default.test.js | 60 ++++++++++++++++++ 10 files changed, 271 insertions(+), 6 deletions(-) create mode 100644 server/health.js create mode 100644 server/health.test.js create mode 100644 server/socket/namespaces/default.test.js diff --git a/README_DEPLOYMENT.md b/README_DEPLOYMENT.md index 0174b6c..0e16e5a 100644 --- a/README_DEPLOYMENT.md +++ b/README_DEPLOYMENT.md @@ -112,6 +112,22 @@ Keep the grace shorter than the time in which an abandoned room should appear active. Explicit leaves, kicks, and bans remain immediate and do not use the grace window. +### Health Checks + +The API and socket processes expose dependency-aware health endpoints. A +healthy response has HTTP status `200`; an unavailable MongoDB, PostgreSQL, or +Redis dependency returns `503` with the failing check marked `error`. + +```bash +curl -sS https://letscube.net/health/api +curl -sS https://letscube.net/health/socket +``` + +The default Socket.IO namespace also accepts a `health_check` event. It returns +the socket health report through the acknowledgment callback, or emits +`health_status` when no callback is provided. This tests a real Socket.IO +connection in addition to the HTTP readiness endpoint. + ## Backups And Restore Run a MongoDB backup: diff --git a/client/src/lib/protocol.json b/client/src/lib/protocol.json index 8448c6f..563ae31 100644 --- a/client/src/lib/protocol.json +++ b/client/src/lib/protocol.json @@ -5,6 +5,8 @@ "CONNECT_ERR": "connect_error", "RECONNECT_ERR": "reconnect_error", "RECONNECT": "reconnect", + "HEALTH_CHECK": "health_check", + "HEALTH_STATUS": "health_status", "ERROR": "errorrr", "UPDATE_ROOMS": "update_rooms", "UPDATE_ROOM": "update_room", diff --git a/compose.yml b/compose.yml index f2009cb..4a30135 100644 --- a/compose.yml +++ b/compose.yml @@ -50,7 +50,7 @@ services: expose: - "8080" healthcheck: - test: ["CMD-SHELL", "node -e \"require('http').get({host:'127.0.0.1',port:process.env.PORT||8080,path:'/'},res=>process.exit(res.statusCode<500?0:1)).on('error',()=>process.exit(1))\""] + test: ["CMD-SHELL", "node -e \"require('http').get({host:'127.0.0.1',port:process.env.PORT||8080,path:'/health/api'},res=>process.exit(res.statusCode===200?0:1)).on('error',()=>process.exit(1))\""] interval: 30s timeout: 5s retries: 5 @@ -69,7 +69,7 @@ services: expose: - "9000" healthcheck: - test: ["CMD-SHELL", "node -e \"require('http').get({host:'127.0.0.1',port:process.env.SOCKETIO_PORT||9000,path:'/socket.io/?EIO=4&transport=polling'},res=>process.exit(res.statusCode<500?0:1)).on('error',()=>process.exit(1))\""] + test: ["CMD-SHELL", "node -e \"require('http').get({host:'127.0.0.1',port:process.env.SOCKETIO_PORT||9000,path:'/health/socket'},res=>process.exit(res.statusCode===200?0:1)).on('error',()=>process.exit(1))\""] interval: 30s timeout: 5s retries: 5 diff --git a/nginx.conf b/nginx.conf index ac675c1..88c98f2 100644 --- a/nginx.conf +++ b/nginx.conf @@ -29,6 +29,10 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + location = /health/socket { + proxy_pass http://socket:9000/health/socket; + } + location /socket.io/ { proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; diff --git a/server/health.js b/server/health.js new file mode 100644 index 0000000..e6d4782 --- /dev/null +++ b/server/health.js @@ -0,0 +1,56 @@ +const runCheck = async (check, timeoutMs) => { + let timer; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), timeoutMs); + if (timer.unref) { + timer.unref(); + } + }); + + const result = await Promise.race([ + Promise.resolve() + .then(check) + .then((healthy) => healthy !== false) + .catch(() => false), + timeout, + ]); + + clearTimeout(timer); + return result; +}; + +const createHealthReporter = ({ + service, + checks, + checkTimeoutMs = 2000, + now = () => new Date(), + uptime = () => process.uptime(), +}) => async () => { + const checkResults = await Promise.all(Object.entries(checks).map(async ([name, check]) => ( + [name, await runCheck(check, checkTimeoutMs) ? 'ok' : 'error'] + ))); + const normalizedChecks = Object.fromEntries(checkResults); + const healthy = Object.values(normalizedChecks).every((status) => status === 'ok'); + + return { + status: healthy ? 'ok' : 'error', + service, + timestamp: now().toISOString(), + uptimeSeconds: Math.floor(uptime()), + checks: normalizedChecks, + }; +}; + +const createHealthHandler = (reportHealth) => async (_req, res) => { + const report = await reportHealth(); + + res.statusCode = report.status === 'ok' ? 200 : 503; + res.setHeader('Cache-Control', 'no-store'); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.end(JSON.stringify(report)); +}; + +module.exports = { + createHealthHandler, + createHealthReporter, +}; diff --git a/server/health.test.js b/server/health.test.js new file mode 100644 index 0000000..0a074b3 --- /dev/null +++ b/server/health.test.js @@ -0,0 +1,79 @@ +/* eslint-env jest */ + +const { createHealthHandler, createHealthReporter } = require('./health'); + +describe('health reporting', () => { + const fixedTime = new Date('2026-07-10T12:00:00.000Z'); + + it('reports healthy dependencies', async () => { + const reportHealth = createHealthReporter({ + service: 'api', + checks: { + mongodb: () => true, + postgres: async () => true, + }, + now: () => fixedTime, + uptime: () => 12.9, + }); + + await expect(reportHealth()).resolves.toEqual({ + status: 'ok', + service: 'api', + timestamp: fixedTime.toISOString(), + uptimeSeconds: 12, + checks: { + mongodb: 'ok', + postgres: 'ok', + }, + }); + }); + + it('reports failed and timed-out dependencies without exposing errors', async () => { + const reportHealth = createHealthReporter({ + service: 'socket', + checks: { + mongodb: () => { + throw new Error('connection details'); + }, + redis: () => new Promise(() => {}), + }, + checkTimeoutMs: 1, + now: () => fixedTime, + uptime: () => 3, + }); + + await expect(reportHealth()).resolves.toEqual({ + status: 'error', + service: 'socket', + timestamp: fixedTime.toISOString(), + uptimeSeconds: 3, + checks: { + mongodb: 'error', + redis: 'error', + }, + }); + }); + + it('returns 503 and disables caching when a health check fails', async () => { + const report = { + status: 'error', + service: 'api', + checks: { mongodb: 'error' }, + }; + const response = { + setHeader: jest.fn(), + end: jest.fn(), + }; + const handler = createHealthHandler(jest.fn().mockResolvedValue(report)); + + await handler({}, response); + + expect(response.statusCode).toBe(503); + expect(response.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-store'); + expect(response.setHeader).toHaveBeenCalledWith( + 'Content-Type', + 'application/json; charset=utf-8', + ); + expect(response.end).toHaveBeenCalledWith(JSON.stringify(report)); + }); +}); diff --git a/server/index.js b/server/index.js index 0516e33..6deeb45 100644 --- a/server/index.js +++ b/server/index.js @@ -7,7 +7,8 @@ const passport = require('passport'); const config = require('./runtimeConfig'); const { connect } = require('./database'); -const { initializePostgres, startPostgresMaintenance } = require('./postgres'); +const { createHealthHandler, createHealthReporter } = require('./health'); +const { initializePostgres, pool, startPostgresMaintenance } = require('./postgres'); const session = require('./middlewares/session'); const logger = require('./logger'); const auth = require('./auth'); @@ -28,6 +29,21 @@ const init = async () => { await initializePostgres(); startPostgresMaintenance(); + const reportHealth = createHealthReporter({ + service: 'api', + checks: { + mongodb: () => mongoose.connection.readyState === 1, + postgres: async () => { + if (config.postgres.enabled) { + await pool.query('SELECT 1'); + } + return true; + }, + }, + }); + + app.get('/health/api', createHealthHandler(reportHealth)); + /* Logging */ app.use(morgan('combined', { diff --git a/server/socket/index.js b/server/socket/index.js index 39f7f30..68266bb 100644 --- a/server/socket/index.js +++ b/server/socket/index.js @@ -7,7 +7,8 @@ const expressSocketSession = require('express-socket.io-session'); const config = require('../runtimeConfig'); const session = require('../middlewares/session'); const { connect } = require('../database'); -const { initializePostgres } = require('../postgres'); +const { createHealthHandler, createHealthReporter } = require('../health'); +const { initializePostgres, pool } = require('../postgres'); const logger = require('../logger'); const loggerMiddleware = require('./middlewares/logger'); const authenticateMiddleware = require('./middlewares/authenticate'); @@ -65,6 +66,28 @@ const init = async () => { io.adapter(createAdapter(pubClient, subClient)); + const reportHealth = createHealthReporter({ + service: 'socket', + checks: { + mongodb: () => mongoose.connection.readyState === 1, + postgres: async () => { + if (config.postgres.enabled) { + await pool.query('SELECT 1'); + } + return true; + }, + redis: async () => pubClient.status === 'ready' + && subClient.status === 'ready' + && (await pubClient.ping()) === 'PONG', + }, + }); + const handleHealth = createHealthHandler(reportHealth); + server.on('request', (req, res) => { + if (req.method === 'GET' && req.url.split('?')[0] === '/health/socket') { + handleHealth(req, res); + } + }); + const middlewares = [ expressSocketSession(session(mongoose), { autoSave: true, @@ -74,7 +97,7 @@ const init = async () => { ]; initRooms(io, middlewares); - initDefault(io, middlewares); + initDefault(io, middlewares, reportHealth); watchNamespaceErrors(io, '/'); watchNamespaceErrors(io, '/rooms'); diff --git a/server/socket/namespaces/default.js b/server/socket/namespaces/default.js index e4284e0..0c2084a 100644 --- a/server/socket/namespaces/default.js +++ b/server/socket/namespaces/default.js @@ -1,7 +1,7 @@ const logger = require('../../logger'); const Protocol = require('../../../client/src/lib/protocol.json'); -module.exports = (io, middlewares) => { +module.exports = (io, middlewares, reportHealth) => { const ns = io.of('/'); middlewares.forEach((middleware) => { @@ -24,6 +24,15 @@ module.exports = (io, middlewares) => { updateUsersOnline(); + socket.on(Protocol.HEALTH_CHECK, async (acknowledgment) => { + const report = await reportHealth(); + if (typeof acknowledgment === 'function') { + acknowledgment(report); + } else { + socket.emit(Protocol.HEALTH_STATUS, report); + } + }); + socket.on(Protocol.DISCONNECT, () => { logger.info(`socket ${socket.id} disconnected from default;`); updateUsersOnline(); diff --git a/server/socket/namespaces/default.test.js b/server/socket/namespaces/default.test.js new file mode 100644 index 0000000..cc71e21 --- /dev/null +++ b/server/socket/namespaces/default.test.js @@ -0,0 +1,60 @@ +/* eslint-env jest */ + +const Protocol = require('../../../client/src/lib/protocol.json'); +const initDefault = require('./default'); + +jest.mock('../../logger', () => ({ + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), +})); + +describe('default socket namespace health check', () => { + const setup = (report) => { + const handlers = {}; + const socket = { + id: 'socket-123', + user: null, + on: jest.fn((event, handler) => { + handlers[event] = handler; + }), + emit: jest.fn(), + }; + const namespace = { + adapter: { + allRooms: jest.fn().mockResolvedValue(new Set()), + }, + emit: jest.fn(), + on: jest.fn((event, handler) => { + handlers[event] = handler; + }), + use: jest.fn(), + }; + const io = { of: jest.fn().mockReturnValue(namespace) }; + const reportHealth = jest.fn().mockResolvedValue(report); + + initDefault(io, [], reportHealth); + handlers.connection(socket); + + return { handlers, reportHealth, socket }; + }; + + it('acknowledges a health check with the current report', async () => { + const report = { status: 'ok', service: 'socket' }; + const { handlers, reportHealth } = setup(report); + const acknowledgment = jest.fn(); + await handlers[Protocol.HEALTH_CHECK](acknowledgment); + + expect(reportHealth).toHaveBeenCalledTimes(1); + expect(acknowledgment).toHaveBeenCalledWith(report); + }); + + it('emits health status when no acknowledgment is provided', async () => { + const report = { status: 'ok', service: 'socket' }; + const { handlers, socket } = setup(report); + + await handlers[Protocol.HEALTH_CHECK](); + + expect(socket.emit).toHaveBeenCalledWith(Protocol.HEALTH_STATUS, report); + }); +}); From 366254c8755599ed53d2b4b58f11a82f7ee9ec5a Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Fri, 10 Jul 2026 22:03:24 -0700 Subject: [PATCH 03/56] Preserve active solves across deploys Persist and retry completed solves with idempotent acknowledgements across socket reconnects. Roll services selectively with health-gated rollback so routine deploys minimize room disruption. --- .env.example | 4 + README_DEPLOYMENT.md | 115 ++++- client/src/components/Navigation.jsx | 143 +++++- client/src/components/Navigation.test.jsx | 101 ++++ client/src/components/Room/Common/Main.jsx | 112 ++++- .../src/components/Room/Common/Main.test.jsx | 180 +++++++ .../Room/GrandPrix/GrandPrixMain.jsx | 23 +- client/src/components/Timer/ManualTimer.jsx | 9 +- .../src/components/Timer/ManualTimer.test.jsx | 47 ++ client/src/components/Timer/StackmatTimer.jsx | 4 + client/src/store/middlewares/rooms.js | 397 +++++++++++++++- client/src/store/middlewares/rooms.test.js | 444 ++++++++++++++++++ client/src/store/room/actions.js | 36 +- client/src/store/room/reducer.js | 96 +++- client/src/store/room/reducer.test.js | 66 +++ client/src/store/room/resultOutbox.js | 152 ++++++ client/src/store/room/resultOutbox.test.js | 121 +++++ compose.prod.yml | 3 + compose.yml | 3 + scripts/classify-deploy-target.sh | 41 ++ scripts/deploy.sh | 286 +++++++++-- scripts/test-deploy-classifier.sh | 31 ++ scripts/test-deploy.sh | 275 +++++++++++ server/health.js | 28 +- server/health.test.js | 69 +++ server/index.js | 13 +- server/models/room.js | 1 + server/models/room.test.js | 20 + server/runtimeConfig.js | 3 + server/runtimeConfig.test.js | 32 ++ server/socket/index.js | 13 +- server/socket/lib/reconnectGrace.js | 5 + server/socket/lib/reconnectGrace.test.js | 30 ++ server/socket/lib/resultSubmission.js | 219 +++++++++ server/socket/lib/resultSubmission.test.js | 397 ++++++++++++++++ server/socket/lib/roomAvailability.js | 5 + server/socket/lib/roomAvailability.test.js | 15 + server/socket/namespaces/rooms.js | 261 +++++++--- 38 files changed, 3600 insertions(+), 200 deletions(-) create mode 100644 client/src/components/Navigation.test.jsx create mode 100644 client/src/components/Room/Common/Main.test.jsx create mode 100644 client/src/components/Timer/ManualTimer.test.jsx create mode 100644 client/src/store/middlewares/rooms.test.js create mode 100644 client/src/store/room/reducer.test.js create mode 100644 client/src/store/room/resultOutbox.js create mode 100644 client/src/store/room/resultOutbox.test.js create mode 100755 scripts/classify-deploy-target.sh create mode 100755 scripts/test-deploy-classifier.sh create mode 100755 scripts/test-deploy.sh create mode 100644 server/runtimeConfig.test.js create mode 100644 server/socket/lib/resultSubmission.js create mode 100644 server/socket/lib/resultSubmission.test.js create mode 100644 server/socket/lib/roomAvailability.js create mode 100644 server/socket/lib/roomAvailability.test.js diff --git a/.env.example b/.env.example index bc02498..4f826cd 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,10 @@ PORT=8080 SOCKETIO_PORT=9000 # Preserve room membership while clients reconnect after a socket deploy. ROOM_RECONNECT_GRACE_MS=60000 +# Grand Prix is intentionally disabled until the mode is redesigned. +GRAND_PRIX_ENABLED=false +# Manual Compose commands use this tag. scripts/deploy.sh overrides it with +# the full deployed commit SHA. APP_IMAGE_TAG=local # Public URLs used by the client build diff --git a/README_DEPLOYMENT.md b/README_DEPLOYMENT.md index 0e16e5a..ce85f15 100644 --- a/README_DEPLOYMENT.md +++ b/README_DEPLOYMENT.md @@ -1,6 +1,6 @@ # LetsCube Deployment -This repo supports running LetsCube with Docker Compose on one VPS. The intended production stack is `api`, `socket`, `mongo`, `redis`, and `nginx`. +This repo supports running LetsCube with Docker Compose on one VPS. The intended production stack is `api`, `socket`, `mongo`, `postgres`, `redis`, and `nginx`. ## Current Startup Audit @@ -42,10 +42,16 @@ cp .env.example .env.prod Start or update production: ```bash -docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod up -d --build +APP_IMAGE_TAG="$(git rev-parse HEAD)" \ + docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod up -d --build ``` -The nginx container listens on ports `80` and `443`, proxies `/socket.io/` to `socket:9000`, and proxies everything else to `api:8080`. MongoDB and Redis are internal only. +Using the full commit SHA keeps manually deployed application images +immutable. `APP_IMAGE_TAG=local` remains available for direct development and +one-off Compose use. The deployment script always overrides `APP_IMAGE_TAG` +with the commit it checked out. + +The nginx container listens on ports `80` and `443`, proxies `/socket.io/` to `socket:9000`, and proxies everything else to `api:8080`. MongoDB, PostgreSQL, and Redis are internal only. TLS expects Let’s Encrypt files at: @@ -70,6 +76,7 @@ View logs: docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f api docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f socket docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f mongo +docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f postgres docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f redis docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f nginx ``` @@ -87,20 +94,78 @@ Deploy latest code from the server checkout: APP_DIR=/opt/letscube scripts/deploy.sh ``` -The deploy script builds the new application image and applies PostgreSQL -migrations before replacing either application process. It then replaces and -health-checks `api` and `socket` one at a time. MongoDB, PostgreSQL, and Redis -are started without recreating existing containers. nginx is gracefully -reloaded after each application replacement so it resolves the new container -without dropping unrelated connections. +The default `DEPLOY_TARGET=auto` chooses the smallest safe application target: + +| Changed paths | Resolved target | +| --- | --- | +| Documentation, CI, or agent metadata only | `none` | +| Browser client only | `api` (which also serves the static client) | +| Shared socket protocol, server, dependency, container, deployment, or unknown files | `all` | + +Override the classifier when the intended runtime impact is known: -Socket clients may briefly reconnect while the `socket` container is replaced. -Room membership, the current scramble, waiting/competing state, and room admin -are preserved during `ROOM_RECONNECT_GRACE_MS` (60 seconds by default). A -client that reconnects within that window resumes the existing room instead of -leaving and rejoining it. Only users with no socket on any Socket.IO instance +```bash +DEPLOY_TARGET=api APP_DIR=/opt/letscube scripts/deploy.sh +DEPLOY_TARGET=socket APP_DIR=/opt/letscube scripts/deploy.sh +DEPLOY_TARGET=all APP_DIR=/opt/letscube scripts/deploy.sh +DEPLOY_TARGET=none APP_DIR=/opt/letscube scripts/deploy.sh +``` + +Accepted values are `auto`, `api`, `socket`, `all`, and `none`. Explicit +targets are honored even when the checkout is already current. With `auto`, an +unchanged checkout exits without replacing healthy running containers. If API, +socket, or nginx is missing, `auto` bootstraps both application services; +`DEPLOY_FORCE=true` also rebuilds and replaces both at the current commit. +Readiness waits are capped at 60 seconds before automatic rollback; override +that bound with `DEPLOY_WAIT_TIMEOUT_SECONDS` when diagnosing unusually slow +startup. + +You can verify the conservative path classifier without fetching code or +touching Docker: + +```bash +scripts/test-deploy-classifier.sh +scripts/test-deploy.sh +scripts/classify-deploy-target.sh client/src/App.jsx +``` + +For any selected application target, the deploy script builds one shared image +tagged with the full commit SHA, waits for backing services without recreating +them, and applies PostgreSQL migrations before replacing application +containers. It snapshots the exact image ID of every selected running service, +then replaces and health-checks services one at a time. For `all`, socket rolls +out before API/static so the backward-compatible server is ready before the new +browser client is served. nginx is gracefully reloaded only after a selected +service changes, so it resolves the replacement container without dropping +unrelated connections. If nginx is not running during a bootstrap deploy, its +startup is deferred until the selected application services are ready. + +If replacement readiness or the nginx reload fails, recent diagnostic logs are +printed and every application service attempted in that deploy is restored in +reverse order using its exact pre-deploy image and Compose configuration. nginx +is then reloaded again. +Rollback image tags are retained locally for manual recovery until normal image +cleanup. Database migrations are not automatically reversed, so migrations +must remain backward-compatible with the previous application image. + +The checkout remains at the fetched commit after a failed deploy. Correct the +failure and rerun with the explicit target printed by the script; a later +`auto` run with no new commit otherwise treats the checkout as up to date. + +Socket clients briefly reconnect only when `socket` is selected. Room +membership, the current scramble, waiting/competing state, and room admin are +preserved during `ROOM_RECONNECT_GRACE_MS` (60 seconds by default). A client +that reconnects within that window resumes the existing room without being +logically removed first. Only users with no socket on any Socket.IO instance after the grace window are removed from the room. +An in-progress solve continues timing in the browser during that reconnect. If +the solve finishes before the socket is ready, the result is retained locally, +the next solve is blocked, and the saved result is submitted only after the +client rejoins the room. The local copy is cleared only after the server +acknowledges persistence, so a refresh or repeated reconnect does not discard +the time. + Set the grace window in `.env.prod` if production deploys or client reconnects need more time: @@ -112,11 +177,23 @@ Keep the grace shorter than the time in which an abandoned room should appear active. Explicit leaves, kicks, and bans remain immediate and do not use the grace window. +Grand Prix is intentionally disabled in production: + +```bash +GRAND_PRIX_ENABLED=false +``` + +Keep this setting disabled until the legacy scheduler is redesigned. Setting +it to `true` explicitly restores the old mode for development or controlled +testing. + ### Health Checks -The API and socket processes expose dependency-aware health endpoints. A -healthy response has HTTP status `200`; an unavailable MongoDB, PostgreSQL, or -Redis dependency returns `503` with the failing check marked `error`. +The API and socket processes expose dependency-aware health endpoints. +Unavailable MongoDB or Redis returns HTTP `503` with the failing check marked +`error`. PostgreSQL dual writes are optional, so a PostgreSQL outage returns +HTTP `200` with overall status `degraded` while the primary MongoDB/Redis path +remains ready. ```bash curl -sS https://letscube.net/health/api @@ -163,10 +240,10 @@ Do not upgrade the old VPS in place for this migration. 5. Clone this repo to `/opt/letscube`. 6. Create `.env.prod` with production secrets and domains. 7. Copy or issue Let’s Encrypt certs for `letscube.net`. -8. Start MongoDB and Redis containers. +8. Start MongoDB, PostgreSQL, and Redis containers. 9. Restore MongoDB from the production backup. 10. Start the full production stack. 11. Test frontend loading, login, room create/join, race/session flows, Redis-backed socket behavior, backups, and restore on non-production data. -12. Confirm MongoDB and Redis are not publicly reachable. +12. Confirm MongoDB, PostgreSQL, and Redis are not publicly reachable. 13. Point DNS at the new VPS. 14. Keep the old VPS available for rollback until the new stack has been stable. diff --git a/client/src/components/Navigation.jsx b/client/src/components/Navigation.jsx index d9b849f..7e84bd5 100644 --- a/client/src/components/Navigation.jsx +++ b/client/src/components/Navigation.jsx @@ -1,9 +1,13 @@ import React, { Suspense, lazy } from 'react'; import PropTypes from 'prop-types'; -import { Switch, Route, Redirect } from 'react-router-dom'; +import { + Switch, Route, Redirect, useLocation, +} from 'react-router-dom'; import { connect, useDispatch } from 'react-redux'; +import { push } from 'connected-react-router'; // import Backdrop from '@material-ui/core/Backdrop'; import CircularProgress from '@material-ui/core/CircularProgress'; +import Button from '@material-ui/core/Button'; import Snackbar from '@material-ui/core/Snackbar'; import Alert from '@material-ui/lab/Alert'; import { makeStyles } from '@material-ui/core/styles'; @@ -13,12 +17,97 @@ import Footer from './Footer'; import WCARedirect from './WCARedirect'; import Admin from './Admin'; import { closeMessage } from '../store/messages/actions'; +import { discardPendingResult } from '../store/room/actions'; +import { + canDiscardPendingResult, + isPendingResult, + pendingResultBelongsToUser, +} from '../store/room/resultOutbox'; import Text from './Text'; const Lobby = lazy(() => import('./Lobby/index')); const Room = lazy(() => import('./Room/index')); const Profile = lazy(() => import('./common/Profile')); +export const shouldShowGlobalPendingResult = (room, pendingResult) => ( + isPendingResult(pendingResult) + && !(room.accessCode && room.type === 'normal' && !room.fetching) +); + +export function GlobalPendingResultAlert({ + atPendingRoom, + error, + onDiscard, + onReturn, + pendingResult, + status, + userId, +}) { + const belongsToUser = pendingResultBelongsToUser(pendingResult, userId); + const canDiscardResult = canDiscardPendingResult(pendingResult, status); + let message; + + if (!belongsToUser) { + message = canDiscardResult + ? 'A saved time for another account is stored on this device. Switch back to that account or discard it.' + : 'A saved time for another account may already be submitting. Switch back to that account to finish it.'; + } else if (status === 'failed') { + message = `Your saved time could not be submitted: ${error.message}`; + } else if (atPendingRoom) { + message = canDiscardResult + ? 'Your time is still saved on this device, but its room is not currently joined. Restore access to the room or discard the saved result.' + : 'Your time may already be submitting, but its room is not currently joined. Restore access to the room to finish it.'; + } else { + message = canDiscardResult + ? `Your saved time is waiting in room ${pendingResult.roomId}. Return there to submit it, or discard it.` + : `Your saved time is waiting in room ${pendingResult.roomId}. Return there to finish submitting it.`; + } + + return ( + + {belongsToUser && !atPendingRoom && ( + + )} + {canDiscardResult && ( + + )} + + )} + > + {message} + + ); +} + +GlobalPendingResultAlert.propTypes = { + atPendingRoom: PropTypes.bool.isRequired, + error: PropTypes.shape({ + message: PropTypes.string, + }), + onDiscard: PropTypes.func.isRequired, + onReturn: PropTypes.func.isRequired, + pendingResult: PropTypes.shape({ + deliveryAttempted: PropTypes.bool, + roomId: PropTypes.string, + submissionId: PropTypes.string, + userId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + }).isRequired, + status: PropTypes.oneOf(['pending', 'sending', 'failed']).isRequired, + userId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), +}; + +GlobalPendingResultAlert.defaultProps = { + error: null, + userId: undefined, +}; + const useStyles = makeStyles((theme) => ({ root: { display: 'flex', @@ -46,10 +135,20 @@ const useStyles = makeStyles((theme) => ({ })); function Navigation({ - room, connected, user, messages, server, + room, roomList, connected, user, messages, server, }) { const classes = useStyles(); const dispatch = useDispatch(); + const location = useLocation(); + const roomConnectionInterrupted = !!room._id && !roomList.connected; + const connectionInterrupted = room._id + ? roomConnectionInterrupted + : (!connected || server.reconnecting); + const resultSubmission = room.resultSubmission || {}; + const pendingResult = resultSubmission.pendingResult; + const showGlobalPendingResult = shouldShowGlobalPendingResult(room, pendingResult); + const pendingRoomPath = pendingResult ? `/rooms/${pendingResult.roomId}` : null; + const atPendingRoom = location.pathname === pendingRoomPath; const handleClose = (index, event, reason) => { if (reason === 'clickaway') { @@ -82,9 +181,11 @@ function Navigation({
- {(!connected || server.reconnecting) && ( - - Disconnected from server. + {connectionInterrupted && ( + + {roomConnectionInterrupted + ? 'Room connection interrupted. You can finish your solve; your completed time will be saved on this device.' + : 'Disconnected from server.'} { (server.reconnecting || server.reconnectAttempts > 0) && ( ` Reconnecting...${server.reconnectAttempts} attempts` )} @@ -99,6 +200,17 @@ function Navigation({ )} )} + {showGlobalPendingResult && ( + dispatch(discardPendingResult(pendingResult.submissionId))} + onReturn={() => dispatch(push(pendingRoomPath))} + pendingResult={pendingResult} + status={resultSubmission.status} + userId={user.id} + /> + )}
{!user.fetching && ( @@ -145,6 +257,23 @@ Navigation.propTypes = { })), room: PropTypes.shape({ _id: PropTypes.string, + accessCode: PropTypes.string, + fetching: PropTypes.bool, + type: PropTypes.string, + resultSubmission: PropTypes.shape({ + status: PropTypes.oneOf(['idle', 'pending', 'sending', 'failed']), + pendingResult: PropTypes.shape({ + deliveryAttempted: PropTypes.bool, + roomId: PropTypes.string, + submissionId: PropTypes.string, + }), + error: PropTypes.shape({ + message: PropTypes.string, + }), + }), + }), + roomList: PropTypes.shape({ + connected: PropTypes.bool, }), server: PropTypes.shape({ reconnecting: PropTypes.bool, @@ -161,6 +290,9 @@ Navigation.defaultProps = { room: { id: undefined, }, + roomList: { + connected: false, + }, server: { reconnecting: false, reconnectAttempts: 0, @@ -174,6 +306,7 @@ const mapStateToProps = (state) => ({ user: state.user, messages: state.messages.messages, room: state.room, + roomList: state.roomList, }); export default connect(mapStateToProps)(Navigation); diff --git a/client/src/components/Navigation.test.jsx b/client/src/components/Navigation.test.jsx new file mode 100644 index 0000000..6dd8103 --- /dev/null +++ b/client/src/components/Navigation.test.jsx @@ -0,0 +1,101 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import Alert from '@material-ui/lab/Alert'; +import Button from '@material-ui/core/Button'; +import { + GlobalPendingResultAlert, + shouldShowGlobalPendingResult, +} from './Navigation'; +import { + createPendingResult, + markPendingResultAttempted, +} from '../store/room/resultOutbox'; + +const pendingResult = createPendingResult({ + userId: 42, + roomId: 'original-room', + attemptId: 7, + attemptKey: 'attempt-seven', + result: { time: 1234, penalties: {} }, +}, { + createId: () => 'submission-one', + now: () => 1000, +}); + +describe('global pending result alert', () => { + it('is hidden when a joined normal room can render the room-level controls', () => { + expect(shouldShowGlobalPendingResult({ + accessCode: 'ABC123', + fetching: false, + type: 'normal', + }, pendingResult)).toBe(false); + + expect(shouldShowGlobalPendingResult({ + accessCode: 'ABC123', + fetching: true, + type: 'normal', + }, pendingResult)).toBe(true); + }); + + it('keeps discard accessible when the pending room could not be joined', () => { + const onDiscard = jest.fn(); + const wrapper = shallow( + , + ); + const actions = shallow(
{wrapper.find(Alert).prop('action')}
); + + expect(wrapper.text()).toContain('room is not currently joined'); + expect(actions.find(Button)).toHaveLength(1); + expect(actions.find(Button).text()).toBe('Discard saved result'); + + actions.find(Button).simulate('click'); + expect(onDiscard).toHaveBeenCalledTimes(1); + }); + + it('offers a return action when the user is somewhere else', () => { + const onReturn = jest.fn(); + const wrapper = shallow( + , + ); + const actions = shallow(
{wrapper.find(Alert).prop('action')}
); + + expect(actions.find(Button).map((button) => button.text())).toEqual([ + 'Return to room', + 'Discard saved result', + ]); + actions.find(Button).at(0).simulate('click'); + expect(onReturn).toHaveBeenCalledTimes(1); + }); + + it('does not offer discard after delivery has started', () => { + const wrapper = shallow( + , + ); + const actions = shallow(
{wrapper.find(Alert).prop('action')}
); + + expect(actions.find(Button)).toHaveLength(1); + expect(actions.find(Button).text()).toBe('Return to room'); + expect(wrapper.text()).toContain('finish submitting'); + }); +}); diff --git a/client/src/components/Room/Common/Main.jsx b/client/src/components/Room/Common/Main.jsx index 4a09c00..7cdadda 100644 --- a/client/src/components/Room/Common/Main.jsx +++ b/client/src/components/Room/Common/Main.jsx @@ -6,16 +6,26 @@ import Grid from '@material-ui/core/Grid'; import Paper from '@material-ui/core/Paper'; import Divider from '@material-ui/core/Divider'; import Typography from '@material-ui/core/Typography'; +import Button from '@material-ui/core/Button'; import ClickAwayListener from '@material-ui/core/ClickAwayListener'; +import Alert from '@material-ui/lab/Alert'; import { Cube } from 'react-cube-svg'; import UIfx from 'uifx'; +import { push } from 'connected-react-router'; import notificationAsset from '../../../assets/notification.mp3'; import calcStats from '../../../lib/stats'; import { submitResult, + discardPendingResult, sendStatus, timerFocused, } from '../../../store/room/actions'; +import { + canDiscardPendingResult, + isPendingResult, + pendingResultBelongsToUser, + pendingResultMatches, +} from '../../../store/room/resultOutbox'; import { StatsDialogProvider } from './StatsDialogProvider'; import { EditDialogProvider } from './EditDialogProvider'; import TimesTable from './TimesTable'; @@ -36,6 +46,9 @@ const useStyles = withStyles((theme) => ({ waitingForBox: { padding: '.5em', }, + submissionAlert: { + borderRadius: 0, + }, scrambleBox: { padding: '.5em', textAlign: 'center', @@ -45,12 +58,12 @@ const useStyles = withStyles((theme) => ({ }, })); -class Main extends React.Component { +export class Main extends React.Component { constructor(props) { super(props); this.state = { - currentAttemptId: undefined, + currentAttempt: undefined, }; } @@ -68,26 +81,27 @@ class Main extends React.Component { onSubmitTime(event) { const { dispatch, room, user } = this.props; - const { currentAttemptId } = this.state; + const { currentAttempt } = this.state; if (!room.attempts.length) { return; } - // Don't even bother sending the result. if (!user.id) { return; } const latestAttempt = room.attempts ? room.attempts[room.attempts.length - 1] : {}; + const submittedAttempt = currentAttempt || latestAttempt; dispatch(submitResult({ - id: currentAttemptId || latestAttempt.id, + id: submittedAttempt.id, + attemptKey: submittedAttempt._id, result: { time: event.time, penalties: event.penalties, }, })); - this.setState({ currentAttemptId: null }); + this.setState({ currentAttempt: null }); } onTimerFocused = () => { @@ -108,28 +122,89 @@ class Main extends React.Component { handlePriming() { const { room } = this.props; const latestAttempt = room.attempts ? room.attempts[room.attempts.length - 1] : {}; - this.setState({ currentAttemptId: latestAttempt.id }); + this.setState({ currentAttempt: latestAttempt }); } render() { const { - classes, dispatch, room, user, onlyShowSelf, + classes, dispatch, room, user, onlyShowSelf, roomConnected, } = this.props; const { users, attempts, waitingFor, } = room; + const resultSubmission = room.resultSubmission || {}; + const pendingResult = resultSubmission.pendingResult; + const hasPendingResult = isPendingResult(pendingResult); + const canDiscardResult = canDiscardPendingResult( + pendingResult, + resultSubmission.status, + ); + const pendingBelongsToUser = pendingResultBelongsToUser(pendingResult, user.id); + const pendingMatchesRoom = pendingResultMatches(pendingResult, { + userId: user.id, + roomId: room._id, + }); const latestAttempt = (attempts && attempts.length) ? attempts[attempts.length - 1] : {}; const timerDisabled = !room.timerFocused || !room.competing[user.id] - || !room.waitingFor[user.id]; + || !room.waitingFor[user.id] || hasPendingResult; const hidden = room.competing[user.id] && !waitingFor[user.id]; + let submissionMessage = ''; + if (!pendingBelongsToUser) { + submissionMessage = canDiscardResult + ? 'A saved time for another account is stored on this device. Switch back to that account or discard it before timing another solve.' + : 'A saved time for another account may already be submitting. Switch back to that account to finish it.'; + } else if (!pendingMatchesRoom) { + submissionMessage = canDiscardResult + ? `Your saved time belongs to room ${pendingResult.roomId}. Return there to submit it, or discard it before timing another solve.` + : `Your saved time belongs to room ${pendingResult.roomId}. Return there to finish submitting it.`; + } else if (resultSubmission.status === 'failed') { + submissionMessage = `Your saved time could not be submitted: ${resultSubmission.error.message}`; + } else if (resultSubmission.status === 'sending') { + submissionMessage = 'Submitting your saved time...'; + } else if (roomConnected) { + submissionMessage = 'Your time is saved on this device and waiting to submit.'; + } else { + submissionMessage = 'Your time is saved on this device. It will submit after the room reconnects.'; + } + const stats = calcStats(attempts, users); const showScramble = latestAttempt.scrambles && room.event === '333'; return ( { this.onTimerDefocused(); }}> { this.onTimerFocused(); }}> + {hasPendingResult && ( + + {pendingBelongsToUser && !pendingMatchesRoom && ( + + )} + {canDiscardResult && ( + + )} + + )} + > + {submissionMessage} + + )}
@@ -231,12 +306,23 @@ Main.propTypes = { waitingFor: PropTypes.shape(), statuses: PropTypes.shape(), attempts: PropTypes.arrayOf(PropTypes.shape({ + _id: PropTypes.string, id: PropTypes.number, })), admin: PropTypes.shape({ id: PropTypes.number, }), timerFocused: PropTypes.bool, + resultSubmission: PropTypes.shape({ + status: PropTypes.oneOf(['idle', 'pending', 'sending', 'failed']), + pendingResult: PropTypes.shape({ + deliveryAttempted: PropTypes.bool, + submissionId: PropTypes.string, + }), + error: PropTypes.shape({ + message: PropTypes.string, + }), + }), }), user: PropTypes.shape({ id: PropTypes.number, @@ -247,6 +333,7 @@ Main.propTypes = { dispatch: PropTypes.func.isRequired, classes: PropTypes.shape().isRequired, onlyShowSelf: PropTypes.bool, + roomConnected: PropTypes.bool, }; Main.defaultProps = { @@ -264,6 +351,11 @@ Main.defaultProps = { id: undefined, }, timerFocused: true, + resultSubmission: { + status: 'idle', + pendingResult: null, + error: null, + }, }, user: { id: undefined, @@ -272,10 +364,12 @@ Main.defaultProps = { timerType: 'spacebar', }, onlyShowSelf: false, + roomConnected: false, }; const mapStateToProps = (state) => ({ room: state.room, + roomConnected: state.roomList.connected, user: state.user, }); diff --git a/client/src/components/Room/Common/Main.test.jsx b/client/src/components/Room/Common/Main.test.jsx new file mode 100644 index 0000000..4f28d8b --- /dev/null +++ b/client/src/components/Room/Common/Main.test.jsx @@ -0,0 +1,180 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import Alert from '@material-ui/lab/Alert'; +import Button from '@material-ui/core/Button'; +import Timer from '../../Timer/index'; +import { Main } from './Main'; +import { + createPendingResult, + markPendingResultAttempted, +} from '../../../store/room/resultOutbox'; +import { + DISCARD_PENDING_RESULT, + SUBMIT_RESULT, +} from '../../../store/room/actions'; + +jest.mock('../../Timer/StackmatTimer', () => () => null); +jest.mock('react-cube-svg', () => ({ Cube: () => null })); + +const pendingResult = createPendingResult({ + userId: 42, + roomId: 'original-room', + attemptId: 7, + attemptKey: 'attempt-seven', + result: { time: 1234, penalties: {} }, +}, { + createId: () => 'submission-one', + now: () => 1000, +}); + +const makeProps = (overrides = {}) => ({ + classes: { + root: 'root', + submissionAlert: 'submissionAlert', + scrambleBox: 'scrambleBox', + waitingForBox: 'waitingForBox', + }, + dispatch: jest.fn(), + onlyShowSelf: false, + roomConnected: true, + user: { + id: 42, + useInspection: false, + muteTimer: true, + timerType: 'spacebar', + }, + room: { + _id: 'current-room', + event: '333', + users: [{ id: 42 }], + competing: { 42: true }, + waitingFor: { 42: true }, + attempts: [{ _id: 'attempt-eight', id: 8, scrambles: ['R U'], results: {} }], + timerFocused: true, + resultSubmission: { + status: 'pending', + pendingResult, + error: null, + }, + }, + ...overrides, +}); + +describe('room pending result UX', () => { + it('submits against the immutable attempt that was primed', () => { + const props = makeProps({ + room: { + ...makeProps().room, + attempts: [{ + _id: 'primed-attempt', id: 0, scrambles: ['R U'], results: {}, + }], + resultSubmission: { + status: 'idle', + pendingResult: null, + error: null, + }, + }, + }); + const wrapper = shallow(
); + wrapper.instance().handlePriming(); + wrapper.setProps({ + room: { + ...props.room, + attempts: [{ + _id: 'replacement-attempt', id: 0, scrambles: ['F R'], results: {}, + }], + }, + }); + + wrapper.instance().onSubmitTime({ time: 1234, penalties: {} }); + + expect(props.dispatch).toHaveBeenCalledWith({ + type: SUBMIT_RESULT, + result: { + id: 0, + attemptKey: 'primed-attempt', + result: { time: 1234, penalties: {} }, + }, + }); + }); + + it('does not disable the timer merely because the room socket disconnected', () => { + const props = makeProps({ + roomConnected: false, + room: { + ...makeProps().room, + resultSubmission: { + status: 'idle', + pendingResult: null, + error: null, + }, + }, + }); + const wrapper = shallow(
); + + expect(wrapper.find(Timer).prop('disabled')).toBe(false); + expect(wrapper.find(Alert)).toHaveLength(0); + }); + + it('blocks another solve and offers return/discard actions outside the original room', () => { + const props = makeProps(); + const wrapper = shallow(
); + const actions = shallow(
{wrapper.find(Alert).prop('action')}
); + + expect(wrapper.find(Timer).prop('disabled')).toBe(true); + expect(wrapper.find(Alert).text()).toContain('Your saved time belongs to room original-room.'); + expect(actions.find(Button).map((button) => button.text())).toEqual([ + 'Return to room', + 'Discard saved result', + ]); + + actions.find(Button).at(1).simulate('click'); + expect(props.dispatch).toHaveBeenCalledWith({ + type: DISCARD_PENDING_RESULT, + submissionId: 'submission-one', + }); + }); + + it('also blocks a solve when the device outbox belongs to another account', () => { + const anotherUsersResult = { + ...pendingResult, + userId: 99, + }; + const props = makeProps({ + room: { + ...makeProps().room, + resultSubmission: { + status: 'pending', + pendingResult: anotherUsersResult, + error: null, + }, + }, + }); + const wrapper = shallow(
); + const actions = shallow(
{wrapper.find(Alert).prop('action')}
); + + expect(wrapper.find(Timer).prop('disabled')).toBe(true); + expect(wrapper.find(Alert).text()).toContain('another account'); + expect(actions.find(Button)).toHaveLength(1); + expect(actions.find(Button).text()).toBe('Discard saved result'); + }); + + it('keeps an attempted result without offering discard', () => { + const props = makeProps({ + room: { + ...makeProps().room, + resultSubmission: { + status: 'pending', + pendingResult: markPendingResultAttempted(pendingResult), + error: null, + }, + }, + }); + const wrapper = shallow(
); + const actions = shallow(
{wrapper.find(Alert).prop('action')}
); + + expect(actions.find(Button)).toHaveLength(1); + expect(actions.find(Button).text()).toBe('Return to room'); + expect(wrapper.find(Alert).text()).toContain('finish submitting'); + }); +}); diff --git a/client/src/components/Room/GrandPrix/GrandPrixMain.jsx b/client/src/components/Room/GrandPrix/GrandPrixMain.jsx index 7bc2331..1a656ac 100644 --- a/client/src/components/Room/GrandPrix/GrandPrixMain.jsx +++ b/client/src/components/Room/GrandPrix/GrandPrixMain.jsx @@ -19,6 +19,7 @@ import { toggleFollowUser, } from '../../../store/room/actions'; import { getRegisteredUsers } from '../../../store/room/selectors'; +import { isPendingResult } from '../../../store/room/resultOutbox'; import { StatsDialogProvider } from '../Common/StatsDialogProvider'; import { EditDialogProvider } from '../Common/EditDialogProvider'; import TimesTable from '../Common/TimesTable'; @@ -184,7 +185,7 @@ function Main({ room, user }) { const classes = useStyles(); const dispatch = useDispatch(); const theme = useTheme(); - const [currentAttemptId, setCurrentAttemptId] = useState(undefined); + const [currentAttempt, setCurrentAttempt] = useState(undefined); const [followUserDialogOpen, setFollowUserDialogOpen] = useState(false); const { @@ -204,14 +205,16 @@ function Main({ room, user }) { } const latestAttempt = room.attempts ? room.attempts[room.attempts.length - 1] : {}; + const submittedAttempt = currentAttempt || latestAttempt; dispatch(submitResult({ - id: currentAttemptId || latestAttempt.id, + id: submittedAttempt.id, + attemptKey: submittedAttempt._id, result: { time: event.time, penalties: event.penalties, }, })); - setCurrentAttemptId(null); + setCurrentAttempt(null); }; const onTimerFocused = () => { @@ -228,7 +231,7 @@ function Main({ room, user }) { const handlePriming = () => { const latestAttempt = room.attempts ? room.attempts[room.attempts.length - 1] : {}; - setCurrentAttemptId(latestAttempt.id); + setCurrentAttempt(latestAttempt); }; const handleToggleUserFollow = (userId) => { @@ -237,7 +240,8 @@ function Main({ room, user }) { const latestAttempt = attempts && attempts.length && attempts[attempts.length - 1]; const showScrambleBox = latestAttempt && !latestAttempt.results[user.id]; - const timerDisabled = !room.timerFocused || !room.competing[user.id] || !showScrambleBox; + const timerDisabled = !room.timerFocused || !room.competing[user.id] || !showScrambleBox + || isPendingResult(room.resultSubmission && room.resultSubmission.pendingResult); const stats = calcStats(attempts, users); const showScramble = latestAttempt.scrambles && room.event === '333'; @@ -369,8 +373,14 @@ Main.propTypes = { statuses: PropTypes.shape(), registered: PropTypes.shape(), attempts: PropTypes.arrayOf(PropTypes.shape({ + _id: PropTypes.string, id: PropTypes.number, })), + resultSubmission: PropTypes.shape({ + pendingResult: PropTypes.shape({ + submissionId: PropTypes.string, + }), + }), admin: PropTypes.shape({ id: PropTypes.number, }), @@ -399,6 +409,9 @@ Main.defaultProps = { statues: {}, registered: {}, attempts: [], + resultSubmission: { + pendingResult: null, + }, admin: { id: undefined, }, diff --git a/client/src/components/Timer/ManualTimer.jsx b/client/src/components/Timer/ManualTimer.jsx index f9cef7a..4b69a6d 100644 --- a/client/src/components/Timer/ManualTimer.jsx +++ b/client/src/components/Timer/ManualTimer.jsx @@ -60,7 +60,7 @@ const STATUS = { const STATUSES = [STATUS.RESTING, STATUS.INSPECTING, STATUS.TIMING, STATUS.SUBMITTING]; -class ManualTimer extends React.Component { +export class ManualTimer extends React.Component { constructor(props) { super(props); @@ -194,10 +194,15 @@ class ManualTimer extends React.Component { const { onSubmitTime } = this.props; const { penalties } = this.state; + const time = this.finalTime(); + + if (!Number.isFinite(time)) { + return; + } if (onSubmitTime) { onSubmitTime({ - time: this.finalTime(), + time, penalties: { DNF: penalties.DNF, inspection: penalties.inspection, diff --git a/client/src/components/Timer/ManualTimer.test.jsx b/client/src/components/Timer/ManualTimer.test.jsx new file mode 100644 index 0000000..a149cb2 --- /dev/null +++ b/client/src/components/Timer/ManualTimer.test.jsx @@ -0,0 +1,47 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { ManualTimer } from './ManualTimer'; + +const makeProps = () => ({ + classes: { + root: 'root', + input: 'input', + inputProps: 'inputProps', + penaltyBox: 'penaltyBox', + }, + onPriming: jest.fn(), + onStatusChange: jest.fn(), + onSubmitTime: jest.fn(), + useInspection: false, +}); + +describe('manual timer submission', () => { + it.each(['', 'not-a-time'])('keeps invalid input for correction: %p', (timeInput) => { + const props = makeProps(); + const wrapper = shallow(); + wrapper.setState({ timeInput }); + + wrapper.instance().submitTime({ preventDefault: jest.fn() }); + + expect(props.onSubmitTime).not.toHaveBeenCalled(); + expect(wrapper.state('timeInput')).toBe(timeInput); + }); + + it('submits and resets a valid time', () => { + const props = makeProps(); + const wrapper = shallow(); + wrapper.setState({ timeInput: '5.00' }); + + wrapper.instance().submitTime({ preventDefault: jest.fn() }); + + expect(props.onSubmitTime).toHaveBeenCalledWith({ + time: 5000, + penalties: { + AUF: false, + DNF: false, + inspection: false, + }, + }); + expect(wrapper.state('timeInput')).toBe(''); + }); +}); diff --git a/client/src/components/Timer/StackmatTimer.jsx b/client/src/components/Timer/StackmatTimer.jsx index 039ec65..e1b2045 100644 --- a/client/src/components/Timer/StackmatTimer.jsx +++ b/client/src/components/Timer/StackmatTimer.jsx @@ -228,6 +228,10 @@ class Timer extends React.Component { setStatus (status) { this.setState({ status }); + if (status === STATUS.TIMING) { + this.props.onPriming(); + } + if (STATUSES.indexOf(status) > -1) { this.props.onStatusChange(status); } diff --git a/client/src/store/middlewares/rooms.js b/client/src/store/middlewares/rooms.js index c0e621e..f65f321 100644 --- a/client/src/store/middlewares/rooms.js +++ b/client/src/store/middlewares/rooms.js @@ -21,8 +21,8 @@ import { START_ROOM, PAUSE_ROOM, UPDATE_USER, + DISCARD_PENDING_RESULT, joinRoom, - // leaveRoom, roomUpdated, resetRoom, userJoined, @@ -34,7 +34,21 @@ import { updateAdmin, updateCompetingForUser, nextSolveAt, + resultSubmissionPending, + resultSubmissionSending, + resultSubmissionFailed, + resultSubmissionCleared, } from '../room/actions'; +import { + canDiscardPendingResult, + clearPendingResult, + createPendingResult, + markPendingResultAttempted, + markPendingResultFailed, + pendingResultMatches, + persistPendingResult, + readPendingResult, +} from '../room/resultOutbox'; import { ROOMS_CONNECT, ROOMS_DISCONNECT, @@ -57,29 +71,273 @@ import { } from '../admin/actions'; import { manager } from './manager'; -const roomsNamespaceMiddleware = (store) => { +const DEFAULT_ACK_TIMEOUT_MS = 10000; +const DEFAULT_RETRY_DELAY_MS = 2000; + +export const createRoomsNamespaceMiddleware = ({ + NamespaceClass = Namespace, + namespaceManager = manager, + storage = window.localStorage, + createSubmissionId = uuid, + now = Date.now, + setTimeoutFn = window.setTimeout.bind(window), + clearTimeoutFn = window.clearTimeout.bind(window), + ackTimeoutMs = DEFAULT_ACK_TIMEOUT_MS, + retryDelayMs = DEFAULT_RETRY_DELAY_MS, +} = {}) => (store) => { + let roomsConnected = false; + let joinedRoomId = null; + let pendingJoinRequest = null; + let joinGeneration = 0; + let submissionGeneration = 0; + let submissionAckTimer = null; + let submissionRetryTimer = null; + let hydratedPendingResult = false; + let storageWarningSubmissionId = null; + + const currentSubmission = () => store.getState().room.resultSubmission; + const readStoredPendingResult = () => { + try { + return readPendingResult(storage); + } catch (storageError) { + return null; + } + }; + + const warnPendingResultNotBackedUp = (submissionId) => { + if (storageWarningSubmissionId === submissionId) { + return; + } + + storageWarningSubmissionId = submissionId; + store.dispatch(createMessage({ + severity: 'warning', + text: 'This result could not be backed up on this device. Keep this tab open while it submits.', + })); + }; + + const clearSubmissionTimers = () => { + if (submissionAckTimer !== null) { + clearTimeoutFn(submissionAckTimer); + submissionAckTimer = null; + } + if (submissionRetryTimer !== null) { + clearTimeoutFn(submissionRetryTimer); + submissionRetryTimer = null; + } + }; + + const invalidateSubmissionAttempt = () => { + submissionGeneration += 1; + clearSubmissionTimers(); + }; + + const normalizeSubmissionError = (error, fallbackMessage) => ({ + statusCode: error && error.statusCode, + message: (error && error.message) || fallbackMessage, + retryable: !!(error && error.retryable), + }); + + const normalizeResultForComparison = (result = {}) => ({ + time: result.time, + AUF: !!(result.penalties && result.penalties.AUF), + DNF: !!(result.penalties && result.penalties.DNF), + inspection: !!(result.penalties && result.penalties.inspection), + }); + + const resultEchoMatches = (echo, pendingResult) => { + const { room, user } = store.getState(); + const echoedSubmissionId = echo.result && echo.result.submissionId; + const pendingValue = normalizeResultForComparison(pendingResult.result); + const echoedValue = normalizeResultForComparison(echo.result); + + return pendingResultMatches(pendingResult, { userId: user.id, roomId: room._id }) + && String(joinedRoomId) === String(room._id) + && String(echo.userId) === String(user.id) + && String(echo.id) === String(pendingResult.attemptId) + && echoedSubmissionId === undefined + && pendingValue.time === echoedValue.time + && pendingValue.AUF === echoedValue.AUF + && pendingValue.DNF === echoedValue.DNF + && pendingValue.inspection === echoedValue.inspection; + }; + + let flushPendingResult; + + const completePendingResult = (pendingResult) => { + const submission = currentSubmission(); + if (!submission.pendingResult + || submission.pendingResult.submissionId !== pendingResult.submissionId) { + return false; + } + + invalidateSubmissionAttempt(); + try { + const cleared = clearPendingResult(pendingResult.submissionId, storage); + if (!cleared) { + const storedPendingResult = readStoredPendingResult(); + if (storedPendingResult) { + store.dispatch(resultSubmissionPending(storedPendingResult)); + flushPendingResult(); + return false; + } + } + } catch (storageError) { + store.dispatch(createMessage({ + severity: 'warning', + text: 'Your result was saved, but its local backup could not be removed.', + })); + } + store.dispatch(resultSubmissionCleared(pendingResult.submissionId)); + return true; + }; + + let namespace; + + flushPendingResult = () => { + const submission = currentSubmission(); + let pendingResult = submission && submission.pendingResult; + const { room, user } = store.getState(); + + if (!pendingResult + || submission.status === 'sending' + || submission.status === 'failed' + || !roomsConnected + || String(joinedRoomId) !== String(room._id) + || !pendingResultMatches(pendingResult, { userId: user.id, roomId: room._id })) { + return; + } + + if (submissionRetryTimer !== null) { + clearTimeoutFn(submissionRetryTimer); + submissionRetryTimer = null; + } + + if (pendingResult.deliveryAttempted !== true) { + pendingResult = markPendingResultAttempted(pendingResult); + store.dispatch(resultSubmissionPending(pendingResult)); + try { + persistPendingResult(pendingResult, storage); + } catch (storageError) { + warnPendingResultNotBackedUp(pendingResult.submissionId); + } + } + + const generation = submissionGeneration + 1; + submissionGeneration = generation; + store.dispatch(resultSubmissionSending(pendingResult.submissionId)); + + const retrySubmission = () => { + if (generation !== submissionGeneration) { + return; + } + + invalidateSubmissionAttempt(); + store.dispatch(resultSubmissionPending(pendingResult)); + submissionRetryTimer = setTimeoutFn(() => { + submissionRetryTimer = null; + flushPendingResult(); + }, retryDelayMs); + }; + + submissionAckTimer = setTimeoutFn(() => { + submissionAckTimer = null; + retrySubmission(); + }, ackTimeoutMs); + + namespace.emit(Protocol.SUBMIT_RESULT, { + id: pendingResult.attemptId, + attemptKey: pendingResult.attemptKey, + result: pendingResult.result, + submissionId: pendingResult.submissionId, + }, (error, receipt) => { + if (generation !== submissionGeneration) { + return; + } + + if (submissionAckTimer !== null) { + clearTimeoutFn(submissionAckTimer); + submissionAckTimer = null; + } + + if (error) { + if (error.retryable) { + retrySubmission(); + return; + } + + invalidateSubmissionAttempt(); + const submissionError = normalizeSubmissionError( + error, + 'The server rejected this result.', + ); + pendingResult = markPendingResultFailed(pendingResult, submissionError); + try { + persistPendingResult(pendingResult, storage); + } catch (storageError) { + warnPendingResultNotBackedUp(pendingResult.submissionId); + } + store.dispatch(resultSubmissionPending(pendingResult)); + store.dispatch(resultSubmissionFailed(pendingResult.submissionId, submissionError)); + return; + } + + const accepted = receipt + && receipt.submissionId === pendingResult.submissionId + && (receipt.status === 'saved' || receipt.status === 'duplicate'); + + if (!accepted) { + retrySubmission(); + return; + } + + completePendingResult(pendingResult); + }); + }; + const reconnectToRoom = () => { - if (store.getState().room.accessCode) { + const { room } = store.getState(); + const request = pendingJoinRequest || (room.accessCode ? { + id: room._id, + password: room.password, + } : null); + + if (request) { store.dispatch(joinRoom({ - id: store.getState().room._id, - password: store.getState().room.password, + ...request, + reconnecting: true, })); } }; - const namespace = new Namespace({ - manager, + const handleDisconnected = () => { + roomsConnected = false; + joinedRoomId = null; + joinGeneration += 1; + + const submission = currentSubmission(); + const pendingResult = submission && submission.pendingResult; + invalidateSubmissionAttempt(); + if (pendingResult && submission.status === 'sending') { + store.dispatch(resultSubmissionPending(pendingResult)); + } + + store.dispatch(disconnected()); + }; + + namespace = new NamespaceClass({ + manager: namespaceManager, namespace: '/rooms', onChange: (isConnected) => { store.dispatch(connectionChanged(isConnected)); }, onConnected: () => { + roomsConnected = true; + joinedRoomId = null; store.dispatch(connected()); reconnectToRoom(); }, - onDisconnected: () => { - store.dispatch(disconnected()); - }, + onDisconnected: handleDisconnected, events: { [Protocol.ERROR]: (error) => { // eslint-disable-next-line no-console @@ -171,11 +429,15 @@ const roomsNamespaceMiddleware = (store) => { } }, [Protocol.NEW_RESULT]: (result) => { + const submission = currentSubmission(); + if (submission.pendingResult + && resultEchoMatches(result, submission.pendingResult)) { + completePendingResult(submission.pendingResult); + } store.dispatch(newResult(result)); }, [Protocol.EDIT_RESULT]: (result) => { store.dispatch(editResult(result)); - // calculate grand prix points }, [Protocol.MESSAGE]: (message) => { store.dispatch(receiveChat(message)); @@ -203,7 +465,6 @@ const roomsNamespaceMiddleware = (store) => { }, }); - // catch attempt to join room here and then fetch socket event const reducers = { [ROOMS_CONNECT]: () => { namespace.connect(); @@ -211,17 +472,14 @@ const roomsNamespaceMiddleware = (store) => { [ROOMS_DISCONNECT]: () => { namespace.disconnect(); }, - // no real point in this being here oper other places '@@router/LOCATION_CHANGE': ({ payload }) => { - // TODO: improve if (payload.location.pathname === '/' || payload.location.pathname === '/profile') { document.title = 'Let\'s Cube'; } }, [USER_CHANGED]: () => { - // TODO: improve - manager.disconnect(); - manager.connect(); + namespaceManager.disconnect(); + namespaceManager.connect(); }, [DELETE_ROOM]: ({ id }) => { namespace.emit(Protocol.DELETE_ROOM, id, (err) => { @@ -234,7 +492,17 @@ const roomsNamespaceMiddleware = (store) => { }); }, [JOIN_ROOM]: ({ id, password }) => { + const generation = joinGeneration + 1; + joinGeneration = generation; + joinedRoomId = null; + pendingJoinRequest = { id, password }; namespace.emit(Protocol.JOIN_ROOM, { id, password }, (err, room) => { + if (generation !== joinGeneration) { + return; + } + + pendingJoinRequest = null; + if (err) { if (err.banned) { store.dispatch(push('/')); @@ -244,10 +512,13 @@ const roomsNamespaceMiddleware = (store) => { severity: 'error', text: err.message, })); + return; } if (room) { store.dispatch(roomUpdated(room)); + joinedRoomId = room._id || id; + flushPendingResult(); if (room.accessCode) { store.dispatch(createMessage({ @@ -268,18 +539,93 @@ const roomsNamespaceMiddleware = (store) => { return; } - // store.dispatch(roomJoined(room.accessCode)); store.dispatch(roomUpdated(room)); + joinedRoomId = room._id; store.dispatch(push(`/rooms/${room._id}`)); + flushPendingResult(); }); }, [LEAVE_ROOM]: () => { + joinedRoomId = null; + pendingJoinRequest = null; + joinGeneration += 1; if (store.getState().room._id) { namespace.emit(Protocol.LEAVE_ROOM); } }, [SUBMIT_RESULT]: (event) => { - namespace.emit(Protocol.SUBMIT_RESULT, event.result); + const submission = currentSubmission(); + const storedPendingResult = readStoredPendingResult(); + + if ((submission && submission.pendingResult) || storedPendingResult) { + if (!submission.pendingResult && storedPendingResult) { + store.dispatch(resultSubmissionPending(storedPendingResult)); + } + store.dispatch(createMessage({ + severity: 'warning', + text: 'A result is already waiting to be submitted.', + })); + return; + } + + const { room, user } = store.getState(); + let pendingResult; + try { + pendingResult = createPendingResult({ + userId: user.id, + roomId: room._id, + attemptId: event.result.id, + attemptKey: event.result.attemptKey, + result: event.result.result, + }, { + createId: createSubmissionId, + now, + }); + } catch (error) { + store.dispatch(createMessage({ + severity: 'warning', + text: 'This result is invalid and was not submitted.', + })); + return; + } + + store.dispatch(resultSubmissionPending(pendingResult)); + try { + persistPendingResult(pendingResult, storage); + } catch (storageError) { + warnPendingResultNotBackedUp(pendingResult.submissionId); + } + + flushPendingResult(); + }, + [DISCARD_PENDING_RESULT]: ({ submissionId }) => { + const submission = currentSubmission(); + if (!submission.pendingResult + || submission.pendingResult.submissionId !== submissionId) { + return; + } + + if (!canDiscardPendingResult(submission.pendingResult, submission.status)) { + store.dispatch(createMessage({ + severity: 'warning', + text: 'This result may already have reached the server and must finish submitting.', + })); + return; + } + + invalidateSubmissionAttempt(); + try { + if (!clearPendingResult(submissionId, storage)) { + throw new Error('A newer result is saved on this device.'); + } + } catch (storageError) { + store.dispatch(resultSubmissionFailed(submissionId, { + message: storageError.message || 'The saved result could not be discarded.', + retryable: false, + })); + return; + } + store.dispatch(resultSubmissionCleared(submissionId)); }, [SEND_EDIT_RESULT]: (event) => { namespace.emit(Protocol.SEND_EDIT_RESULT, event.result); @@ -334,13 +680,20 @@ const roomsNamespaceMiddleware = (store) => { }, }; - // Return the handler that will be called for each action dispatched return (next) => (action) => { + if (!hydratedPendingResult) { + hydratedPendingResult = true; + const pendingResult = readStoredPendingResult(); + if (pendingResult) { + store.dispatch(resultSubmissionPending(pendingResult)); + } + } + if (reducers[action.type]) { reducers[action.type](action); } - next(action); // This is a middleware, we still need to call this! + next(action); }; }; -export default roomsNamespaceMiddleware; +export default createRoomsNamespaceMiddleware(); diff --git a/client/src/store/middlewares/rooms.test.js b/client/src/store/middlewares/rooms.test.js new file mode 100644 index 0000000..57a03af --- /dev/null +++ b/client/src/store/middlewares/rooms.test.js @@ -0,0 +1,444 @@ +import { + applyMiddleware, combineReducers, createStore, +} from 'redux'; +import Protocol from '../../lib/protocol'; +import roomReducer from '../room/reducer'; +import roomsReducer from '../rooms/reducer'; +import messagesReducer from '../messages/reducers'; +import { + discardPendingResult, + joinRoom, + submitResult, +} from '../room/actions'; +import { connectSocket } from '../rooms/actions'; +import { + PENDING_RESULT_STORAGE_KEY, + createPendingResult, + markPendingResultAttempted, + markPendingResultFailed, + persistPendingResult, +} from '../room/resultOutbox'; +import { createRoomsNamespaceMiddleware } from './rooms'; + +jest.mock('./manager', () => ({ + manager: { + connect: jest.fn(), + disconnect: jest.fn(), + }, +})); + +class FakeNamespace { + constructor(options) { + this.options = options; + this.emitted = []; + FakeNamespace.instance = this; + } + + connect() { + this.connected = true; + } + + disconnect() { + this.connected = false; + } + + emit(event, ...args) { + this.emitted.push({ + event, + args, + pendingResultAtEmit: window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY), + }); + } +} + +const makeScheduler = () => { + let nextId = 1; + const timers = new Map(); + + return { + setTimeoutFn: (callback, delay) => { + const id = nextId; + nextId += 1; + timers.set(id, { callback, delay }); + return id; + }, + clearTimeoutFn: (id) => timers.delete(id), + runNext: (delay) => { + const entry = Array.from(timers.entries()).find(([, timer]) => timer.delay === delay); + if (!entry) { + throw new Error(`No timer scheduled for ${delay}ms.`); + } + const [id, timer] = entry; + timers.delete(id); + timer.callback(); + }, + }; +}; + +const initialRoom = () => ({ + ...roomReducer(undefined, { type: '@@INIT' }), + fetching: false, + _id: 'room-one', + accessCode: 'ABC123', + password: null, + users: [{ id: 42, displayName: 'Cuber' }], + competing: { 42: true }, + waitingFor: { 42: true }, + attempts: [{ + _id: 'attempt-one', id: 12, scrambles: ['R U'], results: {}, + }], +}); + +const buildStore = ({ + roomState = initialRoom(), + storage = window.localStorage, +} = {}) => { + const scheduler = makeScheduler(); + const namespaceManager = { + connect: jest.fn(), + disconnect: jest.fn(), + }; + const middleware = createRoomsNamespaceMiddleware({ + NamespaceClass: FakeNamespace, + namespaceManager, + storage, + createSubmissionId: () => 'submission-one', + now: () => 1000, + setTimeoutFn: scheduler.setTimeoutFn, + clearTimeoutFn: scheduler.clearTimeoutFn, + ackTimeoutMs: 100, + retryDelayMs: 25, + }); + const reducer = combineReducers({ + room: roomReducer, + roomList: roomsReducer, + messages: messagesReducer, + user: (state = { id: 42, muteTimer: true }) => state, + chat: (state = {}) => state, + admin: (state = {}) => state, + }); + const store = createStore(reducer, { + room: roomState, + roomList: roomsReducer(undefined, { type: '@@INIT' }), + messages: messagesReducer(undefined, { type: '@@INIT' }), + user: { id: 42, muteTimer: true }, + chat: {}, + admin: {}, + }, applyMiddleware(middleware)); + + return { + namespace: FakeNamespace.instance, + scheduler, + store, + }; +}; + +const emissionsFor = (namespace, event) => ( + namespace.emitted.filter((emission) => emission.event === event) +); + +const acknowledgeJoin = (namespace) => { + const join = emissionsFor(namespace, Protocol.JOIN_ROOM).slice(-1)[0]; + const room = initialRoom(); + delete room.resultSubmission; + join.args[1](null, room); +}; + +const result = { + id: 12, + attemptKey: 'attempt-one', + result: { + time: 1234, + penalties: {}, + }, +}; + +describe('rooms result outbox middleware', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('keeps the loaded room mounted while reconnecting its socket', () => { + const { namespace, store } = buildStore(); + const { attempts } = store.getState().room; + store.dispatch(connectSocket()); + + namespace.options.onConnected(); + + expect(emissionsFor(namespace, Protocol.JOIN_ROOM)).toHaveLength(1); + expect(store.getState().room.fetching).toBe(false); + expect(store.getState().room.attempts).toBe(attempts); + }); + + it('persists while disconnected and submits only after the room join acknowledgement', () => { + const { namespace, store } = buildStore(); + store.dispatch(connectSocket()); + + store.dispatch(submitResult(result)); + + expect(window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY)).not.toBeNull(); + expect(emissionsFor(namespace, Protocol.SUBMIT_RESULT)).toHaveLength(0); + + namespace.options.onConnected(); + expect(emissionsFor(namespace, Protocol.JOIN_ROOM)).toHaveLength(1); + expect(emissionsFor(namespace, Protocol.SUBMIT_RESULT)).toHaveLength(0); + + acknowledgeJoin(namespace); + + const submission = emissionsFor(namespace, Protocol.SUBMIT_RESULT)[0]; + expect(submission.pendingResultAtEmit).not.toBeNull(); + expect(JSON.parse(submission.pendingResultAtEmit).deliveryAttempted).toBe(true); + expect(submission.args[0]).toEqual({ + id: 12, + attemptKey: 'attempt-one', + result: result.result, + submissionId: 'submission-one', + }); + + submission.args[1](null, { + submissionId: 'submission-one', + status: 'saved', + }); + + expect(window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY)).toBeNull(); + expect(store.getState().room.resultSubmission.status).toBe('idle'); + }); + + it('still submits from memory when local storage is unavailable', () => { + const storage = { + getItem: jest.fn(() => null), + setItem: jest.fn(() => { + throw new Error('storage unavailable'); + }), + removeItem: jest.fn(), + }; + const { namespace, store } = buildStore({ storage }); + store.dispatch(connectSocket()); + namespace.options.onConnected(); + acknowledgeJoin(namespace); + + store.dispatch(submitResult(result)); + + expect(storage.setItem).toHaveBeenCalledTimes(2); + expect(emissionsFor(namespace, Protocol.SUBMIT_RESULT)).toHaveLength(1); + expect(store.getState().room.resultSubmission.status).toBe('sending'); + expect(store.getState().messages.messages.some((message) => ( + message.text.includes('Keep this tab open') + ))).toBe(true); + }); + + it('retries a transient error with the same submission id', () => { + const { namespace, scheduler, store } = buildStore(); + store.dispatch(connectSocket()); + namespace.options.onConnected(); + acknowledgeJoin(namespace); + + store.dispatch(submitResult(result)); + const firstSubmission = emissionsFor(namespace, Protocol.SUBMIT_RESULT)[0]; + firstSubmission.args[1]({ message: 'MongoDB unavailable', retryable: true }); + + expect(store.getState().room.resultSubmission.status).toBe('pending'); + scheduler.runNext(25); + + const submissions = emissionsFor(namespace, Protocol.SUBMIT_RESULT); + expect(submissions).toHaveLength(2); + expect(submissions[1].args[0].submissionId).toBe(submissions[0].args[0].submissionId); + }); + + it('does not discard a result after delivery has started', () => { + const { namespace, scheduler, store } = buildStore(); + store.dispatch(connectSocket()); + namespace.options.onConnected(); + acknowledgeJoin(namespace); + + store.dispatch(submitResult(result)); + scheduler.runNext(100); + store.dispatch(discardPendingResult('submission-one')); + + expect(store.getState().room.resultSubmission.status).toBe('pending'); + expect(window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY)).not.toBeNull(); + + scheduler.runNext(25); + expect(emissionsFor(namespace, Protocol.SUBMIT_RESULT)).toHaveLength(2); + }); + + it('protects a restored result that may already have reached the server', () => { + const restoredResult = markPendingResultAttempted(createPendingResult({ + userId: 42, + roomId: 'room-one', + attemptId: 12, + attemptKey: 'attempt-one', + result: { time: 1234, penalties: {} }, + }, { + createId: () => 'restored-submission', + now: () => 500, + })); + persistPendingResult(restoredResult); + + const { store } = buildStore(); + store.dispatch(discardPendingResult('restored-submission')); + + expect(store.getState().room.resultSubmission.pendingResult).toEqual(restoredResult); + expect(window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY)).not.toBeNull(); + }); + + it('rejects an invalid result without blocking the next valid result', () => { + const { namespace, store } = buildStore(); + store.dispatch(connectSocket()); + namespace.options.onConnected(); + acknowledgeJoin(namespace); + + store.dispatch(submitResult({ + ...result, + result: { time: undefined, penalties: {} }, + })); + + expect(store.getState().room.resultSubmission.status).toBe('idle'); + expect(emissionsFor(namespace, Protocol.SUBMIT_RESULT)).toHaveLength(0); + + store.dispatch(submitResult(result)); + expect(emissionsFor(namespace, Protocol.SUBMIT_RESULT)).toHaveLength(1); + }); + + it('accepts an exact self result echo as a legacy persistence receipt', () => { + const { namespace, store } = buildStore(); + store.dispatch(connectSocket()); + namespace.options.onConnected(); + acknowledgeJoin(namespace); + + store.dispatch(submitResult(result)); + const submission = emissionsFor(namespace, Protocol.SUBMIT_RESULT)[0]; + + namespace.options.events[Protocol.NEW_RESULT]({ + id: 12, + userId: 42, + result: { time: 1234, penalties: { DNF: true } }, + }); + expect(store.getState().room.resultSubmission.status).toBe('sending'); + + namespace.options.events[Protocol.NEW_RESULT]({ + id: 12, + userId: 42, + result: { time: 1234, penalties: {}, submissionId: 'another-submission' }, + }); + expect(store.getState().room.resultSubmission.status).toBe('sending'); + + namespace.options.events[Protocol.NEW_RESULT]({ + id: 12, + userId: 42, + result: { time: 1234, penalties: {}, submissionId: 'submission-one' }, + }); + expect(store.getState().room.resultSubmission.status).toBe('sending'); + + namespace.options.events[Protocol.NEW_RESULT]({ + id: 12, + userId: 42, + result: { time: 1234, penalties: {} }, + }); + + expect(window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY)).toBeNull(); + expect(store.getState().room.resultSubmission.status).toBe('idle'); + + submission.args[1](null, { + submissionId: 'submission-one', + status: 'saved', + }); + expect(store.getState().room.resultSubmission.status).toBe('idle'); + }); + + it('retains a terminal failure until the user explicitly discards it', () => { + const { namespace, store } = buildStore(); + store.dispatch(connectSocket()); + namespace.options.onConnected(); + acknowledgeJoin(namespace); + + store.dispatch(submitResult(result)); + const submission = emissionsFor(namespace, Protocol.SUBMIT_RESULT)[0]; + submission.args[1]({ message: 'Attempt already has a result', retryable: false }); + + expect(store.getState().room.resultSubmission.status).toBe('failed'); + expect(JSON.parse( + window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY), + ).terminalFailure).toBe(true); + + store.dispatch(discardPendingResult('submission-one')); + + expect(store.getState().room.resultSubmission.status).toBe('idle'); + expect(window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY)).toBeNull(); + }); + + it('restores a terminal failure as discardable after a refresh', () => { + const failedResult = markPendingResultFailed(createPendingResult({ + userId: 42, + roomId: 'room-one', + attemptId: 12, + attemptKey: 'attempt-one', + result: { time: 1234, penalties: {} }, + }, { + createId: () => 'restored-submission', + now: () => 500, + }), { + statusCode: 409, + message: 'A result already exists for this attempt', + retryable: false, + }); + persistPendingResult(failedResult); + + const { store } = buildStore(); + store.dispatch(discardPendingResult('restored-submission')); + + expect(store.getState().room.resultSubmission.status).toBe('idle'); + expect(window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY)).toBeNull(); + }); + + it('does not submit a restored result for a different user or room', () => { + const restoredResult = createPendingResult({ + userId: 99, + roomId: 'room-two', + attemptId: 8, + attemptKey: 'attempt-two', + result: { time: 999, penalties: {} }, + }, { + createId: () => 'restored-submission', + now: () => 500, + }); + persistPendingResult(restoredResult); + + const { namespace, store } = buildStore(); + store.dispatch(connectSocket()); + namespace.options.onConnected(); + acknowledgeJoin(namespace); + + expect(store.getState().room.resultSubmission.pendingResult).toEqual(restoredResult); + expect(emissionsFor(namespace, Protocol.SUBMIT_RESULT)).toHaveLength(0); + }); + + it('replays an interrupted initial room join after reconnecting', () => { + const { namespace, store } = buildStore({ + roomState: { + ...initialRoom(), + _id: null, + accessCode: null, + }, + }); + store.dispatch(connectSocket()); + namespace.options.onConnected(); + store.dispatch(joinRoom({ id: 'room-two', password: 'secret' })); + + expect(emissionsFor(namespace, Protocol.JOIN_ROOM)).toHaveLength(1); + namespace.options.onDisconnected(); + namespace.options.onConnected(); + + const joins = emissionsFor(namespace, Protocol.JOIN_ROOM); + expect(joins).toHaveLength(2); + expect(joins[1].args[0]).toEqual({ id: 'room-two', password: 'secret' }); + + joins[1].args[1](null, { + ...initialRoom(), + _id: 'room-two', + accessCode: 'ROOM2', + }); + expect(store.getState().room.fetching).toBe(false); + expect(store.getState().room._id).toBe('room-two'); + }); +}); diff --git a/client/src/store/room/actions.js b/client/src/store/room/actions.js index 811b8cd..e954a92 100644 --- a/client/src/store/room/actions.js +++ b/client/src/store/room/actions.js @@ -27,6 +27,11 @@ export const NEXT_SOLVE_AT = 'room/next_solve_at'; export const START_ROOM = 'room/start_room'; export const PAUSE_ROOM = 'room/pause_room'; export const TOGGLE_FOLLOW_USER = 'room/toggle_follow_user'; +export const RESULT_SUBMISSION_PENDING = 'room/result_submission_pending'; +export const RESULT_SUBMISSION_SENDING = 'room/result_submission_sending'; +export const RESULT_SUBMISSION_FAILED = 'room/result_submission_failed'; +export const RESULT_SUBMISSION_CLEARED = 'room/result_submission_cleared'; +export const DISCARD_PENDING_RESULT = 'room/discard_pending_result'; export const roomUpdated = (room) => ({ type: ROOM_UPDATED, @@ -43,11 +48,14 @@ export const deleteRoom = (id) => ({ id, }); -export const joinRoom = ({ id, spectating, password }) => ({ +export const joinRoom = ({ + id, spectating, password, reconnecting = false, +}) => ({ type: JOIN_ROOM, id, spectating, password, + reconnecting, }); // We're submitting a new result @@ -56,6 +64,32 @@ export const submitResult = (result) => ({ result, }); +export const resultSubmissionPending = (pendingResult) => ({ + type: RESULT_SUBMISSION_PENDING, + pendingResult, +}); + +export const resultSubmissionSending = (submissionId) => ({ + type: RESULT_SUBMISSION_SENDING, + submissionId, +}); + +export const resultSubmissionFailed = (submissionId, error) => ({ + type: RESULT_SUBMISSION_FAILED, + submissionId, + error, +}); + +export const resultSubmissionCleared = (submissionId) => ({ + type: RESULT_SUBMISSION_CLEARED, + submissionId, +}); + +export const discardPendingResult = (submissionId) => ({ + type: DISCARD_PENDING_RESULT, + submissionId, +}); + // A new result came in export const newResult = (result) => ({ type: NEW_RESULT, diff --git a/client/src/store/room/reducer.js b/client/src/store/room/reducer.js index f371728..0a7fd65 100644 --- a/client/src/store/room/reducer.js +++ b/client/src/store/room/reducer.js @@ -16,8 +16,18 @@ import { UPDATE_USER_BANNED, NEXT_SOLVE_AT, TOGGLE_FOLLOW_USER, + RESULT_SUBMISSION_PENDING, + RESULT_SUBMISSION_SENDING, + RESULT_SUBMISSION_FAILED, + RESULT_SUBMISSION_CLEARED, } from './actions'; +const EMPTY_RESULT_SUBMISSION = { + status: 'idle', + pendingResult: null, + error: null, +}; + const INITIAL_STATE = { fetching: null, _id: null, @@ -47,21 +57,30 @@ const INITIAL_STATE = { following: {}, registeredUsers: 0, twitchChannel: '', + resultSubmission: EMPTY_RESULT_SUBMISSION, }; -const editResult = (state, action) => ({ - ...state, - attempts: state.attempts.map((attempt) => ( - attempt.id === action.result.id ? ({ - ...attempt, - results: { ...attempt.results, [action.result.userId]: action.result.result }, - }) : attempt - )), - waitingFor: { - ...state.waitingFor, - [action.result.userId]: false, - }, -}); +const editResult = (state, action) => { + const latestAttempt = state.attempts[state.attempts.length - 1]; + const resultIsForLatestAttempt = latestAttempt + && latestAttempt.id === action.result.id; + + return { + ...state, + attempts: state.attempts.map((attempt) => ( + attempt.id === action.result.id ? ({ + ...attempt, + results: { ...attempt.results, [action.result.userId]: action.result.result }, + }) : attempt + )), + waitingFor: resultIsForLatestAttempt + ? { + ...state.waitingFor, + [action.result.userId]: false, + } + : state.waitingFor, + }; +}; const reducers = { [ROOM_UPDATED]: (state, { room }) => ({ @@ -94,14 +113,16 @@ const reducers = { }), [JOIN_ROOM]: (state, action) => ({ ...state, - fetching: true, + fetching: action.reconnecting ? state.fetching : true, password: action.password, }), - [RESET_ROOM]: () => ({ + [RESET_ROOM]: (state) => ({ ...INITIAL_STATE, + resultSubmission: state.resultSubmission, }), - [LEAVE_ROOM]: () => ({ + [LEAVE_ROOM]: (state) => ({ ...INITIAL_STATE, + resultSubmission: state.resultSubmission, }), [NEW_ATTEMPT]: (state, action) => ({ ...state, @@ -151,6 +172,49 @@ const reducers = { [action.userId]: !state.following[action.userId], }, }), + [RESULT_SUBMISSION_PENDING]: (state, action) => ({ + ...state, + resultSubmission: { + status: action.pendingResult.terminalFailure ? 'failed' : 'pending', + pendingResult: action.pendingResult, + error: action.pendingResult.terminalFailure ? action.pendingResult.failure : null, + }, + }), + [RESULT_SUBMISSION_SENDING]: (state, action) => ( + state.resultSubmission.pendingResult + && state.resultSubmission.pendingResult.submissionId === action.submissionId + ? { + ...state, + resultSubmission: { + ...state.resultSubmission, + status: 'sending', + error: null, + }, + } + : state + ), + [RESULT_SUBMISSION_FAILED]: (state, action) => ( + state.resultSubmission.pendingResult + && state.resultSubmission.pendingResult.submissionId === action.submissionId + ? { + ...state, + resultSubmission: { + ...state.resultSubmission, + status: 'failed', + error: action.error, + }, + } + : state + ), + [RESULT_SUBMISSION_CLEARED]: (state, action) => ( + state.resultSubmission.pendingResult + && state.resultSubmission.pendingResult.submissionId === action.submissionId + ? { + ...state, + resultSubmission: EMPTY_RESULT_SUBMISSION, + } + : state + ), }; // Socket reducer diff --git a/client/src/store/room/reducer.test.js b/client/src/store/room/reducer.test.js new file mode 100644 index 0000000..f8181f5 --- /dev/null +++ b/client/src/store/room/reducer.test.js @@ -0,0 +1,66 @@ +import { joinRoom, newResult } from './actions'; +import roomReducer from './reducer'; + +const initialState = () => ({ + ...roomReducer(undefined, { type: '@@INIT' }), + attempts: [{ + _id: 'old-attempt', + id: 0, + results: {}, + }, { + _id: 'latest-attempt', + id: 1, + results: {}, + }], + waitingFor: { 42: true }, +}); + +describe('room result updates', () => { + it('does not clear current waiting state for a delayed historical result', () => { + const state = roomReducer(initialState(), newResult({ + id: 0, + userId: 42, + result: { time: 1234, penalties: {} }, + })); + + expect(state.attempts[0].results[42]).toEqual({ time: 1234, penalties: {} }); + expect(state.waitingFor[42]).toBe(true); + }); + + it('clears waiting state when the result belongs to the latest attempt', () => { + const state = roomReducer(initialState(), newResult({ + id: 1, + userId: 42, + result: { time: 2345, penalties: {} }, + })); + + expect(state.attempts[1].results[42]).toEqual({ time: 2345, penalties: {} }); + expect(state.waitingFor[42]).toBe(false); + }); +}); + +describe('room joins', () => { + it('shows the loading state for an initial room join', () => { + const state = roomReducer(undefined, joinRoom({ id: 'room-1' })); + + expect(state.fetching).toBe(true); + }); + + it('keeps an active room mounted during a reconnect join', () => { + const loadedRoom = { + ...roomReducer(undefined, { type: '@@INIT' }), + fetching: false, + _id: 'room-1', + accessCode: 'access-code', + attempts: [{ _id: 'attempt-1', id: 0 }], + }; + + const state = roomReducer(loadedRoom, joinRoom({ + id: 'room-1', + reconnecting: true, + })); + + expect(state.fetching).toBe(false); + expect(state.attempts).toBe(loadedRoom.attempts); + }); +}); diff --git a/client/src/store/room/resultOutbox.js b/client/src/store/room/resultOutbox.js new file mode 100644 index 0000000..265adb5 --- /dev/null +++ b/client/src/store/room/resultOutbox.js @@ -0,0 +1,152 @@ +import { uuid } from '../../lib/utils'; + +export const PENDING_RESULT_VERSION = 1; +export const PENDING_RESULT_STORAGE_KEY = 'letscube.pendingResult.v1'; + +const hasId = (value) => ( + (typeof value === 'string' && value.length > 0) + || (typeof value === 'number' && Number.isFinite(value)) +); + +export const isPendingResult = (pendingResult) => ( + !!pendingResult + && pendingResult.version === PENDING_RESULT_VERSION + && typeof pendingResult.submissionId === 'string' + && pendingResult.submissionId.length > 0 + && hasId(pendingResult.userId) + && hasId(pendingResult.roomId) + && hasId(pendingResult.attemptId) + && (pendingResult.attemptKey === undefined || hasId(pendingResult.attemptKey)) + && (pendingResult.deliveryAttempted === undefined + || typeof pendingResult.deliveryAttempted === 'boolean') + && (pendingResult.terminalFailure === undefined + || typeof pendingResult.terminalFailure === 'boolean') + && (!pendingResult.terminalFailure + || (!!pendingResult.failure + && typeof pendingResult.failure === 'object' + && typeof pendingResult.failure.message === 'string' + && pendingResult.failure.message.length > 0)) + && !!pendingResult.result + && typeof pendingResult.result === 'object' + && typeof pendingResult.result.time === 'number' + && Number.isFinite(pendingResult.result.time) + && typeof pendingResult.createdAt === 'number' + && Number.isFinite(pendingResult.createdAt) +); + +export const createPendingResult = ({ + userId, + roomId, + attemptId, + attemptKey, + result, +}, { + createId = uuid, + now = Date.now, +} = {}) => { + if (!hasId(attemptKey)) { + throw new Error('A new pending result requires an immutable attempt key.'); + } + + const pendingResult = { + version: PENDING_RESULT_VERSION, + submissionId: createId(), + userId, + roomId, + attemptId, + attemptKey, + deliveryAttempted: false, + terminalFailure: false, + result, + createdAt: now(), + }; + + if (!isPendingResult(pendingResult)) { + throw new Error('Cannot create an invalid pending result.'); + } + + return pendingResult; +}; + +export const markPendingResultAttempted = (pendingResult) => { + const attemptedResult = { + ...pendingResult, + deliveryAttempted: true, + }; + + if (!isPendingResult(attemptedResult)) { + throw new Error('Cannot mark an invalid pending result as attempted.'); + } + + return attemptedResult; +}; + +export const markPendingResultFailed = (pendingResult, failure) => { + const failedResult = { + ...pendingResult, + deliveryAttempted: true, + terminalFailure: true, + failure, + }; + + if (!isPendingResult(failedResult)) { + throw new Error('Cannot mark an invalid pending result as failed.'); + } + + return failedResult; +}; + +export const canDiscardPendingResult = (pendingResult, status) => ( + isPendingResult(pendingResult) + && (status === 'failed' + || pendingResult.terminalFailure === true + || pendingResult.deliveryAttempted !== true) +); + +export const readPendingResult = (storage = window.localStorage) => { + const serialized = storage.getItem(PENDING_RESULT_STORAGE_KEY); + if (!serialized) { + return null; + } + + try { + const pendingResult = JSON.parse(serialized); + return isPendingResult(pendingResult) ? pendingResult : null; + } catch (error) { + return null; + } +}; + +export const persistPendingResult = (pendingResult, storage = window.localStorage) => { + if (!isPendingResult(pendingResult)) { + throw new Error('Cannot save an invalid pending result.'); + } + + storage.setItem(PENDING_RESULT_STORAGE_KEY, JSON.stringify(pendingResult)); + return pendingResult; +}; + +export const clearPendingResult = (submissionId, storage = window.localStorage) => { + const pendingResult = readPendingResult(storage); + + if (pendingResult && pendingResult.submissionId !== submissionId) { + return false; + } + + storage.removeItem(PENDING_RESULT_STORAGE_KEY); + return true; +}; + +export const pendingResultMatches = (pendingResult, { userId, roomId }) => ( + isPendingResult(pendingResult) + && hasId(userId) + && hasId(roomId) + && String(pendingResult.userId) === String(userId) + && String(pendingResult.roomId) === String(roomId) +); + +export const pendingResultBelongsToUser = (pendingResult, userId) => ( + isPendingResult(pendingResult) + && hasId(userId) + && String(pendingResult.userId) === String(userId) +); diff --git a/client/src/store/room/resultOutbox.test.js b/client/src/store/room/resultOutbox.test.js new file mode 100644 index 0000000..014a409 --- /dev/null +++ b/client/src/store/room/resultOutbox.test.js @@ -0,0 +1,121 @@ +import { + PENDING_RESULT_STORAGE_KEY, + canDiscardPendingResult, + clearPendingResult, + createPendingResult, + markPendingResultAttempted, + markPendingResultFailed, + pendingResultBelongsToUser, + pendingResultMatches, + persistPendingResult, + readPendingResult, +} from './resultOutbox'; + +const makePendingResult = (overrides = {}) => createPendingResult({ + userId: 42, + roomId: 'room-one', + attemptId: 12, + attemptKey: 'attempt-one', + result: { + time: 1234, + penalties: {}, + }, + ...overrides, +}, { + createId: () => 'submission-one', + now: () => 1000, +}); + +describe('result outbox storage', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('persists and restores the complete pending result', () => { + const pendingResult = makePendingResult(); + + persistPendingResult(pendingResult); + + const serialized = window.localStorage.getItem(PENDING_RESULT_STORAGE_KEY); + expect(JSON.parse(serialized)).toEqual(pendingResult); + expect(readPendingResult()).toEqual(pendingResult); + }); + + it('only clears the matching submission', () => { + const pendingResult = makePendingResult(); + persistPendingResult(pendingResult); + + expect(clearPendingResult('another-submission')).toBe(false); + expect(readPendingResult()).toEqual(pendingResult); + + expect(clearPendingResult(pendingResult.submissionId)).toBe(true); + expect(readPendingResult()).toBeNull(); + }); + + it('matches both user and room before allowing automatic submission', () => { + const pendingResult = makePendingResult(); + + expect(pendingResultBelongsToUser(pendingResult, '42')).toBe(true); + expect(pendingResultMatches( + pendingResult, + { userId: '42', roomId: 'room-one' }, + )).toBe(true); + expect(pendingResultMatches(pendingResult, { userId: 7, roomId: 'room-one' })).toBe(false); + expect(pendingResultMatches(pendingResult, { userId: 42, roomId: 'room-two' })).toBe(false); + }); + + it('does not restore malformed storage data', () => { + window.localStorage.setItem(PENDING_RESULT_STORAGE_KEY, '{not-json'); + expect(readPendingResult()).toBeNull(); + + window.localStorage.setItem(PENDING_RESULT_STORAGE_KEY, JSON.stringify({ version: 1 })); + expect(readPendingResult()).toBeNull(); + }); + + it('restores legacy pending results without an attempt key', () => { + const pendingResult = makePendingResult(); + delete pendingResult.attemptKey; + window.localStorage.setItem( + PENDING_RESULT_STORAGE_KEY, + JSON.stringify(pendingResult), + ); + + expect(readPendingResult()).toEqual(pendingResult); + }); + + it('requires new pending results to identify the immutable attempt', () => { + expect(() => makePendingResult({ attemptKey: undefined })).toThrow( + 'requires an immutable attempt key', + ); + }); + + it.each([undefined, Number.NaN])('rejects an invalid result time', (time) => { + expect(() => makePendingResult({ + result: { time, penalties: {} }, + })).toThrow('Cannot create an invalid pending result'); + }); + + it('protects a result from discard after delivery starts', () => { + const pendingResult = makePendingResult(); + const attemptedResult = markPendingResultAttempted(pendingResult); + + expect(canDiscardPendingResult(pendingResult, 'pending')).toBe(true); + expect(attemptedResult.deliveryAttempted).toBe(true); + expect(canDiscardPendingResult(attemptedResult, 'sending')).toBe(false); + expect(canDiscardPendingResult(attemptedResult, 'pending')).toBe(false); + expect(canDiscardPendingResult(attemptedResult, 'failed')).toBe(true); + }); + + it('persists a terminal failure as safe to discard', () => { + const failedResult = markPendingResultFailed(makePendingResult(), { + statusCode: 409, + message: 'A result already exists for this attempt', + retryable: false, + }); + persistPendingResult(failedResult); + + const restoredResult = readPendingResult(); + expect(restoredResult).toEqual(failedResult); + expect(canDiscardPendingResult(restoredResult, 'pending')).toBe(true); + }); +}); diff --git a/compose.prod.yml b/compose.prod.yml index a76a6bb..f75bc21 100644 --- a/compose.prod.yml +++ b/compose.prod.yml @@ -21,6 +21,9 @@ services: mongo: restart: unless-stopped + postgres: + restart: unless-stopped + redis: restart: unless-stopped diff --git a/compose.yml b/compose.yml index 4a30135..e22418e 100644 --- a/compose.yml +++ b/compose.yml @@ -5,6 +5,7 @@ x-app-environment: &app-environment PORT: ${PORT:-8080} SOCKETIO_PORT: ${SOCKETIO_PORT:-9000} ROOM_RECONNECT_GRACE_MS: ${ROOM_RECONNECT_GRACE_MS:-60000} + GRAND_PRIX_ENABLED: ${GRAND_PRIX_ENABLED:-false} MONGO_URL: ${MONGO_URL:-mongodb://mongo:27017/letscube} REDIS_URL: ${REDIS_URL:-redis://redis:6379} POSTGRES_ENABLED: ${POSTGRES_ENABLED:-true} @@ -52,6 +53,7 @@ services: healthcheck: test: ["CMD-SHELL", "node -e \"require('http').get({host:'127.0.0.1',port:process.env.PORT||8080,path:'/health/api'},res=>process.exit(res.statusCode===200?0:1)).on('error',()=>process.exit(1))\""] interval: 30s + start_interval: 2s timeout: 5s retries: 5 start_period: 30s @@ -71,6 +73,7 @@ services: healthcheck: test: ["CMD-SHELL", "node -e \"require('http').get({host:'127.0.0.1',port:process.env.SOCKETIO_PORT||9000,path:'/health/socket'},res=>process.exit(res.statusCode===200?0:1)).on('error',()=>process.exit(1))\""] interval: 30s + start_interval: 2s timeout: 5s retries: 5 start_period: 30s diff --git a/scripts/classify-deploy-target.sh b/scripts/classify-deploy-target.sh new file mode 100755 index 0000000..1afbb6c --- /dev/null +++ b/scripts/classify-deploy-target.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Classify changed paths for scripts/deploy.sh. Paths may be passed as +# arguments or, for ad-hoc use, one per line on stdin. +if [ "$#" -eq 0 ] && [ ! -t 0 ]; then + mapfile -t changed_paths +else + changed_paths=("$@") +fi + +target="none" + +for path in "${changed_paths[@]}"; do + case "$path" in + # Repository documentation and CI/agent metadata do not affect runtime. + .github/*|.agents/*|.codex/*|docs/*|AGENTS.md|README.md|README_*.md|LICENSE|LICENSE.*|*.md) + ;; + + # The server imports this client-owned protocol file at runtime. + client/src/lib/protocol.js|client/src/lib/protocol.json|client/src/lib/events.json) + echo "all" + exit 0 + ;; + + # The API image serves the built browser client. A client-only change does + # not require interrupting existing Socket.IO connections. + client/*) + target="api" + ;; + + # Server, dependency, container, deployment, and unknown changes are + # intentionally conservative because API and socket share one image. + *) + echo "all" + exit 0 + ;; + esac +done + +echo "$target" diff --git a/scripts/deploy.sh b/scripts/deploy.sh index f13f378..b177e38 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -7,8 +7,35 @@ REMOTE="${REMOTE:-origin}" BRANCH="${BRANCH:-master}" DEPLOY_STRATEGY="${DEPLOY_STRATEGY:-ff-only}" DEPLOY_FORCE="${DEPLOY_FORCE:-false}" +DEPLOY_TARGET="${DEPLOY_TARGET:-auto}" +DEPLOY_WAIT_TIMEOUT_SECONDS="${DEPLOY_WAIT_TIMEOUT_SECONDS:-60}" COMPOSE=(docker compose -f compose.yml -f compose.prod.yml --env-file "$ENV_FILE") +ROLLBACK_COMPOSE=() +rollback_compose_dir="" + +cleanup_deploy_files() { + if [ -n "$rollback_compose_dir" ]; then + rm -rf -- "$rollback_compose_dir" + fi +} + +trap cleanup_deploy_files EXIT + +case "$DEPLOY_TARGET" in + auto|api|socket|all|none) + ;; + *) + echo "Unsupported DEPLOY_TARGET: $DEPLOY_TARGET" >&2 + echo "Use auto, api, socket, all, or none." >&2 + exit 2 + ;; +esac + +if ! [[ "$DEPLOY_WAIT_TIMEOUT_SECONDS" =~ ^[1-9][0-9]*$ ]]; then + echo "DEPLOY_WAIT_TIMEOUT_SECONDS must be a positive integer." >&2 + exit 2 +fi cd "$APP_DIR" @@ -30,52 +57,251 @@ case "$DEPLOY_STRATEGY" in esac current_commit="$(git rev-parse HEAD)" -if [ "$previous_commit" = "$current_commit" ] && [ "$DEPLOY_FORCE" != "true" ]; then - echo "Already up to date at $current_commit" - "${COMPOSE[@]}" ps - exit 0 + +if [ "$DEPLOY_TARGET" = "auto" ]; then + missing_services=() + for service in api socket nginx; do + if ! container_id="$("${COMPOSE[@]}" ps --status running -q "$service")"; then + echo "Failed to inspect the $service container." >&2 + exit 1 + fi + if [ -z "$container_id" ]; then + missing_services+=("$service") + fi + done + + if [ "${#missing_services[@]}" -gt 0 ]; then + echo "Missing runtime services: ${missing_services[*]}; deploying all." + resolved_target="all" + elif [ "$previous_commit" = "$current_commit" ]; then + if [ "$DEPLOY_FORCE" != "true" ]; then + echo "Already up to date at $current_commit" + "${COMPOSE[@]}" ps + exit 0 + fi + + # There is no diff to classify. DEPLOY_FORCE preserves the historical + # behavior of replacing the complete application deployment. + resolved_target="all" + else + mapfile -d '' changed_files < <( + git diff --name-only --no-renames -z "$previous_commit" "$current_commit" + ) + resolved_target="$( + bash scripts/classify-deploy-target.sh "${changed_files[@]}" + )" + fi +else + resolved_target="$DEPLOY_TARGET" fi +case "$resolved_target" in + api) + selected_services=(api) + ;; + socket) + selected_services=(socket) + ;; + all) + # Roll out the backward-compatible socket handler before serving the new + # browser client that depends on its result acknowledgments. + selected_services=(socket api) + ;; + none) + echo "Updated checkout to $current_commit; no application services selected." + "${COMPOSE[@]}" ps + exit 0 + ;; + *) + echo "Deploy classifier returned an invalid target: $resolved_target" >&2 + exit 2 + ;; +esac + +echo "Deploying commit $current_commit (target: $resolved_target)" + +print_retry_hint() { + echo "Retry this checked-out commit with DEPLOY_TARGET=$resolved_target after correcting the failure." >&2 +} + +prepare_rollback_compose() { + local project_dir + + project_dir="$(pwd -P)" + rollback_compose_dir="$(mktemp -d "${TMPDIR:-/tmp}/letscube-deploy.XXXXXX")" + + if ! git show "$previous_commit:compose.yml" > "$rollback_compose_dir/compose.yml" \ + || ! git show "$previous_commit:compose.prod.yml" \ + > "$rollback_compose_dir/compose.prod.yml"; then + echo "Failed to snapshot pre-deploy Compose configuration." >&2 + return 1 + fi + + ROLLBACK_COMPOSE=( + docker compose + --project-directory "$project_dir" + -f "$rollback_compose_dir/compose.yml" + -f "$rollback_compose_dir/compose.prod.yml" + --env-file "$ENV_FILE" + ) +} + +if ! prepare_rollback_compose; then + print_retry_hint + exit 1 +fi + +# Shell environment takes precedence over APP_IMAGE_TAG in the env file. This +# gives every deployed revision an immutable image tag while keeping direct +# Compose usage compatible with the default `local` tag. +export APP_IMAGE_TAG="$current_commit" + if ! "${COMPOSE[@]}" build api; then - echo "Application image build failed." >&2 + echo "Application image build failed; no application containers were replaced." >&2 + print_retry_hint exit 1 fi -if ! "${COMPOSE[@]}" up -d --no-recreate mongo postgres redis; then - echo "Failed to start backing services." >&2 +if ! "${COMPOSE[@]}" up -d --no-recreate --wait \ + --wait-timeout "$DEPLOY_WAIT_TIMEOUT_SECONDS" mongo postgres redis; then + echo "Failed to start backing services; no application containers were replaced." >&2 + print_retry_hint exit 1 fi if ! "${COMPOSE[@]}" run --rm --no-deps migrate; then echo "PostgreSQL migration failed; application containers were not replaced." >&2 + print_retry_hint exit 1 fi -# Replace one application process at a time. API sessions live in MongoDB, and -# socket room membership is preserved during the configured reconnect grace. -if ! "${COMPOSE[@]}" up -d --no-deps --wait api; then - echo "API deployment failed. Recent API logs:" >&2 - "${COMPOSE[@]}" logs --tail=120 api >&2 || true - exit 1 -fi +declare -A rollback_image_ids=() +declare -A rollback_tags=() +deploy_id="$(date -u +%Y%m%dT%H%M%SZ)-$$" -if ! "${COMPOSE[@]}" up -d --no-deps --wait nginx \ - || ! "${COMPOSE[@]}" exec -T nginx nginx -s reload; then - echo "Failed to reload nginx after the API deployment." >&2 - exit 1 -fi +snapshot_service_image() { + local service="$1" + local container_id + local image_id + local rollback_tag -if ! "${COMPOSE[@]}" up -d --no-deps --wait socket; then - echo "Socket deployment failed. Recent socket logs:" >&2 - "${COMPOSE[@]}" logs --tail=120 socket >&2 || true - exit 1 -fi + container_id="$("${COMPOSE[@]}" ps -q "$service")" + if [ -z "$container_id" ]; then + echo "No running $service container found; this service has no rollback image." + return 0 + fi -if ! "${COMPOSE[@]}" up -d --no-deps --wait nginx \ - || ! "${COMPOSE[@]}" exec -T nginx nginx -s reload; then - echo "Failed to reload nginx after the socket deployment. Recent service logs:" >&2 - "${COMPOSE[@]}" logs --tail=120 api socket nginx >&2 || true - exit 1 -fi + image_id="$(docker inspect --format '{{.Image}}' "$container_id")" + rollback_tag="rollback-${service}-${previous_commit:0:12}-${deploy_id}" + docker image tag "$image_id" "letscube-app:$rollback_tag" + + rollback_image_ids["$service"]="$image_id" + rollback_tags["$service"]="$rollback_tag" + echo "Saved $service rollback image $image_id as letscube-app:$rollback_tag" +} + +reload_nginx() { + local compose_variable="${1:-COMPOSE}" + local -n compose_command="$compose_variable" + local container_id + + container_id="$("${compose_command[@]}" ps --status running -q nginx)" + if [ -z "$container_id" ]; then + echo "nginx is not running; starting it without replacing dependencies." + "${compose_command[@]}" up -d --no-deps --wait \ + --wait-timeout "$DEPLOY_WAIT_TIMEOUT_SECONDS" nginx || return 1 + fi + + "${compose_command[@]}" exec -T nginx nginx -s reload +} + +rollback_services() { + local services=("$@") + local original_tag="$APP_IMAGE_TAG" + local rollback_failed="false" + local restored_any="false" + local index + local service + local rollback_tag + + echo "Restoring application services from their pre-deploy images." >&2 + + for ((index=${#services[@]} - 1; index >= 0; index--)); do + service="${services[$index]}" + rollback_tag="${rollback_tags[$service]-}" + + if [ -z "$rollback_tag" ]; then + echo "Cannot restore $service: there was no running pre-deploy container." >&2 + rollback_failed="true" + continue + fi + + export APP_IMAGE_TAG="$rollback_tag" + if "${ROLLBACK_COMPOSE[@]}" up -d --no-deps --wait \ + --wait-timeout "$DEPLOY_WAIT_TIMEOUT_SECONDS" "$service"; then + echo "Restored $service from ${rollback_image_ids[$service]}." >&2 + restored_any="true" + else + echo "Failed to restore $service. Recent logs:" >&2 + "${ROLLBACK_COMPOSE[@]}" logs --tail=120 "$service" >&2 || true + rollback_failed="true" + fi + done + + export APP_IMAGE_TAG="$original_tag" + + if [ "$restored_any" = "true" ] && ! reload_nginx ROLLBACK_COMPOSE; then + echo "Application service rollback completed, but nginx reload failed." >&2 + "${ROLLBACK_COMPOSE[@]}" logs --tail=120 nginx >&2 || true + rollback_failed="true" + fi + + [ "$rollback_failed" = "false" ] +} + +# Snapshot every selected service before the first replacement. If a later +# service fails, the entire application portion of this deploy can return to a +# consistent set of exact pre-deploy images. +for service in "${selected_services[@]}"; do + snapshot_service_image "$service" +done + +attempted_services=() + +last_service_index=$((${#selected_services[@]} - 1)) + +for service_index in "${!selected_services[@]}"; do + service="${selected_services[$service_index]}" + attempted_services+=("$service") + + if ! "${COMPOSE[@]}" up -d --no-deps --wait \ + --wait-timeout "$DEPLOY_WAIT_TIMEOUT_SECONDS" "$service"; then + echo "$service deployment failed. Recent logs:" >&2 + "${COMPOSE[@]}" logs --tail=120 "$service" >&2 || true + rollback_services "${attempted_services[@]}" || \ + echo "Automatic rollback was incomplete; manual intervention is required." >&2 + print_retry_hint + exit 1 + fi + + # On a bootstrap deployment, nginx cannot start until both application + # services exist because its config resolves both upstream names. Existing + # nginx containers are still reloaded after each selected replacement. + if [ "$service_index" -lt "$last_service_index" ] \ + && [ -z "$("${COMPOSE[@]}" ps --status running -q nginx)" ]; then + echo "nginx is not running; deferring startup until selected services are ready." + continue + fi + + if ! reload_nginx COMPOSE; then + echo "Failed to reload nginx after the $service deployment. Recent logs:" >&2 + "${COMPOSE[@]}" logs --tail=120 "$service" nginx >&2 || true + rollback_services "${attempted_services[@]}" || \ + echo "Automatic rollback was incomplete; manual intervention is required." >&2 + print_retry_hint + exit 1 + fi +done +echo "Deployment complete: $resolved_target now uses letscube-app:$current_commit" "${COMPOSE[@]}" ps diff --git a/scripts/test-deploy-classifier.sh b/scripts/test-deploy-classifier.sh new file mode 100755 index 0000000..70368b2 --- /dev/null +++ b/scripts/test-deploy-classifier.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +CLASSIFIER="$SCRIPT_DIR/classify-deploy-target.sh" + +assert_target() { + local expected="$1" + shift + + local actual + actual="$(bash "$CLASSIFIER" "$@")" + if [ "$actual" != "$expected" ]; then + echo "Expected '$expected' for: $*" >&2 + echo "Got '$actual'" >&2 + exit 1 + fi +} + +assert_target none +assert_target none README.md docs/operations.md .github/workflows/ci.yml +assert_target api client/src/App.jsx client/src/styles.css +assert_target all client/src/lib/protocol.js +assert_target all client/src/lib/protocol.json +assert_target all client/src/lib/events.json +assert_target all server/socket/index.js +assert_target all yarn.lock +assert_target all client/src/App.jsx compose.yml +assert_target all unknown-runtime-file + +echo "Deploy target classifier checks passed." diff --git a/scripts/test-deploy.sh b/scripts/test-deploy.sh new file mode 100755 index 0000000..ef82299 --- /dev/null +++ b/scripts/test-deploy.sh @@ -0,0 +1,275 @@ +#!/usr/bin/env bash +set -euo pipefail + +log_command() { + local command="$1" + shift + + { + printf '%s' "$command" + printf ' %q' "$@" + printf '\n' + } >> "$DEPLOY_TEST_LOG" +} + +fake_git() { + log_command git "$@" + + case "$1" in + rev-parse) + if [ -e "$FAKE_GIT_STATE" ]; then + printf '%s\n' "$FAKE_CURRENT_COMMIT" + else + : > "$FAKE_GIT_STATE" + printf '%s\n' "$FAKE_PREVIOUS_COMMIT" + fi + ;; + fetch|merge) + ;; + diff) + if [ -n "${FAKE_CHANGED_PATH:-}" ]; then + printf '%s\0' "$FAKE_CHANGED_PATH" + fi + ;; + show) + printf '%s\n' \ + 'name: letscube' \ + 'services:' \ + ' api:' \ + ' image: letscube-app:${APP_IMAGE_TAG:-local}' \ + ' socket:' \ + ' image: letscube-app:${APP_IMAGE_TAG:-local}' + ;; + *) + echo "Unexpected fake git command: $*" >&2 + return 1 + ;; + esac +} + +fake_docker() { + log_command docker "$@" + + case "$1" in + inspect) + printf 'sha256:old-image\n' + return 0 + ;; + image) + return 0 + ;; + compose) + shift + ;; + *) + echo "Unexpected fake docker command: $*" >&2 + return 1 + ;; + esac + + local args=("$@") + local subcommand="" + local service="${args[${#args[@]} - 1]}" + local is_rollback="false" + local arg + + for arg in "${args[@]}"; do + case "$arg" in + build|exec|logs|ps|run|up) + if [ -z "$subcommand" ]; then + subcommand="$arg" + fi + ;; + esac + if [[ "$arg" == */letscube-deploy.*/* ]]; then + is_rollback="true" + fi + done + + if [ "$subcommand" = "ps" ]; then + case "$service" in + api) + [ -z "${FAKE_RUNNING_API:-}" ] || printf '%s\n' "$FAKE_RUNNING_API" + ;; + socket) + [ -z "${FAKE_RUNNING_SOCKET:-}" ] || printf '%s\n' "$FAKE_RUNNING_SOCKET" + ;; + nginx) + [ -z "${FAKE_RUNNING_NGINX:-}" ] || printf '%s\n' "$FAKE_RUNNING_NGINX" + ;; + esac + return 0 + fi + + if [ "$subcommand" = "up" ] && [ "$service" = "socket" ]; then + if [ "$is_rollback" = "true" ]; then + { + printf 'APP_IMAGE_TAG=%s\n' "${APP_IMAGE_TAG:-}" + printf '%s\n' "${args[*]}" + } > "$FAKE_ROLLBACK_MARKER" + elif [ "${FAKE_FAIL_CURRENT_SOCKET:-false}" = "true" ]; then + return 1 + fi + fi +} + +case "$(basename -- "$0")" in + git) + fake_git "$@" + exit + ;; + docker) + fake_docker "$@" + exit + ;; +esac + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +DEPLOY_SCRIPT="$SCRIPT_DIR/deploy.sh" +CLASSIFIER_SCRIPT="$SCRIPT_DIR/classify-deploy-target.sh" +TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/letscube-deploy-test.XXXXXX")" + +cleanup() { + rm -rf -- "$TEST_ROOT" +} + +trap cleanup EXIT + +FAKE_BIN="$TEST_ROOT/bin" +mkdir -p "$FAKE_BIN" +ln -s "$SCRIPT_DIR/test-deploy.sh" "$FAKE_BIN/git" +ln -s "$SCRIPT_DIR/test-deploy.sh" "$FAKE_BIN/docker" + +prepare_app() { + local app_dir="$1" + + mkdir -p "$app_dir/scripts" + cp "$DEPLOY_SCRIPT" "$app_dir/scripts/deploy.sh" + cp "$CLASSIFIER_SCRIPT" "$app_dir/scripts/classify-deploy-target.sh" +} + +assert_contains() { + local value="$1" + local expected="$2" + + if [[ "$value" != *"$expected"* ]]; then + echo "Expected output to contain: $expected" >&2 + exit 1 + fi +} + +command_line() { + local file="$1" + local expected="$2" + local number=0 + local line + + while IFS= read -r line; do + number=$((number + 1)) + if [[ "$line" == *"$expected"* ]]; then + printf '%s\n' "$number" + return 0 + fi + done < "$file" + + echo "Missing command containing: $expected" >&2 + return 1 +} + +run_bootstrap_test() { + local scenario="$TEST_ROOT/bootstrap" + local app_dir="$scenario/app" + local log="$scenario/commands.log" + local output="$scenario/output.log" + local git_state="$scenario/git-state" + local rollback_marker="$scenario/rollback" + local commit="1111111111111111111111111111111111111111" + local backing_line + local migration_line + local socket_line + local api_line + + mkdir -p "$scenario/tmp" + prepare_app "$app_dir" + + PATH="$FAKE_BIN:$PATH" \ + APP_DIR="$app_dir" \ + DEPLOY_TEST_LOG="$log" \ + DEPLOY_WAIT_TIMEOUT_SECONDS=17 \ + ENV_FILE=.env.prod \ + FAKE_CURRENT_COMMIT="$commit" \ + FAKE_GIT_STATE="$git_state" \ + FAKE_PREVIOUS_COMMIT="$commit" \ + FAKE_ROLLBACK_MARKER="$rollback_marker" \ + TMPDIR="$scenario/tmp" \ + bash "$app_dir/scripts/deploy.sh" > "$output" 2>&1 + + assert_contains "$(<"$output")" 'Missing runtime services: api socket nginx; deploying all.' + assert_contains "$(<"$output")" '(target: all)' + + backing_line="$(command_line "$log" 'up -d --no-recreate --wait --wait-timeout 17 mongo postgres redis')" + migration_line="$(command_line "$log" 'run --rm --no-deps migrate')" + socket_line="$(command_line "$log" 'up -d --no-deps --wait --wait-timeout 17 socket')" + api_line="$(command_line "$log" 'up -d --no-deps --wait --wait-timeout 17 api')" + + if [ "$backing_line" -ge "$migration_line" ]; then + echo 'Migrations ran before backing services became ready.' >&2 + exit 1 + fi + if [ "$socket_line" -ge "$api_line" ]; then + echo 'Bootstrap did not deploy socket before API.' >&2 + exit 1 + fi +} + +run_rollback_test() { + local scenario="$TEST_ROOT/rollback" + local app_dir="$scenario/app" + local log="$scenario/commands.log" + local output="$scenario/output.log" + local git_state="$scenario/git-state" + local rollback_marker="$scenario/rollback" + local previous_commit="1111111111111111111111111111111111111111" + local current_commit="2222222222222222222222222222222222222222" + local rollback_details + + mkdir -p "$scenario/tmp" + prepare_app "$app_dir" + + if PATH="$FAKE_BIN:$PATH" \ + APP_DIR="$app_dir" \ + DEPLOY_TEST_LOG="$log" \ + DEPLOY_WAIT_TIMEOUT_SECONDS=17 \ + ENV_FILE=.env.prod \ + FAKE_CHANGED_PATH=server/index.js \ + FAKE_CURRENT_COMMIT="$current_commit" \ + FAKE_FAIL_CURRENT_SOCKET=true \ + FAKE_GIT_STATE="$git_state" \ + FAKE_PREVIOUS_COMMIT="$previous_commit" \ + FAKE_ROLLBACK_MARKER="$rollback_marker" \ + FAKE_RUNNING_API=api-container \ + FAKE_RUNNING_NGINX=nginx-container \ + FAKE_RUNNING_SOCKET=socket-container \ + TMPDIR="$scenario/tmp" \ + bash "$app_dir/scripts/deploy.sh" > "$output" 2>&1; then + echo 'Expected the simulated socket deployment to fail.' >&2 + exit 1 + fi + + if [ ! -f "$rollback_marker" ]; then + echo 'Rollback did not recreate the socket service.' >&2 + exit 1 + fi + + rollback_details="$(<"$rollback_marker")" + assert_contains "$rollback_details" "--project-directory $app_dir" + assert_contains "$rollback_details" '/letscube-deploy.' + assert_contains "$rollback_details" 'APP_IMAGE_TAG=rollback-socket-111111111111-' + assert_contains "$(<"$log")" "git show $previous_commit:compose.yml" + assert_contains "$(<"$log")" "git show $previous_commit:compose.prod.yml" +} + +run_bootstrap_test +run_rollback_test + +echo 'Deploy script checks passed.' diff --git a/server/health.js b/server/health.js index e6d4782..e10c3a8 100644 --- a/server/health.js +++ b/server/health.js @@ -26,14 +26,30 @@ const createHealthReporter = ({ now = () => new Date(), uptime = () => process.uptime(), }) => async () => { - const checkResults = await Promise.all(Object.entries(checks).map(async ([name, check]) => ( - [name, await runCheck(check, checkTimeoutMs) ? 'ok' : 'error'] + const checkResults = await Promise.all(Object.entries(checks).map(async ([name, value]) => { + const descriptor = typeof value === 'function' ? { check: value } : value; + const required = descriptor.required !== false; + const status = await runCheck(descriptor.check, checkTimeoutMs) ? 'ok' : 'error'; + return { name, required, status }; + })); + const normalizedChecks = Object.fromEntries(checkResults.map(({ name, status }) => ( + [name, status] ))); - const normalizedChecks = Object.fromEntries(checkResults); - const healthy = Object.values(normalizedChecks).every((status) => status === 'ok'); + const requiredCheckFailed = checkResults.some(({ required, status }) => ( + required && status === 'error' + )); + const optionalCheckFailed = checkResults.some(({ required, status }) => ( + !required && status === 'error' + )); + let status = 'ok'; + if (requiredCheckFailed) { + status = 'error'; + } else if (optionalCheckFailed) { + status = 'degraded'; + } return { - status: healthy ? 'ok' : 'error', + status, service, timestamp: now().toISOString(), uptimeSeconds: Math.floor(uptime()), @@ -44,7 +60,7 @@ const createHealthReporter = ({ const createHealthHandler = (reportHealth) => async (_req, res) => { const report = await reportHealth(); - res.statusCode = report.status === 'ok' ? 200 : 503; + res.statusCode = report.status === 'error' ? 503 : 200; res.setHeader('Cache-Control', 'no-store'); res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify(report)); diff --git a/server/health.test.js b/server/health.test.js index 0a074b3..48880b2 100644 --- a/server/health.test.js +++ b/server/health.test.js @@ -54,6 +54,57 @@ describe('health reporting', () => { }); }); + it('reports degraded when only an optional dependency fails', async () => { + const reportHealth = createHealthReporter({ + service: 'socket', + checks: { + mongodb: () => true, + postgres: { + required: false, + check: () => false, + }, + redis: () => true, + }, + now: () => fixedTime, + uptime: () => 5, + }); + + await expect(reportHealth()).resolves.toEqual({ + status: 'degraded', + service: 'socket', + timestamp: fixedTime.toISOString(), + uptimeSeconds: 5, + checks: { + mongodb: 'ok', + postgres: 'error', + redis: 'ok', + }, + }); + }); + + it('reports error when a required dependency fails alongside an optional one', async () => { + const reportHealth = createHealthReporter({ + service: 'socket', + checks: { + mongodb: () => false, + postgres: { + required: false, + check: () => false, + }, + }, + now: () => fixedTime, + uptime: () => 5, + }); + + await expect(reportHealth()).resolves.toMatchObject({ + status: 'error', + checks: { + mongodb: 'error', + postgres: 'error', + }, + }); + }); + it('returns 503 and disables caching when a health check fails', async () => { const report = { status: 'error', @@ -76,4 +127,22 @@ describe('health reporting', () => { ); expect(response.end).toHaveBeenCalledWith(JSON.stringify(report)); }); + + it('returns 200 for degraded health', async () => { + const report = { + status: 'degraded', + service: 'api', + checks: { postgres: 'error' }, + }; + const response = { + setHeader: jest.fn(), + end: jest.fn(), + }; + const handler = createHealthHandler(jest.fn().mockResolvedValue(report)); + + await handler({}, response); + + expect(response.statusCode).toBe(200); + expect(response.end).toHaveBeenCalledWith(JSON.stringify(report)); + }); }); diff --git a/server/index.js b/server/index.js index 6deeb45..95d74fa 100644 --- a/server/index.js +++ b/server/index.js @@ -33,11 +33,14 @@ const init = async () => { service: 'api', checks: { mongodb: () => mongoose.connection.readyState === 1, - postgres: async () => { - if (config.postgres.enabled) { - await pool.query('SELECT 1'); - } - return true; + postgres: { + required: false, + check: async () => { + if (config.postgres.enabled) { + await pool.query('SELECT 1'); + } + return true; + }, }, }, }); diff --git a/server/models/room.js b/server/models/room.js index d9cb67f..366f6e8 100644 --- a/server/models/room.js +++ b/server/models/room.js @@ -18,6 +18,7 @@ const Result = new mongoose.Schema({ required: true, }, penalties: Object, + submissionId: String, }, { timestamps: true, }); diff --git a/server/models/room.test.js b/server/models/room.test.js index a28039a..131ad2b 100644 --- a/server/models/room.test.js +++ b/server/models/room.test.js @@ -52,6 +52,26 @@ describe('room security helpers', () => { expect(room.expireAt.getTime()).toBeLessThanOrEqual(Date.now() + 10 * 60 * 1000); }); + it('stores an optional result submission id', () => { + const room = RoomModel.hydrate({ + _id: '507f1f77bcf86cd799439011', + name: 'Practice room', + attempts: [{ + id: 0, + scrambles: ['R U R\''], + results: { + 1234: { + time: 12000, + penalties: {}, + submissionId: 'submission-123', + }, + }, + }], + }); + + expect(room.attempts[0].results.get('1234').submissionId).toBe('submission-123'); + }); + it('collects only the changed result for incremental PostgreSQL writes', () => { const room = RoomModel.hydrate({ _id: '507f1f77bcf86cd799439011', diff --git a/server/runtimeConfig.js b/server/runtimeConfig.js index 296228f..16ed0f2 100644 --- a/server/runtimeConfig.js +++ b/server/runtimeConfig.js @@ -94,6 +94,9 @@ module.exports = { hashSecret: process.env.METRICS_HASH_SECRET || authSecret, retentionDays: parsePositiveInteger(process.env.METRICS_RETENTION_DAYS, 90), }, + grandPrix: { + enabled: process.env.GRAND_PRIX_ENABLED === 'true', + }, postgres: { enabled: process.env.POSTGRES_ENABLED !== 'false', connectionString: process.env.DATABASE_URL, diff --git a/server/runtimeConfig.test.js b/server/runtimeConfig.test.js new file mode 100644 index 0000000..c18e344 --- /dev/null +++ b/server/runtimeConfig.test.js @@ -0,0 +1,32 @@ +/* eslint-env jest */ + +// eslint-disable-next-line global-require +const loadRuntimeConfig = () => require('./runtimeConfig'); + +describe('Grand Prix runtime configuration', () => { + const originalValue = process.env.GRAND_PRIX_ENABLED; + + afterEach(() => { + if (originalValue === undefined) { + delete process.env.GRAND_PRIX_ENABLED; + } else { + process.env.GRAND_PRIX_ENABLED = originalValue; + } + jest.resetModules(); + }); + + it('is disabled by default', () => { + delete process.env.GRAND_PRIX_ENABLED; + + expect(loadRuntimeConfig().grandPrix.enabled).toBe(false); + }); + + it('is enabled only by the explicit lowercase true value', () => { + process.env.GRAND_PRIX_ENABLED = 'true'; + expect(loadRuntimeConfig().grandPrix.enabled).toBe(true); + + jest.resetModules(); + process.env.GRAND_PRIX_ENABLED = 'TRUE'; + expect(loadRuntimeConfig().grandPrix.enabled).toBe(false); + }); +}); diff --git a/server/socket/index.js b/server/socket/index.js index 68266bb..dc4ebff 100644 --- a/server/socket/index.js +++ b/server/socket/index.js @@ -70,11 +70,14 @@ const init = async () => { service: 'socket', checks: { mongodb: () => mongoose.connection.readyState === 1, - postgres: async () => { - if (config.postgres.enabled) { - await pool.query('SELECT 1'); - } - return true; + postgres: { + required: false, + check: async () => { + if (config.postgres.enabled) { + await pool.query('SELECT 1'); + } + return true; + }, }, redis: async () => pubClient.status === 'ready' && subClient.status === 'ready' diff --git a/server/socket/lib/reconnectGrace.js b/server/socket/lib/reconnectGrace.js index a755364..162b2ed 100644 --- a/server/socket/lib/reconnectGrace.js +++ b/server/socket/lib/reconnectGrace.js @@ -19,6 +19,11 @@ const createReconnectGrace = ({ clearTimer(timer); timers.delete(key); } + + const finalization = finalizationQueues.get(roomId.toString()); + return finalization + ? finalization.then(() => true, () => true) + : Promise.resolve(false); }; const enqueueFinalization = (departure, requireInactive) => { diff --git a/server/socket/lib/reconnectGrace.test.js b/server/socket/lib/reconnectGrace.test.js index 2cb2b27..0d5cf8b 100644 --- a/server/socket/lib/reconnectGrace.test.js +++ b/server/socket/lib/reconnectGrace.test.js @@ -70,6 +70,36 @@ describe('reconnect grace', () => { expect(finalizeDeparture).not.toHaveBeenCalled(); }); + it('lets a reconnect wait for cleanup already in progress', async () => { + let finishFinalization; + let markFinalizationStarted; + const finalizationStarted = new Promise((resolve) => { + markFinalizationStarted = resolve; + }); + finalizeDeparture.mockImplementationOnce(() => new Promise((resolve) => { + markFinalizationStarted(); + finishFinalization = resolve; + })); + const manager = createManager(); + + manager.schedule(departure); + jest.runAllTimers(); + await finalizationStarted; + + let reconnectReleased = false; + const reconnect = manager.cancel(departure.roomId, departure.userId) + .then((waited) => { + reconnectReleased = true; + return waited; + }); + await Promise.resolve(); + + expect(reconnectReleased).toBe(false); + + finishFinalization(true); + await expect(reconnect).resolves.toBe(true); + }); + it('finalizes an explicit departure without waiting for socket inactivity', async () => { const manager = createManager(); diff --git a/server/socket/lib/resultSubmission.js b/server/socket/lib/resultSubmission.js new file mode 100644 index 0000000..4a1c4d9 --- /dev/null +++ b/server/socket/lib/resultSubmission.js @@ -0,0 +1,219 @@ +const MAX_ATTEMPT_KEY_LENGTH = 128; +const MAX_SUBMISSION_ID_LENGTH = 128; + +class ResultSubmissionError extends Error { + constructor(statusCode, message, retryable, cause) { + super(message); + this.name = 'ResultSubmissionError'; + this.statusCode = statusCode; + this.retryable = retryable; + this.cause = cause; + } + + toResponse() { + return { + statusCode: this.statusCode, + message: this.message, + retryable: this.retryable, + }; + } +} + +const submissionError = (statusCode, message, retryable = false, cause) => ( + new ResultSubmissionError(statusCode, message, retryable, cause) +); + +const normalizedPenalties = (penalties = {}) => ({ + AUF: !!penalties.AUF, + DNF: !!penalties.DNF, + inspection: !!penalties.inspection, +}); + +const resultsMatch = (existingResult, incomingResult) => ( + existingResult.time === incomingResult.time + && Object.entries(normalizedPenalties(existingResult.penalties)).every(([key, value]) => ( + normalizedPenalties(incomingResult.penalties)[key] === value + )) +); + +const validateSubmission = ({ + attemptId, attemptKey, result, submissionId, +}) => { + if (!Number.isInteger(attemptId) || attemptId < 0) { + throw submissionError(400, 'Invalid ID for attempt submission'); + } + + if (!result || !Number.isFinite(result.time)) { + throw submissionError(400, 'Invalid result submission'); + } + + if (result.penalties !== undefined + && result.penalties !== null + && (typeof result.penalties !== 'object' || Array.isArray(result.penalties))) { + throw submissionError(400, 'Invalid result penalties'); + } + + if (submissionId !== undefined + && (typeof submissionId !== 'string' + || submissionId.trim().length === 0 + || submissionId.length > MAX_SUBMISSION_ID_LENGTH)) { + throw submissionError(400, 'Invalid result submission ID'); + } + + if (attemptKey !== undefined + && (typeof attemptKey !== 'string' + || attemptKey.trim().length === 0 + || attemptKey.length > MAX_ATTEMPT_KEY_LENGTH)) { + throw submissionError(400, 'Invalid result attempt key'); + } + + return { + attemptId, + attemptKey: attemptKey === undefined ? undefined : attemptKey.trim(), + result: { + time: result.time, + penalties: { ...(result.penalties || {}) }, + }, + submissionId: submissionId === undefined ? undefined : submissionId.trim(), + }; +}; + +const createResultSubmissionService = ({ + advanceRoom = async (room) => room, + fetchRoom, +}) => { + const queues = new Map(); + + const enqueue = (key, task) => { + const previous = queues.get(key) || Promise.resolve(); + const current = previous.catch(() => {}).then(task); + queues.set(key, current); + current.finally(() => { + if (queues.get(key) === current) { + queues.delete(key); + } + }).catch(() => {}); + return current; + }; + + const submit = ({ + roomId, + userId, + attemptId, + attemptKey, + result, + submissionId, + onSaved = () => {}, + }) => { + const submission = validateSubmission({ + attemptId, attemptKey, result, submissionId, + }); + const userKey = userId.toString(); + const queueKey = roomId.toString(); + + return enqueue(queueKey, async () => { + try { + const room = await fetchRoom(roomId); + if (!room) { + throw submissionError(404, 'Could not find room for result submission'); + } + + if (!room.inRoom.get(userKey) || room.banned.get(userKey)) { + throw submissionError(403, 'Not authorized to submit a result to this room'); + } + + const attempt = room.attempts[submission.attemptId]; + if (!attempt) { + throw submissionError(400, 'Invalid ID for attempt submission'); + } + if (submission.attemptKey !== undefined + && (!attempt._id || attempt._id.toString() !== submission.attemptKey)) { + throw submissionError(409, 'Attempt changed before result submission'); + } + + if (room.type === 'grand_prix') { + submission.result.penalties.DNF = submission.result.penalties.DNF + || submission.attemptId < room.attempts.length - 1; + } + + const previousResult = attempt.results.get(userKey); + let outcome; + if (previousResult) { + if (submission.submissionId + && previousResult.submissionId === submission.submissionId) { + outcome = { + room, + result: previousResult, + status: 'duplicate', + submissionId: submission.submissionId, + }; + } else if (submission.submissionId + && !previousResult.submissionId + && resultsMatch(previousResult, submission.result)) { + previousResult.submissionId = submission.submissionId; + if (typeof room.markModified === 'function') { + room.markModified(`attempts.${submission.attemptId}.results.${userKey}`); + } + const savedRoom = await room.save(); + outcome = { + room: savedRoom, + result: savedRoom.attempts[submission.attemptId].results.get(userKey), + status: 'duplicate', + submissionId: submission.submissionId, + }; + } else { + throw submissionError(409, 'A result already exists for this attempt'); + } + } else { + const storedResult = { + ...submission.result, + ...(submission.submissionId ? { submissionId: submission.submissionId } : {}), + }; + attempt.results.set(userKey, storedResult); + if (submission.attemptId === room.attempts.length - 1) { + room.waitingFor.set(userKey, false); + } + + const savedRoom = await room.save(); + outcome = { + room: savedRoom, + result: savedRoom.attempts[submission.attemptId].results.get(userKey), + status: 'saved', + submissionId: submission.submissionId, + }; + await onSaved(outcome); + } + + if (typeof outcome.room.doneWithScramble === 'function' + && outcome.room.doneWithScramble()) { + try { + outcome.room = await advanceRoom(outcome.room); + } catch (err) { + throw submissionError( + 503, + 'Result saved, but failed to advance the room', + true, + err, + ); + } + } + + return outcome; + } catch (err) { + if (err instanceof ResultSubmissionError) { + throw err; + } + throw submissionError(503, 'Failed to save result', true, err); + } + }); + }; + + return { submit }; +}; + +module.exports = { + createResultSubmissionService, + ResultSubmissionError, + resultsMatch, + validateSubmission, +}; diff --git a/server/socket/lib/resultSubmission.test.js b/server/socket/lib/resultSubmission.test.js new file mode 100644 index 0000000..6ac809b --- /dev/null +++ b/server/socket/lib/resultSubmission.test.js @@ -0,0 +1,397 @@ +/* eslint-env jest */ + +const { + createResultSubmissionService, + ResultSubmissionError, + validateSubmission, +} = require('./resultSubmission'); + +const makeRoom = ({ + attempts = 1, + existingResult, + inRoom = true, + banned = false, + type = 'normal', +} = {}) => { + const room = { + _id: 'room-123', + type, + inRoom: new Map([['42', inRoom], ['43', inRoom]]), + banned: new Map([['42', banned], ['43', banned]]), + waitingFor: new Map([['42', true], ['43', true]]), + attempts: Array.from({ length: attempts }, (_, id) => ({ + _id: `attempt-${id}`, + id, + results: new Map(), + })), + }; + if (existingResult) { + room.attempts[0].results.set('42', existingResult); + } + room.save = jest.fn().mockImplementation(async () => room); + return room; +}; + +const submit = (service, overrides = {}) => service.submit({ + roomId: 'room-123', + userId: 42, + attemptId: 0, + result: { + time: 12345, + penalties: { AUF: false }, + }, + submissionId: 'submission-123', + ...overrides, +}); + +describe('result submission validation', () => { + it('accepts legacy submissions without an id', () => { + expect(validateSubmission({ + attemptId: 0, + result: { time: 1234, penalties: {} }, + })).toEqual({ + attemptId: 0, + attemptKey: undefined, + result: { time: 1234, penalties: {} }, + submissionId: undefined, + }); + }); + + it.each([ + [{ attemptId: -1, result: { time: 1234 } }, 'Invalid ID for attempt submission'], + [{ attemptId: 0, result: { time: Number.NaN } }, 'Invalid result submission'], + [{ attemptId: 0, result: { time: 1234 }, submissionId: '' }, 'Invalid result submission ID'], + [{ attemptId: 0, attemptKey: 123, result: { time: 1234 } }, 'Invalid result attempt key'], + ])('rejects invalid submission payloads', (payload, message) => { + expect(() => validateSubmission(payload)).toThrow(message); + }); +}); + +describe('createResultSubmissionService', () => { + it('persists a result before reporting it as saved', async () => { + const room = makeRoom(); + const service = createResultSubmissionService({ + fetchRoom: jest.fn().mockResolvedValue(room), + }); + + await expect(submit(service)).resolves.toMatchObject({ + room, + status: 'saved', + submissionId: 'submission-123', + }); + expect(room.save).toHaveBeenCalledTimes(1); + expect(room.waitingFor.get('42')).toBe(false); + expect(room.attempts[0].results.get('42')).toMatchObject({ + time: 12345, + penalties: { AUF: false }, + submissionId: 'submission-123', + }); + }); + + it('accepts a matching immutable attempt key', async () => { + const room = makeRoom(); + const service = createResultSubmissionService({ + fetchRoom: jest.fn().mockResolvedValue(room), + }); + + await expect(submit(service, { attemptKey: 'attempt-0' })).resolves.toMatchObject({ + status: 'saved', + }); + }); + + it('rejects a reused attempt number when the immutable attempt key changed', async () => { + const room = makeRoom(); + const service = createResultSubmissionService({ + fetchRoom: jest.fn().mockResolvedValue(room), + }); + + await expect(submit(service, { attemptKey: 'attempt-before-event-reset' })) + .rejects.toMatchObject({ + statusCode: 409, + retryable: false, + }); + expect(room.save).not.toHaveBeenCalled(); + }); + + it('treats the same submission id as a duplicate without saving again', async () => { + const existingResult = { + time: 12345, + penalties: {}, + submissionId: 'submission-123', + }; + const room = makeRoom({ existingResult }); + const service = createResultSubmissionService({ + fetchRoom: jest.fn().mockResolvedValue(room), + }); + + await expect(submit(service)).resolves.toEqual({ + room, + result: existingResult, + status: 'duplicate', + submissionId: 'submission-123', + }); + expect(room.save).not.toHaveBeenCalled(); + }); + + it('adopts an id for an identical result saved by an older server', async () => { + const existingResult = { + time: 12345, + penalties: { DNF: false, inspection: false }, + }; + const room = makeRoom({ existingResult }); + room.markModified = jest.fn(); + const service = createResultSubmissionService({ + fetchRoom: jest.fn().mockResolvedValue(room), + }); + + await expect(submit(service, { + result: { + time: 12345, + penalties: { AUF: false }, + }, + })).resolves.toMatchObject({ + status: 'duplicate', + submissionId: 'submission-123', + }); + expect(existingResult.submissionId).toBe('submission-123'); + expect(room.markModified).toHaveBeenCalledWith('attempts.0.results.42'); + expect(room.save).toHaveBeenCalledTimes(1); + }); + + it('rejects a mismatched result saved by an older server', async () => { + const existingResult = { + time: 11111, + penalties: {}, + }; + const room = makeRoom({ existingResult }); + const service = createResultSubmissionService({ + fetchRoom: jest.fn().mockResolvedValue(room), + }); + + await expect(submit(service)).rejects.toMatchObject({ + statusCode: 409, + retryable: false, + }); + expect(existingResult.submissionId).toBeUndefined(); + expect(room.save).not.toHaveBeenCalled(); + }); + + it('rejects a conflicting result without overwriting it', async () => { + const existingResult = { + time: 11111, + penalties: {}, + submissionId: 'older-submission', + }; + const room = makeRoom({ existingResult }); + const service = createResultSubmissionService({ + fetchRoom: jest.fn().mockResolvedValue(room), + }); + + await expect(submit(service)).rejects.toMatchObject({ + statusCode: 409, + retryable: false, + }); + expect(room.attempts[0].results.get('42')).toBe(existingResult); + expect(room.save).not.toHaveBeenCalled(); + }); + + it('allows submission to an original attempt after the room advances', async () => { + const room = makeRoom({ attempts: 2 }); + const service = createResultSubmissionService({ + fetchRoom: jest.fn().mockResolvedValue(room), + }); + + await expect(submit(service, { attemptId: 0 })).resolves.toMatchObject({ + status: 'saved', + }); + expect(room.attempts[0].results.has('42')).toBe(true); + expect(room.attempts[1].results.has('42')).toBe(false); + expect(room.waitingFor.get('42')).toBe(true); + }); + + it('serializes duplicate submissions in one room', async () => { + const room = makeRoom(); + let finishSave; + let notifySaveStarted; + const saveStarted = new Promise((resolve) => { + notifySaveStarted = resolve; + }); + room.save.mockImplementation(() => new Promise((resolve) => { + finishSave = () => resolve(room); + notifySaveStarted(); + })); + const fetchRoom = jest.fn().mockResolvedValue(room); + const service = createResultSubmissionService({ fetchRoom }); + + const first = submit(service); + await saveStarted; + const second = submit(service); + await Promise.resolve(); + + expect(fetchRoom).toHaveBeenCalledTimes(1); + finishSave(); + + await expect(first).resolves.toMatchObject({ status: 'saved' }); + await expect(second).resolves.toMatchObject({ status: 'duplicate' }); + expect(fetchRoom).toHaveBeenCalledTimes(2); + expect(room.save).toHaveBeenCalledTimes(1); + }); + + it('serializes simultaneous submissions from different users in one room', async () => { + const room = makeRoom(); + let finishFirstSave; + let notifyFirstSaveStarted; + const firstSaveStarted = new Promise((resolve) => { + notifyFirstSaveStarted = resolve; + }); + room.save + .mockImplementationOnce(() => new Promise((resolve) => { + finishFirstSave = () => resolve(room); + notifyFirstSaveStarted(); + })) + .mockImplementation(async () => room); + const fetchRoom = jest.fn().mockResolvedValue(room); + const service = createResultSubmissionService({ fetchRoom }); + + const first = submit(service); + await firstSaveStarted; + const second = submit(service, { + userId: 43, + submissionId: 'submission-456', + result: { time: 23456, penalties: {} }, + }); + await Promise.resolve(); + + expect(fetchRoom).toHaveBeenCalledTimes(1); + finishFirstSave(); + + await expect(first).resolves.toMatchObject({ status: 'saved' }); + await expect(second).resolves.toMatchObject({ status: 'saved' }); + expect(fetchRoom).toHaveBeenCalledTimes(2); + expect(room.save).toHaveBeenCalledTimes(2); + expect(room.attempts[0].results.has('42')).toBe(true); + expect(room.attempts[0].results.has('43')).toBe(true); + expect(room.waitingFor.get('42')).toBe(false); + expect(room.waitingFor.get('43')).toBe(false); + }); + + it('repairs failed room advancement on a duplicate retry without republishing', async () => { + const room = makeRoom(); + room.doneWithScramble = jest.fn(() => ( + room.attempts.length === 1 && room.waitingFor.get('42') === false + )); + const advanceError = new Error('new attempt save failed'); + const advanceRoom = jest.fn() + .mockRejectedValueOnce(advanceError) + .mockImplementation(async (currentRoom) => { + currentRoom.attempts.push({ + _id: 'attempt-1', + id: 1, + results: new Map(), + }); + currentRoom.waitingFor.set('42', true); + return currentRoom; + }); + const onSaved = jest.fn(); + const service = createResultSubmissionService({ + advanceRoom, + fetchRoom: jest.fn().mockResolvedValue(room), + }); + + await expect(submit(service, { onSaved })).rejects.toMatchObject({ + statusCode: 503, + retryable: true, + cause: advanceError, + }); + expect(room.attempts[0].results.get('42')).toMatchObject({ + submissionId: 'submission-123', + }); + expect(onSaved).toHaveBeenCalledTimes(1); + + await expect(submit(service, { onSaved })).resolves.toMatchObject({ + status: 'duplicate', + }); + expect(advanceRoom).toHaveBeenCalledTimes(2); + expect(room.attempts).toHaveLength(2); + expect(onSaved).toHaveBeenCalledTimes(1); + }); + + it('does not advance twice when duplicate retries arrive concurrently', async () => { + const existingResult = { + time: 12345, + penalties: {}, + submissionId: 'submission-123', + }; + const room = makeRoom({ existingResult }); + room.waitingFor.set('42', false); + room.doneWithScramble = jest.fn(() => room.attempts.length === 1); + let finishAdvance; + let notifyAdvanceStarted; + const advanceStarted = new Promise((resolve) => { + notifyAdvanceStarted = resolve; + }); + const advanceRoom = jest.fn(() => new Promise((resolve) => { + notifyAdvanceStarted(); + finishAdvance = () => { + room.attempts.push({ + _id: 'attempt-1', + id: 1, + results: new Map(), + }); + resolve(room); + }; + })); + const fetchRoom = jest.fn().mockResolvedValue(room); + const service = createResultSubmissionService({ advanceRoom, fetchRoom }); + + const first = submit(service); + await advanceStarted; + const second = submit(service); + await Promise.resolve(); + + expect(fetchRoom).toHaveBeenCalledTimes(1); + finishAdvance(); + + await expect(first).resolves.toMatchObject({ status: 'duplicate' }); + await expect(second).resolves.toMatchObject({ status: 'duplicate' }); + expect(fetchRoom).toHaveBeenCalledTimes(2); + expect(advanceRoom).toHaveBeenCalledTimes(1); + expect(room.attempts).toHaveLength(2); + }); + + it.each([ + [{ inRoom: false }, 403, false], + [{ banned: true }, 403, false], + [{ attempts: 0 }, 400, false], + ])('rejects invalid room state', async (roomOptions, statusCode, retryable) => { + const room = makeRoom(roomOptions); + const service = createResultSubmissionService({ + fetchRoom: jest.fn().mockResolvedValue(room), + }); + + await expect(submit(service)).rejects.toMatchObject({ statusCode, retryable }); + expect(room.save).not.toHaveBeenCalled(); + }); + + it('marks database failures as retryable without exposing their details', async () => { + const databaseError = new Error('private connection details'); + const service = createResultSubmissionService({ + fetchRoom: jest.fn().mockRejectedValue(databaseError), + }); + + let failure; + try { + await submit(service); + } catch (err) { + failure = err; + } + + expect(failure).toBeInstanceOf(ResultSubmissionError); + expect(failure.toResponse()).toEqual({ + statusCode: 503, + message: 'Failed to save result', + retryable: true, + }); + expect(failure.cause).toBe(databaseError); + }); +}); diff --git a/server/socket/lib/roomAvailability.js b/server/socket/lib/roomAvailability.js new file mode 100644 index 0000000..7b09f67 --- /dev/null +++ b/server/socket/lib/roomAvailability.js @@ -0,0 +1,5 @@ +const isRoomTypeEnabled = (type, grandPrixEnabled) => ( + type !== 'grand_prix' || grandPrixEnabled +); + +module.exports = { isRoomTypeEnabled }; diff --git a/server/socket/lib/roomAvailability.test.js b/server/socket/lib/roomAvailability.test.js new file mode 100644 index 0000000..e9cacc9 --- /dev/null +++ b/server/socket/lib/roomAvailability.test.js @@ -0,0 +1,15 @@ +/* eslint-env jest */ + +const { isRoomTypeEnabled } = require('./roomAvailability'); + +describe('room type availability', () => { + it('always exposes normal rooms', () => { + expect(isRoomTypeEnabled('normal', false)).toBe(true); + expect(isRoomTypeEnabled('normal', true)).toBe(true); + }); + + it('exposes Grand Prix rooms only when explicitly enabled', () => { + expect(isRoomTypeEnabled('grand_prix', false)).toBe(false); + expect(isRoomTypeEnabled('grand_prix', true)).toBe(true); + }); +}); diff --git a/server/socket/namespaces/rooms.js b/server/socket/namespaces/rooms.js index 0047820..e61f614 100644 --- a/server/socket/namespaces/rooms.js +++ b/server/socket/namespaces/rooms.js @@ -11,8 +11,13 @@ const { Room, User } = require('../../models'); const { encodeUserRoom } = require('../utils'); const roomMap = require('../lib/roomMap'); const { canAccessRoom, canDeleteRoom } = require('../lib/roomAuthorization'); +const { isRoomTypeEnabled: checkRoomTypeEnabled } = require('../lib/roomAvailability'); const { removeUserFromRoomSockets } = require('../lib/roomSockets'); const { createReconnectGrace } = require('../lib/reconnectGrace'); +const { + createResultSubmissionService, + ResultSubmissionError, +} = require('../lib/resultSubmission'); const { createSafeSocketHandler, optionalAcknowledgment, @@ -37,12 +42,14 @@ const roomMask = (room) => ({ // Data for people in room const joinRoomMask = _.partial(_.pick, _, privateRoomKeys); +const isRoomTypeEnabled = (type) => checkRoomTypeEnabled(type, config.grandPrix.enabled); const fetchRoom = (id) => Room.findById({ _id: id }).populate('users').populate('admin').populate('owner'); const getRooms = (userId) => Room.find().populate('users').populate('admin').populate('owner') .then((rooms) => rooms.filter((room) => ( - userId ? !room.banned.get(userId.toString()) : true + isRoomTypeEnabled(room.type) + && (userId ? !room.banned.get(userId.toString()) : true) )).map(roomMask)); const roomTimerObj = {}; @@ -80,20 +87,33 @@ module.exports = (io, middlewares) => { }))); } - function sendNewScramble(room) { - return room.newAttempt().then((r) => { - logger.debug('Sending new scramble to room', { roomId: room.id }); - ns().in(room.accessCode).emit(Protocol.NEW_ATTEMPT, { - waitingFor: r.waitingFor, - attempt: r.attempts[r.attempts.length - 1], - }); + async function createAndSendNewScramble(room) { + const updatedRoom = await room.newAttempt(); + logger.debug('Sending new scramble to room', { roomId: room.id }); + ns().in(room.accessCode).emit(Protocol.NEW_ATTEMPT, { + waitingFor: updatedRoom.waitingFor, + attempt: updatedRoom.attempts[updatedRoom.attempts.length - 1], + }); + return updatedRoom; + } - return r; - }).catch((err) => { + function sendNewScramble(room) { + return createAndSendNewScramble(room).catch((err) => { logger.error(err); }); } + const resultSubmissions = createResultSubmissionService({ + advanceRoom: createAndSendNewScramble, + fetchRoom, + }); + + function sendGlobalRoomUpdate(room) { + if (isRoomTypeEnabled(room.type)) { + ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(room)); + } + } + async function finalizeUserDeparture({ roomId, userId, connectionId, leaveReason, }) { @@ -104,6 +124,11 @@ module.exports = (io, middlewares) => { return false; } + if (leaveReason === 'disconnect' + && await hasActiveSocketsForUserRoom(userId, roomId)) { + return false; + } + const updatedRoom = await room.dropUser({ id: userId }, (_room) => { ns().in(room.accessCode).emit(Protocol.UPDATE_ADMIN, _room.admin); }); @@ -117,11 +142,11 @@ module.exports = (io, middlewares) => { }); ns().in(updatedRoom.accessCode).emit(Protocol.USER_LEFT, userId); - ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(updatedRoom)); + sendGlobalRoomUpdate(updatedRoom); if (updatedRoom.doneWithScramble()) { logger.debug('everyone done, sending new scramble'); - sendNewScramble(updatedRoom); + await sendNewScramble(updatedRoom); } return true; @@ -192,16 +217,18 @@ module.exports = (io, middlewares) => { clearInterval(roomTimerObj[room._id]); } - Room.find({ type: 'grand_prix' }) - .then((rooms) => { - rooms.forEach(async (room) => { - if (room.startTime && Date.now() < new Date(room.startTime).getTime()) { - awaitRoomStart(room); - } else { - startTimer(room); - } + if (config.grandPrix.enabled) { + Room.find({ type: 'grand_prix' }) + .then((rooms) => { + rooms.forEach(async (room) => { + if (room.startTime && Date.now() < new Date(room.startTime).getTime()) { + awaitRoomStart(room); + } else { + startTimer(room); + } + }); }); - }); + } async function updateClientsWithUsers() { const sockets = [...await ns().adapter.allRooms([])] @@ -361,22 +388,29 @@ module.exports = (io, middlewares) => { } socket.join(encodeUserRoom(socket.userId, room._id)); - reconnectGrace.cancel(room._id, socket.userId); + const waitedForCleanup = await reconnectGrace.cancel(room._id, socket.userId); + const activeRoom = waitedForCleanup ? await fetchRoom(room._id) : room; + if (!activeRoom) { + return cb({ + statusCode: 404, + message: 'Room no longer exists', + }); + } - const r = await room.addUser(socket.user, spectating, (_room) => { + const r = await activeRoom.addUser(socket.user, spectating, (_room) => { ns().in(_room.accessCode).emit(Protocol.UPDATE_ADMIN, _room.admin); }); if (!r) { - // Join the socket to the room anyways but don't add them - socket.room = room; + // The user is already present in persisted room state. + socket.room = activeRoom; await metrics.beginRoomVisit({ - room, + room: activeRoom, userId: socket.userId, connectionId: socket.id, - activeUserCount: room.usersLength, + activeUserCount: activeRoom.usersLength, }); - return cb(null, joinRoomMask(room)); + return cb(null, joinRoomMask(activeRoom)); } socket.room = r; @@ -390,12 +424,12 @@ module.exports = (io, middlewares) => { socket.emit(Protocol.JOIN, joinRoomMask(r)); cb(null, joinRoomMask(r)); - broadcast(Protocol.USER_JOIN, socket.user); // tell everyone - ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(r)); + broadcast(Protocol.USER_JOIN, socket.user); + sendGlobalRoomUpdate(r); - if (room.doneWithScramble()) { + if (r.doneWithScramble()) { logger.debug('everyone done, sending new scramble'); - sendNewScramble(room); + sendNewScramble(r); } } @@ -423,6 +457,13 @@ module.exports = (io, middlewares) => { }); } + if (!isRoomTypeEnabled(room.type)) { + return rejectJoin('grand_prix_disabled', { + statusCode: 403, + message: 'Grand Prix rooms are disabled', + }, room); + } + if (room.private && !password) { return rejectJoin('password_required', { statusCode: 403, @@ -473,10 +514,10 @@ module.exports = (io, middlewares) => { }); } - if (options.type === 'grand_prix') { + if (options.type === 'grand_prix' && !config.grandPrix.enabled) { return acknowledgment({ statusCode: 403, - message: 'Not allowed to make a Grand Prix Room', + message: 'Grand Prix rooms are disabled', }); } @@ -594,51 +635,89 @@ module.exports = (io, middlewares) => { } }); - on(Protocol.SUBMIT_RESULT, async ({ id, result }) => { - if (!socket.user || !socket.roomId) { - return; - } - - try { - if (!socket.room.attempts[id]) { - socket.emit(Protocol.ERROR, { - statusCode: 400, + on(Protocol.SUBMIT_RESULT, async (payload = {}, cb) => { + const acknowledgment = optionalAcknowledgment(cb); + const rejectSubmission = (error) => { + if (typeof cb === 'function') { + return acknowledgment(error); + } + if (socket.connected) { + return socket.emit(Protocol.ERROR, { + ...error, event: Protocol.SUBMIT_RESULT, - message: 'Invalid ID for attempt submission', }); - return; - } - - if (socket.room.type === 'grand_prix') { - result.penalties.DNF = result.penalties.DNF - || id < socket.room.attempts.length - 1; } - const previousResult = socket.room.attempts[id].results.get(socket.user.id.toString()); - socket.room.attempts[id].results.set(socket.user.id.toString(), result); - socket.room.waitingFor.set(socket.user.id.toString(), false); - - const r = await socket.room.save(); + return undefined; + }; - if (!previousResult) { - await metrics.recordRoomResult({ - room: r, - userId: socket.userId, - }); - } + if (!socket.user) { + return rejectSubmission({ + statusCode: 401, + message: 'Must be logged in to submit a result', + retryable: false, + }); + } + if (!socket.roomId) { + return rejectSubmission({ + statusCode: 409, + message: 'Must rejoin the room before submitting a result', + retryable: true, + }); + } - ns().in(r.accessCode).emit(Protocol.NEW_RESULT, { - id, - result, + const { + id, attemptKey, result, submissionId, + } = payload || {}; + let submission; + try { + submission = await resultSubmissions.submit({ + roomId: socket.roomId, userId: socket.user.id, + attemptId: id, + attemptKey, + result, + submissionId, + onSaved: ({ room, result: savedResult }) => { + socket.room = room; + try { + ns().in(room.accessCode).emit(Protocol.NEW_RESULT, { + id, + result: savedResult, + userId: socket.user.id, + }); + } catch (err) { + logger.error(err); + } + Promise.resolve() + .then(() => metrics.recordRoomResult({ + room, + userId: socket.userId, + })) + .catch((err) => logger.error(err)); + }, }); - - if (r.doneWithScramble()) { - logger.debug('everyone done, sending new scramble'); - sendNewScramble(r); + } catch (err) { + if (err.cause) { + logger.error(err.cause); + } else if (!(err instanceof ResultSubmissionError)) { + logger.error(err); } - } catch (e) { - logger.error(e); + const error = err instanceof ResultSubmissionError + ? err.toResponse() + : { + statusCode: 500, + message: 'Failed to save result', + retryable: true, + }; + return rejectSubmission(error); } + + socket.room = submission.room; + acknowledgment(null, { + submissionId: submission.submissionId, + status: submission.status, + }); + return undefined; }); on(Protocol.SEND_EDIT_RESULT, async (result) => { @@ -683,6 +762,14 @@ module.exports = (io, middlewares) => { if (!checkAdmin()) { return; } + if (!isRoomTypeEnabled(socket.room.type)) { + socket.emit(Protocol.ERROR, { + statusCode: 403, + event: Protocol.REQUEST_SCRAMBLE, + message: 'Grand Prix rooms are disabled', + }); + return; + } sendNewScramble(socket.room); }); @@ -691,6 +778,14 @@ module.exports = (io, middlewares) => { if (!checkAdmin()) { return; } + if (!isRoomTypeEnabled(socket.room.type)) { + socket.emit(Protocol.ERROR, { + statusCode: 403, + event: Protocol.CHANGE_EVENT, + message: 'Grand Prix rooms are disabled', + }); + return; + } socket.room.changeEvent(event).then((r) => { ns().in(r.accessCode).emit(Protocol.UPDATE_ROOM, joinRoomMask(socket.room)); @@ -702,8 +797,13 @@ module.exports = (io, middlewares) => { return; } - if (options.type === 'grand_prix') { + if (options.type === 'grand_prix' && !config.grandPrix.enabled) { logger.error(`${socket.id} attempted to edit room to grand_prix`); + socket.emit(Protocol.ERROR, { + statusCode: 403, + event: Protocol.EDIT_ROOM, + message: 'Grand Prix rooms are disabled', + }); return; } @@ -712,7 +812,10 @@ module.exports = (io, middlewares) => { ns().in(room.accessCode).emit(Protocol.UPDATE_ROOM, joinRoomMask(room)); Room.find().then((rooms) => { - ns().emit(Protocol.UPDATE_ROOMS, rooms.map(roomMask)); + ns().emit( + Protocol.UPDATE_ROOMS, + rooms.filter((candidate) => isRoomTypeEnabled(candidate.type)).map(roomMask), + ); }); } catch (e) { (logger.error(e)); @@ -723,6 +826,14 @@ module.exports = (io, middlewares) => { if (!checkAdmin()) { return; } + if (!isRoomTypeEnabled(socket.room.type)) { + socket.emit(Protocol.ERROR, { + statusCode: 403, + event: Protocol.START_ROOM, + message: 'Grand Prix rooms are disabled', + }); + return; + } const room = await socket.room.start(); try { @@ -766,7 +877,7 @@ module.exports = (io, middlewares) => { } ns().in(socket.room.accessCode).emit(Protocol.USER_LEFT, userId); - ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(room)); + sendGlobalRoomUpdate(room); if (room.doneWithScramble()) { logger.debug('everyone done, sending new scramble'); @@ -800,7 +911,7 @@ module.exports = (io, middlewares) => { } ns().in(room.accessCode).emit(Protocol.UPDATE_ROOM, joinRoomMask(room)); - ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(room)); + sendGlobalRoomUpdate(room); if (room.doneWithScramble()) { logger.debug('everyone done, sending new scramble'); @@ -824,7 +935,7 @@ module.exports = (io, middlewares) => { } ns().in(room.accessCode).emit(Protocol.UPDATE_ROOM, joinRoomMask(room)); - ns().emit(Protocol.GLOBAL_ROOM_UPDATED, roomMask(room)); + sendGlobalRoomUpdate(room); } catch (e) { logger.error(e); } From 9a1be2e963c8437e697b9dbb22ef54e79cd0fdb4 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 06:48:11 -0700 Subject: [PATCH 04/56] Fix private room reconnect recovery Avoid duplicate joins and preserve pending solves through private-room reconnects. Remember successful room passwords on-device, make same-room joins idempotent, surface password errors, redact credentials, and cover the recovery paths. --- client/src/components/Navigation.jsx | 11 +- client/src/components/Navigation.test.jsx | 3 +- client/src/components/Room/Common/Login.jsx | 25 +- client/src/components/RoomConfigureDialog.jsx | 20 +- .../components/RoomConfigureDialog.test.jsx | 93 +++++ client/src/store/middlewares/rooms.js | 150 +++++++- client/src/store/middlewares/rooms.test.js | 361 +++++++++++++++++- client/src/store/room/actions.js | 6 + client/src/store/room/reducer.js | 7 + client/src/store/room/reducer.test.js | 11 +- client/src/store/room/roomPasswordStorage.js | 32 ++ .../store/room/roomPasswordStorage.test.js | 38 ++ server/models/room.js | 16 +- server/models/room.test.js | 81 ++++ server/socket/middlewares/logger.js | 20 +- server/socket/middlewares/logger.test.js | 45 +++ server/socket/namespaces/rooms.js | 73 +++- server/socket/namespaces/rooms.test.js | 275 +++++++++++++ 18 files changed, 1221 insertions(+), 46 deletions(-) create mode 100644 client/src/components/RoomConfigureDialog.test.jsx create mode 100644 client/src/store/room/roomPasswordStorage.js create mode 100644 client/src/store/room/roomPasswordStorage.test.js create mode 100644 server/socket/middlewares/logger.test.js create mode 100644 server/socket/namespaces/rooms.test.js diff --git a/client/src/components/Navigation.jsx b/client/src/components/Navigation.jsx index 7e84bd5..82257d1 100644 --- a/client/src/components/Navigation.jsx +++ b/client/src/components/Navigation.jsx @@ -40,6 +40,7 @@ export function GlobalPendingResultAlert({ onDiscard, onReturn, pendingResult, + privateRoom, status, userId, }) { @@ -54,9 +55,12 @@ export function GlobalPendingResultAlert({ } else if (status === 'failed') { message = `Your saved time could not be submitted: ${error.message}`; } else if (atPendingRoom) { + const rejoinMessage = privateRoom + ? 'Enter the room password below to rejoin and submit it.' + : 'Rejoin the room to submit it.'; message = canDiscardResult - ? 'Your time is still saved on this device, but its room is not currently joined. Restore access to the room or discard the saved result.' - : 'Your time may already be submitting, but its room is not currently joined. Restore access to the room to finish it.'; + ? `Your time is still saved on this device. ${rejoinMessage}` + : `Your time may already be submitting. ${rejoinMessage}`; } else { message = canDiscardResult ? `Your saved time is waiting in room ${pendingResult.roomId}. Return there to submit it, or discard it.` @@ -99,12 +103,14 @@ GlobalPendingResultAlert.propTypes = { submissionId: PropTypes.string, userId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), }).isRequired, + privateRoom: PropTypes.bool, status: PropTypes.oneOf(['pending', 'sending', 'failed']).isRequired, userId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), }; GlobalPendingResultAlert.defaultProps = { error: null, + privateRoom: false, userId: undefined, }; @@ -207,6 +213,7 @@ function Navigation({ onDiscard={() => dispatch(discardPendingResult(pendingResult.submissionId))} onReturn={() => dispatch(push(pendingRoomPath))} pendingResult={pendingResult} + privateRoom={!!room.private} status={resultSubmission.status} userId={user.id} /> diff --git a/client/src/components/Navigation.test.jsx b/client/src/components/Navigation.test.jsx index 6dd8103..984b4f0 100644 --- a/client/src/components/Navigation.test.jsx +++ b/client/src/components/Navigation.test.jsx @@ -45,13 +45,14 @@ describe('global pending result alert', () => { onDiscard={onDiscard} onReturn={jest.fn()} pendingResult={pendingResult} + privateRoom status="pending" userId={42} />, ); const actions = shallow(
{wrapper.find(Alert).prop('action')}
); - expect(wrapper.text()).toContain('room is not currently joined'); + expect(wrapper.text()).toContain('Enter the room password below'); expect(actions.find(Button)).toHaveLength(1); expect(actions.find(Button).text()).toBe('Discard saved result'); diff --git a/client/src/components/Room/Common/Login.jsx b/client/src/components/Room/Common/Login.jsx index 8650f05..abc29d1 100644 --- a/client/src/components/Room/Common/Login.jsx +++ b/client/src/components/Room/Common/Login.jsx @@ -22,21 +22,12 @@ const useStyles = makeStyles((theme) => ({ }, })); -function Login({ dispatch, roomId, loginFailed }) { +function Login({ dispatch, roomId, joinError }) { const classes = useStyles(); const [password, setPassword] = useState(''); - const [resetPassword, setResetPassword] = useState(false); - - // Forgive me lord - if (loginFailed && password && !resetPassword) { - setPassword(''); - - setResetPassword(true); - } const login = (event) => { event.preventDefault(); - setResetPassword(false); dispatch(joinRoom({ id: roomId, password, @@ -47,8 +38,8 @@ function Login({ dispatch, roomId, loginFailed }) { setPassword(event.target.value); }; - const helperText = (loginFailed && loginFailed.error) - ? loginFailed.error.message : 'Enter password to login'; + const helperText = joinError + ? joinError.message : 'Enter password to login'; return ( @@ -77,7 +68,7 @@ function Login({ dispatch, roomId, loginFailed }) { type="password" autoFocus helperText={helperText} - error={!!loginFailed} + error={!!joinError} value={password} onChange={updatePassword} /> @@ -96,18 +87,20 @@ function Login({ dispatch, roomId, loginFailed }) { Login.propTypes = { roomId: PropTypes.string, - loginFailed: PropTypes.shape(), + joinError: PropTypes.shape({ + message: PropTypes.string, + }), dispatch: PropTypes.func.isRequired, }; Login.defaultProps = { roomId: undefined, - loginFailed: null, + joinError: null, }; const mapStateToProps = (state) => ({ roomId: state.room._id, - loginFailed: state.socket.loginFailed, + joinError: state.room.joinError, }); export default connect(mapStateToProps)(Login); diff --git a/client/src/components/RoomConfigureDialog.jsx b/client/src/components/RoomConfigureDialog.jsx index 3ca0100..379f7b4 100644 --- a/client/src/components/RoomConfigureDialog.jsx +++ b/client/src/components/RoomConfigureDialog.jsx @@ -47,7 +47,7 @@ function RoomConfigureDialog({ const classes = useStyles(); const [stateName, setName] = React.useState(room.name); const [statePrivate, setPrivate] = React.useState(room.private); - const [statePassword, setPassword] = React.useState(room.private ? room.accessCode : null); + const [statePassword, setPassword] = React.useState(''); const [stateType, setType] = React.useState(room.type); const [stateRequireRI, setRequireRI] = React.useState(room.requireRevealedIdentity); const [stateStartTime, setStartTime] = React.useState(room.startTime ? ( @@ -58,7 +58,7 @@ function RoomConfigureDialog({ const handleCancel = () => { setName(room.name); setPrivate(room.private); - setPassword(null); + setPassword(''); setType(room.type); setRequireRI(room.requireRevealedIdentity); setStartTime(room.startTime ? ( @@ -73,14 +73,14 @@ function RoomConfigureDialog({ onSave({ name: stateName, private: statePrivate, - password: statePrivate ? statePassword : null, + password: statePrivate ? statePassword || undefined : null, type: stateType, requireRevealedIdentity: stateRequireRI, startTime: stateStartTime ? new Date(stateStartTime) : null, twitchChannel: stateTwitchChannel, }); - setPassword(null); + setPassword(''); onCancel(); // Close the dialog box }; @@ -132,7 +132,9 @@ function RoomConfigureDialog({ type="password" disabled={!statePrivate} onChange={handlePasswordChange} - // autoFocus + value={statePassword} + helperText={room.private && statePrivate ? 'Leave blank to keep the current password.' : undefined} + autoComplete="new-password" fullWidth /> @@ -203,7 +205,11 @@ function RoomConfigureDialog({ - @@ -216,7 +222,6 @@ RoomConfigureDialog.propTypes = { _id: PropTypes.string, name: PropTypes.string, private: PropTypes.bool, - accessCode: PropTypes.string, type: PropTypes.string, requireRevealedIdentity: PropTypes.bool, startTime: PropTypes.string, @@ -232,7 +237,6 @@ RoomConfigureDialog.defaultProps = { _id: undefined, name: '', private: false, - accessCode: null, type: 'normal', requireRevealedIdentity: false, startTime: '', diff --git a/client/src/components/RoomConfigureDialog.test.jsx b/client/src/components/RoomConfigureDialog.test.jsx new file mode 100644 index 0000000..2c11845 --- /dev/null +++ b/client/src/components/RoomConfigureDialog.test.jsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import Button from '@material-ui/core/Button'; +import FormControlLabel from '@material-ui/core/FormControlLabel'; +import TextField from '@material-ui/core/TextField'; +import RoomConfigureDialog from './RoomConfigureDialog'; + +const privateRoom = { + _id: 'room-one', + name: 'Private room', + private: true, + type: 'normal', + requireRevealedIdentity: false, + startTime: '', + twitchChannel: '', +}; + +describe('RoomConfigureDialog passwords', () => { + it('allows an existing private room to be edited without replacing its password', () => { + const onSave = jest.fn(); + const wrapper = shallow( + , + ); + + const save = wrapper.find(Button).filterWhere((button) => button.text() === 'Save'); + expect(save.prop('disabled')).toBe(false); + save.simulate('click'); + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + private: true, + password: undefined, + })); + }); + + it('sends an explicitly changed password', () => { + const onSave = jest.fn(); + const wrapper = shallow( + , + ); + + wrapper.find(TextField).findWhere((field) => field.prop('id') === 'password') + .simulate('change', { target: { value: 'new-password' } }); + wrapper.find(Button).filterWhere((button) => button.text() === 'Save').simulate('click'); + + expect(onSave).toHaveBeenCalledWith(expect.objectContaining({ + private: true, + password: 'new-password', + })); + }); + + it('requires a password when making an existing public room private', () => { + const wrapper = shallow( + , + ); + + wrapper.find(FormControlLabel).first().prop('control').props.onChange(); + wrapper.update(); + + const save = wrapper.find(Button).filterWhere((button) => button.text() === 'Save'); + expect(save.prop('disabled')).toBe(true); + }); + + it('requires a password when creating a private room', () => { + const wrapper = shallow( + , + ); + + wrapper.find(FormControlLabel).first().prop('control').props.onChange(); + wrapper.update(); + + const create = wrapper.find(Button).filterWhere((button) => button.text() === 'Create'); + expect(create.prop('disabled')).toBe(true); + }); +}); diff --git a/client/src/store/middlewares/rooms.js b/client/src/store/middlewares/rooms.js index f65f321..9795ce9 100644 --- a/client/src/store/middlewares/rooms.js +++ b/client/src/store/middlewares/rooms.js @@ -23,6 +23,7 @@ import { UPDATE_USER, DISCARD_PENDING_RESULT, joinRoom, + roomJoinFailed, roomUpdated, resetRoom, userJoined, @@ -49,6 +50,11 @@ import { persistPendingResult, readPendingResult, } from '../room/resultOutbox'; +import { + clearRoomPassword, + persistRoomPassword, + readRoomPassword, +} from '../room/roomPasswordStorage'; import { ROOMS_CONNECT, ROOMS_DISCONNECT, @@ -94,6 +100,7 @@ export const createRoomsNamespaceMiddleware = ({ let submissionRetryTimer = null; let hydratedPendingResult = false; let storageWarningSubmissionId = null; + let passwordStorageWarningRoomId = null; const currentSubmission = () => store.getState().room.resultSubmission; const readStoredPendingResult = () => { @@ -104,6 +111,73 @@ export const createRoomsNamespaceMiddleware = ({ } }; + const readStoredRoomPassword = (roomId) => { + try { + return readRoomPassword(roomId, storage); + } catch (storageError) { + return null; + } + }; + + const forgetRoomPassword = (roomId) => { + try { + clearRoomPassword(roomId, storage); + } catch (storageError) { + // The next invalid join will try again. + } + }; + + const warnPasswordStorage = (roomId) => { + if (passwordStorageWarningRoomId === String(roomId)) { + return; + } + + passwordStorageWarningRoomId = String(roomId); + store.dispatch(createMessage({ + severity: 'warning', + text: 'Room password storage is unavailable on this device.', + })); + }; + + const rememberRoomPassword = (roomId, password) => { + if (!password) { + return; + } + + try { + persistRoomPassword(roomId, password, storage); + } catch (storageError) { + warnPasswordStorage(roomId); + } + }; + + const applySuccessfulRoomEdit = (room, options) => { + const updatedRoom = room || store.getState().room; + const roomId = updatedRoom._id || store.getState().room._id; + if (options.private === false || updatedRoom.private === false) { + forgetRoomPassword(roomId); + store.dispatch(roomUpdated({ + ...updatedRoom, + password: null, + })); + return; + } + + if (options.password) { + rememberRoomPassword(roomId, options.password); + store.dispatch(roomUpdated({ + ...updatedRoom, + password: options.password, + })); + return; + } + + store.dispatch(roomUpdated({ + ...updatedRoom, + password: store.getState().room.password || readStoredRoomPassword(roomId), + })); + }; + const warnPendingResultNotBackedUp = (submissionId) => { if (storageWarningSubmissionId === submissionId) { return; @@ -299,7 +373,6 @@ export const createRoomsNamespaceMiddleware = ({ const { room } = store.getState(); const request = pendingJoinRequest || (room.accessCode ? { id: room._id, - password: room.password, } : null); if (request) { @@ -351,6 +424,15 @@ export const createRoomsNamespaceMiddleware = ({ store.dispatch(roomsUpdated(rooms)); }, [Protocol.UPDATE_ROOM]: (room) => { + if (room.private === false) { + forgetRoomPassword(room._id); + store.dispatch(roomUpdated({ + ...room, + password: null, + })); + return; + } + store.dispatch(roomUpdated(room)); }, [Protocol.GLOBAL_ROOM_UPDATED]: (room) => { @@ -360,6 +442,7 @@ export const createRoomsNamespaceMiddleware = ({ store.dispatch(roomCreated(room)); }, [Protocol.ROOM_DELETED]: (room) => { + forgetRoomPassword(room); store.dispatch(roomDeleted(room)); if (room === store.getState().room._id) { store.dispatch(push('/')); @@ -488,15 +571,25 @@ export const createRoomsNamespaceMiddleware = ({ severity: 'error', text: err.message, })); + return; } + + forgetRoomPassword(id); }); }, [JOIN_ROOM]: ({ id, password }) => { + const storedPassword = readStoredRoomPassword(id); + const roomPassword = password || storedPassword; const generation = joinGeneration + 1; joinGeneration = generation; joinedRoomId = null; pendingJoinRequest = { id, password }; - namespace.emit(Protocol.JOIN_ROOM, { id, password }, (err, room) => { + + if (!roomsConnected) { + return; + } + + namespace.emit(Protocol.JOIN_ROOM, { id, password: roomPassword }, (err, room) => { if (generation !== joinGeneration) { return; } @@ -504,10 +597,29 @@ export const createRoomsNamespaceMiddleware = ({ pendingJoinRequest = null; if (err) { + if (err.reason === 'not_found' || err.statusCode === 404) { + forgetRoomPassword(id); + } + + const invalidPassword = err.reason === 'invalid_password' + || /invalid password/i.test(err.message || ''); + if (invalidPassword && storedPassword === roomPassword) { + forgetRoomPassword(id); + } + + const passwordAccessError = err.reason === 'password_required' + || err.reason === 'invalid_password' + || (room && room.private && err.statusCode === 403 + && /password/i.test(err.message || '')); + if (room && passwordAccessError) { + store.dispatch(roomUpdated(room)); + } + if (err.banned) { store.dispatch(push('/')); } + store.dispatch(roomJoinFailed(err)); store.dispatch(createMessage({ severity: 'error', text: err.message, @@ -516,8 +628,18 @@ export const createRoomsNamespaceMiddleware = ({ } if (room) { - store.dispatch(roomUpdated(room)); - joinedRoomId = room._id || id; + const joinedRoomIdValue = room._id || id; + if (room.private) { + rememberRoomPassword(joinedRoomIdValue, roomPassword); + } else { + forgetRoomPassword(joinedRoomIdValue); + } + + store.dispatch(roomUpdated({ + ...room, + password: room.private ? roomPassword || null : null, + })); + joinedRoomId = joinedRoomIdValue; flushPendingResult(); if (room.accessCode) { @@ -539,7 +661,13 @@ export const createRoomsNamespaceMiddleware = ({ return; } - store.dispatch(roomUpdated(room)); + if (room.private) { + rememberRoomPassword(room._id, options.password); + } + store.dispatch(roomUpdated({ + ...room, + password: room.private ? options.password : null, + })); joinedRoomId = room._id; store.dispatch(push(`/rooms/${room._id}`)); flushPendingResult(); @@ -637,7 +765,17 @@ export const createRoomsNamespaceMiddleware = ({ namespace.emit(Protocol.CHANGE_EVENT, event); }, [EDIT_ROOM]: ({ options }) => { - namespace.emit(Protocol.EDIT_ROOM, options); + namespace.emit(Protocol.EDIT_ROOM, options, (err, room) => { + if (err) { + store.dispatch(createMessage({ + severity: 'error', + text: err.message, + })); + return; + } + + applySuccessfulRoomEdit(room, options); + }); }, [SEND_CHAT]: ({ message }) => { namespace.emit(Protocol.MESSAGE, message); diff --git a/client/src/store/middlewares/rooms.test.js b/client/src/store/middlewares/rooms.test.js index 57a03af..3054bcc 100644 --- a/client/src/store/middlewares/rooms.test.js +++ b/client/src/store/middlewares/rooms.test.js @@ -7,10 +7,11 @@ import roomsReducer from '../rooms/reducer'; import messagesReducer from '../messages/reducers'; import { discardPendingResult, + editRoom, joinRoom, submitResult, } from '../room/actions'; -import { connectSocket } from '../rooms/actions'; +import { connectSocket, createRoom } from '../rooms/actions'; import { PENDING_RESULT_STORAGE_KEY, createPendingResult, @@ -18,6 +19,10 @@ import { markPendingResultFailed, persistPendingResult, } from '../room/resultOutbox'; +import { + persistRoomPassword, + readRoomPassword, +} from '../room/roomPasswordStorage'; import { createRoomsNamespaceMiddleware } from './rooms'; jest.mock('./manager', () => ({ @@ -153,7 +158,7 @@ const result = { }, }; -describe('rooms result outbox middleware', () => { +describe('rooms middleware', () => { beforeEach(() => { window.localStorage.clear(); }); @@ -170,6 +175,358 @@ describe('rooms result outbox middleware', () => { expect(store.getState().room.attempts).toBe(attempts); }); + it('waits for the socket connection before emitting the initial room join', () => { + const { namespace, store } = buildStore({ + roomState: { + ...initialRoom(), + _id: null, + accessCode: null, + }, + }); + store.dispatch(connectSocket()); + store.dispatch(joinRoom({ id: 'room-two', password: 'secret' })); + + expect(emissionsFor(namespace, Protocol.JOIN_ROOM)).toHaveLength(0); + + namespace.options.onConnected(); + + const joins = emissionsFor(namespace, Protocol.JOIN_ROOM); + expect(joins).toHaveLength(1); + expect(joins[0].args[0]).toEqual({ id: 'room-two', password: 'secret' }); + }); + + it('uses a saved private room password after a refresh', () => { + persistRoomPassword('private-room', 'saved-password'); + const { namespace, store } = buildStore({ + roomState: { + ...initialRoom(), + _id: null, + accessCode: null, + }, + }); + store.dispatch(connectSocket()); + namespace.options.onConnected(); + store.dispatch(joinRoom({ id: 'private-room' })); + + const join = emissionsFor(namespace, Protocol.JOIN_ROOM)[0]; + expect(join.args[0]).toEqual({ + id: 'private-room', + password: 'saved-password', + }); + + const joinedRoom = { + ...initialRoom(), + _id: 'private-room', + accessCode: 'PRIVATE', + private: true, + }; + delete joinedRoom.resultSubmission; + join.args[1](null, joinedRoom); + + expect(store.getState().room.password).toBe('saved-password'); + expect(readRoomPassword('private-room')).toBe('saved-password'); + }); + + it('remembers a successful private room password', () => { + const roomState = { + ...initialRoom(), + _id: null, + accessCode: null, + }; + const built = buildStore({ roomState }); + built.store.dispatch(connectSocket()); + built.namespace.options.onConnected(); + built.store.dispatch(joinRoom({ id: 'private-room', password: 'secret' })); + + const join = emissionsFor(built.namespace, Protocol.JOIN_ROOM)[0]; + const joinedRoom = { + ...initialRoom(), + _id: 'private-room', + accessCode: 'PRIVATE', + private: true, + }; + delete joinedRoom.resultSubmission; + join.args[1](null, joinedRoom); + expect(readRoomPassword('private-room')).toBe('secret'); + expect(built.store.getState().room.password).toBe('secret'); + }); + + it('clears a rejected stored password and shows the password form', () => { + persistRoomPassword('private-room', 'stale-password'); + const { namespace, store } = buildStore({ + roomState: { + ...initialRoom(), + _id: null, + accessCode: null, + }, + }); + store.dispatch(connectSocket()); + namespace.options.onConnected(); + store.dispatch(joinRoom({ id: 'private-room' })); + + const joins = emissionsFor(namespace, Protocol.JOIN_ROOM); + expect(joins).toHaveLength(1); + expect(joins[0].args[0]).toEqual({ + id: 'private-room', + password: 'stale-password', + }); + + joins[0].args[1]({ + statusCode: 403, + message: 'Invalid password', + reason: 'invalid_password', + }, { + _id: 'private-room', + name: 'Private room', + private: true, + type: 'normal', + }); + + expect(readRoomPassword('private-room')).toBeNull(); + expect(emissionsFor(namespace, Protocol.JOIN_ROOM)).toHaveLength(1); + expect(store.getState().room).toMatchObject({ + _id: 'private-room', + private: true, + fetching: false, + }); + expect(store.getState().room.joinError.message).toBe('Invalid password'); + }); + + it('remembers the password for a newly created private room', () => { + const { namespace, store } = buildStore(); + store.dispatch(connectSocket()); + namespace.options.onConnected(); + store.dispatch(createRoom({ + name: 'Private room', + password: 'new-password', + private: true, + type: 'normal', + })); + + const creation = emissionsFor(namespace, Protocol.CREATE_ROOM)[0]; + creation.args[1](null, { + ...initialRoom(), + _id: 'new-private-room', + accessCode: 'NEWPRIVATE', + private: true, + }); + + expect(readRoomPassword('new-private-room')).toBe('new-password'); + expect(store.getState().room.password).toBe('new-password'); + }); + + it('remembers a changed private room password after the edit succeeds', () => { + persistRoomPassword('room-one', 'old-password'); + const { namespace, store } = buildStore({ + roomState: { + ...initialRoom(), + private: true, + password: 'old-password', + }, + }); + store.dispatch(editRoom({ + name: 'Private room', + private: true, + password: 'new-password', + type: 'normal', + })); + + const edit = emissionsFor(namespace, Protocol.EDIT_ROOM)[0]; + expect(readRoomPassword('room-one')).toBe('old-password'); + + edit.args[1](null, { + ...initialRoom(), + private: true, + }); + + expect(readRoomPassword('room-one')).toBe('new-password'); + expect(store.getState().room.password).toBe('new-password'); + }); + + it('keeps the saved password when an edit does not replace it', () => { + persistRoomPassword('room-one', 'existing-password'); + const { namespace, store } = buildStore({ + roomState: { + ...initialRoom(), + private: true, + password: 'existing-password', + }, + }); + store.dispatch(editRoom({ + name: 'Renamed room', + private: true, + password: undefined, + type: 'normal', + })); + + const edit = emissionsFor(namespace, Protocol.EDIT_ROOM)[0]; + edit.args[1](null, { + ...initialRoom(), + name: 'Renamed room', + private: true, + }); + + expect(readRoomPassword('room-one')).toBe('existing-password'); + expect(store.getState().room.password).toBe('existing-password'); + }); + + it('keeps the saved password when a private room edit fails', () => { + persistRoomPassword('room-one', 'existing-password'); + const { namespace, store } = buildStore({ + roomState: { + ...initialRoom(), + private: true, + password: 'existing-password', + }, + }); + store.dispatch(editRoom({ + name: 'Private room', + private: true, + password: 'rejected-password', + type: 'normal', + })); + + const edit = emissionsFor(namespace, Protocol.EDIT_ROOM)[0]; + edit.args[1]({ + statusCode: 500, + message: 'Failed to edit room', + }); + + expect(readRoomPassword('room-one')).toBe('existing-password'); + expect(store.getState().room.password).toBe('existing-password'); + }); + + it('clears the saved password after making a room public', () => { + persistRoomPassword('room-one', 'existing-password'); + const { namespace, store } = buildStore({ + roomState: { + ...initialRoom(), + private: true, + password: 'existing-password', + }, + }); + store.dispatch(editRoom({ + name: 'Public room', + private: false, + password: null, + type: 'normal', + })); + + const edit = emissionsFor(namespace, Protocol.EDIT_ROOM)[0]; + expect(readRoomPassword('room-one')).toBe('existing-password'); + + edit.args[1](null, { + ...initialRoom(), + name: 'Public room', + private: false, + }); + + expect(readRoomPassword('room-one')).toBeNull(); + expect(store.getState().room.password).toBeNull(); + }); + + it('clears the saved password when a room update makes it public', () => { + persistRoomPassword('room-one', 'existing-password'); + const { namespace, store } = buildStore({ + roomState: { + ...initialRoom(), + private: true, + password: 'existing-password', + }, + }); + + namespace.options.events[Protocol.UPDATE_ROOM]({ + ...initialRoom(), + private: false, + }); + + expect(readRoomPassword('room-one')).toBeNull(); + expect(store.getState().room.password).toBeNull(); + }); + + it('loads the private room mask when a password is required', () => { + const { namespace, store } = buildStore({ + roomState: { + ...initialRoom(), + _id: null, + accessCode: null, + }, + }); + store.dispatch(connectSocket()); + namespace.options.onConnected(); + store.dispatch(joinRoom({ id: 'private-room' })); + + const join = emissionsFor(namespace, Protocol.JOIN_ROOM)[0]; + join.args[1]({ + statusCode: 403, + message: 'Room requires password to join', + }, { + _id: 'private-room', + name: 'Private room', + private: true, + type: 'normal', + }); + + expect(store.getState().room).toMatchObject({ + _id: 'private-room', + fetching: false, + private: true, + }); + expect(emissionsFor(namespace, Protocol.SUBMIT_RESULT)).toHaveLength(0); + }); + + it('submits a restored result after private room access is restored', () => { + persistPendingResult(createPendingResult({ + userId: 42, + roomId: 'private-room', + attemptId: 12, + attemptKey: 'attempt-one', + result: { time: 1234, penalties: {} }, + }, { + createId: () => 'restored-submission', + now: () => 500, + })); + const { namespace, store } = buildStore({ + roomState: { + ...initialRoom(), + _id: null, + accessCode: null, + }, + }); + store.dispatch(connectSocket()); + namespace.options.onConnected(); + store.dispatch(joinRoom({ id: 'private-room' })); + + let join = emissionsFor(namespace, Protocol.JOIN_ROOM)[0]; + join.args[1]({ + statusCode: 403, + message: 'Room requires password to join', + reason: 'password_required', + }, { + _id: 'private-room', + name: 'Private room', + private: true, + type: 'normal', + }); + + expect(emissionsFor(namespace, Protocol.SUBMIT_RESULT)).toHaveLength(0); + + store.dispatch(joinRoom({ id: 'private-room', password: 'secret' })); + [, join] = emissionsFor(namespace, Protocol.JOIN_ROOM); + const joinedRoom = { + ...initialRoom(), + _id: 'private-room', + accessCode: 'PRIVATE', + private: true, + }; + delete joinedRoom.resultSubmission; + join.args[1](null, joinedRoom); + + const submissions = emissionsFor(namespace, Protocol.SUBMIT_RESULT); + expect(submissions).toHaveLength(1); + expect(submissions[0].args[0].submissionId).toBe('restored-submission'); + }); + it('persists while disconnected and submits only after the room join acknowledgement', () => { const { namespace, store } = buildStore(); store.dispatch(connectSocket()); diff --git a/client/src/store/room/actions.js b/client/src/store/room/actions.js index e954a92..4e179b2 100644 --- a/client/src/store/room/actions.js +++ b/client/src/store/room/actions.js @@ -2,6 +2,7 @@ export const ROOM_UPDATED = 'room/updated'; export const RESET_ROOM = 'room/reset'; export const DELETE_ROOM = 'room/delete'; export const JOIN_ROOM = 'room/join'; +export const ROOM_JOIN_FAILED = 'room/join_failed'; export const LEAVE_ROOM = 'room/leave'; export const SUBMIT_RESULT = 'room/submit_result'; export const NEW_RESULT = 'room/new_result'; @@ -58,6 +59,11 @@ export const joinRoom = ({ reconnecting, }); +export const roomJoinFailed = (error) => ({ + type: ROOM_JOIN_FAILED, + error, +}); + // We're submitting a new result export const submitResult = (result) => ({ type: SUBMIT_RESULT, diff --git a/client/src/store/room/reducer.js b/client/src/store/room/reducer.js index 0a7fd65..616050d 100644 --- a/client/src/store/room/reducer.js +++ b/client/src/store/room/reducer.js @@ -5,6 +5,7 @@ import { USER_LEFT, LEAVE_ROOM, JOIN_ROOM, + ROOM_JOIN_FAILED, NEW_ATTEMPT, NEW_RESULT, EDIT_RESULT, @@ -58,6 +59,7 @@ const INITIAL_STATE = { registeredUsers: 0, twitchChannel: '', resultSubmission: EMPTY_RESULT_SUBMISSION, + joinError: null, }; const editResult = (state, action) => { @@ -115,6 +117,11 @@ const reducers = { ...state, fetching: action.reconnecting ? state.fetching : true, password: action.password, + joinError: null, + }), + [ROOM_JOIN_FAILED]: (state, action) => ({ + ...state, + joinError: action.error, }), [RESET_ROOM]: (state) => ({ ...INITIAL_STATE, diff --git a/client/src/store/room/reducer.test.js b/client/src/store/room/reducer.test.js index f8181f5..b2c1af2 100644 --- a/client/src/store/room/reducer.test.js +++ b/client/src/store/room/reducer.test.js @@ -1,4 +1,4 @@ -import { joinRoom, newResult } from './actions'; +import { joinRoom, newResult, roomJoinFailed } from './actions'; import roomReducer from './reducer'; const initialState = () => ({ @@ -63,4 +63,13 @@ describe('room joins', () => { expect(state.fetching).toBe(false); expect(state.attempts).toBe(loadedRoom.attempts); }); + + it('shows a join error until the next attempt', () => { + const failedState = roomReducer(undefined, roomJoinFailed({ + message: 'Invalid password', + })); + + expect(failedState.joinError).toEqual({ message: 'Invalid password' }); + expect(roomReducer(failedState, joinRoom({ id: 'room-1' })).joinError).toBeNull(); + }); }); diff --git a/client/src/store/room/roomPasswordStorage.js b/client/src/store/room/roomPasswordStorage.js new file mode 100644 index 0000000..d282834 --- /dev/null +++ b/client/src/store/room/roomPasswordStorage.js @@ -0,0 +1,32 @@ +export const ROOM_PASSWORD_STORAGE_PREFIX = 'letscube.roomPassword.v1.'; + +const storageKey = (roomId) => { + if ((typeof roomId !== 'string' && typeof roomId !== 'number') + || String(roomId).length === 0) { + throw new Error('A room password requires a room ID.'); + } + + return `${ROOM_PASSWORD_STORAGE_PREFIX}${encodeURIComponent(String(roomId))}`; +}; + +export const readRoomPassword = (roomId, storage = window.localStorage) => { + const password = storage.getItem(storageKey(roomId)); + return password || null; +}; + +export const persistRoomPassword = ( + roomId, + password, + storage = window.localStorage, +) => { + if (typeof password !== 'string' || password.length === 0) { + throw new Error('Cannot save an empty room password.'); + } + + storage.setItem(storageKey(roomId), password); + return password; +}; + +export const clearRoomPassword = (roomId, storage = window.localStorage) => { + storage.removeItem(storageKey(roomId)); +}; diff --git a/client/src/store/room/roomPasswordStorage.test.js b/client/src/store/room/roomPasswordStorage.test.js new file mode 100644 index 0000000..81326fb --- /dev/null +++ b/client/src/store/room/roomPasswordStorage.test.js @@ -0,0 +1,38 @@ +import { + ROOM_PASSWORD_STORAGE_PREFIX, + clearRoomPassword, + persistRoomPassword, + readRoomPassword, +} from './roomPasswordStorage'; + +describe('private room password storage', () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + it('stores passwords separately for each room', () => { + persistRoomPassword('room-one', 'first-password'); + persistRoomPassword('room-two', 'second-password'); + + expect(readRoomPassword('room-one')).toBe('first-password'); + expect(readRoomPassword('room-two')).toBe('second-password'); + expect(window.localStorage.getItem( + `${ROOM_PASSWORD_STORAGE_PREFIX}room-one`, + )).toBe('first-password'); + }); + + it('clears a password without affecting other rooms', () => { + persistRoomPassword('room-one', 'first-password'); + persistRoomPassword('room-two', 'second-password'); + + clearRoomPassword('room-one'); + + expect(readRoomPassword('room-one')).toBeNull(); + expect(readRoomPassword('room-two')).toBe('second-password'); + }); + + it('rejects missing room IDs and empty passwords', () => { + expect(() => persistRoomPassword(null, 'secret')).toThrow('room ID'); + expect(() => persistRoomPassword('room-one', '')).toThrow('empty room password'); + }); +}); diff --git a/server/models/room.js b/server/models/room.js index 366f6e8..8ce96ce 100644 --- a/server/models/room.js +++ b/server/models/room.js @@ -295,9 +295,23 @@ Room.methods.changeEvent = function (event) { }; Room.methods.edit = async function (options) { + // Older clients sent the room access code when the password field was unchanged. + const legacyUnchangedPassword = !!this.password + && options.password === this.accessCode; + const replacesPassword = typeof options.password === 'string' + && options.password.length > 0 + && !legacyUnchangedPassword; + if (options.private && !replacesPassword && !this.password) { + const error = new Error('A password is required to make a room private'); + error.statusCode = 400; + throw error; + } + this.name = options.name; if (options.private) { - this.password = await bcrypt.hash(options.password, PASSWORD_SALT_ROUNDS); + if (replacesPassword) { + this.password = await bcrypt.hash(options.password, PASSWORD_SALT_ROUNDS); + } } else { this.password = null; } diff --git a/server/models/room.test.js b/server/models/room.test.js index 131ad2b..f72e2cd 100644 --- a/server/models/room.test.js +++ b/server/models/room.test.js @@ -39,6 +39,87 @@ describe('room security helpers', () => { expect(room.save).toHaveBeenCalledTimes(1); }); + it('keeps the existing password when a private room edit leaves it blank', async () => { + const password = await bcrypt.hash('existing-password', 4); + const room = { + password, + save: jest.fn().mockResolvedValue(undefined), + }; + + await Room.methods.edit.call(room, { + name: 'Renamed private room', + private: true, + password: '', + type: 'normal', + requireRevealedIdentity: false, + startTime: null, + }); + + expect(room.password).toBe(password); + expect(room.save).toHaveBeenCalledTimes(1); + }); + + it('ignores the access-code placeholder sent by older clients', async () => { + const password = await bcrypt.hash('existing-password', 4); + const room = { + accessCode: 'ROOM-CODE', + password, + save: jest.fn().mockResolvedValue(undefined), + }; + + await Room.methods.edit.call(room, { + name: 'Renamed private room', + private: true, + password: 'ROOM-CODE', + type: 'normal', + requireRevealedIdentity: false, + startTime: null, + }); + + expect(room.password).toBe(password); + expect(room.save).toHaveBeenCalledTimes(1); + }); + + it('requires a password when making a public room private', async () => { + const room = { + password: null, + save: jest.fn().mockResolvedValue(undefined), + }; + + await expect(Room.methods.edit.call(room, { + name: 'Private room', + private: true, + password: undefined, + type: 'normal', + requireRevealedIdentity: false, + startTime: null, + })).rejects.toMatchObject({ + message: 'A password is required to make a room private', + statusCode: 400, + }); + + expect(room.save).not.toHaveBeenCalled(); + }); + + it('removes the password when making a private room public', async () => { + const room = { + password: await bcrypt.hash('existing-password', 4), + save: jest.fn().mockResolvedValue(undefined), + }; + + await Room.methods.edit.call(room, { + name: 'Public room', + private: false, + password: null, + type: 'normal', + requireRevealedIdentity: false, + startTime: null, + }); + + expect(room.password).toBeNull(); + expect(room.save).toHaveBeenCalledTimes(1); + }); + it('marks stale rooms for expiration in ten minutes', async () => { const before = Date.now(); const room = { diff --git a/server/socket/middlewares/logger.js b/server/socket/middlewares/logger.js index 8d5edf7..b4d61fd 100644 --- a/server/socket/middlewares/logger.js +++ b/server/socket/middlewares/logger.js @@ -1,12 +1,28 @@ const logger = require('../../logger'); +const redactPasswords = (value) => { + if (Array.isArray(value)) { + return value.map(redactPasswords); + } + + if (!value || typeof value !== 'object' + || Object.getPrototypeOf(value) !== Object.prototype) { + return value; + } + + return Object.entries(value).reduce((redacted, [key, entry]) => ({ + ...redacted, + [key]: /password/i.test(key) ? '[REDACTED]' : redactPasswords(entry), + }), {}); +}; + module.exports = (socket, next) => { socket.onAny((event, data) => { logger.info(event, { id: socket.id, userId: socket.userId, roomId: socket.roomId, - data, + data: redactPasswords(data), }); }); @@ -16,3 +32,5 @@ module.exports = (socket, next) => { next(); }; + +module.exports.redactPasswords = redactPasswords; diff --git a/server/socket/middlewares/logger.test.js b/server/socket/middlewares/logger.test.js new file mode 100644 index 0000000..2743cd8 --- /dev/null +++ b/server/socket/middlewares/logger.test.js @@ -0,0 +1,45 @@ +/* eslint-env jest */ + +const logger = require('../../logger'); +const loggerMiddleware = require('./logger'); + +jest.mock('../../logger', () => ({ + error: jest.fn(), + info: jest.fn(), +})); + +describe('socket logger middleware', () => { + it('redacts passwords without changing the event payload', () => { + const handlers = {}; + const socket = { + id: 'socket-one', + userId: 101, + roomId: null, + onAny: jest.fn((handler) => { + handlers.any = handler; + }), + on: jest.fn((event, handler) => { + handlers[event] = handler; + }), + }; + const next = jest.fn(); + const payload = { + id: 'room-one', + password: 'room-secret', + nested: { newPassword: 'another-secret' }, + }; + + loggerMiddleware(socket, next); + handlers.any('join_room', payload); + + expect(logger.info).toHaveBeenCalledWith('join_room', expect.objectContaining({ + data: { + id: 'room-one', + password: '[REDACTED]', + nested: { newPassword: '[REDACTED]' }, + }, + })); + expect(payload.password).toBe('room-secret'); + expect(next).toHaveBeenCalledTimes(1); + }); +}); diff --git a/server/socket/namespaces/rooms.js b/server/socket/namespaces/rooms.js index e61f614..338de97 100644 --- a/server/socket/namespaces/rooms.js +++ b/server/socket/namespaces/rooms.js @@ -360,6 +360,11 @@ module.exports = (io, middlewares) => { async function joinRoom(room, cb, spectating) { if (socket.roomId) { + if (String(socket.roomId) === String(room._id)) { + socket.room = socket.room || room; + return cb(null, joinRoomMask(socket.room)); + } + logger.debug('Socket is already in room', { roomId: socket.room._id }); await metrics.recordRoomJoinFailure({ room: socket.room, @@ -445,7 +450,10 @@ module.exports = (io, middlewares) => { connectionId: socket.id, failureReason, }); - return acknowledgment(error, room ? roomMask(room) : undefined); + return acknowledgment({ + ...error, + reason: failureReason, + }, room ? roomMask(room) : undefined); }; try { @@ -464,6 +472,18 @@ module.exports = (io, middlewares) => { }, room); } + if (socket.roomId) { + if (String(socket.roomId) === String(room._id)) { + socket.room = room; + return acknowledgment(null, joinRoomMask(room)); + } + + return rejectJoin('already_in_room', { + statusCode: 400, + message: 'Socket is already in room', + }, room); + } + if (room.private && !password) { return rejectJoin('password_required', { statusCode: 403, @@ -792,24 +812,57 @@ module.exports = (io, middlewares) => { }).catch(logger.error); }); - on(Protocol.EDIT_ROOM, async (options) => { - if (!checkAdmin()) { - return; + on(Protocol.EDIT_ROOM, async (options = {}, cb) => { + const acknowledgment = optionalAcknowledgment(cb); + const rejectEdit = (error) => { + const response = { + ...error, + event: Protocol.EDIT_ROOM, + }; + + if (typeof cb === 'function') { + return acknowledgment(response); + } + + return socket.emit(Protocol.ERROR, response); + }; + + if (!socket.user) { + logger.debug('Unauthenticated user attempting to edit a room'); + return rejectEdit({ + statusCode: 403, + message: 'Must be logged in', + }); + } + + if (!socket.room) { + logger.debug('User not in a room attempting to edit it'); + return rejectEdit({ + statusCode: 400, + message: 'Must be in a room', + }); + } + + if (socket.room.admin.id !== socket.user.id) { + logger.debug('Non-admin attempting to edit a room'); + return rejectEdit({ + statusCode: 403, + message: 'Must be admin of room', + }); } if (options.type === 'grand_prix' && !config.grandPrix.enabled) { logger.error(`${socket.id} attempted to edit room to grand_prix`); - socket.emit(Protocol.ERROR, { + return rejectEdit({ statusCode: 403, - event: Protocol.EDIT_ROOM, message: 'Grand Prix rooms are disabled', }); - return; } try { const room = await socket.room.edit(options); ns().in(room.accessCode).emit(Protocol.UPDATE_ROOM, joinRoomMask(room)); + acknowledgment(null, joinRoomMask(room)); Room.find().then((rooms) => { ns().emit( @@ -818,7 +871,11 @@ module.exports = (io, middlewares) => { ); }); } catch (e) { - (logger.error(e)); + logger.error(e); + return rejectEdit({ + statusCode: e.statusCode || 500, + message: e.statusCode ? e.message : 'Failed to edit room', + }); } }); diff --git a/server/socket/namespaces/rooms.test.js b/server/socket/namespaces/rooms.test.js new file mode 100644 index 0000000..e24cb4c --- /dev/null +++ b/server/socket/namespaces/rooms.test.js @@ -0,0 +1,275 @@ +/* eslint-env jest */ + +const Protocol = require('../../../client/src/lib/protocol.json'); +const metrics = require('../../metrics'); +const { Room, User } = require('../../models'); +const initRooms = require('./rooms'); + +jest.mock('../../runtimeConfig', () => ({ + grandPrix: { enabled: false }, + socketio: { reconnectGraceMs: 60000 }, +})); +jest.mock('../../logger', () => ({ + debug: jest.fn(), + error: jest.fn(), + info: jest.fn(), +})); +jest.mock('../../metrics', () => ({ + beginRoomVisit: jest.fn(), + endRoomVisit: jest.fn(), + recordRoomCreated: jest.fn(), + recordRoomJoinFailure: jest.fn(), +})); +jest.mock('../../models', () => ({ + Room: { + find: jest.fn(), + findById: jest.fn(), + }, + User: { + find: jest.fn(), + }, +})); +jest.mock('../../postgres/dualWrite', () => ({ + markRoomDeleted: jest.fn(), +})); +jest.mock('../lib/reconnectGrace', () => ({ + createReconnectGrace: jest.fn(() => ({ + cancel: jest.fn().mockResolvedValue(false), + finalize: jest.fn(), + schedule: jest.fn(), + startReconciliation: jest.fn(), + })), +})); +jest.mock('../lib/socketHandler', () => ({ + createSafeSocketHandler: (socket) => (event, handler) => socket.on(event, handler), + optionalAcknowledgment: (acknowledgment) => ( + typeof acknowledgment === 'function' ? acknowledgment : () => {} + ), +})); + +const queryResult = (value) => { + const query = { + populate: jest.fn(() => query), + then: (resolve, reject) => Promise.resolve(value).then(resolve, reject), + }; + return query; +}; + +const makeRoom = (overrides = {}) => ({ + _id: 'room-one', + accessCode: 'PRIVATE', + private: true, + password: 'bcrypt-hash-one', + type: 'normal', + owner: { id: 101 }, + admin: { id: 101 }, + users: [], + usersInRoom: [], + inRoom: new Map(), + banned: new Map(), + registered: new Map(), + requireRevealedIdentity: false, + usersLength: 1, + authenticate: jest.fn().mockResolvedValue(true), + addUser: jest.fn().mockResolvedValue(false), + ...overrides, +}); + +const makeSocket = ({ + id, session, userId = 202, +}) => { + const handlers = {}; + return { + id, + userId, + user: { + id: userId, + name: `User ${userId}`, + showWCAID: true, + }, + handshake: { session }, + handlers, + broadcast: { + to: jest.fn(() => ({ emit: jest.fn() })), + }, + emit: jest.fn(), + join: jest.fn(), + leave: jest.fn(), + on: jest.fn((event, handler) => { + handlers[event] = handler; + }), + use: jest.fn(), + }; +}; + +const setup = (rooms) => { + const namespace = { + adapter: { + allRooms: jest.fn().mockResolvedValue(new Set()), + sockets: jest.fn().mockResolvedValue(new Set()), + }, + emit: jest.fn(), + in: jest.fn(() => ({ emit: jest.fn() })), + on: jest.fn(), + use: jest.fn(), + }; + const io = { of: jest.fn().mockReturnValue(namespace) }; + let connect; + namespace.on.mockImplementation((event, handler) => { + if (event === 'connection') { + connect = handler; + } + }); + Room.find.mockImplementation(() => queryResult([])); + Room.findById.mockImplementation(({ _id }) => queryResult(rooms.get(String(_id)))); + User.find.mockResolvedValue([]); + initRooms(io, []); + + return async (socket) => { + await connect(socket); + return socket; + }; +}; + +const joinRoom = async (socket, payload) => { + const acknowledgment = jest.fn(); + await socket.handlers[Protocol.JOIN_ROOM](payload, acknowledgment); + return acknowledgment; +}; + +describe('private room namespace joins', () => { + beforeEach(() => { + jest.clearAllMocks(); + metrics.beginRoomVisit.mockResolvedValue(undefined); + metrics.recordRoomJoinFailure.mockResolvedValue(undefined); + }); + + it('requires a valid password for a private room', async () => { + const room = makeRoom({ + authenticate: jest.fn((password) => Promise.resolve(password === 'secret')), + }); + const connect = setup(new Map([[room._id, room]])); + const socket = await connect(makeSocket({ id: 'private-socket', session: {} })); + + const missingAcknowledgment = await joinRoom(socket, { id: room._id }); + expect(room.authenticate).not.toHaveBeenCalled(); + expect(missingAcknowledgment).toHaveBeenCalledWith( + expect.objectContaining({ reason: 'password_required' }), + expect.objectContaining({ _id: room._id, private: true }), + ); + + const invalidAcknowledgment = await joinRoom(socket, { + id: room._id, + password: 'incorrect', + }); + expect(invalidAcknowledgment).toHaveBeenCalledWith( + expect.objectContaining({ reason: 'invalid_password' }), + expect.objectContaining({ _id: room._id, private: true }), + ); + + const validAcknowledgment = await joinRoom(socket, { + id: room._id, + password: 'secret', + }); + expect(validAcknowledgment).toHaveBeenCalledWith( + null, + expect.objectContaining({ _id: room._id, accessCode: room.accessCode }), + ); + }); + + it('accepts a duplicate join for the same room and rejects a different room', async () => { + const room = makeRoom(); + const otherRoom = makeRoom({ + _id: 'room-two', + accessCode: 'OTHER', + private: false, + password: null, + }); + const connect = setup(new Map([ + [room._id, room], + [otherRoom._id, otherRoom], + ])); + const socket = makeSocket({ id: 'joined-socket', session: {} }); + socket.roomId = room._id; + socket.room = room; + await connect(socket); + + const sameRoomAcknowledgment = await joinRoom(socket, { id: room._id }); + expect(sameRoomAcknowledgment).toHaveBeenCalledWith( + null, + expect.objectContaining({ _id: room._id }), + ); + + const otherRoomAcknowledgment = await joinRoom(socket, { id: otherRoom._id }); + expect(otherRoomAcknowledgment).toHaveBeenCalledWith( + expect.objectContaining({ reason: 'already_in_room' }), + expect.objectContaining({ _id: otherRoom._id }), + ); + }); +}); + +describe('room namespace edits', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('acknowledges a successful edit with the updated room', async () => { + const room = makeRoom(); + room.edit = jest.fn().mockImplementation(async () => { + room.password = 'bcrypt-hash-two'; + return room; + }); + const connect = setup(new Map([[room._id, room]])); + const socket = makeSocket({ + id: 'admin-socket', + session: {}, + userId: room.admin.id, + }); + socket.room = room; + socket.roomId = room._id; + await connect(socket); + const acknowledgment = jest.fn(); + const options = { + name: 'Renamed room', + private: true, + password: 'new-password', + type: 'normal', + }; + + await socket.handlers[Protocol.EDIT_ROOM](options, acknowledgment); + + expect(room.edit).toHaveBeenCalledWith(options); + expect(acknowledgment).toHaveBeenCalledWith( + null, + expect.objectContaining({ _id: room._id, private: true }), + ); + }); + + it('returns an edit failure through the acknowledgment', async () => { + const room = makeRoom(); + const error = Object.assign(new Error('A password is required'), { statusCode: 400 }); + room.edit = jest.fn().mockRejectedValue(error); + const connect = setup(new Map([[room._id, room]])); + const socket = makeSocket({ + id: 'admin-socket', + session: {}, + userId: room.admin.id, + }); + socket.room = room; + socket.roomId = room._id; + await connect(socket); + const acknowledgment = jest.fn(); + + await socket.handlers[Protocol.EDIT_ROOM]({ + name: 'Private room', + private: true, + type: 'normal', + }, acknowledgment); + + expect(acknowledgment).toHaveBeenCalledWith(expect.objectContaining({ + statusCode: 400, + event: Protocol.EDIT_ROOM, + message: 'A password is required', + })); + }); +}); From 4745a301130407ea8e0dc785425e0a0cd0e990b2 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 10:11:37 -0700 Subject: [PATCH 05/56] Add shared scramble provider Create a shared event catalog and route supported events through cubing.js, with Scrambow retained for custom practice scrambles. --- client/package.json | 1 + client/src/lib/events.js | 2 +- client/src/lib/events.json | 134 ----------------------- package.json | 3 +- packages/scrambles/.eslintrc.json | 16 +++ packages/scrambles/events.js | 112 +++++++++++++++++++ packages/scrambles/index.js | 64 +++++++++++ packages/scrambles/index.test.js | 101 +++++++++++++++++ packages/scrambles/package.json | 30 +++++ packages/scrambles/providers/cubing.js | 29 +++++ packages/scrambles/providers/scrambow.js | 7 ++ server/models/room.js | 15 +-- server/models/room.test.js | 33 ++++++ server/package.json | 2 +- yarn.lock | 94 ++++++++++++++++ 15 files changed, 496 insertions(+), 147 deletions(-) delete mode 100644 client/src/lib/events.json create mode 100644 packages/scrambles/.eslintrc.json create mode 100644 packages/scrambles/events.js create mode 100644 packages/scrambles/index.js create mode 100644 packages/scrambles/index.test.js create mode 100644 packages/scrambles/package.json create mode 100644 packages/scrambles/providers/cubing.js create mode 100644 packages/scrambles/providers/scrambow.js diff --git a/client/package.json b/client/package.json index d4f12c8..c40301c 100644 --- a/client/package.json +++ b/client/package.json @@ -29,6 +29,7 @@ "socket.io-client": "^4.8.3", "stackmat-signal-processor": "^0.1.0", "uifx": "^2.0.7", + "letscube-scrambles": "0.1.0", "workbox-window": "^7.4.1" }, "scripts": { diff --git a/client/src/lib/events.js b/client/src/lib/events.js index 79bcb7f..db152ef 100644 --- a/client/src/lib/events.js +++ b/client/src/lib/events.js @@ -1,4 +1,4 @@ -import Events from './events.json'; +import Events from 'letscube-scrambles/events'; export { Events }; diff --git a/client/src/lib/events.json b/client/src/lib/events.json deleted file mode 100644 index 78cbcf5..0000000 --- a/client/src/lib/events.json +++ /dev/null @@ -1,134 +0,0 @@ -[ - { - "id": "333", - "scrambler": "333", - "name": "3x3", - "group": "WCA" - }, - { - "id": "222", - "scrambler": "222", - "name": "2x2", - "group": "WCA" - }, - { - "id": "444", - "scrambler": "444", - "name": "4x4", - "group": "WCA" - }, - { - "id": "555", - "scrambler": "555", - "name": "5x5", - "group": "WCA" - }, - { - "id": "666", - "scrambler": "666", - "name": "6x6", - "group": "WCA" - }, - { - "id": "777", - "scrambler": "777", - "name": "7x7", - "group": "WCA" - }, - { - "id": "333bf", - "scrambler": "333", - "name": "3x3 Blindfolded", - "group": "WCA" - }, - { - "id": "333oh", - "scrambler": "333", - "name": "3x3 One-Handed", - "group": "WCA" - }, - { - "id": "333ft", - "scrambler": "333", - "name": "3x3 With Feet", - "group": "WCA" - }, - { - "id": "minx", - "scrambler": "minx", - "name": "Megaminx", - "group": "WCA" - }, - { - "id": "pyram", - "scrambler": "pyram", - "name": "Pyraminx", - "group": "WCA" - }, - { - "id": "clock", - "scrambler": "clock", - "name": "Clock", - "group": "WCA" - }, - { - "id": "clock-optimal", - "scrambler": "clock-optimal", - "name": "Clock (Optimal)", - "group": "Other" - }, - { - "id": "skewb", - "scrambler": "skewb", - "name": "Skewb", - "group": "WCA" - }, - { - "id": "sq1", - "scrambler": "sq1", - "name": "Square-1", - "group": "WCA" - }, - { - "id": "444bf", - "scrambler": "444", - "name": "4x4 Blindfolded", - "group": "WCA" - }, - { - "id": "555bf", - "scrambler": "555", - "name": "5x5 Blindfolded", - "group": "WCA" - }, - { - "id": "fto", - "scrambler": "fto", - "name": "FTO", - "group": "Other" - }, - { - "id": "pll", - "scrambler": "pll", - "name": "PLL", - "group": "Other" - }, - { - "id": "zbll", - "scrambler": "zbll", - "name": "ZBLL", - "group": "Other" - }, - { - "id": "lse", - "scrambler": "lse", - "name": "Last Six Edges", - "group": "Other" - }, - { - "id": "ru", - "scrambler": "ru", - "name": "RU 2gen", - "group": "Other" - } -] diff --git a/package.json b/package.json index 5790db9..d722ac2 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,8 @@ "packageManager": "yarn@1.22.22", "workspaces": [ "client", - "server" + "server", + "packages/*" ], "resolutions": { "@hono/node-server": "1.19.13", diff --git a/packages/scrambles/.eslintrc.json b/packages/scrambles/.eslintrc.json new file mode 100644 index 0000000..078961d --- /dev/null +++ b/packages/scrambles/.eslintrc.json @@ -0,0 +1,16 @@ +{ + "env": { + "es6": true, + "node": true, + "jest": true + }, + "extends": [ + "airbnb-base" + ], + "parserOptions": { + "ecmaVersion": 2020 + }, + "rules": { + "no-console": "error" + } +} diff --git a/packages/scrambles/events.js b/packages/scrambles/events.js new file mode 100644 index 0000000..7440f20 --- /dev/null +++ b/packages/scrambles/events.js @@ -0,0 +1,112 @@ +module.exports = [ + { + id: '333', + name: '3x3', + group: 'WCA', + }, + { + id: '222', + name: '2x2', + group: 'WCA', + }, + { + id: '444', + name: '4x4', + group: 'WCA', + }, + { + id: '555', + name: '5x5', + group: 'WCA', + }, + { + id: '666', + name: '6x6', + group: 'WCA', + }, + { + id: '777', + name: '7x7', + group: 'WCA', + }, + { + id: '333bf', + name: '3x3 Blindfolded', + group: 'WCA', + }, + { + id: '333oh', + name: '3x3 One-Handed', + group: 'WCA', + }, + { + id: '333ft', + name: '3x3 With Feet', + group: 'WCA', + }, + { + id: 'minx', + name: 'Megaminx', + group: 'WCA', + }, + { + id: 'pyram', + name: 'Pyraminx', + group: 'WCA', + }, + { + id: 'clock', + name: 'Clock', + group: 'WCA', + }, + { + id: 'clock-optimal', + name: 'Clock (Optimal)', + group: 'Other', + }, + { + id: 'skewb', + name: 'Skewb', + group: 'WCA', + }, + { + id: 'sq1', + name: 'Square-1', + group: 'WCA', + }, + { + id: '444bf', + name: '4x4 Blindfolded', + group: 'WCA', + }, + { + id: '555bf', + name: '5x5 Blindfolded', + group: 'WCA', + }, + { + id: 'fto', + name: 'FTO', + group: 'Other', + }, + { + id: 'pll', + name: 'PLL', + group: 'Other', + }, + { + id: 'zbll', + name: 'ZBLL', + group: 'Other', + }, + { + id: 'lse', + name: 'Last Six Edges', + group: 'Other', + }, + { + id: 'ru', + name: 'RU 2gen', + group: 'Other', + }, +]; diff --git a/packages/scrambles/index.js b/packages/scrambles/index.js new file mode 100644 index 0000000..cce4ed7 --- /dev/null +++ b/packages/scrambles/index.js @@ -0,0 +1,64 @@ +const events = require('./events'); +const { generateCubingScramble } = require('./providers/cubing'); +const { generateScrambowScramble } = require('./providers/scrambow'); + +const cubingEventIds = new Set([ + '222', + '333', + '333bf', + '333oh', + '333ft', + '444', + '444bf', + '555', + '555bf', + '666', + '777', + 'minx', + 'pyram', + 'clock', + 'skewb', + 'sq1', + 'fto', +]); + +const scrambowTypesByEventId = { + 333: '333', + 222: '222', + 444: '444', + 555: '555', + 666: '666', + 777: '777', + '333bf': '333', + '333oh': '333', + '333ft': '333', + minx: 'minx', + pyram: 'pyram', + clock: 'clock', + 'clock-optimal': 'clock-optimal', + skewb: 'skewb', + sq1: 'sq1', + '444bf': '444', + '555bf': '555', + fto: 'fto', + pll: 'pll', + zbll: 'zbll', + lse: 'lse', + ru: 'ru', +}; + +const knownEventIds = new Set(events.map(({ id }) => id)); + +async function generateScramble(eventId) { + if (!knownEventIds.has(eventId)) { + throw new Error(`Unsupported scramble event: ${eventId}`); + } + + if (cubingEventIds.has(eventId)) { + return generateCubingScramble(eventId); + } + + return generateScrambowScramble(scrambowTypesByEventId[eventId]); +} + +module.exports = { generateScramble }; diff --git a/packages/scrambles/index.test.js b/packages/scrambles/index.test.js new file mode 100644 index 0000000..cce566a --- /dev/null +++ b/packages/scrambles/index.test.js @@ -0,0 +1,101 @@ +/** @jest-environment node */ + +jest.mock('./providers/cubing', () => ({ + generateCubingScramble: jest.fn(), +})); +jest.mock('./providers/scrambow', () => ({ + generateScrambowScramble: jest.fn(), +})); + +const events = require('./events'); +const { generateScramble } = require('./index'); +const { generateCubingScramble } = require('./providers/cubing'); +const { generateScrambowScramble } = require('./providers/scrambow'); + +const cubingEventIds = [ + '222', + '333', + '333bf', + '333oh', + '333ft', + '444', + '444bf', + '555', + '555bf', + '666', + '777', + 'minx', + 'pyram', + 'clock', + 'skewb', + 'sq1', + 'fto', +]; + +describe('generateScramble', () => { + beforeEach(() => { + jest.clearAllMocks(); + generateCubingScramble.mockResolvedValue('cubing scramble'); + generateScrambowScramble.mockReturnValue('scrambow scramble'); + }); + + it.each(cubingEventIds)('uses cubing.js for %s', async (eventId) => { + await expect(generateScramble(eventId)).resolves.toBe('cubing scramble'); + + expect(generateCubingScramble).toHaveBeenCalledWith(eventId); + expect(generateScrambowScramble).not.toHaveBeenCalled(); + }); + + it('uses Scrambow for the custom practice events', async () => { + await expect(generateScramble('clock-optimal')).resolves.toBe('scrambow scramble'); + await expect(generateScramble('pll')).resolves.toBe('scrambow scramble'); + await expect(generateScramble('zbll')).resolves.toBe('scrambow scramble'); + await expect(generateScramble('lse')).resolves.toBe('scrambow scramble'); + await expect(generateScramble('ru')).resolves.toBe('scrambow scramble'); + + expect(generateScrambowScramble).toHaveBeenNthCalledWith(1, 'clock-optimal'); + expect(generateScrambowScramble).toHaveBeenNthCalledWith(2, 'pll'); + expect(generateScrambowScramble).toHaveBeenNthCalledWith(3, 'zbll'); + expect(generateScrambowScramble).toHaveBeenNthCalledWith(4, 'lse'); + expect(generateScrambowScramble).toHaveBeenNthCalledWith(5, 'ru'); + }); + + it('rejects events that are not in the shared catalog', async () => { + await expect(generateScramble('unknown')).rejects.toThrow('Unsupported scramble event: unknown'); + + expect(generateCubingScramble).not.toHaveBeenCalled(); + expect(generateScrambowScramble).not.toHaveBeenCalled(); + }); + + it('surfaces cubing.js failures without using Scrambow', async () => { + const error = new Error('cubing worker failed'); + generateCubingScramble.mockRejectedValue(error); + + await expect(generateScramble('333')).rejects.toBe(error); + expect(generateScrambowScramble).not.toHaveBeenCalled(); + }); +}); + +describe('events', () => { + it('contains every event supported by the generator', () => { + expect(events.map(({ id }) => id)).toEqual(expect.arrayContaining([ + ...cubingEventIds, + 'clock-optimal', + 'pll', + 'zbll', + 'lse', + 'ru', + ])); + }); + + it('does not expose provider implementation details', () => { + events.forEach((event) => { + expect(event).toEqual(expect.objectContaining({ + id: expect.any(String), + name: expect.any(String), + group: expect.any(String), + })); + expect(event.scrambler).toBeUndefined(); + }); + }); +}); diff --git a/packages/scrambles/package.json b/packages/scrambles/package.json new file mode 100644 index 0000000..9a6ad32 --- /dev/null +++ b/packages/scrambles/package.json @@ -0,0 +1,30 @@ +{ + "name": "letscube-scrambles", + "version": "0.1.0", + "private": true, + "main": "index.js", + "exports": { + ".": "./index.js", + "./events": "./events.js" + }, + "files": [ + "index.js", + "events.js", + "providers" + ], + "dependencies": { + "cubing": "0.63.3", + "scrambow": "^1.8.1" + }, + "scripts": { + "lint": "eslint ./", + "test": "jest --passWithNoTests", + "test:ci": "yarn test" + }, + "devDependencies": { + "eslint": "6.8.0", + "eslint-config-airbnb-base": "14.1.0", + "eslint-plugin-import": "2.20.2", + "jest": "^26.6.0" + } +} diff --git a/packages/scrambles/providers/cubing.js b/packages/scrambles/providers/cubing.js new file mode 100644 index 0000000..b26b825 --- /dev/null +++ b/packages/scrambles/providers/cubing.js @@ -0,0 +1,29 @@ +let cubingModulesPromise; + +function loadCubingModules() { + if (!cubingModulesPromise) { + cubingModulesPromise = Promise.all([ + // cubing publishes ESM-only subpath exports, which eslint-plugin-import 2.20 cannot resolve. + // eslint-disable-next-line import/no-unresolved + import('cubing/scramble'), + // eslint-disable-next-line import/no-unresolved + import('cubing/search'), + ]).then(([scramble, search]) => { + search.setSearchDebug({ + logPerf: false, + scramblePrefetchLevel: 'none', + showWorkerInstantiationWarnings: false, + }); + return scramble; + }); + } + + return cubingModulesPromise; +} + +async function generateCubingScramble(eventId) { + const { randomScrambleForEvent } = await loadCubingModules(); + return (await randomScrambleForEvent(eventId)).toString(); +} + +module.exports = { generateCubingScramble }; diff --git a/packages/scrambles/providers/scrambow.js b/packages/scrambles/providers/scrambow.js new file mode 100644 index 0000000..f02b5c1 --- /dev/null +++ b/packages/scrambles/providers/scrambow.js @@ -0,0 +1,7 @@ +const { Scrambow } = require('scrambow'); + +function generateScrambowScramble(scramblerType) { + return new Scrambow().setType(scramblerType).get(1)[0].scramble_string; +} + +module.exports = { generateScrambowScramble }; diff --git a/server/models/room.js b/server/models/room.js index 366f6e8..37bdd72 100644 --- a/server/models/room.js +++ b/server/models/room.js @@ -1,8 +1,7 @@ const mongoose = require('mongoose'); -const { Scrambow } = require('scrambow'); const bcrypt = require('bcrypt'); const { v4: uuidv4 } = require('uuid'); -const Events = require('../../client/src/lib/events.json'); +const { generateScramble } = require('letscube-scrambles'); const { mirrorRoomChanges } = require('../postgres/dualWrite'); const PASSWORD_SALT_ROUNDS = 10; @@ -127,10 +126,6 @@ Room.index({ expireAfterSeconds: 0, }); -Room.virtual('scrambler').get(function () { - return new Scrambow().setType(Events.find((e) => e.id === this.event).scrambler); -}); - Room.virtual('usersInRoom').get(function () { return this.users.filter(({ id }) => this.inRoom.get(id.toString())); }); @@ -268,16 +263,16 @@ Room.methods.doneWithScramble = function () { return (this.waitingForCount === 0 || this.attempts.length === 0) && this.usersInRoom.length > 0; }; -Room.methods.genAttempt = function () { +Room.methods.genAttempt = async function () { return { id: this.attempts.length, - scrambles: this.scrambler.get(1).map((i) => i.scramble_string), + scrambles: [await generateScramble(this.event)], results: {}, }; }; -Room.methods.newAttempt = function () { - const attempt = this.genAttempt(); +Room.methods.newAttempt = async function () { + const attempt = await this.genAttempt(); this.attempts = this.attempts.concat([attempt]); diff --git a/server/models/room.test.js b/server/models/room.test.js index 131ad2b..49daee2 100644 --- a/server/models/room.test.js +++ b/server/models/room.test.js @@ -3,11 +3,44 @@ const bcrypt = require('bcrypt'); const mongoose = require('mongoose'); +const { generateScramble } = require('letscube-scrambles'); const { collectPostgresChanges, Room } = require('./room'); +jest.mock('letscube-scrambles', () => ({ + generateScramble: jest.fn(), +})); + const RoomModel = mongoose.model('RoomPostgresChangesTest', Room); describe('room security helpers', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('creates attempts with the shared async scramble generator', async () => { + generateScramble.mockResolvedValue('R U R\''); + const room = { + event: '333', + attempts: [], + usersInRoom: [{ id: 1234 }], + waitingFor: new Map(), + competing: new Map([['1234', true]]), + genAttempt: Room.methods.genAttempt, + save: jest.fn().mockResolvedValue(undefined), + }; + + await Room.methods.newAttempt.call(room); + + expect(generateScramble).toHaveBeenCalledWith('333'); + expect(room.attempts).toEqual([{ + id: 0, + scrambles: ['R U R\''], + results: {}, + }]); + expect(room.waitingFor.get('1234')).toBe(true); + expect(room.save).toHaveBeenCalledTimes(1); + }); + it('authenticates passwords asynchronously', async () => { const password = 'correct horse battery staple'; const room = { diff --git a/server/package.json b/server/package.json index e06ba46..fed3219 100644 --- a/server/package.json +++ b/server/package.json @@ -12,6 +12,7 @@ "express-socket.io-session": "^1.3.5", "getconfig": "^4.5.0", "ioredis": "^5.8.2", + "letscube-scrambles": "0.1.0", "lodash": "^4.17.21", "mongoose": "^6.13.9", "morgan": "^1.11.0", @@ -23,7 +24,6 @@ "pg": "8.22.0", "prisma": "7.8.0", "rest-api-errors": "^1.2.1", - "scrambow": "^1.8.1", "socket.io": "^4.8.3", "uuid": "^11.1.1", "winston": "3.2.1" diff --git a/yarn.lock b/yarn.lock index 2a44c39..a8b328d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2061,6 +2061,18 @@ dependencies: "@tybys/wasm-util" "^0.10.3" +"@optique/core@1.1.1", "@optique/core@>=0.9.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@optique/core/-/core-1.1.1.tgz#dfedf03cee4dbdc11b7a99d83908ccd4ba403c1f" + integrity sha512-tbZLdH5wEnh8IRG8KTmWPZCKHLmD4bXigoPdzoSUVi+V46qZTNEaTdqtR7EuEwhbPI0KQjLSYRZCxFTdr9OZkA== + +"@optique/run@>=0.9.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@optique/run/-/run-1.1.1.tgz#38565ef3c612fb36bc1eb205b0de6ac2a98932ac" + integrity sha512-jSZeuiljDEWYkQ67bVDucRknzFVwiSH1j4ho2jFWfcdMMa+WDRSfZA3GZoHA8zKj0Bf6UbJ+FMs/2IReQ0KYNQ== + dependencies: + "@optique/core" "1.1.1" + "@oxc-project/types@=0.139.0": version "0.139.0" resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.139.0.tgz#38d76b9dbf934c2a02be174fb32ceebf182fe742" @@ -2684,6 +2696,11 @@ resolved "https://registry.yarnpkg.com/@turbo/windows-arm64/-/windows-arm64-2.10.4.tgz#ab5f0c53266bbe18a52d1f5a0a15f718ba2378f3" integrity sha512-Iv02YgOpaEShc2OkG7mgCJ2pEw1RUKiKbs0h8W5wAf4jZ5vpmraTEjuGTgHRuOORQnC1GN3KHo5WB+hu1abRMA== +"@tweenjs/tween.js@~23.1.3": + version "23.1.3" + resolved "https://registry.yarnpkg.com/@tweenjs/tween.js/-/tween.js-23.1.3.tgz#eff0245735c04a928bb19c026b58c2a56460539d" + integrity sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA== + "@tybys/wasm-util@^0.10.3": version "0.10.3" resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.3.tgz#015cba9e9dd47ce14d03d2a8c5d547bfb169665d" @@ -2885,6 +2902,11 @@ resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.3.tgz#6209321eb2c1712a7e7466422b8cb1fc0d9dd5d8" integrity sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw== +"@types/stats.js@*": + version "0.17.4" + resolved "https://registry.yarnpkg.com/@types/stats.js/-/stats.js-0.17.4.tgz#1933e5ff153a23c7664487833198d685c22e791e" + integrity sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA== + "@types/testing-library__dom@*": version "7.5.0" resolved "https://registry.yarnpkg.com/@types/testing-library__dom/-/testing-library__dom-7.5.0.tgz#e0a00dd766983b1d6e9d10d33e708005ce6ad13e" @@ -2908,6 +2930,18 @@ "@types/testing-library__dom" "*" pretty-format "^25.1.0" +"@types/three@^0.169.0": + version "0.169.0" + resolved "https://registry.yarnpkg.com/@types/three/-/three-0.169.0.tgz#9cf4c33575721669d88846f22b265696d2c2e7a6" + integrity sha512-oan7qCgJBt03wIaK+4xPWclYRPG9wzcg7Z2f5T8xYTNEF95kh0t0lklxLLYBDo7gQiGLYzE6iF4ta7nXF2bcsw== + dependencies: + "@tweenjs/tween.js" "~23.1.3" + "@types/stats.js" "*" + "@types/webxr" "*" + "@webgpu/types" "*" + fflate "~0.8.2" + meshoptimizer "~0.18.1" + "@types/tmp@^0.2.3": version "0.2.6" resolved "https://registry.yarnpkg.com/@types/tmp/-/tmp-0.2.6.tgz#d785ee90c52d7cc020e249c948c36f7b32d1e217" @@ -2928,11 +2962,21 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.11.tgz#11af57b127e32487774841f7a4e54eab166d03c4" integrity sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA== +"@types/web-bluetooth@^0.0.20": + version "0.0.20" + resolved "https://registry.yarnpkg.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz#f066abfcd1cbe66267cdbbf0de010d8a41b41597" + integrity sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow== + "@types/webidl-conversions@*": version "7.0.3" resolved "https://registry.yarnpkg.com/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz#1306dbfa53768bcbcfc95a1c8cde367975581859" integrity sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA== +"@types/webxr@*": + version "0.5.24" + resolved "https://registry.yarnpkg.com/@types/webxr/-/webxr-0.5.24.tgz#734d5d90dadc5809a53e422726c60337fa2f4a44" + integrity sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg== + "@types/whatwg-url@^8.2.1": version "8.2.2" resolved "https://registry.yarnpkg.com/@types/whatwg-url/-/whatwg-url-8.2.2.tgz#749d5b3873e845897ada99be4448041d4cc39e63" @@ -3024,6 +3068,11 @@ dependencies: "@rolldown/pluginutils" "^1.0.1" +"@webgpu/types@*": + version "0.1.71" + resolved "https://registry.yarnpkg.com/@webgpu/types/-/types-0.1.71.tgz#46ed33ba7816029d622d0b52e1e0004bf441db98" + integrity sha512-mMy8/ODcKhab808co15eW+yN+HgXoQxRQHTiBV9Mrvl1r0ufnid7YOcI+gi4eUWSWl9ezD6TW2KXccrL8HCh2A== + abab@^2.0.0, abab@^2.0.3, abab@^2.0.5: version "2.0.6" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" @@ -4598,6 +4647,19 @@ csstype@^3.0.2, csstype@^3.2.2: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a" integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ== +cubing@0.63.3: + version "0.63.3" + resolved "https://registry.yarnpkg.com/cubing/-/cubing-0.63.3.tgz#b1e0ec2c5385a39a4b20559f5a13ffda2b577981" + integrity sha512-151hSarw9sN06iohHClHQ3RaiI5MNpaIyyb1+I0cuRerHq3jfyhb2Eyif9YAEIbEJiiMc6AUsNFo00Rnti2JWw== + dependencies: + "@optique/core" ">=0.9.1" + "@optique/run" ">=0.9.1" + "@types/three" "^0.169.0" + "@types/web-bluetooth" "^0.0.20" + random-uint-below v3.3.0 + three "^0.170.0" + type-fest "^5.2.0" + cypress@^15.18.1: version "15.18.1" resolved "https://registry.yarnpkg.com/cypress/-/cypress-15.18.1.tgz#b4d6e0f124efd18e5694cc1bd6dfd15db9319604" @@ -5943,6 +6005,11 @@ fecha@^4.2.0: resolved "https://registry.yarnpkg.com/fecha/-/fecha-4.2.3.tgz#4d9ccdbc61e8629b259fdca67e65891448d569fd" integrity sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw== +fflate@~0.8.2: + version "0.8.3" + resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.3.tgz#bc27d8eb30343d4d512abb03480202ce65d825fc" + integrity sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA== + figures@^3.0.0: version "3.2.0" resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af" @@ -8635,6 +8702,11 @@ merge-stream@^2.0.0: resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== +meshoptimizer@~0.18.1: + version "0.18.1" + resolved "https://registry.yarnpkg.com/meshoptimizer/-/meshoptimizer-0.18.1.tgz#cdb90907f30a7b5b1190facd3b7ee6b7087797d8" + integrity sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw== + methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" @@ -9949,6 +10021,11 @@ random-bytes@~1.0.0: resolved "https://registry.yarnpkg.com/random-bytes/-/random-bytes-1.0.0.tgz#4f68a1dc0ae58bd3fb95848c30324db75d64360b" integrity sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ== +random-uint-below@v3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/random-uint-below/-/random-uint-below-3.3.0.tgz#4d911f497215eb9103f8c09bc5d1a3e21cbaaa20" + integrity sha512-5x3lft1bn5gev9qA9iVFcmd0+t10j3eDl/QKB56T/3v/bdrM7WsfO2D0rb1JYcQdkcV0/Bs5iydgQmkTE6rczA== + range-parser@~1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" @@ -11555,6 +11632,11 @@ table@^5.2.3: slice-ansi "^2.1.0" string-width "^3.0.0" +tagged-tag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/tagged-tag/-/tagged-tag-1.0.0.tgz#a0b5917c2864cba54841495abfa3f6b13edcf4d6" + integrity sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng== + temp-dir@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/temp-dir/-/temp-dir-2.0.0.tgz#bde92b05bdfeb1516e804c9c00ad45177f31321e" @@ -11622,6 +11704,11 @@ text-table@^0.2.0: resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4" integrity sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw== +three@^0.170.0: + version "0.170.0" + resolved "https://registry.yarnpkg.com/three/-/three-0.170.0.tgz#6087f97aab79e9e9312f9c89fcef6808642dfbb7" + integrity sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ== + throat@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/throat/-/throat-5.0.0.tgz#c5199235803aad18754a667d659b5e72ce16764b" @@ -11875,6 +11962,13 @@ type-fest@^0.8.0, type-fest@^0.8.1: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.8.1.tgz#09e249ebde851d3b1e48d27c105444667f17b83d" integrity sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA== +type-fest@^5.2.0: + version "5.8.0" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-5.8.0.tgz#6d517998257c33159db4d4da6f18efa33bd47df3" + integrity sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA== + dependencies: + tagged-tag "^1.0.0" + type-is@~1.6.18: version "1.6.18" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" From d179f34131558b5b8fbac93a08105023933b17a3 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 19:04:02 -0700 Subject: [PATCH 06/56] Fix Vite event catalog export Publish the shared event catalog as JSON so Vite can import it from the linked workspace without CommonJS export interop errors. --- packages/scrambles/events.js | 112 ------------------------------- packages/scrambles/events.json | 24 +++++++ packages/scrambles/index.js | 2 +- packages/scrambles/index.test.js | 2 +- packages/scrambles/package.json | 4 +- 5 files changed, 28 insertions(+), 116 deletions(-) delete mode 100644 packages/scrambles/events.js create mode 100644 packages/scrambles/events.json diff --git a/packages/scrambles/events.js b/packages/scrambles/events.js deleted file mode 100644 index 7440f20..0000000 --- a/packages/scrambles/events.js +++ /dev/null @@ -1,112 +0,0 @@ -module.exports = [ - { - id: '333', - name: '3x3', - group: 'WCA', - }, - { - id: '222', - name: '2x2', - group: 'WCA', - }, - { - id: '444', - name: '4x4', - group: 'WCA', - }, - { - id: '555', - name: '5x5', - group: 'WCA', - }, - { - id: '666', - name: '6x6', - group: 'WCA', - }, - { - id: '777', - name: '7x7', - group: 'WCA', - }, - { - id: '333bf', - name: '3x3 Blindfolded', - group: 'WCA', - }, - { - id: '333oh', - name: '3x3 One-Handed', - group: 'WCA', - }, - { - id: '333ft', - name: '3x3 With Feet', - group: 'WCA', - }, - { - id: 'minx', - name: 'Megaminx', - group: 'WCA', - }, - { - id: 'pyram', - name: 'Pyraminx', - group: 'WCA', - }, - { - id: 'clock', - name: 'Clock', - group: 'WCA', - }, - { - id: 'clock-optimal', - name: 'Clock (Optimal)', - group: 'Other', - }, - { - id: 'skewb', - name: 'Skewb', - group: 'WCA', - }, - { - id: 'sq1', - name: 'Square-1', - group: 'WCA', - }, - { - id: '444bf', - name: '4x4 Blindfolded', - group: 'WCA', - }, - { - id: '555bf', - name: '5x5 Blindfolded', - group: 'WCA', - }, - { - id: 'fto', - name: 'FTO', - group: 'Other', - }, - { - id: 'pll', - name: 'PLL', - group: 'Other', - }, - { - id: 'zbll', - name: 'ZBLL', - group: 'Other', - }, - { - id: 'lse', - name: 'Last Six Edges', - group: 'Other', - }, - { - id: 'ru', - name: 'RU 2gen', - group: 'Other', - }, -]; diff --git a/packages/scrambles/events.json b/packages/scrambles/events.json new file mode 100644 index 0000000..abbd215 --- /dev/null +++ b/packages/scrambles/events.json @@ -0,0 +1,24 @@ +[ + { "id": "333", "name": "3x3", "group": "WCA" }, + { "id": "222", "name": "2x2", "group": "WCA" }, + { "id": "444", "name": "4x4", "group": "WCA" }, + { "id": "555", "name": "5x5", "group": "WCA" }, + { "id": "666", "name": "6x6", "group": "WCA" }, + { "id": "777", "name": "7x7", "group": "WCA" }, + { "id": "333bf", "name": "3x3 Blindfolded", "group": "WCA" }, + { "id": "333oh", "name": "3x3 One-Handed", "group": "WCA" }, + { "id": "333ft", "name": "3x3 With Feet", "group": "WCA" }, + { "id": "minx", "name": "Megaminx", "group": "WCA" }, + { "id": "pyram", "name": "Pyraminx", "group": "WCA" }, + { "id": "clock", "name": "Clock", "group": "WCA" }, + { "id": "clock-optimal", "name": "Clock (Optimal)", "group": "Other" }, + { "id": "skewb", "name": "Skewb", "group": "WCA" }, + { "id": "sq1", "name": "Square-1", "group": "WCA" }, + { "id": "444bf", "name": "4x4 Blindfolded", "group": "WCA" }, + { "id": "555bf", "name": "5x5 Blindfolded", "group": "WCA" }, + { "id": "fto", "name": "FTO", "group": "Other" }, + { "id": "pll", "name": "PLL", "group": "Other" }, + { "id": "zbll", "name": "ZBLL", "group": "Other" }, + { "id": "lse", "name": "Last Six Edges", "group": "Other" }, + { "id": "ru", "name": "RU 2gen", "group": "Other" } +] diff --git a/packages/scrambles/index.js b/packages/scrambles/index.js index cce4ed7..10b7ad0 100644 --- a/packages/scrambles/index.js +++ b/packages/scrambles/index.js @@ -1,4 +1,4 @@ -const events = require('./events'); +const events = require('./events.json'); const { generateCubingScramble } = require('./providers/cubing'); const { generateScrambowScramble } = require('./providers/scrambow'); diff --git a/packages/scrambles/index.test.js b/packages/scrambles/index.test.js index cce566a..8783466 100644 --- a/packages/scrambles/index.test.js +++ b/packages/scrambles/index.test.js @@ -7,7 +7,7 @@ jest.mock('./providers/scrambow', () => ({ generateScrambowScramble: jest.fn(), })); -const events = require('./events'); +const events = require('./events.json'); const { generateScramble } = require('./index'); const { generateCubingScramble } = require('./providers/cubing'); const { generateScrambowScramble } = require('./providers/scrambow'); diff --git a/packages/scrambles/package.json b/packages/scrambles/package.json index 9a6ad32..e409093 100644 --- a/packages/scrambles/package.json +++ b/packages/scrambles/package.json @@ -5,11 +5,11 @@ "main": "index.js", "exports": { ".": "./index.js", - "./events": "./events.js" + "./events": "./events.json" }, "files": [ "index.js", - "events.js", + "events.json", "providers" ], "dependencies": { From 0b9d9f02643bd8d16101ecf4baba0fff7c86723d Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 19:15:24 -0700 Subject: [PATCH 07/56] Fix Docker shared workspace build Copy the scramble workspace manifest before dependency installation and its source into the build and runtime stages so Yarn resolves the workspace locally instead of npm. --- Dockerfile | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Dockerfile b/Dockerfile index 0383245..cbbe169 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,7 @@ RUN corepack enable COPY package.json yarn.lock ./ COPY client/package.json ./client/package.json COPY server/package.json ./server/package.json +COPY packages/scrambles/package.json ./packages/scrambles/package.json RUN yarn install --frozen-lockfile ARG REACT_APP_API_ORIGIN= @@ -20,6 +21,7 @@ ENV REACT_APP_SOCKETIO_ORIGIN=${REACT_APP_SOCKETIO_ORIGIN} ENV REACT_APP_WCA_ORIGIN=${REACT_APP_WCA_ORIGIN} ENV REACT_APP_WCA_CLIENT_ID=${REACT_APP_WCA_CLIENT_ID} +COPY packages/scrambles ./packages/scrambles COPY client ./client RUN yarn workspace letscube-client build @@ -34,6 +36,7 @@ RUN corepack enable COPY package.json yarn.lock ./ COPY client/package.json ./client/package.json COPY server/package.json ./server/package.json +COPY packages/scrambles/package.json ./packages/scrambles/package.json RUN yarn install --frozen-lockfile --production=true \ && yarn cache clean \ && mkdir -p server/node_modules @@ -55,6 +58,7 @@ COPY --from=server-deps --chown=letscube:letscube /app/node_modules ./node_modul COPY --from=server-deps --chown=letscube:letscube /app/server/node_modules ./server/node_modules COPY --from=client-build --chown=letscube:letscube /app/client/build ./client/build COPY --from=client-build --chown=letscube:letscube /app/client/src/lib ./client/src/lib +COPY --from=client-build --chown=letscube:letscube /app/packages/scrambles ./packages/scrambles USER letscube EXPOSE 8080 9000 From 87fce8f706e5fb9b8669f0837be0af77b5460643 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 19:34:58 -0700 Subject: [PATCH 08/56] Harden Docker Yarn installs Limit concurrent registry downloads and use a longer request timeout so production image builds tolerate transient registry latency. --- Dockerfile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index cbbe169..e7bdecb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,7 @@ COPY package.json yarn.lock ./ COPY client/package.json ./client/package.json COPY server/package.json ./server/package.json COPY packages/scrambles/package.json ./packages/scrambles/package.json -RUN yarn install --frozen-lockfile +RUN yarn install --frozen-lockfile --network-concurrency 1 --network-timeout 600000 ARG REACT_APP_API_ORIGIN= ARG REACT_APP_SOCKETIO_ORIGIN= @@ -37,7 +37,7 @@ COPY package.json yarn.lock ./ COPY client/package.json ./client/package.json COPY server/package.json ./server/package.json COPY packages/scrambles/package.json ./packages/scrambles/package.json -RUN yarn install --frozen-lockfile --production=true \ +RUN yarn install --frozen-lockfile --production=true --network-concurrency 1 --network-timeout 600000 \ && yarn cache clean \ && mkdir -p server/node_modules From 233220d6778a60b2f4d31c694e2d60189ca4f452 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 19:42:20 -0700 Subject: [PATCH 09/56] Refresh project documentation Reorganize durable project guidance into focused development, architecture, data, realtime, operations, security, and package docs. Keep AGENTS.md concise while adding code-structure, dependency, and worktree conventions. --- AGENTS.md | 124 +++++++------- CONTRIBUTING.md | 128 ++++++++++----- README.md | 126 ++++++--------- README_DEPLOYMENT.md | 249 ----------------------------- SECURITY.md | 40 +++++ client/src/store/default/README.md | 7 - docs/README.md | 39 ++++- docs/_config.yml | 3 +- docs/architecture.md | 122 ++++++++++++++ docs/data.md | 144 +++++++++++++++++ docs/development.md | 202 +++++++++++++++++++++++ docs/operations.md | 236 +++++++++++++++++++++++++++ docs/realtime.md | 136 ++++++++++++++++ packages/scrambles/README.md | 63 ++++++++ 14 files changed, 1182 insertions(+), 437 deletions(-) delete mode 100644 README_DEPLOYMENT.md create mode 100644 SECURITY.md delete mode 100644 client/src/store/default/README.md create mode 100644 docs/architecture.md create mode 100644 docs/data.md create mode 100644 docs/development.md create mode 100644 docs/operations.md create mode 100644 docs/realtime.md create mode 100644 packages/scrambles/README.md diff --git a/AGENTS.md b/AGENTS.md index 54c4edf..deddb60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,73 +1,81 @@ -# Let's Cube Agent Gotchas +# Let's Cube Agent Guidelines -This file is intentionally short. It captures repo-specific traps that are easy -to miss when making changes here. +This file is only for durable, repository-specific instructions that change how +coding agents should work. Feature behavior, architecture, deployment details, +known bugs, and operational procedures belong in normal documentation and tests. +Do not duplicate them here. -## Project Layout +## Find The Source Of Truth -- This is a Yarn classic workspace monorepo. `client/` and `server/` are - workspaces, and the root `yarn.lock` is the only dependency lockfile. -- Root scripts are workspace-aware and mostly run through Turbo. Prefer root - commands like `yarn lint`, `yarn test`, and `yarn build` unless you need a - narrow workspace command. +- Read the relevant README, `docs/` file, tests, configuration, and surrounding + code before changing behavior. Prefer those sources over conversation history + or assumptions from similarly named branches. +- When a change introduces a durable feature rule or operational procedure, + update the owning documentation or tests instead of adding it to this file. +- Keep this file short. Do not use it for task status, backlog notes, temporary + workarounds, or descriptions of individual features. -## Runtime Setup +## Keep Changes Focused -- Local development needs MongoDB and Redis. -- The repo targets Node 22. Install from the repo root with `yarn install`. -- The backend is two separate processes: - - `yarn start:server` starts Express/static/auth/API on port `8080`. - - `yarn start:socket` starts Socket.IO on port `9000`. -- The client runs separately with `yarn start:client`. -- Client env values live in `client/.env.development` and expect the API at - `http://localhost:8080` and Socket.IO at `http://localhost:9000`. +- Match existing project patterns and make the smallest coherent change. The + repository contains legacy and modernized areas side by side, so avoid broad + rewrites or dependency upgrades unless they are part of the task. +- Do not mix cleanup, refactoring, generated-file churn, or unrelated fixes into + a feature branch. Preserve pre-existing user changes and untracked files. +- If the requested direction changes, remove the abandoned implementation before + building the replacement. -## Dependency Age +## Code Structure And Comments -- The stack is old: React 16, Webpack 4, Material UI v4, Socket.IO v3, - Mongoose 6, and a customized CRA/Webpack toolchain. -- Be cautious with modern Node/npm changes. The old CRA/Webpack toolchain is - likely to be the first thing to break. +- Prefer clear names and structure over comments. Add a comment only to explain a + non-obvious invariant, external constraint, compatibility requirement, safety + concern, or intentional tradeoff. Explain why, not what the next line does. +- Do not leave development narration, conversation history, commented-out code, + or vague TODO/FIXME notes. Link actionable follow-up work to an issue, and + update or remove comments when the surrounding behavior changes. +- Keep functions cohesive and easy to understand, but do not mechanically + extract every expression. A function should own a meaningful unit of behavior, + not merely wrap a one-liner unless an interface or callback requires it. +- Treat a convoluted function name as a design smell: simplify the responsibility + or keep the operation inline. Split functions that mix responsibilities or + accumulate difficult branching, without maximizing the number of functions. +- Do not add speculative abstractions, configuration, or fallback paths for + hypothetical future needs. Implement the current contract and make violated + invariants visible instead of silently masking them. -## Socket.IO +## Branch And Worktree Hygiene -- Socket protocol constants live in `client/src/lib/protocol.js` and are also - imported by the server. Update protocol constants, client middleware, and - server namespace handlers together. -- `Protocol.ERROR` intentionally maps to the literal event name `errorrr`. - Do not casually rename it to `error`, which can collide with Socket.IO's own - error behavior. -- Client socket connections are owned by Redux middleware in - `client/src/store/middlewares/`, not by React components. -- Echo-style socket features often need separate incoming and outgoing events. - Reusing one event for both directions can create infinite loops. +- This repository often has several active worktrees and stale local branches. + Confirm the current worktree, branch, status, and merge base before editing. +- Start unrelated work in a fresh branch and worktree so active changes remain + isolated. Unless explicitly told otherwise, fetch first and create the new + worktree from `origin/master`, not from the currently checked-out feature + branch. +- Before publishing, inspect both the commit list and + `git diff --stat origin/master...HEAD`. Rebase or cherry-pick onto the intended + base if the diff contains unrelated work. -## Room State +## Monorepo Discipline -- Room user state uses Mongoose `Map`s keyed by WCA numeric user ids converted - to strings. Preserve that convention when reading or writing maps like - `waitingFor`, `competing`, `banned`, `inRoom`, and `registered`. -- Room data is deliberately masked differently for lobby users and joined users - in `server/socket/namespaces/rooms.js`. -- Normal rooms are marked stale with `expireAt` TTL when empty. Grand Prix rooms - are timer-driven and have different lifecycle behavior. +- This is a Yarn classic workspace monorepo. Install from the repository root, + use the root `yarn.lock`, and do not create workspace-level lockfiles. +- Prefer root Turbo commands for broad checks and workspace-filtered commands + for focused iteration. Follow existing package boundaries rather than adding + cross-workspace source imports. +- Before adding a dependency, check whether the platform or an existing + dependency already solves the problem clearly. Add it only to the workspace + that uses it and explain non-obvious choices. -## Auth And Sessions +## Local Machine Gotcha -- Express sessions are stored in MongoDB and shared with Socket.IO through - `express-socket.io-session`. -- Client API requests use `credentials: 'include'`; auth, CORS, and socket - changes need to preserve cookie behavior. -- WCA OAuth uses the `redirectUri` sent by the client. The mixed - `callbackUrl`/`callbackURL` config spelling is not currently the source of - truth for the auth redirect. +- Use the system Docker engine on this machine. The Docker Desktop context is + unreliable, so inspect the current context and run project Compose commands + with `DOCKER_CONTEXT=default`. -## Checks +## Verification And Handoff -- Tests are sparse. The server test script currently passes when no tests exist. -- The root pre-commit hook runs `yarn lint && yarn test`. -- Useful focused checks: - - `yarn turbo run lint --filter=letscube-client` - - `yarn turbo run test:ci --filter=letscube-client` - - `yarn turbo run lint --filter=letscube-server` - - `yarn turbo run test:ci --filter=letscube-server` +- Run the narrowest meaningful lint and tests while iterating, then broaden + checks when a change crosses workspace or runtime boundaries. +- Before handoff, review the complete diff for scope, run `git diff --check`, + and state exactly which checks passed, failed, or were not run. +- When committing, include a concise body explaining what changed and why. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d1766e5..d9e48c8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,69 +1,111 @@ -The project is split into 2 parts: the server, and the client. +# Contributing to Let's Cube -# Sending a Pull Request +Thank you for helping improve Let's Cube. Keep changes focused, preserve the +existing behavior outside the task, and include tests when behavior changes. -1. Fork the repo -2. Clone it to your machine +## Before You Start -```bash -git clone git@github.com:/letscube.git -cd letscube -git remote add upstream git@github.com:coder13/letscube.git -``` +Read the [development guide](docs/development.md) and the documentation for the +area being changed. The repository targets Node `22.17.0`, Yarn classic, and +Docker Compose. -3. Sync your local `master` branch with upstream: -```bash -get checkout master -git pull upstream master -``` +Install dependencies once from the repository root: -4. Create a new feature branch: -```bash -git checkout -b feature-branch +```sh +nvm use +corepack enable +yarn install --frozen-lockfile ``` -5. Commit and push the changes: -```bash -git push -u +Do not run separate installs or create lockfiles inside workspaces. + +## Branches + +Create branches from the latest `master`: + +```sh +git fetch origin +git switch -c your-branch origin/master ``` -7. Go to the [repository](https://github.com/coder13/letscube) and make a Pull Request to the `dev` branch. +If you use a fork, replace `origin` with the remote that tracks the canonical +repository. Pull requests should target `master`. + +Keep each pull request limited to one coherent change. Before publishing, +inspect both the commit list and the complete diff against `master` to make sure +the branch does not include work inherited from another feature branch. + +## Code Style -# Installing and Running: +- Follow the surrounding JavaScript and React patterns. +- The client and server use ESLint configurations based on Airbnb's style. +- Prefer small modules organized by responsibility over broad rewrites. +- Add comments only when they explain a non-obvious constraint or decision. +- Avoid unrelated formatting, dependency, or generated-file churn. -`npm install` in the root directory installs the pre-commit hook. +The repository contains modernized and legacy areas side by side. A change does +not need to modernize adjacent code unless that work is part of its purpose. -## to start the bash server: +## Tests And Checks -```bash -cd server/ -npm install -npm start +Run focused checks while iterating: + +```sh +yarn turbo run lint --filter=letscube-client +yarn turbo run test:ci --filter=letscube-client + +yarn turbo run lint --filter=letscube-server +yarn turbo run test:ci --filter=letscube-server + +yarn turbo run lint --filter=letscube-scrambles +yarn turbo run test:ci --filter=letscube-scrambles ``` -## to start the client dev server: +Run the broad checks before requesting review when the change crosses workspace +boundaries: -```bash -cd client/ -npm install -npm start +```sh +yarn lint +yarn test +yarn build ``` -# Pre-commit hook -When installed properly, the pre-commit hook won't let you commit without the client and server being properly linted and tests being ran. +Use `yarn cypress:run` for behavior that spans the browser, API, and Socket.IO +server. See [Development](docs/development.md#full-stack-cypress) for the local +stack expected by Cypress. -# Linting +Every behavior change should have a test at the narrowest useful layer. UI +changes should include screenshots or a short recording in the pull request. -Both the client and the server use eslint and a slight variation of [airbnb's style guide](https://github.com/airbnb/javascript). +## Database Changes + +PostgreSQL schema changes must be represented in Prisma and committed as a +migration. Validate them with: + +```sh +yarn workspace letscube-server postgres:schema:validate +yarn workspace letscube-server postgres:migrate +yarn workspace letscube-server postgres:schema:check +``` -# Tests +Migrations must remain compatible with the previously deployed application +image because application rollback does not reverse database migrations. Read +[Data and migrations](docs/data.md) before changing persistence. -LetsCube is currently using Jest and Enzyme for testing. Right now there is very minimal tests but I would like to add much more coverage in time. For any complicated computational code, it should be tested to make sure there are no errors. +## Pull Requests -# Nit picky stuff: +A pull request should include: -This project uses React: A component should be made whenever something gets too complicated (like with the timesTable) or if it's going to be used repeatedly. +- the problem and intended behavior; +- a concise summary of the implementation; +- the checks that passed, failed, or were not run; +- migration, deployment, or compatibility considerations; +- screenshots for visible UI changes; and +- links to relevant issues. -Redux: I'd like to keep the number of reducers relatively low. Please get in contact with me before adding another reducer. +Write commit subjects in the imperative mood. Include a short commit body that +explains what changed and why. -Socket.IO: A common code pattern I found myself using is socket events to send information from a client and a separate event to echo the data to clients. If not done properly and one event was used for both, an infinite loop would happen. Take this into consideration when creating events that echo to other users. +Do not include credentials, production data, access tokens, or private logs. If +you discover a vulnerability, follow [SECURITY.md](SECURITY.md) instead of +opening a public issue. diff --git a/README.md b/README.md index 363d1e4..2064538 100644 --- a/README.md +++ b/README.md @@ -1,93 +1,71 @@ # Let's Cube -This is a Progressive Web app written in Node.JS, Express, MongoDB, -PostgreSQL, socket.io, React, and Material UI. +Let's Cube is a progressive web app for racing other cubers in real time. It +combines a React client, an Express API, and a Socket.IO server with shared room +state and scramble generation. -This project consists of a client and a server. +The production site is [letscube.net](https://letscube.net). -## Development: +## Repository Layout -Make sure Node.JS and Docker are installed. Docker Compose starts the local -MongoDB, PostgreSQL, and Redis services used by the app. +- `client/` — React, Redux, Material UI, Vite, and the service worker +- `server/` — Express, authentication, Socket.IO, persistence, and metrics +- `packages/scrambles/` — shared event catalog and scramble provider +- `cypress/` — full-stack browser smoke tests +- `scripts/` — deployment, backup, restore, and deployment tests +- `docs/` — architecture, development, data, realtime, and operations guides -``` -git clone https://github.com/coder13/letscube.git -cd letscube -yarn install +This is a Yarn classic workspace monorepo. Install dependencies from the root +and use the root `yarn.lock`. + +## Quick Start + +Prerequisites are Node `22.17.0`, Yarn `1.22.22`, Docker, and Docker Compose. + +```sh +nvm use +corepack enable +yarn install --frozen-lockfile docker compose -f docker-compose.yml up -d +yarn workspace letscube-server postgres:migrate ``` -The local PostgreSQL service is exposed on port `55433` to avoid collisions -with other projects. MongoDB remains the source of truth while PostgreSQL is a -non-blocking dual-write target. Prisma owns the PostgreSQL schema and migration -history. Apply committed migrations locally with: +Run the three application processes in separate terminals: -```bash -yarn workspace letscube-server postgres:migrate +```sh +yarn start:client # http://localhost:3000 +yarn start:server # http://localhost:8080 +yarn start:socket # http://localhost:9000 ``` -Create schema changes during development with -`yarn workspace letscube-server postgres:migrate:dev`. Production Compose runs -`prisma migrate deploy` as a separate one-shot service, keeping schema changes -out of the API and Socket.IO startup paths. CI applies every committed migration -to PostgreSQL 17 and checks the resulting database for drift from -`server/prisma/schema.prisma`. +The default development configuration uses the WCA staging site. A real WCA +OAuth application is required to exercise the complete login flow. -**Server** +See [Development](docs/development.md) for environment configuration, Docker +alternatives, tests, database migrations, and troubleshooting. -The server is split across 2 processes: +## Documentation -```bash -yarn start:server # Starts the file server, auth, and API requests on port 8080 -yarn start:socket # Run this in a separate terminal for Socket.IO on port 9000 -``` +- [Documentation index](docs/README.md) +- [Architecture](docs/architecture.md) +- [Development](docs/development.md) +- [Realtime behavior](docs/realtime.md) +- [Data and migrations](docs/data.md) +- [Production operations](docs/operations.md) +- [Contributing](CONTRIBUTING.md) +- [Security](SECURITY.md) -**Client** +## Checks -```bash -yarn start:client +```sh +yarn lint +yarn test +yarn build ``` -For more on the internals and contributing, check out the [wiki](https://github.com/coder13/LetsCube/wiki) - -## Metrics - -The server stores pseudonymous room and authentication events in both the -`metric_events` MongoDB collection and the PostgreSQL `analytics.events` table. -It records room creations, joins, join failures, leaves and visit duration, -accepted result counts, and authentication failures. Peak room users and peak -room solve counts are the maximum `activeUserCount` and `roomSolveCount` values -for each pseudonymous room. - -Raw events expire after 90 days by default. Set `METRICS_RETENTION_DAYS` to a -different positive number of days, or set `METRICS_ENABLED=false` to disable -collection. Production should set a dedicated `METRICS_HASH_SECRET`; when it is -not set, the session `AUTH_SECRET` is used. Changing this secret breaks the -ability to correlate pseudonymous users and rooms across the change. - -Metrics never include names, email addresses, WCA IDs, room names, passwords, -access codes, OAuth credentials, chat content, scramble text, or solve times. - -## PostgreSQL dual writes - -New MongoDB writes are mirrored into PostgreSQL without changing application -reads. PostgreSQL receives users and preferences, rooms and participant state, -attempts, durable solve results, and sanitized analytics events. OAuth access -tokens are deliberately not copied. Writes use deterministic UUIDs and upserts, -so retries and future backfills are idempotent. Live room saves mirror only the -attempts and results changed by that save; complete room snapshots are reserved -for explicit backfills. Changing a room event explicitly replaces that room's -PostgreSQL attempts so removed MongoDB attempts do not remain queryable. - -Solve penalties use dedicated boolean columns rather than JSON so histories and -statistics remain compact and index-friendly. User solve history is indexed by -creation time and solve ID for stable cursor pagination. - -Set `POSTGRES_ENABLED=false` to disable mirroring. Production should set -`PGHOST`, `PGDATABASE`, `PGUSER`, and `POSTGRES_PASSWORD`, or provide a -`DATABASE_URL`. When runtime traffic uses a pooled connection, set -`DIRECT_DATABASE_URL` to the direct connection used by Prisma Migrate. External -TLS connections can set `PGSSL=true` and provide a CA with `PGSSL_CA`; -certificate verification is enabled by default. PostgreSQL failures are logged -but do not fail the corresponding MongoDB-backed application operation during -this migration phase. +The pre-commit hook runs lint and unit tests. Pull requests also run focused +client and server jobs plus a Cypress full-stack smoke test. + +## License + +Let's Cube is available under the terms in [LICENSE](LICENSE). diff --git a/README_DEPLOYMENT.md b/README_DEPLOYMENT.md deleted file mode 100644 index ce85f15..0000000 --- a/README_DEPLOYMENT.md +++ /dev/null @@ -1,249 +0,0 @@ -# LetsCube Deployment - -This repo supports running LetsCube with Docker Compose on one VPS. The intended production stack is `api`, `socket`, `mongo`, `postgres`, `redis`, and `nginx`. - -## Current Startup Audit - -- The app currently has two Node entrypoints, not one: `server/index.js` for Express/API/static frontend on port `8080`, and `server/socket/index.js` for Socket.IO on port `9000`. -- The Express server serves `client/build` directly, so the production Docker image builds the React client and copies it next to the server code. -- PM2 is no longer needed in Docker. Compose runs `api` and `socket` as separate services with `restart: unless-stopped`. -- MongoDB was previously read from `server/config/*.json` as `mongodb://127.0.0.1/letscube`; containers should use `MONGO_URL=mongodb://mongo:27017/letscube`. -- Redis was previously hardcoded to `localhost:6379` in the socket process; it is now env-driven with `REDIS_URL=redis://redis:6379`. -- Required production secrets are `AUTH_SECRET`, `WCA_CLIENT_ID`, and `WCA_CLIENT_SECRET`. The client build also needs `REACT_APP_WCA_CLIENT_ID`. -- The target Node runtime is Node `22.17.0`, and the frontend is built with Vite. -- No separate background workers or queues were found. Redis is used by Socket.IO for coordination. - -## Development - -Create `.env.dev` from `.env.example`, then run: - -```bash -docker compose -f compose.yml -f compose.dev.yml --env-file .env.dev up -``` - -Development ports: - -- Client: `http://localhost:3000` -- API/static server: `http://localhost:8080` -- Socket.IO: `http://localhost:9000` -- MongoDB: `127.0.0.1:27017` -- Redis: `127.0.0.1:6379` - -The dev override bind mounts the repo and keeps container `node_modules` in named volumes. - -## Production - -Create `.env.prod` from `.env.example` on the VPS and fill in real values: - -```bash -cp .env.example .env.prod -``` - -Start or update production: - -```bash -APP_IMAGE_TAG="$(git rev-parse HEAD)" \ - docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod up -d --build -``` - -Using the full commit SHA keeps manually deployed application images -immutable. `APP_IMAGE_TAG=local` remains available for direct development and -one-off Compose use. The deployment script always overrides `APP_IMAGE_TAG` -with the commit it checked out. - -The nginx container listens on ports `80` and `443`, proxies `/socket.io/` to `socket:9000`, and proxies everything else to `api:8080`. MongoDB, PostgreSQL, and Redis are internal only. - -TLS expects Let’s Encrypt files at: - -```text -/etc/letsencrypt/live/www.letscube.net/fullchain.pem -/etc/letsencrypt/live/www.letscube.net/privkey.pem -``` - -Copy existing certs from the old VPS or issue new certs before starting nginx. - -## Operations - -View status: - -```bash -docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod ps -``` - -View logs: - -```bash -docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f api -docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f socket -docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f mongo -docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f postgres -docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f redis -docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs -f nginx -``` - -Restart services: - -```bash -docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod restart api -docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod restart socket -``` - -Deploy latest code from the server checkout: - -```bash -APP_DIR=/opt/letscube scripts/deploy.sh -``` - -The default `DEPLOY_TARGET=auto` chooses the smallest safe application target: - -| Changed paths | Resolved target | -| --- | --- | -| Documentation, CI, or agent metadata only | `none` | -| Browser client only | `api` (which also serves the static client) | -| Shared socket protocol, server, dependency, container, deployment, or unknown files | `all` | - -Override the classifier when the intended runtime impact is known: - -```bash -DEPLOY_TARGET=api APP_DIR=/opt/letscube scripts/deploy.sh -DEPLOY_TARGET=socket APP_DIR=/opt/letscube scripts/deploy.sh -DEPLOY_TARGET=all APP_DIR=/opt/letscube scripts/deploy.sh -DEPLOY_TARGET=none APP_DIR=/opt/letscube scripts/deploy.sh -``` - -Accepted values are `auto`, `api`, `socket`, `all`, and `none`. Explicit -targets are honored even when the checkout is already current. With `auto`, an -unchanged checkout exits without replacing healthy running containers. If API, -socket, or nginx is missing, `auto` bootstraps both application services; -`DEPLOY_FORCE=true` also rebuilds and replaces both at the current commit. -Readiness waits are capped at 60 seconds before automatic rollback; override -that bound with `DEPLOY_WAIT_TIMEOUT_SECONDS` when diagnosing unusually slow -startup. - -You can verify the conservative path classifier without fetching code or -touching Docker: - -```bash -scripts/test-deploy-classifier.sh -scripts/test-deploy.sh -scripts/classify-deploy-target.sh client/src/App.jsx -``` - -For any selected application target, the deploy script builds one shared image -tagged with the full commit SHA, waits for backing services without recreating -them, and applies PostgreSQL migrations before replacing application -containers. It snapshots the exact image ID of every selected running service, -then replaces and health-checks services one at a time. For `all`, socket rolls -out before API/static so the backward-compatible server is ready before the new -browser client is served. nginx is gracefully reloaded only after a selected -service changes, so it resolves the replacement container without dropping -unrelated connections. If nginx is not running during a bootstrap deploy, its -startup is deferred until the selected application services are ready. - -If replacement readiness or the nginx reload fails, recent diagnostic logs are -printed and every application service attempted in that deploy is restored in -reverse order using its exact pre-deploy image and Compose configuration. nginx -is then reloaded again. -Rollback image tags are retained locally for manual recovery until normal image -cleanup. Database migrations are not automatically reversed, so migrations -must remain backward-compatible with the previous application image. - -The checkout remains at the fetched commit after a failed deploy. Correct the -failure and rerun with the explicit target printed by the script; a later -`auto` run with no new commit otherwise treats the checkout as up to date. - -Socket clients briefly reconnect only when `socket` is selected. Room -membership, the current scramble, waiting/competing state, and room admin are -preserved during `ROOM_RECONNECT_GRACE_MS` (60 seconds by default). A client -that reconnects within that window resumes the existing room without being -logically removed first. Only users with no socket on any Socket.IO instance -after the grace window are removed from the room. - -An in-progress solve continues timing in the browser during that reconnect. If -the solve finishes before the socket is ready, the result is retained locally, -the next solve is blocked, and the saved result is submitted only after the -client rejoins the room. The local copy is cleared only after the server -acknowledges persistence, so a refresh or repeated reconnect does not discard -the time. - -Set the grace window in `.env.prod` if production deploys or client reconnects -need more time: - -```bash -ROOM_RECONNECT_GRACE_MS=90000 -``` - -Keep the grace shorter than the time in which an abandoned room should appear -active. Explicit leaves, kicks, and bans remain immediate and do not use the -grace window. - -Grand Prix is intentionally disabled in production: - -```bash -GRAND_PRIX_ENABLED=false -``` - -Keep this setting disabled until the legacy scheduler is redesigned. Setting -it to `true` explicitly restores the old mode for development or controlled -testing. - -### Health Checks - -The API and socket processes expose dependency-aware health endpoints. -Unavailable MongoDB or Redis returns HTTP `503` with the failing check marked -`error`. PostgreSQL dual writes are optional, so a PostgreSQL outage returns -HTTP `200` with overall status `degraded` while the primary MongoDB/Redis path -remains ready. - -```bash -curl -sS https://letscube.net/health/api -curl -sS https://letscube.net/health/socket -``` - -The default Socket.IO namespace also accepts a `health_check` event. It returns -the socket health report through the acknowledgment callback, or emits -`health_status` when no callback is provided. This tests a real Socket.IO -connection in addition to the HTTP readiness endpoint. - -## Backups And Restore - -Run a MongoDB backup: - -```bash -APP_DIR=/opt/letscube BACKUP_DIR=/opt/letscube/backups scripts/backup-mongo.sh -``` - -The backup script creates timestamped gzip archives and keeps 7 daily, 4 weekly, and 3 monthly local backups. To upload off-server, configure `BACKUP_S3_URI` plus AWS or S3-compatible credentials in the environment. - -Example cron: - -```cron -15 3 * * * cd /opt/letscube && /opt/letscube/scripts/backup-mongo.sh >> /opt/letscube/logs/backup-mongo.log 2>&1 -``` - -Restore from a backup: - -```bash -APP_DIR=/opt/letscube scripts/restore-mongo.sh /opt/letscube/backups/letscube-mongo-daily-YYYYmmddTHHMMSSZ.archive.gz -``` - -The restore script requires typing `RESTORE` and uses `mongorestore --drop`. - -## Migration To A New VPS - -Do not upgrade the old VPS in place for this migration. - -1. Snapshot the existing VPS. -2. Take a fresh MongoDB backup on the current server and copy it off-server. -3. Create a new VPS. -4. Install Docker Engine and the modern `docker compose` plugin. -5. Clone this repo to `/opt/letscube`. -6. Create `.env.prod` with production secrets and domains. -7. Copy or issue Let’s Encrypt certs for `letscube.net`. -8. Start MongoDB, PostgreSQL, and Redis containers. -9. Restore MongoDB from the production backup. -10. Start the full production stack. -11. Test frontend loading, login, room create/join, race/session flows, Redis-backed socket behavior, backups, and restore on non-production data. -12. Confirm MongoDB, PostgreSQL, and Redis are not publicly reachable. -13. Point DNS at the new VPS. -14. Keep the old VPS available for rollback until the new stack has been stable. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..fd3f5ad --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,40 @@ +# Security Policy + +## Supported Version + +Let's Cube is deployed continuously from `master`. Security fixes are made on +the current codebase; older commits and abandoned branches are not supported. + +## Reporting A Vulnerability + +Do not open a public issue for a suspected vulnerability. + +Use GitHub's private vulnerability reporting flow for +[`coder13/LetsCube`](https://github.com/coder13/LetsCube/security/advisories/new). +If that flow is unavailable, contact the repository owner privately and share +only enough information to establish a secure follow-up channel. + +Please include: + +- the affected endpoint or component; +- reproduction steps or a minimal proof of concept; +- the likely impact; +- any preconditions, such as authentication or room membership; and +- suggested mitigations, if known. + +Avoid accessing data that is not yours, disrupting production traffic, or +publishing exploit details before a fix is available. + +## Secrets And Sensitive Data + +- Never commit `.env` files, OAuth credentials, session secrets, database + passwords, TLS private keys, access tokens, or production backups. +- Use placeholders in examples and redact secrets from logs and screenshots. +- Use WCA staging credentials and non-production data for local testing. +- Treat room passwords, access codes, session cookies, OAuth data, email + addresses, and production identifiers as sensitive. +- Production access should use individual authenticated accounts and the + least privilege needed for the task. + +See [Data and migrations](docs/data.md) for stored-data boundaries and +[Production operations](docs/operations.md) for backup and deployment safety. diff --git a/client/src/store/default/README.md b/client/src/store/default/README.md deleted file mode 100644 index d1bc5ae..0000000 --- a/client/src/store/default/README.md +++ /dev/null @@ -1,7 +0,0 @@ -Default namespace - ---- - -These are actions related to the default namespace. - -Currently, the only action / data we care about relates to the users online. \ No newline at end of file diff --git a/docs/README.md b/docs/README.md index 5b46e64..63ca2f1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,9 +1,38 @@ -# Letscube +# Let's Cube Documentation -## At a Glance +This directory contains the durable technical documentation for Let's Cube. -Letscube is a progressive webapp that lets users race against eachother solving rubik's cubes in realtime. +## Guides -The frontend is built with React, Material-UI, and Redux. +- [Architecture](architecture.md) — system topology, process boundaries, and + request/data flow +- [Development](development.md) — local setup, commands, tests, and common + development workflows +- [Realtime behavior](realtime.md) — Socket.IO namespaces, room lifecycle, + reconnects, and result delivery +- [Data and migrations](data.md) — MongoDB, PostgreSQL, Prisma, metrics, and + persistence guarantees +- [Production operations](operations.md) — deployment, health checks, rollback, + backups, restore, and capacity management -The backend is built with NodeJS, Express and MongoDB, and they are both connected with socket.io. \ No newline at end of file +Package documentation: + +- [`letscube-scrambles`](../packages/scrambles/README.md) — public exports, + provider policy, and adding events + +Repository-level documents: + +- [Project overview](../README.md) +- [Contributing](../CONTRIBUTING.md) +- [Security](../SECURITY.md) +- [Coding-agent guidance](../AGENTS.md) + +## Documentation Ownership + +Keep setup and contributor workflow in the development and contributing guides. +Put runtime and feature contracts in the closest technical guide or package +README. Put production procedures in the operations guide. Tests and code remain +the final authority when documentation and implementation disagree. + +Do not use documentation files for active task status, issue triage, or backlog +planning; keep those in GitHub issues, milestones, and pull requests. diff --git a/docs/_config.yml b/docs/_config.yml index 2f7efbe..602b29e 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -1 +1,2 @@ -theme: jekyll-theme-minimal \ No newline at end of file +title: Let's Cube Documentation +theme: jekyll-theme-minimal diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..33c6d85 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,122 @@ +# Architecture + +Let's Cube is a realtime progressive web app with independently running HTTP +and Socket.IO servers. The browser is built once and served by the HTTP process +in production. + +## System Topology + +```text + +--------------------+ + | React / Redux | + | browser | + +---------+----------+ + | + HTTPS and Socket.IO + | + +---------v----------+ + | nginx | + +----+-----------+---+ + | | + HTTP/API | | /socket.io + | | + +--------v--+ +--v-------------+ + | Express | | Socket.IO | + | API/static| | room/default NS| + +-----+-----+ +---+----------+-+ + | | | + +-------+-------+ | + | | + +----v----+ +-----v----+ + | MongoDB | | Redis | + +----+----+ +----------+ + | + non-blocking mirrors + | + +----v------+ + |PostgreSQL | + +-----------+ +``` + +PostgreSQL is written independently by both Node processes; it is shown below +MongoDB to emphasize the current migration direction, not a direct +MongoDB-to-PostgreSQL connection. + +## Repository Boundaries + +| Path | Responsibility | +| --- | --- | +| `client/` | React UI, Redux state, socket middleware, timers, and PWA assets | +| `server/` | Express API, authentication, Socket.IO, MongoDB models, PostgreSQL mirrors, and metrics | +| `packages/scrambles/` | Browser-safe event catalog and server-side scramble generation | +| `cypress/` | Full-stack browser smoke tests | +| `scripts/` | Deployment, rollback tests, backup, and restore tooling | + +The root is a Yarn classic workspace managed by Turbo. Shared code should live +in a workspace package rather than importing source files across client/server +boundaries. + +## Runtime Processes + +### Browser client + +The client uses React 16, Redux, connected-react-router, Material UI v4, and +Socket.IO Client. Vite serves and builds the client; Jest still uses the older +CRA/Babel-compatible test transforms. Socket connections are owned by Redux +middleware under `client/src/store/middlewares/` so connection state and room +state stay coordinated outside the component lifecycle. + +### HTTP/API process + +`server/index.js` starts Express on port `8080`. It: + +- connects to MongoDB and optionally PostgreSQL; +- installs the shared Mongo-backed session middleware and Passport; +- serves `/auth`, `/api`, and `/health/api`; +- serves the production client build; and +- falls back to `index.html` for client-side routes. + +### Socket.IO process + +`server/socket/index.js` starts Socket.IO on port `9000`. It: + +- shares the Express session through `express-socket.io-session`; +- authenticates sockets from the session; +- uses the Redis adapter for cross-process socket coordination; +- exposes the default (`/`) and rooms (`/rooms`) namespaces; and +- exposes `/health/socket` on the same HTTP server. + +The API and Socket.IO processes can be replaced independently, but shared +protocol changes must remain compatible while two revisions briefly overlap +during a deployment. + +## Authentication And Sessions + +WCA OAuth is the normal authentication path. The API creates an Express session +stored in MongoDB, and both the HTTP and Socket.IO processes read that session. +Browser API requests include credentials, and Socket.IO is configured for +credentialed cross-origin connections. + +Development defaults point at the WCA staging site. The test-only custom login +flow is enabled with `LETSCUBE_TEST_AUTH=true` and must not be enabled in +production. + +## Data Services + +- **MongoDB** is the application source of truth and backs sessions, users, + rooms, results, and metric events. +- **Redis** supports the Socket.IO adapter and cross-instance presence checks. +- **PostgreSQL** is an optional, non-blocking dual-write target for normalized + application data and analytics. Application reads do not depend on it yet. + +See [Data and migrations](data.md) for schemas and consistency guarantees. + +## Production Shape + +The production Compose stack runs `api`, `socket`, `mongo`, `postgres`, +`redis`, and `nginx`. API and Socket.IO use the same immutable application image +tagged with the deployed commit SHA. nginx terminates TLS, routes Socket.IO +traffic to `socket`, and sends all other traffic to `api`. + +See [Production operations](operations.md) for rollout and recovery behavior and +[Realtime behavior](realtime.md) for disconnect and result-delivery contracts. diff --git a/docs/data.md b/docs/data.md new file mode 100644 index 0000000..e12e6ef --- /dev/null +++ b/docs/data.md @@ -0,0 +1,144 @@ +# Data And Migrations + +Let's Cube currently uses MongoDB as its source of truth and PostgreSQL as a +non-blocking dual-write target. Redis is runtime coordination, not durable +application storage. + +## MongoDB + +MongoDB owns application reads and primary writes. Mongoose models cover: + +- users and WCA profile/preferences; +- rooms, participants, attempts, scrambles, and results; +- pseudonymous metric events; and +- Express sessions through `connect-mongo`. + +Room attempts and participant state are embedded in room documents. User-keyed +maps use string forms of WCA numeric user IDs. Normal empty rooms use the +`expireAt` TTL index for delayed cleanup. + +MongoDB write success determines application success. PostgreSQL mirror failure +must not reject a Mongo-backed operation during the current migration phase. + +## PostgreSQL + +Prisma defines PostgreSQL in `server/prisma/schema.prisma`, using two schemas: + +| Schema | Contents | +| --- | --- | +| `app` | users, rooms, participants, attempts, and solves | +| `analytics` | pseudonymous metric events | + +PostgreSQL receives normalized copies of users/preferences, rooms and +participant state, attempts, durable solves, and sanitized analytics. OAuth +access tokens are deliberately not mirrored. + +Application reads do not use PostgreSQL yet. Setting `POSTGRES_ENABLED=false` +disables initialization and mirrors. PostgreSQL failures are logged, while API +and Socket.IO health report the service as `degraded` instead of unavailable. + +## Dual-Write Guarantees + +`server/postgres/dualWrite.js` derives stable UUIDs from Mongo/WCA identifiers +and uses upserts so retries and future backfills remain idempotent. + +Room saves collect changed attempts/results and mirror only that delta. An +explicit event change replaces that room's PostgreSQL attempts so attempts +removed from MongoDB do not remain queryable. Complete snapshots are reserved +for explicit backfill behavior. + +The mirror intentionally catches database failures and returns control to the +MongoDB-backed request. Monitoring must surface mirror errors because users may +otherwise see success while PostgreSQL lags. + +PostgreSQL `solves.time_ms` is an integer. Any ingestion path accepting +fractional JavaScript numbers must normalize or reject them explicitly rather +than relying on PostgreSQL coercion. + +## Prisma Workflow + +Apply committed migrations: + +```sh +yarn workspace letscube-server postgres:migrate +``` + +Create a migration during development: + +```sh +yarn workspace letscube-server postgres:migrate:dev +``` + +Validate the schema and detect drift: + +```sh +yarn workspace letscube-server postgres:schema:validate +yarn workspace letscube-server postgres:schema:check +``` + +CI starts PostgreSQL 17, validates the Prisma schema, applies every committed +migration, and checks the resulting database against `schema.prisma`. + +Production runs `prisma migrate deploy` as a one-shot Compose service before +application replacement. Application rollback does not reverse migrations, so +migrations must remain compatible with both the new image and the immediately +previous image. + +When runtime traffic uses a pooled `DATABASE_URL`, configure +`DIRECT_DATABASE_URL` for Prisma migration operations. External PostgreSQL can +enable TLS with `PGSSL`; certificate verification is enabled by default and a +CA can be supplied with `PGSSL_CA`. + +## Metrics And Privacy + +The metrics recorder stores room and authentication events in MongoDB's +`metric_events` collection and PostgreSQL's `analytics.events` table. It tracks +room creation, join success/failure, leave duration/reason, accepted results, +peak room users/solves, and authentication failures. + +Actors and rooms are pseudonymized with HMAC-SHA256. Production should set a +dedicated `METRICS_HASH_SECRET`; otherwise the auth secret is used. Changing the +hash secret breaks correlation with older metric records. + +Metrics must not contain: + +- names, email addresses, or WCA IDs; +- room names, passwords, or access codes; +- OAuth credentials or session data; +- chat content; +- scramble text; or +- solve times. + +Raw events expire after 90 days by default. The Node runtime recognizes +`METRICS_RETENTION_DAYS`, `METRICS_ENABLED`, and `METRICS_HASH_SECRET` when they +are present in its process environment. The current Compose service environment +does not forward those names from its env file, so adding them only to +`.env.prod` does not override the defaults; pass them to both application +services or update Compose as part of the configuration change. + +MongoDB uses a TTL index; PostgreSQL maintenance deletes expired analytics +records periodically. + +## Backups And Restore + +The repository backup scripts currently protect MongoDB, the application source +of truth. PostgreSQL can be rebuilt from future backfills only to the extent the +mirror covers the desired history; do not assume it is a complete MongoDB +backup. + +See [Production operations](operations.md#backups) for retention and restore +procedures. Never use routine Docker cleanup to remove database volumes. + +## Changing Persistence + +Before a data change, identify: + +1. the MongoDB source shape and read path; +2. whether PostgreSQL needs a new backward-compatible migration; +3. mirror idempotency and retry behavior; +4. backfill requirements for existing documents; +5. privacy and retention effects; and +6. health, monitoring, backup, and rollback consequences. + +Moving reads to PostgreSQL or making PostgreSQL required is a separate migration +decision and must not happen incidentally as part of a schema change. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..91d4bb2 --- /dev/null +++ b/docs/development.md @@ -0,0 +1,202 @@ +# Development + +## Prerequisites + +- Node `22.17.0` (the version in `.nvmrc`) +- Yarn `1.22.22` through Corepack or a compatible local installation +- Docker Engine with Docker Compose + +Install JavaScript dependencies from the repository root: + +```sh +nvm use +corepack enable +yarn install --frozen-lockfile +``` + +The root `yarn.lock` is authoritative. Do not install separately inside +`client/`, `server/`, or `packages/*`. + +## Local Services With Host Node + +The simplest workflow runs MongoDB, PostgreSQL, and Redis in Docker while the +three application processes run on the host: + +```sh +docker compose -f docker-compose.yml up -d +yarn workspace letscube-server postgres:migrate +``` + +Service ports are bound to localhost: + +| Service | Address | +| --- | --- | +| Client | `http://localhost:3000` | +| API | `http://localhost:8080` | +| Socket.IO | `http://localhost:9000` | +| MongoDB | `127.0.0.1:27017` | +| PostgreSQL | `127.0.0.1:55433` | +| Redis | `127.0.0.1:6379` | + +Start the app in separate terminals: + +```sh +yarn start:client +yarn start:server +yarn start:socket +``` + +Stop the backing services without deleting their data: + +```sh +docker compose -f docker-compose.yml down +``` + +Do not add `--volumes` unless you intentionally want to delete all local +database and Redis data. + +## Full Docker Development Stack + +To run the application processes in containers, copy the environment template +and use the base Compose file with its development override: + +```sh +cp .env.example .env.dev +docker compose -f compose.yml -f compose.dev.yml --env-file .env.dev up +``` + +The override bind-mounts the repository and stores each container's +`node_modules` in a named volume. Replace the example OAuth values in `.env.dev` +when testing a real WCA login. + +## Environment Configuration + +`server/runtimeConfig.js` combines `server/config/*.json` defaults with runtime +environment variables. Important groups are documented in `.env.example`: + +- process ports and feature flags; +- public client build origins; +- MongoDB, Redis, and PostgreSQL connections; +- session and WCA OAuth secrets; +- CORS origins and metric retention; and +- production TLS and backup paths. + +`.env.example` is a template, not a file automatically sourced by host Node +processes. Compose only passes variables referenced in its service environment; +export any additional server override explicitly or add it to the relevant +Compose service. + +The host development client reads `client/.env.development`. It targets the WCA +staging site and the standard local API/socket ports. Vite exposes the existing +`REACT_APP_*` names for compatibility with the client code. + +Never put production secrets in a tracked environment file. + +## Common Commands + +From the repository root: + +```sh +yarn start:client +yarn start:server +yarn start:socket +yarn lint +yarn test +yarn build +``` + +Focused checks: + +```sh +yarn turbo run lint --filter=letscube-client +yarn turbo run test:ci --filter=letscube-client +yarn turbo run lint --filter=letscube-server +yarn turbo run test:ci --filter=letscube-server +yarn turbo run lint --filter=letscube-scrambles +yarn turbo run test:ci --filter=letscube-scrambles +``` + +The client test runner accepts Jest arguments through `client/scripts/test.js`. +For example: + +```sh +yarn workspace letscube-client test --watchAll=false RoomConfigureDialog.test.jsx +yarn workspace letscube-server test roomAuthorization.test.js +``` + +## PostgreSQL Migrations + +Prisma owns `server/prisma/schema.prisma` and the committed migration history. + +```sh +# Apply committed migrations +yarn workspace letscube-server postgres:migrate + +# Create a migration while developing a schema change +yarn workspace letscube-server postgres:migrate:dev + +# Validate the Prisma schema +yarn workspace letscube-server postgres:schema:validate + +# Compare the migrated database with the schema +yarn workspace letscube-server postgres:schema:check +``` + +The schema check needs a reachable migrated PostgreSQL database. See +[Data and migrations](data.md) before changing tables or mirror behavior. + +## Full-Stack Cypress + +Cypress expects the client, API, and Socket.IO server on their standard ports. +One local sequence matching CI is: + +```sh +docker compose -f docker-compose.yml up -d +LETSCUBE_TEST_AUTH=true yarn workspace letscube-server node index.js +yarn workspace letscube-server node socket/ +yarn workspace letscube-client start +yarn cypress:run +``` + +Run the long-lived commands in separate terminals. `yarn cypress:open` starts +the interactive Cypress UI against the same stack. + +The test-only authentication mode bypasses WCA OAuth for Cypress fixtures. Do +not use it for production-like testing. + +## Troubleshooting + +### A port is already in use + +Inspect listeners before starting another copy of the app: + +```sh +lsof -nP -iTCP:3000 -iTCP:8080 -iTCP:9000 -sTCP:LISTEN +``` + +When testing an isolated worktree, use a complete set of alternate client, API, +and socket ports so the client does not accidentally connect to another +branch's backend. + +### Docker points at a missing socket + +Check `docker context show`, `docker context ls`, `DOCKER_HOST`, and +`DOCKER_CONTEXT`. Select a working engine before changing project files; a +stale Docker Desktop context is an environment problem, not an application +failure. + +### A service is unhealthy + +```sh +docker compose -f docker-compose.yml ps +docker compose -f docker-compose.yml logs mongo postgres redis +``` + +The API and Socket.IO health endpoints are `http://localhost:8080/health/api` +and `http://localhost:9000/health/socket`. + +### Tooling changes break unexpectedly + +The repository combines React 16, Material UI v4, Jest 26, ESLint 6, and modern +Vite. Run client unit tests and `yarn workspace letscube-client build` after +dependency or build-tool changes; success in only one path is not sufficient. diff --git a/docs/operations.md b/docs/operations.md new file mode 100644 index 0000000..d06496f --- /dev/null +++ b/docs/operations.md @@ -0,0 +1,236 @@ +# Production Operations + +This guide covers the Docker Compose production stack on a single VPS. Run +state-changing commands only when the deployment or maintenance action has been +explicitly authorized. + +## Production Stack + +The production stack combines `compose.yml` and `compose.prod.yml`: + +| Service | Role | +| --- | --- | +| `api` | Express API and static client on port 8080 inside Compose | +| `socket` | Socket.IO on port 9000 inside Compose | +| `mongo` | Primary application database and session store | +| `postgres` | Optional dual-write and analytics database | +| `redis` | Socket.IO adapter and cross-instance coordination | +| `nginx` | TLS termination and public reverse proxy | + +API and Socket.IO run the same `letscube-app` image with separate commands. +Production images are tagged with the full deployed commit SHA. + +## Environment And Host Prerequisites + +The host needs Docker Engine, Docker Compose, Git, the repository checkout, and +TLS certificates. Copy `.env.example` to an untracked `.env.prod`, replace every +placeholder, and restrict its permissions. + +Required secrets include: + +- `AUTH_SECRET`; +- `WCA_CLIENT_ID` and `WCA_CLIENT_SECRET`; +- `REACT_APP_WCA_CLIENT_ID` for the browser build; and +- `POSTGRES_PASSWORD` when PostgreSQL is enabled. + +Production TLS mounts default to `/etc/letsencrypt`, with nginx expecting the +certificate for `www.letscube.net`. MongoDB, PostgreSQL, and Redis should remain +internal to the Compose network. + +Do not assume a checkout path from an example or an old server. Determine the +active Compose project directory and pass it as `APP_DIR`. + +## Preflight + +Before deploying: + +1. Confirm the intended Docker context and clear stale `DOCKER_HOST` values. +2. Confirm the checkout is owned by the deployment account and has no local + changes. +3. Fetch the target branch and record the current and target commit SHAs. +4. Check free disk space and Docker usage. +5. Record current container status, image IDs, restart counts, and public health. + +Typical read-only checks: + +```sh +git status --short +git rev-parse HEAD +git rev-parse origin/master +df -h / +docker system df +docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod ps +curl -fsS https://letscube.net/health/api +curl -fsS https://letscube.net/health/socket +``` + +## Deploying + +Use `scripts/deploy.sh` for the Docker production stack: + +```sh +APP_DIR=/path/to/active/checkout \ + ENV_FILE=.env.prod \ + scripts/deploy.sh +``` + +The default `DEPLOY_TARGET=auto` classifies the files changed since the previous +commit: + +| Changed paths | Target | +| --- | --- | +| Documentation, CI, or agent metadata only | `none` | +| Browser client only | `api` | +| Server, shared protocol, dependencies, containers, deployment, or unknown | `all` | + +Override only when the runtime impact is known: + +```sh +DEPLOY_TARGET=api APP_DIR=/path/to/checkout scripts/deploy.sh +DEPLOY_TARGET=socket APP_DIR=/path/to/checkout scripts/deploy.sh +DEPLOY_TARGET=all APP_DIR=/path/to/checkout scripts/deploy.sh +DEPLOY_TARGET=none APP_DIR=/path/to/checkout scripts/deploy.sh +``` + +`DEPLOY_FORCE=true` rebuilds both application services at the current commit. +`DEPLOY_WAIT_TIMEOUT_SECONDS` changes the default 60-second readiness deadline. + +The deploy script: + +1. fetches and fast-forwards the configured branch; +2. builds one immutable application image; +3. starts backing services without recreating them; +4. applies committed PostgreSQL migrations; +5. snapshots the current image for every selected application service; +6. replaces and health-checks Socket.IO before API when both are selected; and +7. reloads nginx after each replacement. + +Socket.IO is deployed first so a backward-compatible server is available before +the new browser bundle is served. Shared protocol changes must support this +brief mixed-version window. + +`scripts/update-production.sh` is the older PM2-oriented updater. It is not the +canonical path for the Docker Compose stack. + +## Failed Deployments And Rollback + +A build, backing-service, or migration failure occurs before application +containers are replaced. If replacement readiness or nginx reload fails, +`scripts/deploy.sh` restores every attempted application service in reverse +order from its exact pre-deploy image and Compose definition, then reloads +nginx. + +The Git checkout remains at the fetched commit after failure. Correct the cause +and rerun with the explicit target printed by the script. + +Database migrations are not rolled back automatically. Every production +migration must remain compatible with the previous application image. + +The deploy script can be tested without production access: + +```sh +scripts/test-deploy-classifier.sh +scripts/test-deploy.sh +scripts/classify-deploy-target.sh client/src/components/App.jsx +``` + +## Verification And Monitoring + +After deployment, verify: + +- the checkout exactly matches the intended `origin/master` commit; +- selected containers use that commit's image and have zero unexpected restarts; +- all Compose services are healthy; +- `/health/api` and `/health/socket` succeed publicly; +- a real Socket.IO connection completes; and +- recent API, socket, nginx, and migration logs contain no new errors. + +Useful commands: + +```sh +docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod ps +docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs --tail=200 api +docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs --tail=200 socket +docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod logs --tail=200 nginx +``` + +`/health/api` requires MongoDB and treats PostgreSQL as optional. +`/health/socket` requires MongoDB and Redis and treats PostgreSQL as optional. An +optional PostgreSQL failure returns HTTP 200 with status `degraded`; a required +dependency failure returns HTTP 503 with status `error`. + +The default Socket.IO namespace also supports `health_check`, returning a health +report through its acknowledgement or a `health_status` event. + +## Backups + +Create a MongoDB backup with an explicit checkout and destination: + +```sh +APP_DIR=/path/to/checkout \ + BACKUP_DIR=/path/to/backups \ + scripts/backup-mongo.sh +``` + +The script creates gzip archives and retains 7 daily, 4 weekly, and 3 monthly +local backups. Set `BACKUP_S3_URI` and the documented AWS-compatible variables +to copy new archives off-server. + +Backups are only useful if restore is tested periodically against non-production +data. Monitor backup age, size, and off-server upload failures. + +## Restore + +Restore is destructive: `mongorestore --drop` replaces matching collections in +the target database. Verify the archive, target URI, and current backup before +continuing. + +```sh +APP_DIR=/path/to/checkout \ + scripts/restore-mongo.sh /path/to/letscube-mongo-backup.archive.gz +``` + +The script requires typing `RESTORE`. Never test restore against the production +database. + +## Disk And Log Retention + +Keep enough free space for a cold application image build as well as current and +rollback images. On the current small VPS class, 8–10 GB free is a practical +operating target; investigate at roughly 75% filesystem use and treat 85% as +critical. + +Inspect before deleting: + +```sh +df -h / +docker system df -v +journalctl --disk-usage +du -sh /path/to/backups +``` + +Safe retention principles: + +- keep the current image and at least two known-good rollback revisions; +- prune old unused build cache and dangling images, not in-use images; +- configure bounded Docker and systemd-journal retention; +- keep backup retention explicit; and +- never prune Docker volumes as routine cleanup. + +See the backup scripts before changing retention paths or schedules. Cron and +other host automation are external state; inspect the deployed account's actual +crontab rather than assuming the repository documents its current schedule. + +## Moving To A New VPS + +1. Snapshot the old VPS and take a fresh off-server MongoDB backup. +2. Provision Docker Engine and Compose on the new host. +3. Clone the repository and create a fresh `.env.prod`. +4. Install or copy TLS certificates securely. +5. Start MongoDB, PostgreSQL, and Redis. +6. Restore MongoDB and apply PostgreSQL migrations. +7. Start API, Socket.IO, and nginx. +8. Test login, room creation/join, timing, reconnect, health, backups, and + restore against non-production data. +9. Confirm data services are not publicly reachable. +10. Move DNS and retain the old VPS until the new stack is stable. diff --git a/docs/realtime.md b/docs/realtime.md new file mode 100644 index 0000000..4659d6b --- /dev/null +++ b/docs/realtime.md @@ -0,0 +1,136 @@ +# Realtime Behavior + +Realtime state is split between Socket.IO namespace handlers on the server and +Redux middleware in the browser. This document describes the contracts that +must survive reconnects and rolling deployments. + +## Namespaces And Ownership + +The server exposes two namespaces: + +- `/` tracks global presence and exposes Socket.IO health checks. +- `/rooms` owns room discovery, joins, room state, chat, timing results, and + administration. + +Protocol constants live in `client/src/lib/protocol.js` and its JSON source. +The server imports the same definitions. Add or change events across the shared +constants, client middleware, server handlers, and tests together. + +`Protocol.ERROR` maps to the historical literal event name `errorrr`. Renaming +it to `error` risks colliding with Socket.IO error behavior and requires a +deliberate compatibility migration. + +The browser's connections are managed under `client/src/store/middlewares/`. +React components dispatch actions and consume Redux state; they should not own +independent namespace connections. + +## Session Authentication + +The Express session is stored in MongoDB and attached to Socket.IO through +`express-socket.io-session`. Socket authentication derives the user from that +shared session. Socket.IO and API requests therefore depend on credentialed +cookie behavior remaining aligned across the browser, CORS, nginx, and both +Node processes. + +Redis backs the Socket.IO adapter. It allows namespace operations and presence +checks to account for sockets connected to more than one Socket.IO process. + +## Room Join And State Visibility + +The rooms namespace distinguishes lobby users from joined users. Lobby payloads +are masked more aggressively; joined-room payloads include the state required +to participate. Room passwords and other private authorization state must not +be exposed through lobby updates or logs. + +Room participant maps are keyed by WCA numeric user IDs converted to strings. +This convention applies to waiting, competing, banned, in-room, and registered +state. + +Private-room passwords are cached per room in browser local storage after a +successful join so a reconnect can repeat the join. The cached value is cleared +when the room disappears, becomes public, or rejects the saved password. + +## Disconnect Versus Leave + +A transport disconnect is temporary; an explicit leave, kick, or ban is an +immediate room operation. + +On disconnect, the server schedules departure after +`ROOM_RECONNECT_GRACE_MS` (60 seconds by default). If the user reconnects before +the timer completes, cleanup is cancelled. Before final departure, the server +checks for another active socket for the same user and room through the Redis +adapter. This prevents a deploy or second browser tab from producing a false +leave. + +During the grace window, preserve: + +- room membership; +- the current attempt and scramble; +- waiting and competing state; and +- room administration. + +Normal empty rooms receive an expiration timestamp. Explicit moderation and +leave actions do not use reconnect grace. + +## Client Rejoin + +Socket.IO reconnecting is only the transport step. The client must then rejoin +the rooms namespace and receive a successful room acknowledgement before it can +resume room-scoped operations. + +While reconnecting, keep the current room UI and timer state mounted. Do not +reset room state merely because the transport disconnected. If the room cannot +be rejoined, the UI must expose that state rather than silently submitting to a +different or unjoined room. + +## Result Delivery + +An in-progress solve is browser state and continues while the server is +unavailable. If the solve finishes before the room is rejoined, the client: + +1. creates a versioned pending result with a unique `submissionId` and immutable + attempt key; +2. stores it in local storage before attempting delivery; +3. blocks the next solve while that result is unresolved; +4. waits for both Socket.IO reconnect and successful room rejoin; +5. retries acknowledgement timeouts and retryable errors; and +6. clears the local copy only after the server acknowledges persistence. + +The server validates the attempt ID/key and uses `submissionId` to make retries +idempotent. Repeating the same submission returns success without creating a +second result. A conflicting result for the same user and attempt is rejected. + +The browser may accept the matching result echo as confirmation for older +server behavior, but new protocol work should use explicit acknowledgements. + +## Rolling Deployment Compatibility + +Production deploys replace Socket.IO before API/static. For a short period, the +new Socket.IO server may serve the old browser bundle; after API replacement, +the new browser may connect through clients with cached older assets. + +Protocol changes should therefore be additive or tolerant across adjacent +revisions. Avoid changing one event to mean two directions of traffic: use +separate incoming and broadcast events to prevent echo loops. + +Grand Prix is a legacy timer-driven mode and is disabled in production by +default with `GRAND_PRIX_ENABLED=false`. Normal-room lifecycle changes should +not depend on Grand Prix behavior. + +## Health + +`GET /health/socket` checks MongoDB, Redis, and optional PostgreSQL. The default +namespace also accepts `health_check`; it returns the same report through the +acknowledgement callback or emits `health_status` when no callback is supplied. + +## Change Checklist + +For a realtime behavior change, check: + +- shared protocol constants; +- client Redux middleware and reducers; +- namespace authorization and payload masking; +- disconnect, reconnect, and multi-tab behavior; +- acknowledgement and retry behavior; +- old-client/new-server compatibility; and +- focused client and server socket tests plus the Cypress smoke test. diff --git a/packages/scrambles/README.md b/packages/scrambles/README.md new file mode 100644 index 0000000..9f8302e --- /dev/null +++ b/packages/scrambles/README.md @@ -0,0 +1,63 @@ +# `letscube-scrambles` + +This private workspace owns Let's Cube's event catalog and server-side scramble +generation. It keeps event metadata shared between browser and server without +cross-importing application source directories. + +## Exports + +Generate a scramble: + +```js +const { generateScramble } = require('letscube-scrambles'); + +const scramble = await generateScramble('333'); +``` + +The generator is asynchronous for every event because cubing.js is asynchronous. + +Read the browser-safe catalog: + +```js +import Events from 'letscube-scrambles/events'; +``` + +The catalog subpath exports JSON. Keep it free of provider implementation +details so Vite can consume it as a stable default export. + +## Provider Policy + +Provider selection is explicit in `index.js`: + +- cubing.js is preferred for the events listed in `cubingEventIds`; +- Scrambow handles custom practice events such as PLL, ZBLL, LSE, RU, and + optimal Clock; and +- unknown event IDs are rejected. + +Do not treat Scrambow as a fallback for arbitrary cubing.js failures. A provider +failure should surface so incorrect or degraded scramble generation is visible. + +cubing.js publishes ESM-only subpaths. The CommonJS provider uses a cached +dynamic import and disables scramble prefetching for the server process. + +## Adding Or Changing An Event + +1. Add or update its public `id`, `name`, and `group` in `events.json`. +2. Add the ID to the cubing.js capability set or map it to a Scrambow type. +3. Update routing tests in `index.test.js`. +4. Verify the server awaits scramble generation and the client renders the + catalog entry. +5. Run a real generation smoke test in addition to mocked unit tests when a + provider or provider version changes. + +## Checks + +From the repository root: + +```sh +yarn turbo run lint --filter=letscube-scrambles +yarn turbo run test:ci --filter=letscube-scrambles +``` + +Changes affecting consumers should also run server tests and a production client +build. From c70e39ba6ded2f4dfba0b5325dffa10a768b8457 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 20:04:33 -0700 Subject: [PATCH 10/56] Render 2D scramble previews for all events Replace the 3x3-only SVG preview with lazy-loaded cubing.js visualizations. Add responsive click-to-enlarge dialogs so larger puzzles remain readable while preserving scramble text. --- client/package.json | 2 +- client/src/components/Room/Common/Main.jsx | 7 +- .../src/components/Room/Common/Main.test.jsx | 14 +- .../Room/GrandPrix/GrandPrixMain.jsx | 7 +- .../src/components/common/ScramblePreview.jsx | 170 ++++++++++++++++++ .../common/ScramblePreview.test.jsx | 112 ++++++++++++ client/src/lib/cubingTwisty.js | 8 + yarn.lock | 5 - 8 files changed, 312 insertions(+), 13 deletions(-) create mode 100644 client/src/components/common/ScramblePreview.jsx create mode 100644 client/src/components/common/ScramblePreview.test.jsx create mode 100644 client/src/lib/cubingTwisty.js diff --git a/client/package.json b/client/package.json index c40301c..eb1d2cc 100644 --- a/client/package.json +++ b/client/package.json @@ -9,13 +9,13 @@ "@material-ui/styles": "^4.11.5", "clsx": "1.1.0", "connected-react-router": "6.8.0", + "cubing": "0.63.3", "date-fns": "^2.16.1", "history": "4.10.1", "material-ui-confirm": "2.0.4", "prop-types": "15.7.2", "qs": "^6.9.6", "react": "^16.14.0", - "react-cube-svg": "^0.1.3", "react-dom": "^16.14.0", "react-ga": "2.7.0", "react-markdown": "6.0.3", diff --git a/client/src/components/Room/Common/Main.jsx b/client/src/components/Room/Common/Main.jsx index 7cdadda..44fd4bf 100644 --- a/client/src/components/Room/Common/Main.jsx +++ b/client/src/components/Room/Common/Main.jsx @@ -9,7 +9,6 @@ import Typography from '@material-ui/core/Typography'; import Button from '@material-ui/core/Button'; import ClickAwayListener from '@material-ui/core/ClickAwayListener'; import Alert from '@material-ui/lab/Alert'; -import { Cube } from 'react-cube-svg'; import UIfx from 'uifx'; import { push } from 'connected-react-router'; import notificationAsset from '../../../assets/notification.mp3'; @@ -32,6 +31,7 @@ import TimesTable from './TimesTable'; import HelpPopover from '../../common/HelpPopover'; import Timer from '../../Timer/index'; import Scramble from '../../common/Scramble'; +import ScramblePreview from '../../common/ScramblePreview'; import UserStats from './UserStats'; const useStyles = withStyles((theme) => ({ @@ -170,7 +170,7 @@ export class Main extends React.Component { } const stats = calcStats(attempts, users); - const showScramble = latestAttempt.scrambles && room.event === '333'; + const showScramble = latestAttempt.scrambles && latestAttempt.scrambles.length; return ( { this.onTimerDefocused(); }}> @@ -277,7 +277,8 @@ export class Main extends React.Component { }} variant="outlined" > - diff --git a/client/src/components/Room/Common/Main.test.jsx b/client/src/components/Room/Common/Main.test.jsx index 4f28d8b..cd61307 100644 --- a/client/src/components/Room/Common/Main.test.jsx +++ b/client/src/components/Room/Common/Main.test.jsx @@ -3,6 +3,7 @@ import { shallow } from 'enzyme'; import Alert from '@material-ui/lab/Alert'; import Button from '@material-ui/core/Button'; import Timer from '../../Timer/index'; +import ScramblePreview from '../../common/ScramblePreview'; import { Main } from './Main'; import { createPendingResult, @@ -14,7 +15,6 @@ import { } from '../../../store/room/actions'; jest.mock('../../Timer/StackmatTimer', () => () => null); -jest.mock('react-cube-svg', () => ({ Cube: () => null })); const pendingResult = createPendingResult({ userId: 42, @@ -61,6 +61,18 @@ const makeProps = (overrides = {}) => ({ }); describe('room pending result UX', () => { + it('shows scramble previews for non-3x3 events', () => { + const props = makeProps({ + room: { ...makeProps().room, event: 'pyram' }, + }); + const wrapper = shallow(
); + + expect(wrapper.find(ScramblePreview).props()).toMatchObject({ + event: 'pyram', + scramble: 'R U', + }); + }); + it('submits against the immutable attempt that was primed', () => { const props = makeProps({ room: { diff --git a/client/src/components/Room/GrandPrix/GrandPrixMain.jsx b/client/src/components/Room/GrandPrix/GrandPrixMain.jsx index 1a656ac..d88567e 100644 --- a/client/src/components/Room/GrandPrix/GrandPrixMain.jsx +++ b/client/src/components/Room/GrandPrix/GrandPrixMain.jsx @@ -9,7 +9,6 @@ import Divider from '@material-ui/core/Divider'; import Typography from '@material-ui/core/Typography'; import ClickAwayListener from '@material-ui/core/ClickAwayListener'; import Button from '@material-ui/core/Button'; -import { Cube } from 'react-cube-svg'; import { formatISO9075 } from 'date-fns'; import calcStats from '../../../lib/stats'; import { @@ -26,6 +25,7 @@ import TimesTable from '../Common/TimesTable'; import HelpPopover from '../../common/HelpPopover'; import Timer from '../../Timer/index'; import Scramble from '../../common/Scramble'; +import ScramblePreview from '../../common/ScramblePreview'; import UserStats from '../Common/UserStats'; import UserSelectorDialog from '../Common/UserSelectorDialog'; @@ -244,7 +244,7 @@ function Main({ room, user }) { || isPendingResult(room.resultSubmission && room.resultSubmission.pendingResult); const stats = calcStats(attempts, users); - const showScramble = latestAttempt.scrambles && room.event === '333'; + const showScramble = latestAttempt.scrambles && latestAttempt.scrambles.length; return ( { onTimerDefocused(); }}> @@ -323,7 +323,8 @@ function Main({ room, user }) { }} variant="outlined" > - diff --git a/client/src/components/common/ScramblePreview.jsx b/client/src/components/common/ScramblePreview.jsx new file mode 100644 index 0000000..1841570 --- /dev/null +++ b/client/src/components/common/ScramblePreview.jsx @@ -0,0 +1,170 @@ +import React, { useEffect, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; +import Dialog from '@material-ui/core/Dialog'; +import DialogContent from '@material-ui/core/DialogContent'; +import loadTwistyPlayer from '../../lib/cubingTwisty'; +import Scramble from './Scramble'; + +export const puzzleByEvent = { + 222: '2x2x2', + 333: '3x3x3', + '333bf': '3x3x3', + '333oh': '3x3x3', + '333ft': '3x3x3', + 444: '4x4x4', + '444bf': '4x4x4', + 555: '5x5x5', + '555bf': '5x5x5', + 666: '6x6x6', + 777: '7x7x7', + minx: 'megaminx', + pyram: 'pyraminx', + clock: 'clock', + 'clock-optimal': 'clock', + skewb: 'skewb', + sq1: 'square1', + fto: 'fto', + pll: '3x3x3', + zbll: '3x3x3', + lse: '3x3x3', + ru: '3x3x3', +}; + +function TwistyVisualization({ event, puzzle, scramble, size, expanded }) { + const containerRef = useRef(null); + + useEffect(() => { + let disposed = false; + let player; + + if (!puzzle || !scramble) { + return undefined; + } + + loadTwistyPlayer().then((TwistyPlayer) => { + if (disposed) { + return; + } + + player = new TwistyPlayer({ + puzzle, + alg: scramble, + visualization: '2D', + controlPanel: 'none', + background: 'none', + }); + // cubing.js 0.63.3 exposes this setter but rejects timestamp in constructor config. + player.timestamp = 'end'; + player.style.width = '100%'; + player.style.height = '100%'; + containerRef.current.appendChild(player); + }).catch(() => { + // The scramble text remains available if the preview cannot be rendered. + }); + + return () => { + disposed = true; + if (player && player.parentNode) { + player.parentNode.removeChild(player); + } + }; + }, [puzzle, scramble]); + + if (!puzzle || !scramble) { + return null; + } + + return ( +
+ ); +} + +TwistyVisualization.propTypes = { + event: PropTypes.string.isRequired, + puzzle: PropTypes.string.isRequired, + scramble: PropTypes.string.isRequired, + size: PropTypes.number.isRequired, + expanded: PropTypes.bool, +}; + +TwistyVisualization.defaultProps = { + expanded: false, +}; + +function ScramblePreview({ event, scramble, size }) { + const [expanded, setExpanded] = useState(false); + const puzzle = puzzleByEvent[event]; + + if (!puzzle || !scramble) { + return null; + } + + const handleKeyDown = (keyEvent) => { + if (keyEvent.key === 'Enter' || keyEvent.key === ' ') { + keyEvent.preventDefault(); + setExpanded(true); + } + }; + + return ( + <> +
setExpanded(true)} + onKeyDown={handleKeyDown} + style={{ cursor: 'zoom-in', width: '100%', maxWidth: size }} + > + +
+ setExpanded(false)} + aria-label={`${event} enlarged scramble preview`} + > + + +
+ +
+
+
+ + ); +} + +ScramblePreview.propTypes = { + event: PropTypes.string.isRequired, + scramble: PropTypes.string, + size: PropTypes.number, +}; + +ScramblePreview.defaultProps = { + scramble: '', + size: 240, +}; + +export default ScramblePreview; diff --git a/client/src/components/common/ScramblePreview.test.jsx b/client/src/components/common/ScramblePreview.test.jsx new file mode 100644 index 0000000..92ce1e5 --- /dev/null +++ b/client/src/components/common/ScramblePreview.test.jsx @@ -0,0 +1,112 @@ +import React from 'react'; +import { act, fireEvent, render } from '@testing-library/react'; +import Events from 'letscube-scrambles/events'; +import loadTwistyPlayer from '../../lib/cubingTwisty'; +import ScramblePreview, { puzzleByEvent } from './ScramblePreview'; + +jest.mock('../../lib/cubingTwisty', () => ({ + __esModule: true, + default: jest.fn(), +})); + +const players = []; + +const flushPlayerLoad = () => act(async () => { + await Promise.resolve(); +}); + +class TwistyPlayer { + constructor(config) { + const player = document.createElement('div'); + player.config = config; + players.push(player); + return player; + } +} + +beforeEach(() => { + jest.clearAllMocks(); + players.length = 0; + loadTwistyPlayer.mockResolvedValue(TwistyPlayer); +}); + +it('maps every configured event to a cubing.js puzzle', () => { + expect(Object.keys(puzzleByEvent).sort()).toEqual( + Events.map(({ id }) => id).sort(), + ); +}); + +it('renders the static 2D end state of a scramble', async () => { + const { getByRole } = render( + , + ); + + await flushPlayerLoad(); + expect(players).toHaveLength(1); + + expect(players[0].config).toEqual({ + puzzle: 'pyraminx', + alg: 'R U', + visualization: '2D', + controlPanel: 'none', + background: 'none', + }); + expect(players[0].timestamp).toBe('end'); + expect(getByRole('img')).toContainElement(players[0]); +}); + +it('enlarges the preview with its scramble text', async () => { + const { getByLabelText, getAllByRole, getByText } = render( + , + ); + await flushPlayerLoad(); + + fireEvent.click(getByLabelText('Enlarge 777 scramble preview')); + await flushPlayerLoad(); + + expect(getByLabelText('777 enlarged scramble preview')).toBeInTheDocument(); + expect(getByText('R U')).toBeInTheDocument(); + expect(getAllByRole('img')).toHaveLength(1); + expect(getAllByRole('img')[0]).toHaveStyle('width: 100%'); + expect(players).toHaveLength(2); +}); + +it('replaces and removes the player when the scramble changes', async () => { + const { rerender, unmount, getByRole } = render( + , + ); + await flushPlayerLoad(); + expect(players).toHaveLength(1); + + rerender(); + await flushPlayerLoad(); + expect(players).toHaveLength(2); + + expect(getByRole('img')).not.toContainElement(players[0]); + expect(getByRole('img')).toContainElement(players[1]); + + unmount(); + expect(players[1].parentNode).toBeNull(); +}); + +it('renders nothing without a supported event and scramble', () => { + const { container, rerender } = render( + , + ); + expect(container.innerHTML).toBe(''); + + rerender(); + expect(container.innerHTML).toBe(''); + expect(loadTwistyPlayer).not.toHaveBeenCalled(); +}); + +it('keeps the room usable if cubing.js cannot load', async () => { + loadTwistyPlayer.mockRejectedValue(new Error('preview failed')); + const { getByRole } = render( + , + ); + + await act(async () => {}); + + expect(getByRole('img').innerHTML).toBe(''); +}); diff --git a/client/src/lib/cubingTwisty.js b/client/src/lib/cubingTwisty.js new file mode 100644 index 0000000..4bbc23e --- /dev/null +++ b/client/src/lib/cubingTwisty.js @@ -0,0 +1,8 @@ +const loadTwistyPlayer = () => import( + // cubing publishes ESM-only subpath exports, which eslint-plugin-import cannot resolve. + // eslint-disable-next-line import/no-unresolved + 'cubing/twisty' +) + .then(({ TwistyPlayer }) => TwistyPlayer); + +export default loadTwistyPlayer; diff --git a/yarn.lock b/yarn.lock index a8b328d..2638744 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10071,11 +10071,6 @@ react-app-polyfill@^1.0.6: regenerator-runtime "^0.13.3" whatwg-fetch "^3.0.0" -react-cube-svg@^0.1.3: - version "0.1.5" - resolved "https://registry.yarnpkg.com/react-cube-svg/-/react-cube-svg-0.1.5.tgz#cf58ad81e83be4c3db7a35975f40fe531ceb414a" - integrity sha512-CyaH9PtxbnzvZr69+ryn2qr55oKQ2FYxREF460cucatObSlD8v5nUW9zJe0Yr9A0BQvlmaUpaGLBhloqfjNm5Q== - react-dom@^16.14.0: version "16.14.0" resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.14.0.tgz#7ad838ec29a777fb3c75c3a190f661cf92ab8b89" From 777f711ef367327bfefbd1f0786602f8d4eba95e Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 20:43:15 -0700 Subject: [PATCH 11/56] Remove WCA email from user data Request only public WCA identity, allowlist login fields, and stop MongoDB and PostgreSQL writes. Add a value-free two-phase purge with an enforced privacy rollback floor so the compatibility column can remain safely empty until a later drop. --- .gitignore | 1 + README.md | 20 +++++-- README_DEPLOYMENT.md | 47 +++++++++++++++++ client/src/components/Header.jsx | 16 +++--- client/src/lib/wcaAuth.js | 12 +++++ client/src/lib/wcaAuth.test.js | 15 ++++++ scripts/deploy.sh | 13 +++++ scripts/test-deploy.sh | 40 ++++++++++++++ server/auth/index.js | 11 +--- server/auth/wcaProfile.js | 11 ++++ server/auth/wcaProfile.test.js | 28 ++++++++++ server/models/user.js | 3 -- server/models/user.test.js | 25 +++++++++ server/package.json | 1 + server/postgres/dualWrite.js | 9 ++-- server/postgres/dualWrite.test.js | 7 +-- server/prisma/schema.prisma | 1 + server/privacy/purgeUserEmails.js | 42 +++++++++++++++ server/privacy/userEmailPurge.js | 33 ++++++++++++ server/privacy/userEmailPurge.test.js | 76 +++++++++++++++++++++++++++ 20 files changed, 379 insertions(+), 32 deletions(-) create mode 100644 client/src/lib/wcaAuth.js create mode 100644 client/src/lib/wcaAuth.test.js create mode 100644 server/auth/wcaProfile.js create mode 100644 server/auth/wcaProfile.test.js create mode 100644 server/models/user.test.js create mode 100644 server/privacy/purgeUserEmails.js create mode 100644 server/privacy/userEmailPurge.js create mode 100644 server/privacy/userEmailPurge.test.js diff --git a/.gitignore b/.gitignore index c4b0b9f..0b56507 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,4 @@ client/config/.env.development # Local build/worktree scratch .turbo .worktrees +.privacy-email-cutover diff --git a/README.md b/README.md index 363d1e4..557bfba 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,19 @@ yarn start:client For more on the internals and contributing, check out the [wiki](https://github.com/coder13/LetsCube/wiki) +## Identity privacy + +Let's Cube requests only the WCA OAuth `public` scope. WCA profile data is +allowlisted at login, and the application does not request, retain, mirror, +return, log, analyze, or search WCA email addresses or dates of birth. Product +identity uses the WCA numeric user ID internally and exposes a WCA ID only when +the user has explicitly enabled that existing profile preference. + +Any user-discovery feature must accept only its documented username and visible +WCA ID formats. An email-like input must not be treated as an identifier or +produce an existence signal. This is a required invariant for the Friend System, +not a future discovery mode. + ## Metrics The server stores pseudonymous room and authentication events in both the @@ -71,9 +84,10 @@ access codes, OAuth credentials, chat content, scramble text, or solve times. ## PostgreSQL dual writes New MongoDB writes are mirrored into PostgreSQL without changing application -reads. PostgreSQL receives users and preferences, rooms and participant state, -attempts, durable solve results, and sanitized analytics events. OAuth access -tokens are deliberately not copied. Writes use deterministic UUIDs and upserts, +reads. PostgreSQL receives public identity and preferences, rooms and +participant state, attempts, durable solve results, and sanitized analytics +events. OAuth access tokens are deliberately not copied. Writes use +deterministic UUIDs and upserts, so retries and future backfills are idempotent. Live room saves mirror only the attempts and results changed by that save; complete room snapshots are reserved for explicit backfills. Changing a room event explicitly replaces that room's diff --git a/README_DEPLOYMENT.md b/README_DEPLOYMENT.md index ce85f15..9274f4e 100644 --- a/README_DEPLOYMENT.md +++ b/README_DEPLOYMENT.md @@ -187,6 +187,53 @@ Keep this setting disabled until the legacy scheduler is redesigned. Setting it to `true` explicitly restores the old mode for development or controlled testing. +## WCA Email Privacy Cutover + +The privacy cutover is deliberately separate from ordinary database migrations. +PostgreSQL migrations run before a new application image is healthy, so using a +migration to purge data could let the automatic rollback restore an image that +writes the data again. + +Perform the cutover in this order: + +1. Deploy this release to both application services with `DEPLOY_TARGET=all`. +2. Confirm the API and socket health checks pass, the browser's WCA authorization + request has exactly `scope=public`, and a new login succeeds. +3. From `/opt/letscube`, record the running safe commit as the rollback floor: + + ```bash + git rev-parse HEAD | tee .privacy-email-cutover + ``` + + Future `scripts/deploy.sh` runs refuse any commit that does not descend from + this floor. Do not use direct Compose commands or a saved image tag to restore + an older release after creating the marker. +4. Run the purge from the newly deployed image: + + ```bash + docker compose -f compose.yml -f compose.prod.yml --env-file .env.prod run --rm --no-deps api node server/privacy/purgeUserEmails.js + ``` + + The command reports record counts only. It never prints field values and + exits unsuccessfully if either database still has a value. +5. Run the same command a second time. Every matched, modified, cleared, and + remaining count should be zero; this verifies that the operation is + idempotent. +6. Create and verify a fresh post-cutover backup. Remove every pre-cutover local + and remote backup according to the storage provider's secure deletion + procedure; normal retention is not sufficient for historical private data. + +If the initial deployment rolls back before step 3, do not create the marker or +run the purge. Correct the release and redeploy it first. After step 3, the +privacy-safe commit is the minimum supported rollback image. + +The PostgreSQL `app.users.email` column remains temporarily as an always-empty +compatibility column. Current dual writes never send it a value and clear a +legacy value whenever they update an existing row. Removing the column is phase two: +first deploy an application release that no longer references the compatibility +column, advance every supported rollback image to that release, and only then +apply a migration that drops it. Do not combine that drop with this cutover. + ### Health Checks The API and socket processes expose dependency-aware health endpoints. diff --git a/client/src/components/Header.jsx b/client/src/components/Header.jsx index a30e337..113ee0c 100644 --- a/client/src/components/Header.jsx +++ b/client/src/components/Header.jsx @@ -1,5 +1,4 @@ import React from 'react'; -import qs from 'qs'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; import { Link } from 'react-router-dom'; @@ -15,6 +14,7 @@ import Button from '@material-ui/core/Button'; import MenuItem from '@material-ui/core/MenuItem'; import { apiOrigin } from '../lib/fetch'; import { getNameFromId } from '../lib/events'; +import { getWcaAuthorizationUrl } from '../lib/wcaAuth'; const useStyles = makeStyles(() => ({ root: { @@ -57,13 +57,13 @@ function Header({ user, room }) { }; const login = () => { - localStorage.setItem('letscube.redirect_uri', `${document.location.origin}/wca-redirect`); - const url = `${process.env.REACT_APP_WCA_ORIGIN}/oauth/authorize?${qs.stringify({ - response_type: 'code', - scope: 'public dob email', - redirect_uri: `${document.location.origin}/wca-redirect`, - client_id: process.env.REACT_APP_WCA_CLIENT_ID, - })}`; + const redirectUri = `${document.location.origin}/wca-redirect`; + localStorage.setItem('letscube.redirect_uri', redirectUri); + const url = getWcaAuthorizationUrl({ + origin: process.env.REACT_APP_WCA_ORIGIN, + clientId: process.env.REACT_APP_WCA_CLIENT_ID, + redirectUri, + }); window.location = url; }; diff --git a/client/src/lib/wcaAuth.js b/client/src/lib/wcaAuth.js new file mode 100644 index 0000000..53fb131 --- /dev/null +++ b/client/src/lib/wcaAuth.js @@ -0,0 +1,12 @@ +import qs from 'qs'; + +export const WCA_OAUTH_SCOPE = 'public'; + +export const getWcaAuthorizationUrl = ({ origin, clientId, redirectUri }) => ( + `${origin}/oauth/authorize?${qs.stringify({ + response_type: 'code', + scope: WCA_OAUTH_SCOPE, + redirect_uri: redirectUri, + client_id: clientId, + })}` +); diff --git a/client/src/lib/wcaAuth.test.js b/client/src/lib/wcaAuth.test.js new file mode 100644 index 0000000..5bd42b2 --- /dev/null +++ b/client/src/lib/wcaAuth.test.js @@ -0,0 +1,15 @@ +import { getWcaAuthorizationUrl, WCA_OAUTH_SCOPE } from './wcaAuth'; + +describe('WCA authorization request', () => { + it('requests only the public identity scope', () => { + const url = new URL(getWcaAuthorizationUrl({ + origin: 'https://www.worldcubeassociation.org', + clientId: 'client-id', + redirectUri: 'https://letscube.net/wca-redirect', + })); + + expect(WCA_OAUTH_SCOPE).toBe('public'); + expect(url.searchParams.get('scope')).toBe('public'); + expect(url.searchParams.get('scope')).not.toMatch(/dob|email/); + }); +}); diff --git a/scripts/deploy.sh b/scripts/deploy.sh index b177e38..62605b5 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -58,6 +58,19 @@ esac current_commit="$(git rev-parse HEAD)" +privacy_cutover_file="${PRIVACY_EMAIL_CUTOVER_FILE:-$APP_DIR/.privacy-email-cutover}" +if [ -f "$privacy_cutover_file" ]; then + privacy_cutover_commit="$(tr -d '[:space:]' < "$privacy_cutover_file")" + if ! [[ "$privacy_cutover_commit" =~ ^[0-9a-f]{40}$ ]]; then + echo "Invalid email privacy cutover commit in $privacy_cutover_file" >&2 + exit 2 + fi + if ! git merge-base --is-ancestor "$privacy_cutover_commit" "$current_commit"; then + echo "Refusing to deploy $current_commit: it predates the email privacy cutover." >&2 + exit 1 + fi +fi + if [ "$DEPLOY_TARGET" = "auto" ]; then missing_services=() for service in api socket nginx; do diff --git a/scripts/test-deploy.sh b/scripts/test-deploy.sh index ef82299..c707956 100755 --- a/scripts/test-deploy.sh +++ b/scripts/test-deploy.sh @@ -26,6 +26,9 @@ fake_git() { ;; fetch|merge) ;; + merge-base) + [ "${FAKE_CUTOVER_ANCESTOR:-true}" = "true" ] + ;; diff) if [ -n "${FAKE_CHANGED_PATH:-}" ]; then printf '%s\0' "$FAKE_CHANGED_PATH" @@ -269,7 +272,44 @@ run_rollback_test() { assert_contains "$(<"$log")" "git show $previous_commit:compose.prod.yml" } +run_privacy_floor_test() { + local scenario="$TEST_ROOT/privacy-floor" + local app_dir="$scenario/app" + local log="$scenario/commands.log" + local output="$scenario/output.log" + local git_state="$scenario/git-state" + local rollback_marker="$scenario/rollback" + local previous_commit="2222222222222222222222222222222222222222" + local current_commit="1111111111111111111111111111111111111111" + + mkdir -p "$scenario/tmp" + prepare_app "$app_dir" + printf '%s\n' "$previous_commit" > "$app_dir/.privacy-email-cutover" + + if PATH="$FAKE_BIN:$PATH" \ + APP_DIR="$app_dir" \ + DEPLOY_TEST_LOG="$log" \ + ENV_FILE=.env.prod \ + FAKE_CURRENT_COMMIT="$current_commit" \ + FAKE_CUTOVER_ANCESTOR=false \ + FAKE_GIT_STATE="$git_state" \ + FAKE_PREVIOUS_COMMIT="$previous_commit" \ + FAKE_ROLLBACK_MARKER="$rollback_marker" \ + TMPDIR="$scenario/tmp" \ + bash "$app_dir/scripts/deploy.sh" > "$output" 2>&1; then + echo 'Expected a pre-cutover deployment to be rejected.' >&2 + exit 1 + fi + + assert_contains "$(<"$output")" 'predates the email privacy cutover' + if grep -q '^docker ' "$log"; then + echo 'Privacy floor rejection ran Docker commands.' >&2 + exit 1 + fi +} + run_bootstrap_test run_rollback_test +run_privacy_floor_test echo 'Deploy script checks passed.' diff --git a/server/auth/index.js b/server/auth/index.js index 91219c9..525ce30 100644 --- a/server/auth/index.js +++ b/server/auth/index.js @@ -3,6 +3,7 @@ const CustomStrategy = require('passport-custom').Strategy; const { URLSearchParams } = require('url'); const { User } = require('../models'); const metrics = require('../metrics'); +const { buildWcaUserUpdate } = require('./wcaProfile'); const checkStatus = async (res) => { if (res.ok) { // res.status >= 200 && res.status < 300 @@ -37,7 +38,6 @@ module.exports = (app, passport) => { id: +(process.env.LETSCUBE_TEST_USER_ID || 990001), name: 'Cypress Test User', username: 'cypress', - email: 'cypress@example.com', wcaId: '2026TEST01', accessToken: `test-token-${code}`, avatar: {}, @@ -89,14 +89,7 @@ module.exports = (app, passport) => { User.findOneAndUpdate({ id: +profile.id, - }, { - id: +profile.id, - name: profile.name, - email: profile.email, - wcaId: profile.wca_id, - accessToken: tokenRes.access_token, - avatar: profile.avatar, - }, { + }, buildWcaUserUpdate(profile, tokenRes.access_token), { upsert: true, useFindAndModify: false, new: true, diff --git a/server/auth/wcaProfile.js b/server/auth/wcaProfile.js new file mode 100644 index 0000000..d41a023 --- /dev/null +++ b/server/auth/wcaProfile.js @@ -0,0 +1,11 @@ +const buildWcaUserUpdate = (profile, accessToken) => ({ + id: Number(profile.id), + name: profile.name, + wcaId: profile.wca_id, + accessToken, + avatar: profile.avatar, +}); + +module.exports = { + buildWcaUserUpdate, +}; diff --git a/server/auth/wcaProfile.test.js b/server/auth/wcaProfile.test.js new file mode 100644 index 0000000..0769ee3 --- /dev/null +++ b/server/auth/wcaProfile.test.js @@ -0,0 +1,28 @@ +/** @jest-environment node */ +/* eslint-env jest */ + +const { buildWcaUserUpdate } = require('./wcaProfile'); + +describe('WCA profile ingestion', () => { + it('allowlists the identity fields used by LetsCube', () => { + const update = buildWcaUserUpdate({ + id: '1234', + name: 'Test Solver', + wca_id: '2026TEST01', + avatar: { thumb_url: 'avatar.png' }, + email: 'private@example.com', + dob: '2000-01-01', + unrecognized_field: 'do not retain', + }, 'oauth-token'); + + expect(update).toEqual({ + id: 1234, + name: 'Test Solver', + wcaId: '2026TEST01', + accessToken: 'oauth-token', + avatar: { thumb_url: 'avatar.png' }, + }); + expect(update).not.toHaveProperty('email'); + expect(update).not.toHaveProperty('dob'); + }); +}); diff --git a/server/models/user.js b/server/models/user.js index eb01155..053e993 100644 --- a/server/models/user.js +++ b/server/models/user.js @@ -20,9 +20,6 @@ const schema = new mongoose.Schema({ type: Number, required: true, }, - email: { - type: String, - }, name: { type: String, required: true, diff --git a/server/models/user.test.js b/server/models/user.test.js new file mode 100644 index 0000000..874ac54 --- /dev/null +++ b/server/models/user.test.js @@ -0,0 +1,25 @@ +/** @jest-environment node */ +/* eslint-env jest */ + +const mongoose = require('mongoose'); +const UserSchema = require('./user'); + +const User = mongoose.models.UserPrivacyTest + || mongoose.model('UserPrivacyTest', UserSchema); + +describe('user privacy', () => { + it('does not define or serialize an email field', () => { + const user = new User({ + id: 1234, + name: 'Test Solver', + accessToken: 'oauth-token', + showWCAID: true, + }); + user.set('email', 'historical@example.com', { strict: false }); + + expect(UserSchema.path('email')).toBeUndefined(); + expect(user.toObject()).not.toHaveProperty('email'); + expect(user.toJSON()).not.toHaveProperty('email'); + expect(user.toObject()).not.toHaveProperty('accessToken'); + }); +}); diff --git a/server/package.json b/server/package.json index fed3219..99e154c 100644 --- a/server/package.json +++ b/server/package.json @@ -38,6 +38,7 @@ "postgres:migrate:status": "prisma migrate status --config prisma.config.mjs", "postgres:schema:check": "prisma migrate diff --exit-code --from-config-datasource --to-schema prisma/schema.prisma --config prisma.config.mjs", "postgres:schema:validate": "prisma validate --config prisma.config.mjs", + "privacy:purge-user-emails": "node privacy/purgeUserEmails.js", "test": "jest --passWithNoTests", "test:ci": "yarn test" }, diff --git a/server/postgres/dualWrite.js b/server/postgres/dualWrite.js index 7fea99b..ec04f6f 100644 --- a/server/postgres/dualWrite.js +++ b/server/postgres/dualWrite.js @@ -60,11 +60,11 @@ const upsertUser = async (client, user, fallbackUpdatedAt = new Date()) => { const updatedAt = sourceDate(user.updatedAt, fallbackUpdatedAt); await client.query(` INSERT INTO app.users ( - id, wca_user_id, email, name, username, wca_id, preferences, avatar, + id, wca_user_id, name, username, wca_id, preferences, avatar, source_created_at, source_updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (wca_user_id) DO UPDATE SET - email = EXCLUDED.email, + email = NULL, name = EXCLUDED.name, username = EXCLUDED.username, wca_id = EXCLUDED.wca_id, @@ -76,14 +76,12 @@ const upsertUser = async (client, user, fallbackUpdatedAt = new Date()) => { OR ( app.users.source_updated_at = EXCLUDED.source_updated_at AND ROW( - app.users.email, app.users.name, app.users.username, app.users.wca_id, app.users.preferences, app.users.avatar ) IS DISTINCT FROM ROW( - EXCLUDED.email, EXCLUDED.name, EXCLUDED.username, EXCLUDED.wca_id, @@ -94,7 +92,6 @@ const upsertUser = async (client, user, fallbackUpdatedAt = new Date()) => { `, [ id, wcaUserId, - user.email || null, user.name, user.username || null, user.wcaId || null, diff --git a/server/postgres/dualWrite.test.js b/server/postgres/dualWrite.test.js index 39503fe..9bfeb96 100644 --- a/server/postgres/dualWrite.test.js +++ b/server/postgres/dualWrite.test.js @@ -18,7 +18,7 @@ const { const user = { id: 1234, - email: 'solver@example.com', + email: 'private@example.com', name: 'Test Solver', username: 'solver', wcaId: '2026TEST01', @@ -46,13 +46,14 @@ describe('PostgreSQL dual writer', () => { expect(stableId('user', 1234)).not.toBe(stableId('room', 1234)); }); - it('mirrors users without copying OAuth access tokens', async () => { + it('mirrors users without copying private profile or OAuth fields', async () => { await mirrorUser(user); expect(client.query).toHaveBeenCalledTimes(1); const values = client.query.mock.calls[0][1]; expect(values).toContain(1234); - expect(values).toContain('solver@example.com'); + expect(client.query.mock.calls[0][0]).toContain('email = NULL'); + expect(values).not.toContain('private@example.com'); expect(values).not.toContain('must-not-be-mirrored'); }); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 25c9a74..fa4c43c 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -6,6 +6,7 @@ datasource db { model User { id String @id @db.Uuid wcaUserId BigInt @unique(map: "users_wca_user_id_key") @map("wca_user_id") + /// Compatibility-only during the privacy cutover. This column must remain null. email String? name String username String? diff --git a/server/privacy/purgeUserEmails.js b/server/privacy/purgeUserEmails.js new file mode 100644 index 0000000..4de0bf7 --- /dev/null +++ b/server/privacy/purgeUserEmails.js @@ -0,0 +1,42 @@ +const mongoose = require('mongoose'); +const database = require('../database'); +const logger = require('../logger'); +const { pool } = require('../postgres'); +const { purgeUserEmails } = require('./userEmailPurge'); + +const report = (summary) => { + logger.info( + '[PRIVACY] User email purge result: ' + + `MongoDB matched=${summary.mongoMatched} modified=${summary.mongoModified} ` + + `remaining=${summary.mongoRemaining}; PostgreSQL cleared=${summary.postgresCleared} ` + + `remaining=${summary.postgresRemaining}`, + ); +}; + +const run = async () => { + const connection = await database.connect(); + + try { + return await purgeUserEmails({ + mongoUsers: connection.connection.collection('users'), + postgresClient: pool, + report, + }); + } finally { + await Promise.allSettled([ + mongoose.disconnect(), + pool.end(), + ]); + } +}; + +if (require.main === module) { + run().catch((err) => { + logger.error(err); + process.exitCode = 1; + }); +} + +module.exports = { + run, +}; diff --git a/server/privacy/userEmailPurge.js b/server/privacy/userEmailPurge.js new file mode 100644 index 0000000..9a79996 --- /dev/null +++ b/server/privacy/userEmailPurge.js @@ -0,0 +1,33 @@ +const purgeUserEmails = async ({ mongoUsers, postgresClient, report = () => {} }) => { + const mongoResult = await mongoUsers.updateMany( + { email: { $exists: true } }, + { $unset: { email: '' } }, + ); + const mongoRemaining = await mongoUsers.countDocuments({ email: { $exists: true } }); + + const postgresResult = await postgresClient.query( + 'UPDATE app.users SET email = NULL WHERE email IS NOT NULL', + ); + const postgresVerification = await postgresClient.query( + 'SELECT count(*)::int AS remaining FROM app.users WHERE email IS NOT NULL', + ); + + const summary = { + mongoMatched: mongoResult.matchedCount, + mongoModified: mongoResult.modifiedCount, + mongoRemaining, + postgresCleared: postgresResult.rowCount, + postgresRemaining: postgresVerification.rows[0].remaining, + }; + report(summary); + + if (mongoRemaining !== 0 || summary.postgresRemaining !== 0) { + throw new Error('User email purge verification failed'); + } + + return summary; +}; + +module.exports = { + purgeUserEmails, +}; diff --git a/server/privacy/userEmailPurge.test.js b/server/privacy/userEmailPurge.test.js new file mode 100644 index 0000000..947cb05 --- /dev/null +++ b/server/privacy/userEmailPurge.test.js @@ -0,0 +1,76 @@ +/** @jest-environment node */ +/* eslint-env jest */ + +const { purgeUserEmails } = require('./userEmailPurge'); + +const runPurge = async ({ matchedCount, modifiedCount, postgresCleared }) => { + const mongoUsers = { + updateMany: jest.fn().mockResolvedValue({ matchedCount, modifiedCount }), + countDocuments: jest.fn().mockResolvedValue(0), + }; + const postgresClient = { + query: jest.fn() + .mockResolvedValueOnce({ rowCount: postgresCleared }) + .mockResolvedValueOnce({ rows: [{ remaining: 0 }] }), + }; + const report = jest.fn(); + const summary = await purgeUserEmails({ mongoUsers, postgresClient, report }); + + return { + mongoUsers, postgresClient, report, summary, + }; +}; + +describe('user email purge', () => { + it('removes every MongoDB field and clears every PostgreSQL copy', async () => { + const { + mongoUsers, postgresClient, report, summary, + } = await runPurge({ matchedCount: 4, modifiedCount: 4, postgresCleared: 4 }); + + expect(mongoUsers.updateMany).toHaveBeenCalledWith( + { email: { $exists: true } }, + { $unset: { email: '' } }, + ); + expect(postgresClient.query).toHaveBeenNthCalledWith( + 1, + 'UPDATE app.users SET email = NULL WHERE email IS NOT NULL', + ); + expect(summary).toEqual({ + mongoMatched: 4, + mongoModified: 4, + mongoRemaining: 0, + postgresCleared: 4, + postgresRemaining: 0, + }); + expect(report).toHaveBeenCalledWith(summary); + }); + + it('is idempotent and reports zero changes on a rerun', async () => { + const { summary } = await runPurge({ + matchedCount: 0, + modifiedCount: 0, + postgresCleared: 0, + }); + + expect(summary).toEqual(expect.objectContaining({ + mongoMatched: 0, + mongoModified: 0, + postgresCleared: 0, + })); + }); + + it('fails when either data store still contains a value', async () => { + const mongoUsers = { + updateMany: jest.fn().mockResolvedValue({ matchedCount: 1, modifiedCount: 0 }), + countDocuments: jest.fn().mockResolvedValue(1), + }; + const postgresClient = { + query: jest.fn() + .mockResolvedValueOnce({ rowCount: 0 }) + .mockResolvedValueOnce({ rows: [{ remaining: 0 }] }), + }; + + await expect(purgeUserEmails({ mongoUsers, postgresClient })) + .rejects.toThrow('User email purge verification failed'); + }); +}); From 72e14945d5446db7fb88bc44a9fb533ba3e7e023 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 20:48:10 -0700 Subject: [PATCH 12/56] Make email purge fail closed Exit unsuccessfully when MongoDB cannot be reached and clear the PostgreSQL compatibility column independently of guarded user upserts. This prevents connection failures or equal timestamps from leaving legacy email values behind. --- server/database.js | 2 +- server/database.test.js | 32 +++++++++++++++++++++++++++++++ server/postgres/dualWrite.js | 6 ++++++ server/postgres/dualWrite.test.js | 11 ++++++++--- 4 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 server/database.test.js diff --git a/server/database.js b/server/database.js index 90533f5..44543be 100644 --- a/server/database.js +++ b/server/database.js @@ -12,7 +12,7 @@ module.exports.connect = async () => { logger.debug('[MONGODB] Connected to database.', { url: config.mongodb }); }).catch((err) => { logger.error('[MONGODB] Error when connecting to database', err); - process.exit(); + process.exit(1); }); return mongoose; diff --git a/server/database.test.js b/server/database.test.js new file mode 100644 index 0000000..dec2416 --- /dev/null +++ b/server/database.test.js @@ -0,0 +1,32 @@ +/** @jest-environment node */ +/* eslint-env jest */ + +jest.mock('mongoose', () => ({ + connect: jest.fn(), + set: jest.fn(), +})); +jest.mock('./logger', () => ({ + debug: jest.fn(), + error: jest.fn(), +})); + +const mongoose = require('mongoose'); +const database = require('./database'); + +describe('MongoDB connection failure', () => { + it('exits unsuccessfully so one-off privacy commands fail closed', async () => { + const connectionError = new Error('MongoDB unavailable'); + const exitError = new Error('process exit'); + mongoose.connect.mockRejectedValue(connectionError); + const exit = jest.spyOn(process, 'exit').mockImplementation((code) => { + exitError.code = code; + throw exitError; + }); + + await expect(database.connect()).rejects.toBe(exitError); + expect(exit).toHaveBeenCalledWith(1); + expect(exitError.code).toBe(1); + + exit.mockRestore(); + }); +}); diff --git a/server/postgres/dualWrite.js b/server/postgres/dualWrite.js index ec04f6f..6a5c869 100644 --- a/server/postgres/dualWrite.js +++ b/server/postgres/dualWrite.js @@ -58,6 +58,12 @@ const upsertUser = async (client, user, fallbackUpdatedAt = new Date()) => { const id = stableId('user', wcaUserId); const updatedAt = sourceDate(user.updatedAt, fallbackUpdatedAt); + // Clear the compatibility column independently so a stale or identical + // source timestamp cannot make the guarded upsert preserve a legacy value. + await client.query( + 'UPDATE app.users SET email = NULL WHERE wca_user_id = $1 AND email IS NOT NULL', + [wcaUserId], + ); await client.query(` INSERT INTO app.users ( id, wca_user_id, name, username, wca_id, preferences, avatar, diff --git a/server/postgres/dualWrite.test.js b/server/postgres/dualWrite.test.js index 9bfeb96..fb26cdb 100644 --- a/server/postgres/dualWrite.test.js +++ b/server/postgres/dualWrite.test.js @@ -49,10 +49,15 @@ describe('PostgreSQL dual writer', () => { it('mirrors users without copying private profile or OAuth fields', async () => { await mirrorUser(user); - expect(client.query).toHaveBeenCalledTimes(1); - const values = client.query.mock.calls[0][1]; + expect(client.query).toHaveBeenCalledTimes(2); + expect(client.query).toHaveBeenNthCalledWith( + 1, + 'UPDATE app.users SET email = NULL WHERE wca_user_id = $1 AND email IS NOT NULL', + [1234], + ); + const values = client.query.mock.calls[1][1]; expect(values).toContain(1234); - expect(client.query.mock.calls[0][0]).toContain('email = NULL'); + expect(client.query.mock.calls[1][0]).toContain('email = NULL'); expect(values).not.toContain('private@example.com'); expect(values).not.toContain('must-not-be-mirrored'); }); From 30afeeb35889c09df8e3941321f3c11d8be7469c Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 20:51:57 -0700 Subject: [PATCH 13/56] Build friendship lifecycle foundation Add server-authorized friendship and blocking transitions, privacy-safe REST and realtime contracts, and non-blocking PostgreSQL mirrors. Document the state machine and cover concurrency, replay, blocking, privacy, metrics, and disabled-Postgres behavior. --- README.md | 4 +- client/src/lib/protocol.json | 1 + docs/README.md | 6 +- docs/friendships.md | 90 +++++ server/api.js | 3 + server/api/friends.js | 66 ++++ server/api/friends.test.js | 130 ++++++ server/metrics.js | 6 + server/metrics.test.js | 22 ++ server/models/friendRelationship.js | 73 ++++ server/models/friendRelationship.test.js | 60 +++ server/models/index.js | 9 + server/models/metricEvent.js | 17 + server/models/userBlock.js | 39 ++ server/postgres/dualWrite.js | 99 +++++ server/postgres/dualWrite.test.js | 61 +++ .../migration.sql | 73 ++++ server/prisma/schema.prisma | 67 +++- server/realtime/socialEvents.js | 108 +++++ server/realtime/socialEvents.test.js | 87 ++++ server/social/publicUser.js | 20 + server/social/relationshipService.js | 374 ++++++++++++++++++ server/social/relationshipService.test.js | 333 ++++++++++++++++ server/social/relationshipState.js | 184 +++++++++ server/socket/index.js | 5 + 25 files changed, 1925 insertions(+), 12 deletions(-) create mode 100644 docs/friendships.md create mode 100644 server/api/friends.js create mode 100644 server/api/friends.test.js create mode 100644 server/models/friendRelationship.js create mode 100644 server/models/friendRelationship.test.js create mode 100644 server/models/userBlock.js create mode 100644 server/prisma/migrations/20260712180000_friendship_lifecycle/migration.sql create mode 100644 server/realtime/socialEvents.js create mode 100644 server/realtime/socialEvents.test.js create mode 100644 server/social/publicUser.js create mode 100644 server/social/relationshipService.js create mode 100644 server/social/relationshipService.test.js create mode 100644 server/social/relationshipState.js diff --git a/README.md b/README.md index 363d1e4..8839f30 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,9 @@ The server stores pseudonymous room and authentication events in both the It records room creations, joins, join failures, leaves and visit duration, accepted result counts, and authentication failures. Peak room users and peak room solve counts are the maximum `activeUserCount` and `roomSolveCount` values -for each pseudonymous room. +for each pseudonymous room. Friend-system metrics contain only the pseudonymous +actor, action category, and outcome; they never contain the other user or a +relationship/pair identifier. Raw events expire after 90 days by default. Set `METRICS_RETENTION_DAYS` to a different positive number of days, or set `METRICS_ENABLED=false` to disable diff --git a/client/src/lib/protocol.json b/client/src/lib/protocol.json index 563ae31..e0d2fcb 100644 --- a/client/src/lib/protocol.json +++ b/client/src/lib/protocol.json @@ -7,6 +7,7 @@ "RECONNECT": "reconnect", "HEALTH_CHECK": "health_check", "HEALTH_STATUS": "health_status", + "FRIEND_STATE_INVALIDATED": "friend_state_invalidated", "ERROR": "errorrr", "UPDATE_ROOMS": "update_rooms", "UPDATE_ROOM": "update_room", diff --git a/docs/README.md b/docs/README.md index 5b46e64..4d55c14 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,4 +6,8 @@ Letscube is a progressive webapp that lets users race against eachother solving The frontend is built with React, Material-UI, and Redux. -The backend is built with NodeJS, Express and MongoDB, and they are both connected with socket.io. \ No newline at end of file +The backend is built with NodeJS, Express and MongoDB, and they are both connected with socket.io. + +## Feature contracts + +- [Friendship and blocking lifecycle](friendships.md) diff --git a/docs/friendships.md b/docs/friendships.md new file mode 100644 index 0000000..ac81545 --- /dev/null +++ b/docs/friendships.md @@ -0,0 +1,90 @@ +# Friendship and blocking contract + +MongoDB is the source of truth for friendship state. PostgreSQL receives +non-blocking, idempotent mirrors and must never be read to authorize a social +action while the dual-write migration is in progress. + +## Identity and privacy + +Social APIs accept only the authenticated session user and a numeric target +user id. Responses use an explicit public projection containing `id`, +`username`, and `displayName`; `wcaId` and `avatar` are included only when that +user has enabled WCA identity visibility. Email is not part of this feature's +request, lookup, response, log, realtime, or metrics contracts. + +An API error never says that either user blocked the other. Both block +directions produce the generic `relationship_unavailable` response. A user may +list blocks they created so they can undo them, but cannot list blocks created +by someone else. + +## Canonical state + +There is at most one `friend_relationships` document for an unordered user +pair. `pairKey` is `:` and has a +unique index. Direction belongs only to `requestedBy`. A separate +`user_blocks` document represents each directional block. + +Relationship transitions are: + +| Current state | Action | Result | +| --- | --- | --- | +| none | send | pending, requested by actor | +| pending by actor | send | unchanged | +| pending by target | send | accepted (crossed requests) | +| pending by target | accept | accepted | +| pending by actor | cancel | canceled for 60 seconds | +| pending by target | decline | declined for 24 hours | +| accepted | unfriend | none | +| any | block | relationship removed; directional block retained | +| blocked by actor | unblock | directional block removed; no friendship restored | + +Replayed send, cancel, accept, decline, unfriend, block, and unblock operations +are idempotent. An invalid direction, such as accepting an outgoing request, is +`409 invalid_relationship_transition`. Self-actions and malformed ids are +`400` errors. + +Updates compare the document revision before writing. A unique-pair conflict or +concurrent revision change is read again and the transition is recalculated. +Both friendship writes and block writes check blocking after mutation, so a +concurrent block wins and removes the relationship. After bounded contention, +the API returns `409 relationship_conflict` and the caller should reconcile. + +Each user may have at most 100 pending outgoing requests. A declined pair may +not be requested again for 24 hours, and a canceled pair may not be requested +again for 60 seconds. `request_cooldown` includes `retryAfterSeconds`. + +## REST API + +All routes require an authenticated session. + +| Method | Route | Operation | +| --- | --- | --- | +| `GET` | `/api/friends` | Reconcile friends, requests, and blocks | +| `POST` | `/api/friends/requests` | Send `{ "userId": number }` | +| `DELETE` | `/api/friends/requests/:userId` | Cancel an outgoing request | +| `POST` | `/api/friends/requests/:userId/accept` | Accept an incoming request | +| `POST` | `/api/friends/requests/:userId/decline` | Decline an incoming request | +| `DELETE` | `/api/friends/:userId` | Unfriend | +| `PUT` | `/api/friends/blocks/:userId` | Block | +| `DELETE` | `/api/friends/blocks/:userId` | Unblock | + +`GET /api/friends` is the durable reconciliation endpoint. Its response has +`friends`, `incoming`, `outgoing`, `blocked`, and `reconciledAt`. Clients must +treat it as authoritative rather than applying relationship state locally. + +## Realtime invalidation + +After a durable change, the API publishes one typed +`friend_state_invalidated` event for both users. The Socket.IO process relays a +payload containing only `schemaVersion` and `occurredAt` to each authenticated +user room. It deliberately does not describe the action or the other user. + +On this event, after reconnect, and whenever an action has an ambiguous network +result, clients fetch `GET /api/friends`. Realtime delivery is an optimization; +missing an event cannot corrupt durable state. + +## Metrics + +`social_action` events record only the pseudonymous actor, action category, and +outcome. They do not record target ids, pair keys, relationship ids, user +projections, or graph edges. diff --git a/server/api.js b/server/api.js index 7e7643a..c068879 100644 --- a/server/api.js +++ b/server/api.js @@ -3,6 +3,7 @@ const express = require('express'); const router = express.Router(); const { User } = require('./models'); const auth = require('./middlewares/auth.js'); +const createFriendsRouter = require('./api/friends'); const PREFERENCE_KEYS = new Set([ 'showWCAID', @@ -24,6 +25,8 @@ module.exports = () => { res.json(req.user.toObject()); }); + router.use('/friends', createFriendsRouter()); + router.put('/updateUsername', auth, (req, res) => { // TODO: server side validation of username // TODO: refactor diff --git a/server/api/friends.js b/server/api/friends.js new file mode 100644 index 0000000..e2b42ed --- /dev/null +++ b/server/api/friends.js @@ -0,0 +1,66 @@ +const express = require('express'); + +const auth = require('../middlewares/auth'); +const relationshipService = require('../social/relationshipService'); + +const sendError = (res, err) => res.status(err.statusCode || 500).json({ + code: err.code || 'internal_error', + message: err.statusCode ? err.message : 'The relationship action could not be completed', + ...(err.retryAfterSeconds ? { retryAfterSeconds: err.retryAfterSeconds } : {}), +}); + +const asyncHandler = (handler) => async (req, res) => { + try { + await handler(req, res); + } catch (err) { + sendError(res, err); + } +}; + +const createFriendsRouter = (service = relationshipService) => { + const router = express.Router(); + router.use(auth); + + router.get('/', asyncHandler(async (req, res) => { + res.json(await service.list(req.user)); + })); + + router.post('/requests', asyncHandler(async (req, res) => { + const result = await service.sendRequest(req.user, req.body.userId); + const status = result.outcome === 'request_created' ? 201 : 200; + res.status(status).json(result); + })); + + router.delete('/requests/:userId', asyncHandler(async (req, res) => { + await service.cancelRequest(req.user, req.params.userId); + res.status(204).end(); + })); + + router.post('/requests/:userId/accept', asyncHandler(async (req, res) => { + res.json(await service.acceptRequest(req.user, req.params.userId)); + })); + + router.post('/requests/:userId/decline', asyncHandler(async (req, res) => { + await service.declineRequest(req.user, req.params.userId); + res.status(204).end(); + })); + + router.delete('/:userId', asyncHandler(async (req, res) => { + await service.unfriend(req.user, req.params.userId); + res.status(204).end(); + })); + + router.put('/blocks/:userId', asyncHandler(async (req, res) => { + await service.block(req.user, req.params.userId); + res.status(204).end(); + })); + + router.delete('/blocks/:userId', asyncHandler(async (req, res) => { + await service.unblock(req.user, req.params.userId); + res.status(204).end(); + })); + + return router; +}; + +module.exports = createFriendsRouter; diff --git a/server/api/friends.test.js b/server/api/friends.test.js new file mode 100644 index 0000000..2162b42 --- /dev/null +++ b/server/api/friends.test.js @@ -0,0 +1,130 @@ +/** @jest-environment node */ +/* eslint-env jest */ + +const http = require('http'); +const express = require('express'); + +const createFriendsRouter = require('./friends'); + +const request = (server, { + authenticated = true, body, method = 'GET', path = '/api/friends', +} = {}) => new Promise((resolve, reject) => { + const serializedBody = body ? JSON.stringify(body) : null; + const options = { + headers: { + ...(authenticated ? { authorization: 'test-session' } : {}), + ...(serializedBody ? { + 'content-length': Buffer.byteLength(serializedBody), + 'content-type': 'application/json', + } : {}), + }, + host: '127.0.0.1', + method, + path, + port: server.address().port, + }; + const req = http.request(options, (res) => { + let responseBody = ''; + res.setEncoding('utf8'); + res.on('data', (chunk) => { + responseBody += chunk; + }); + res.on('end', () => resolve({ + body: responseBody ? JSON.parse(responseBody) : null, + statusCode: res.statusCode, + })); + }); + req.on('error', reject); + if (serializedBody) { + req.write(serializedBody); + } + req.end(); +}); + +describe('friend REST routes', () => { + let server; + let service; + + beforeEach((done) => { + service = { + acceptRequest: jest.fn().mockResolvedValue({ outcome: 'request_accepted' }), + block: jest.fn().mockResolvedValue({ outcome: 'blocked' }), + cancelRequest: jest.fn().mockResolvedValue({ outcome: 'request_canceled' }), + declineRequest: jest.fn().mockResolvedValue({ outcome: 'request_declined' }), + list: jest.fn().mockResolvedValue({ friends: [], reconciledAt: new Date() }), + sendRequest: jest.fn().mockResolvedValue({ outcome: 'request_created' }), + unblock: jest.fn().mockResolvedValue({ outcome: 'unblocked' }), + unfriend: jest.fn().mockResolvedValue({ outcome: 'unfriended' }), + }; + const app = express(); + app.use(express.json()); + app.use((req, res, next) => { + req.isAuthenticated = () => req.headers.authorization === 'test-session'; + if (req.isAuthenticated()) { + req.user = { id: 1, name: 'Test User' }; + } + next(); + }); + app.use('/api/friends', createFriendsRouter(service)); + server = app.listen(0, '127.0.0.1', done); + }); + + afterEach((done) => server.close(done)); + + it('requires an authenticated session', async () => { + const response = await request(server, { authenticated: false }); + + expect(response.statusCode).toBe(403); + expect(service.list).not.toHaveBeenCalled(); + }); + + it('exposes the authoritative reconciliation snapshot', async () => { + const response = await request(server); + + expect(response.statusCode).toBe(200); + expect(service.list).toHaveBeenCalledWith(expect.objectContaining({ id: 1 })); + expect(response.body).toEqual(expect.objectContaining({ friends: [] })); + }); + + it.each([ + ['POST', '/api/friends/requests', { userId: 2 }, 'sendRequest', 201], + ['DELETE', '/api/friends/requests/2', null, 'cancelRequest', 204], + ['POST', '/api/friends/requests/2/accept', null, 'acceptRequest', 200], + ['POST', '/api/friends/requests/2/decline', null, 'declineRequest', 204], + ['DELETE', '/api/friends/2', null, 'unfriend', 204], + ['PUT', '/api/friends/blocks/2', null, 'block', 204], + ['DELETE', '/api/friends/blocks/2', null, 'unblock', 204], + ])('maps %s %s to %s', async (method, path, body, serviceMethod, statusCode) => { + const response = await request(server, { body, method, path }); + + expect(response.statusCode).toBe(statusCode); + expect(service[serviceMethod]).toHaveBeenCalledWith( + expect.objectContaining({ id: 1 }), + serviceMethod === 'sendRequest' ? 2 : '2', + ); + if (response.body) { + expect(response.body).not.toHaveProperty('relationship'); + } + }); + + it('returns stable error codes without exposing internal errors', async () => { + service.sendRequest.mockRejectedValue(Object.assign(new Error('Unavailable'), { + code: 'relationship_unavailable', + statusCode: 409, + })); + + const response = await request(server, { + body: { userId: 2 }, + method: 'POST', + path: '/api/friends/requests', + }); + + expect(response).toEqual({ + body: { + code: 'relationship_unavailable', + message: 'Unavailable', + }, + statusCode: 409, + }); + }); +}); diff --git a/server/metrics.js b/server/metrics.js index f1fb7ce..fc182c8 100644 --- a/server/metrics.js +++ b/server/metrics.js @@ -214,6 +214,12 @@ const createMetricRecorder = ({ ...roomProperties(room), roomSolveCount: countRoomSolves(room), }), + recordSocialOutcome: ({ userId, action, outcome }) => write({ + event: METRIC_EVENTS.SOCIAL_ACTION, + ...actorProperties({ userId }), + socialAction: action, + socialOutcome: outcome, + }), beginRoomVisit, endRoomVisit, pseudonymize, diff --git a/server/metrics.test.js b/server/metrics.test.js index c380e1b..dfb7231 100644 --- a/server/metrics.test.js +++ b/server/metrics.test.js @@ -189,4 +189,26 @@ describe('metrics recorder', () => { await expect(recorder.recordAuthFailure('no_user')).resolves.toBeNull(); expect(metricLogger.error).toHaveBeenCalledWith(error); }); + + it('records social outcomes without a target or graph edge', async () => { + const { model, recorder } = createRecorder(); + + await recorder.recordSocialOutcome({ + action: 'accept', + outcome: 'request_accepted', + userId: 123, + }); + + const event = model.create.mock.calls[0][0]; + expect(event).toEqual(expect.objectContaining({ + actorType: 'authenticated', + event: METRIC_EVENTS.SOCIAL_ACTION, + socialAction: 'accept', + socialOutcome: 'request_accepted', + })); + expect(event.actorId).not.toContain('123'); + expect(event).not.toHaveProperty('targetId'); + expect(event).not.toHaveProperty('pairKey'); + expect(event).not.toHaveProperty('relationshipId'); + }); }); diff --git a/server/models/friendRelationship.js b/server/models/friendRelationship.js new file mode 100644 index 0000000..06be976 --- /dev/null +++ b/server/models/friendRelationship.js @@ -0,0 +1,73 @@ +const mongoose = require('mongoose'); + +const RELATIONSHIP_STATUSES = Object.freeze({ + ACCEPTED: 'accepted', + CANCELED: 'canceled', + DECLINED: 'declined', + PENDING: 'pending', +}); + +const FriendRelationship = new mongoose.Schema({ + pairKey: { + type: String, + required: true, + }, + lowUserId: { + type: Number, + required: true, + }, + highUserId: { + type: Number, + required: true, + }, + status: { + type: String, + enum: Object.values(RELATIONSHIP_STATUSES), + required: true, + }, + requestedBy: Number, + cooldownUntil: Date, + stateChangedAt: { + type: Date, + required: true, + }, + revision: { + type: Number, + required: true, + default: 0, + min: 0, + }, +}, { + timestamps: true, + versionKey: false, +}); + +FriendRelationship.index({ pairKey: 1 }, { unique: true }); +FriendRelationship.index({ lowUserId: 1, status: 1 }); +FriendRelationship.index({ highUserId: 1, status: 1 }); +FriendRelationship.index({ requestedBy: 1, status: 1 }); + +FriendRelationship.pre('validate', function validatePair(next) { + if (this.lowUserId >= this.highUserId) { + next(new Error('Friend relationship users must be a normalized distinct pair')); + return; + } + if (this.pairKey !== `${this.lowUserId}:${this.highUserId}`) { + next(new Error('Friend relationship pair key does not match its users')); + return; + } + const requesterIsMember = this.requestedBy === this.lowUserId + || this.requestedBy === this.highUserId; + if (this.status === RELATIONSHIP_STATUSES.ACCEPTED + ? this.requestedBy !== null && this.requestedBy !== undefined + : !requesterIsMember) { + next(new Error('Friend relationship requester does not match its state')); + return; + } + next(); +}); + +module.exports = { + FriendRelationship, + RELATIONSHIP_STATUSES, +}; diff --git a/server/models/friendRelationship.test.js b/server/models/friendRelationship.test.js new file mode 100644 index 0000000..034ba43 --- /dev/null +++ b/server/models/friendRelationship.test.js @@ -0,0 +1,60 @@ +/** @jest-environment node */ +/* eslint-env jest */ + +const { FriendRelationship, UserBlock } = require('./index'); + +describe('friend relationship models', () => { + it('accepts a normalized relationship pair', async () => { + const relationship = new FriendRelationship({ + pairKey: '1:2', + lowUserId: 1, + highUserId: 2, + requestedBy: 1, + revision: 0, + stateChangedAt: new Date(), + status: 'pending', + }); + + await expect(relationship.validate()).resolves.toBeUndefined(); + }); + + it('rejects denormalized pairs and invalid request direction', async () => { + const denormalized = new FriendRelationship({ + pairKey: '2:1', + lowUserId: 2, + highUserId: 1, + requestedBy: 2, + revision: 0, + stateChangedAt: new Date(), + status: 'pending', + }); + const invalidRequester = new FriendRelationship({ + pairKey: '1:2', + lowUserId: 1, + highUserId: 2, + requestedBy: 3, + revision: 0, + stateChangedAt: new Date(), + status: 'pending', + }); + + await expect(denormalized.validate()).rejects.toThrow('normalized distinct pair'); + await expect(invalidRequester.validate()).rejects.toThrow('requester does not match'); + }); + + it('rejects self-blocks and mismatched block pair keys', async () => { + const selfBlock = new UserBlock({ + pairKey: '1:1', + blockerId: 1, + blockedId: 1, + }); + const mismatchedPair = new UserBlock({ + pairKey: '2:1', + blockerId: 1, + blockedId: 2, + }); + + await expect(selfBlock.validate()).rejects.toThrow('cannot block themselves'); + await expect(mismatchedPair.validate()).rejects.toThrow('pair key does not match'); + }); +}); diff --git a/server/models/index.js b/server/models/index.js index 8e5ba12..1b7fa10 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -1,12 +1,21 @@ const mongoose = require('mongoose'); +const { FriendRelationship, RELATIONSHIP_STATUSES } = require('./friendRelationship'); const { Attempt, Room } = require('./room'); const { METRIC_EVENTS, MetricEvent } = require('./metricEvent'); const User = require('./user'); +const UserBlock = require('./userBlock'); module.exports = { Room: mongoose.model('Room', Room, 'rooms'), User: mongoose.model('User', User, 'users'), + FriendRelationship: mongoose.model( + 'FriendRelationship', + FriendRelationship, + 'friend_relationships', + ), + UserBlock: mongoose.model('UserBlock', UserBlock, 'user_blocks'), Attempt: mongoose.model('Attempt', Attempt, 'attempts'), MetricEvent: mongoose.model('MetricEvent', MetricEvent, 'metric_events'), METRIC_EVENTS, + RELATIONSHIP_STATUSES, }; diff --git a/server/models/metricEvent.js b/server/models/metricEvent.js index 56b0a5b..87a0b11 100644 --- a/server/models/metricEvent.js +++ b/server/models/metricEvent.js @@ -8,8 +8,19 @@ const METRIC_EVENTS = Object.freeze({ ROOM_JOIN_FAILED: 'room_join_failed', ROOM_LEFT: 'room_left', ROOM_RESULT_SUBMITTED: 'room_result_submitted', + SOCIAL_ACTION: 'social_action', }); +const SOCIAL_ACTIONS = Object.freeze([ + 'accept', + 'block', + 'cancel', + 'decline', + 'send', + 'unblock', + 'unfriend', +]); + const MetricEvent = new mongoose.Schema({ eventId: { type: String, @@ -45,6 +56,11 @@ const MetricEvent = new mongoose.Schema({ type: String, enum: ['disconnect', 'explicit', 'kick', 'ban'], }, + socialAction: { + type: String, + enum: SOCIAL_ACTIONS, + }, + socialOutcome: String, activeUserCount: { type: Number, min: 0, @@ -109,4 +125,5 @@ MetricEvent.index({ module.exports = { METRIC_EVENTS, MetricEvent, + SOCIAL_ACTIONS, }; diff --git a/server/models/userBlock.js b/server/models/userBlock.js new file mode 100644 index 0000000..d2986e9 --- /dev/null +++ b/server/models/userBlock.js @@ -0,0 +1,39 @@ +const mongoose = require('mongoose'); + +const UserBlock = new mongoose.Schema({ + pairKey: { + type: String, + required: true, + index: true, + }, + blockerId: { + type: Number, + required: true, + }, + blockedId: { + type: Number, + required: true, + }, +}, { + timestamps: true, + versionKey: false, +}); + +UserBlock.index({ blockerId: 1, blockedId: 1 }, { unique: true }); +UserBlock.index({ blockedId: 1 }); + +UserBlock.pre('validate', function validateBlock(next) { + if (this.blockerId === this.blockedId) { + next(new Error('Users cannot block themselves')); + return; + } + const lowUserId = Math.min(this.blockerId, this.blockedId); + const highUserId = Math.max(this.blockerId, this.blockedId); + if (this.pairKey !== `${lowUserId}:${highUserId}`) { + next(new Error('User block pair key does not match its users')); + return; + } + next(); +}); + +module.exports = UserBlock; diff --git a/server/postgres/dualWrite.js b/server/postgres/dualWrite.js index 7fea99b..666e06d 100644 --- a/server/postgres/dualWrite.js +++ b/server/postgres/dualWrite.js @@ -115,6 +115,99 @@ const upsertUser = async (client, user, fallbackUpdatedAt = new Date()) => { const mirrorUser = (user) => withTransaction((client) => upsertUser(client, user)); +const mirrorRelationship = (relationship, users) => withTransaction(async (client) => { + const knownUserIds = new Set(); + for (const user of users) { + const mirroredId = await upsertUser(client, user, relationship.updatedAt); + if (mirroredId) { + knownUserIds.add(numericUserId(user)); + } + } + if (!knownUserIds.has(relationship.lowUserId) + || !knownUserIds.has(relationship.highUserId)) { + return null; + } + + const updatedAt = sourceDate(relationship.updatedAt); + await client.query(` + INSERT INTO app.friend_relationships ( + id, pair_key, low_user_id, high_user_id, status, requested_by_user_id, + cooldown_until, revision, state_changed_at, source_created_at, source_updated_at + ) + SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 + WHERE NOT EXISTS ( + SELECT 1 FROM app.user_blocks WHERE pair_key = $2 + ) + ON CONFLICT (pair_key) DO UPDATE SET + status = EXCLUDED.status, + requested_by_user_id = EXCLUDED.requested_by_user_id, + cooldown_until = EXCLUDED.cooldown_until, + revision = EXCLUDED.revision, + state_changed_at = EXCLUDED.state_changed_at, + source_updated_at = EXCLUDED.source_updated_at, + ingested_at = now() + WHERE app.friend_relationships.revision < EXCLUDED.revision + OR ( + app.friend_relationships.revision = EXCLUDED.revision + AND app.friend_relationships.source_updated_at <= EXCLUDED.source_updated_at + ) + `, [ + stableId('friend-relationship', relationship.pairKey), + relationship.pairKey, + stableId('user', relationship.lowUserId), + stableId('user', relationship.highUserId), + relationship.status, + relationship.requestedBy ? stableId('user', relationship.requestedBy) : null, + relationship.cooldownUntil || null, + relationship.revision, + relationship.stateChangedAt, + relationship.createdAt || null, + updatedAt, + ]); + return relationship.pairKey; +}); + +const mirrorRelationshipDeleted = (pairKey) => query( + 'DELETE FROM app.friend_relationships WHERE pair_key = $1', + [pairKey], +); + +const mirrorBlock = (block, blocker, blocked) => withTransaction(async (client) => { + const updatedAt = sourceDate(block.updatedAt, block.createdAt); + const blockerUserId = await upsertUser(client, blocker, updatedAt); + const blockedUserId = await upsertUser(client, blocked, updatedAt); + if (!blockerUserId || !blockedUserId) { + return null; + } + + await client.query(` + INSERT INTO app.user_blocks ( + id, blocker_id, blocked_id, pair_key, source_created_at, source_updated_at + ) VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (blocker_id, blocked_id) DO UPDATE SET + pair_key = EXCLUDED.pair_key, + source_updated_at = EXCLUDED.source_updated_at, + ingested_at = now() + `, [ + stableId('user-block', `${block.blockerId}:${block.blockedId}`), + blockerUserId, + blockedUserId, + block.pairKey, + block.createdAt || null, + updatedAt, + ]); + await client.query( + 'DELETE FROM app.friend_relationships WHERE pair_key = $1', + [block.pairKey], + ); + return block.pairKey; +}); + +const mirrorBlockDeleted = (blockerId, blockedId) => query(` + DELETE FROM app.user_blocks + WHERE blocker_id = $1 AND blocked_id = $2 +`, [stableId('user', blockerId), stableId('user', blockedId)]); + const upsertRoomState = async (client, room, options = {}) => { if (!room || !room._id) { return null; @@ -539,6 +632,8 @@ const mirrorMetricEvent = (event) => query(` privateRoom: event.privateRoom, failureReason: event.failureReason, leaveReason: event.leaveReason, + socialAction: event.socialAction, + socialOutcome: event.socialOutcome, activeUserCount: event.activeUserCount, roomSolveCount: event.roomSolveCount, durationMs: event.durationMs, @@ -548,7 +643,11 @@ const mirrorMetricEvent = (event) => query(` module.exports = { markRoomDeleted, + mirrorBlock, + mirrorBlockDeleted, mirrorMetricEvent, + mirrorRelationship, + mirrorRelationshipDeleted, mirrorRoom, mirrorRoomChanges, mirrorUser, diff --git a/server/postgres/dualWrite.test.js b/server/postgres/dualWrite.test.js index 39503fe..e0a29ed 100644 --- a/server/postgres/dualWrite.test.js +++ b/server/postgres/dualWrite.test.js @@ -9,7 +9,11 @@ jest.mock('./index', () => ({ const postgres = require('./index'); const { markRoomDeleted, + mirrorBlock, + mirrorBlockDeleted, mirrorMetricEvent, + mirrorRelationship, + mirrorRelationshipDeleted, mirrorRoom, mirrorRoomChanges, mirrorUser, @@ -187,4 +191,61 @@ describe('PostgreSQL dual writer', () => { ['507f1f77bcf86cd799439011'], ); }); + + it('mirrors relationship revisions and directional blocks without graph metrics', async () => { + const otherUser = { + ...user, + id: 5678, + name: 'Other Solver', + username: 'other', + wcaId: '2026TEST02', + }; + const updatedAt = new Date('2026-07-12T20:00:00.000Z'); + + await mirrorRelationship({ + pairKey: '1234:5678', + lowUserId: 1234, + highUserId: 5678, + requestedBy: 1234, + status: 'pending', + revision: 2, + stateChangedAt: updatedAt, + createdAt: updatedAt, + updatedAt, + }, [user, otherUser]); + await mirrorBlock({ + blockerId: 1234, + blockedId: 5678, + pairKey: '1234:5678', + createdAt: updatedAt, + updatedAt, + }, user, otherUser); + + const statements = client.query.mock.calls.map(([sql]) => sql); + expect(statements.some((sql) => sql.includes('INSERT INTO app.friend_relationships'))) + .toBe(true); + expect(statements.some((sql) => sql.includes('INSERT INTO app.user_blocks'))).toBe(true); + expect(statements.some((sql) => sql.includes('DELETE FROM app.friend_relationships'))) + .toBe(true); + }); + + it('makes mirror deletion idempotent and tolerates disabled PostgreSQL', async () => { + await mirrorRelationshipDeleted('1234:5678'); + await mirrorBlockDeleted(1234, 5678); + + expect(postgres.query).toHaveBeenNthCalledWith( + 1, + 'DELETE FROM app.friend_relationships WHERE pair_key = $1', + ['1234:5678'], + ); + expect(postgres.query).toHaveBeenNthCalledWith( + 2, + expect.stringContaining('DELETE FROM app.user_blocks'), + [stableId('user', 1234), stableId('user', 5678)], + ); + + postgres.withTransaction.mockResolvedValueOnce(null); + await expect(mirrorRelationship({ pairKey: '1234:5678' }, [])) + .resolves.toBeNull(); + }); }); diff --git a/server/prisma/migrations/20260712180000_friendship_lifecycle/migration.sql b/server/prisma/migrations/20260712180000_friendship_lifecycle/migration.sql new file mode 100644 index 0000000..e48ead7 --- /dev/null +++ b/server/prisma/migrations/20260712180000_friendship_lifecycle/migration.sql @@ -0,0 +1,73 @@ +CREATE TABLE app.friend_relationships ( + id uuid NOT NULL, + pair_key text NOT NULL, + low_user_id uuid NOT NULL, + high_user_id uuid NOT NULL, + status text NOT NULL, + requested_by_user_id uuid, + cooldown_until timestamptz(6), + revision integer NOT NULL, + state_changed_at timestamptz(6) NOT NULL, + source_created_at timestamptz(6), + source_updated_at timestamptz(6) NOT NULL, + ingested_at timestamptz(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT friend_relationships_pkey PRIMARY KEY (id), + CONSTRAINT friend_relationships_distinct_users CHECK (low_user_id <> high_user_id), + CONSTRAINT friend_relationships_status_check CHECK ( + status IN ('pending', 'accepted', 'declined', 'canceled') + ), + CONSTRAINT friend_relationships_requester_check CHECK ( + (status = 'accepted' AND requested_by_user_id IS NULL) + OR (status <> 'accepted' AND requested_by_user_id IN (low_user_id, high_user_id)) + ) +); + +CREATE UNIQUE INDEX friend_relationships_pair_key_key +ON app.friend_relationships(pair_key); + +CREATE INDEX friend_relationships_low_status_idx +ON app.friend_relationships(low_user_id, status); + +CREATE INDEX friend_relationships_high_status_idx +ON app.friend_relationships(high_user_id, status); + +CREATE INDEX friend_relationships_requester_status_idx +ON app.friend_relationships(requested_by_user_id, status); + +ALTER TABLE app.friend_relationships +ADD CONSTRAINT friend_relationships_low_user_id_fkey +FOREIGN KEY (low_user_id) REFERENCES app.users(id) ON DELETE CASCADE ON UPDATE NO ACTION; + +ALTER TABLE app.friend_relationships +ADD CONSTRAINT friend_relationships_high_user_id_fkey +FOREIGN KEY (high_user_id) REFERENCES app.users(id) ON DELETE CASCADE ON UPDATE NO ACTION; + +ALTER TABLE app.friend_relationships +ADD CONSTRAINT friend_relationships_requested_by_user_id_fkey +FOREIGN KEY (requested_by_user_id) REFERENCES app.users(id) ON DELETE SET NULL ON UPDATE NO ACTION; + +CREATE TABLE app.user_blocks ( + id uuid NOT NULL, + blocker_id uuid NOT NULL, + blocked_id uuid NOT NULL, + pair_key text NOT NULL, + source_created_at timestamptz(6), + source_updated_at timestamptz(6) NOT NULL, + ingested_at timestamptz(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT user_blocks_pkey PRIMARY KEY (id), + CONSTRAINT user_blocks_distinct_users CHECK (blocker_id <> blocked_id) +); + +CREATE UNIQUE INDEX user_blocks_blocker_blocked_key +ON app.user_blocks(blocker_id, blocked_id); + +CREATE INDEX user_blocks_blocked_idx ON app.user_blocks(blocked_id); +CREATE INDEX user_blocks_pair_key_idx ON app.user_blocks(pair_key); + +ALTER TABLE app.user_blocks +ADD CONSTRAINT user_blocks_blocker_id_fkey +FOREIGN KEY (blocker_id) REFERENCES app.users(id) ON DELETE CASCADE ON UPDATE NO ACTION; + +ALTER TABLE app.user_blocks +ADD CONSTRAINT user_blocks_blocked_id_fkey +FOREIGN KEY (blocked_id) REFERENCES app.users(id) ON DELETE CASCADE ON UPDATE NO ACTION; diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 25c9a74..47cb781 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -4,26 +4,73 @@ datasource db { } model User { - id String @id @db.Uuid - wcaUserId BigInt @unique(map: "users_wca_user_id_key") @map("wca_user_id") + id String @id @db.Uuid + wcaUserId BigInt @unique(map: "users_wca_user_id_key") @map("wca_user_id") email String? name String username String? - wcaId String? @map("wca_id") - preferences Json @default("{}") @db.JsonB - avatar Json @default("{}") @db.JsonB - sourceCreatedAt DateTime? @map("source_created_at") @db.Timestamptz(6) - sourceUpdatedAt DateTime @map("source_updated_at") @db.Timestamptz(6) - ingestedAt DateTime @default(now()) @map("ingested_at") @db.Timestamptz(6) - ownedRooms Room[] @relation("RoomOwner") - administeredRooms Room[] @relation("RoomAdmin") + wcaId String? @map("wca_id") + preferences Json @default("{}") @db.JsonB + avatar Json @default("{}") @db.JsonB + sourceCreatedAt DateTime? @map("source_created_at") @db.Timestamptz(6) + sourceUpdatedAt DateTime @map("source_updated_at") @db.Timestamptz(6) + ingestedAt DateTime @default(now()) @map("ingested_at") @db.Timestamptz(6) + ownedRooms Room[] @relation("RoomOwner") + administeredRooms Room[] @relation("RoomAdmin") roomParticipations RoomParticipant[] solves Solve[] + friendshipsAsLow FriendRelationship[] @relation("FriendshipLowUser") + friendshipsAsHigh FriendRelationship[] @relation("FriendshipHighUser") + requestsMade FriendRelationship[] @relation("FriendshipRequester") + blocksMade UserBlock[] @relation("Blocker") + blocksReceived UserBlock[] @relation("Blocked") @@map("users") @@schema("app") } +model FriendRelationship { + id String @id @db.Uuid + pairKey String @unique(map: "friend_relationships_pair_key_key") @map("pair_key") + lowUserId String @map("low_user_id") @db.Uuid + highUserId String @map("high_user_id") @db.Uuid + status String + requestedByUserId String? @map("requested_by_user_id") @db.Uuid + cooldownUntil DateTime? @map("cooldown_until") @db.Timestamptz(6) + revision Int + stateChangedAt DateTime @map("state_changed_at") @db.Timestamptz(6) + sourceCreatedAt DateTime? @map("source_created_at") @db.Timestamptz(6) + sourceUpdatedAt DateTime @map("source_updated_at") @db.Timestamptz(6) + ingestedAt DateTime @default(now()) @map("ingested_at") @db.Timestamptz(6) + lowUser User @relation("FriendshipLowUser", fields: [lowUserId], references: [id], onDelete: Cascade, onUpdate: NoAction) + highUser User @relation("FriendshipHighUser", fields: [highUserId], references: [id], onDelete: Cascade, onUpdate: NoAction) + requestedBy User? @relation("FriendshipRequester", fields: [requestedByUserId], references: [id], onDelete: SetNull, onUpdate: NoAction) + + @@index([lowUserId, status], map: "friend_relationships_low_status_idx") + @@index([highUserId, status], map: "friend_relationships_high_status_idx") + @@index([requestedByUserId, status], map: "friend_relationships_requester_status_idx") + @@map("friend_relationships") + @@schema("app") +} + +model UserBlock { + id String @id @db.Uuid + blockerId String @map("blocker_id") @db.Uuid + blockedId String @map("blocked_id") @db.Uuid + pairKey String @map("pair_key") + sourceCreatedAt DateTime? @map("source_created_at") @db.Timestamptz(6) + sourceUpdatedAt DateTime @map("source_updated_at") @db.Timestamptz(6) + ingestedAt DateTime @default(now()) @map("ingested_at") @db.Timestamptz(6) + blocker User @relation("Blocker", fields: [blockerId], references: [id], onDelete: Cascade, onUpdate: NoAction) + blocked User @relation("Blocked", fields: [blockedId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@unique([blockerId, blockedId], map: "user_blocks_blocker_blocked_key") + @@index([blockedId], map: "user_blocks_blocked_idx") + @@index([pairKey], map: "user_blocks_pair_key_idx") + @@map("user_blocks") + @@schema("app") +} + model Room { id String @id @db.Uuid mongoId String @unique(map: "rooms_mongo_id_key") @map("mongo_id") diff --git a/server/realtime/socialEvents.js b/server/realtime/socialEvents.js new file mode 100644 index 0000000..276045b --- /dev/null +++ b/server/realtime/socialEvents.js @@ -0,0 +1,108 @@ +const Redis = require('ioredis'); + +const Protocol = require('../../client/src/lib/protocol.json'); +const config = require('../runtimeConfig'); +const logger = require('../logger'); +const { encodeUser } = require('../socket/utils'); + +const SOCIAL_EVENT_CHANNEL = 'letscube:social-events:v1'; + +let publisher; + +const createRedisPublisher = () => { + const client = config.redis.url + ? new Redis(config.redis.url, { lazyConnect: true, maxRetriesPerRequest: 1 }) + : new Redis({ + db: config.redis.db, + host: config.redis.host, + lazyConnect: true, + maxRetriesPerRequest: 1, + password: config.redis.password, + port: config.redis.port, + }); + client.on('error', (err) => logger.error(err)); + return client; +}; + +const normalizeRecipients = (userIds) => [...new Set(userIds.map(Number).filter( + (userId) => Number.isSafeInteger(userId) && userId > 0, +))]; + +const createSocialEventPublisher = ({ + client, + eventLogger = logger, + now = () => new Date(), +} = {}) => async ({ userIds }) => { + const recipients = normalizeRecipients(userIds || []); + if (recipients.length === 0) { + return false; + } + + try { + const redis = client || publisher || createRedisPublisher(); + if (!client && !publisher) { + publisher = redis; + } + await redis.publish(SOCIAL_EVENT_CHANNEL, JSON.stringify({ + occurredAt: now().toISOString(), + schemaVersion: 1, + type: Protocol.FRIEND_STATE_INVALIDATED, + userIds: recipients, + })); + return true; + } catch (err) { + eventLogger.error(err); + return false; + } +}; + +const parseSocialEvent = (message) => { + try { + const event = JSON.parse(message); + if (event.schemaVersion !== 1 || event.type !== Protocol.FRIEND_STATE_INVALIDATED) { + return null; + } + const userIds = normalizeRecipients(event.userIds || []); + if (userIds.length === 0) { + return null; + } + return { + payload: { + occurredAt: event.occurredAt, + schemaVersion: event.schemaVersion, + }, + userIds, + }; + } catch (err) { + return null; + } +}; + +const registerSocialEventSubscriber = (io, client, eventLogger = logger) => { + const namespace = io.of('/'); + client.on('message', (channel, message) => { + if (channel !== SOCIAL_EVENT_CHANNEL) { + return; + } + const event = parseSocialEvent(message); + if (!event) { + eventLogger.error(new Error('Ignored invalid social event')); + return; + } + event.userIds.forEach((userId) => { + namespace.to(encodeUser(userId)).emit( + Protocol.FRIEND_STATE_INVALIDATED, + event.payload, + ); + }); + }); + client.subscribe(SOCIAL_EVENT_CHANNEL).catch((err) => eventLogger.error(err)); +}; + +module.exports = { + SOCIAL_EVENT_CHANNEL, + createSocialEventPublisher, + parseSocialEvent, + publishSocialInvalidation: createSocialEventPublisher(), + registerSocialEventSubscriber, +}; diff --git a/server/realtime/socialEvents.test.js b/server/realtime/socialEvents.test.js new file mode 100644 index 0000000..7aacfe7 --- /dev/null +++ b/server/realtime/socialEvents.test.js @@ -0,0 +1,87 @@ +/** @jest-environment node */ +/* eslint-env jest */ + +const Protocol = require('../../client/src/lib/protocol.json'); +const { + SOCIAL_EVENT_CHANNEL, + createSocialEventPublisher, + parseSocialEvent, + registerSocialEventSubscriber, +} = require('./socialEvents'); + +describe('social realtime invalidations', () => { + it('publishes a versioned event with unique valid recipients', async () => { + const client = { publish: jest.fn().mockResolvedValue(1) }; + const publish = createSocialEventPublisher({ + client, + now: () => new Date('2026-07-12T12:00:00.000Z'), + }); + + await expect(publish({ userIds: [2, 1, 2, 'invalid'] })).resolves.toBe(true); + expect(client.publish).toHaveBeenCalledTimes(1); + const [channel, message] = client.publish.mock.calls[0]; + expect(channel).toBe(SOCIAL_EVENT_CHANNEL); + expect(JSON.parse(message)).toEqual({ + occurredAt: '2026-07-12T12:00:00.000Z', + schemaVersion: 1, + type: Protocol.FRIEND_STATE_INVALIDATED, + userIds: [2, 1], + }); + }); + + it('relays only an opaque payload to each authenticated user room', () => { + const handlers = {}; + const emit = jest.fn(); + const to = jest.fn(() => ({ emit })); + const namespace = { to }; + const io = { of: jest.fn(() => namespace) }; + const client = { + on: jest.fn((event, handler) => { + handlers[event] = handler; + }), + subscribe: jest.fn().mockResolvedValue(1), + }; + + registerSocialEventSubscriber(io, client); + handlers.message(SOCIAL_EVENT_CHANNEL, JSON.stringify({ + occurredAt: '2026-07-12T12:00:00.000Z', + schemaVersion: 1, + type: Protocol.FRIEND_STATE_INVALIDATED, + userIds: [1, 2], + })); + + expect(to.mock.calls).toEqual([['user/1'], ['user/2']]); + expect(emit).toHaveBeenCalledTimes(2); + expect(emit).toHaveBeenCalledWith(Protocol.FRIEND_STATE_INVALIDATED, { + occurredAt: '2026-07-12T12:00:00.000Z', + schemaVersion: 1, + }); + expect(emit.mock.calls[0][1]).not.toHaveProperty('userIds'); + }); + + it('ignores malformed or unknown events', () => { + expect(parseSocialEvent('not-json')).toBeNull(); + expect(parseSocialEvent(JSON.stringify({ + schemaVersion: 2, + type: Protocol.FRIEND_STATE_INVALIDATED, + userIds: [1], + }))).toBeNull(); + expect(parseSocialEvent(JSON.stringify({ + schemaVersion: 1, + type: 'relationship_details', + userIds: [1], + }))).toBeNull(); + }); + + it('does not fail durable actions when Redis is unavailable', async () => { + const error = new Error('redis unavailable'); + const eventLogger = { error: jest.fn() }; + const publish = createSocialEventPublisher({ + client: { publish: jest.fn().mockRejectedValue(error) }, + eventLogger, + }); + + await expect(publish({ userIds: [1, 2] })).resolves.toBe(false); + expect(eventLogger.error).toHaveBeenCalledWith(error); + }); +}); diff --git a/server/social/publicUser.js b/server/social/publicUser.js new file mode 100644 index 0000000..1466414 --- /dev/null +++ b/server/social/publicUser.js @@ -0,0 +1,20 @@ +const publicUserProjection = (user) => { + if (!user) { + return null; + } + + const projection = { + id: user.id, + username: user.username || null, + displayName: user.preferRealName ? user.name : (user.username || null), + }; + + if (user.showWCAID) { + projection.wcaId = user.wcaId || null; + projection.avatar = user.avatar || {}; + } + + return projection; +}; + +module.exports = publicUserProjection; diff --git a/server/social/relationshipService.js b/server/social/relationshipService.js new file mode 100644 index 0000000..cfc02df --- /dev/null +++ b/server/social/relationshipService.js @@ -0,0 +1,374 @@ +/* eslint-disable no-await-in-loop, no-continue */ +const logger = require('../logger'); +const metrics = require('../metrics'); +const { + FriendRelationship, + RELATIONSHIP_STATUSES, + User, + UserBlock, +} = require('../models'); +const { + mirrorBlock, + mirrorBlockDeleted, + mirrorRelationship, + mirrorRelationshipDeleted, +} = require('../postgres/dualWrite'); +const { publishSocialInvalidation } = require('../realtime/socialEvents'); +const publicUserProjection = require('./publicUser'); +const { + ACTIONS, + SocialError, + transitionRelationship, +} = require('./relationshipState'); + +const MAX_OUTGOING_REQUESTS = 100; +const MAX_CAS_ATTEMPTS = 6; + +const toPlain = (document) => { + if (!document) { + return null; + } + return typeof document.toObject === 'function' ? document.toObject() : document; +}; + +const lean = async (query) => { + if (query && typeof query.lean === 'function') { + return query.lean(); + } + return query; +}; + +const normalizeUserId = (value) => { + let userId = Number.NaN; + if (typeof value === 'number') { + userId = value; + } else if (typeof value === 'string' && /^\d+$/.test(value)) { + userId = Number(value); + } + if (!Number.isSafeInteger(userId) || userId <= 0) { + throw new SocialError(400, 'invalid_user_id', 'A valid user id is required'); + } + return userId; +}; + +const normalizedPair = (firstUserId, secondUserId) => { + const lowUserId = Math.min(firstUserId, secondUserId); + const highUserId = Math.max(firstUserId, secondUserId); + return { + highUserId, + lowUserId, + pairKey: `${lowUserId}:${highUserId}`, + }; +}; + +const createRelationshipService = ({ + blockModel = UserBlock, + metricRecorder = metrics, + relationshipModel = FriendRelationship, + socialLogger = logger, + userModel = User, + mirrors = { + mirrorBlock, + mirrorBlockDeleted, + mirrorRelationship, + mirrorRelationshipDeleted, + }, + notifier = publishSocialInvalidation, + now = () => new Date(), +} = {}) => { + const runInBackground = (operation) => { + Promise.resolve(operation).catch((err) => socialLogger.error(err)); + }; + + const record = (userId, action, outcome) => runInBackground( + metricRecorder.recordSocialOutcome({ userId, action, outcome }), + ); + + const invalidate = (userIds) => runInBackground(notifier({ userIds })); + + const loadUsers = async (actorUser, targetValue) => { + const actorId = normalizeUserId(actorUser && actorUser.id); + const targetId = normalizeUserId(targetValue); + if (actorId === targetId) { + throw new SocialError(400, 'self_relationship', 'Users cannot act on themselves'); + } + + const targetUser = await lean(userModel.findOne({ id: targetId })); + if (!targetUser) { + throw new SocialError( + 409, + 'relationship_unavailable', + 'The requested relationship action is not available', + ); + } + + return { + actorId, + actorUser: toPlain(actorUser), + pair: normalizedPair(actorId, targetId), + targetId, + targetUser: toPlain(targetUser), + }; + }; + + const pairIsBlocked = async (pairKey) => !!(await lean(blockModel.exists({ pairKey }))); + + const enforceOutgoingLimit = async (actorId) => { + const count = await relationshipModel.countDocuments({ + requestedBy: actorId, + status: RELATIONSHIP_STATUSES.PENDING, + }); + if (count >= MAX_OUTGOING_REQUESTS) { + throw new SocialError( + 409, + 'outgoing_request_limit', + 'The outgoing friend request limit has been reached', + ); + } + }; + + const persistTransition = async (current, transition) => { + if (transition.kind === 'noop') { + return current; + } + + if (transition.kind === 'delete') { + const deleted = await relationshipModel.deleteOne({ + _id: current._id, + revision: current.revision, + }); + return deleted.deletedCount === 1 ? null : undefined; + } + + if (!current) { + try { + return toPlain(await relationshipModel.create({ + ...transition.relationship, + revision: 0, + })); + } catch (err) { + if (err.code === 11000) { + return undefined; + } + throw err; + } + } + + const updated = await relationshipModel.findOneAndUpdate({ + _id: current._id, + revision: current.revision, + }, { + $inc: { revision: 1 }, + $set: { + cooldownUntil: transition.relationship.cooldownUntil, + requestedBy: transition.relationship.requestedBy, + stateChangedAt: transition.relationship.stateChangedAt, + status: transition.relationship.status, + }, + }, { + new: true, + useFindAndModify: false, + }); + return updated ? toPlain(updated) : undefined; + }; + + const act = async (actorUser, targetValue, action) => { + const { + actorId, pair, targetId, targetUser, + } = await loadUsers(actorUser, targetValue); + + if (await pairIsBlocked(pair.pairKey)) { + record(actorId, action, 'unavailable'); + throw new SocialError( + 409, + 'relationship_unavailable', + 'The requested relationship action is not available', + ); + } + + for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt += 1) { + const current = (await lean(relationshipModel.findOne({ pairKey: pair.pairKey }))) || null; + let transition; + try { + transition = transitionRelationship({ + action, + actorId, + current, + now: now(), + pair, + targetId, + }); + + if (action === ACTIONS.SEND && transition.outcome === 'request_created') { + await enforceOutgoingLimit(actorId); + } + } catch (err) { + record(actorId, action, err.code || 'error'); + throw err; + } + + const persisted = await persistTransition(current, transition); + if (persisted === undefined) { + continue; + } + + if (await pairIsBlocked(pair.pairKey)) { + await relationshipModel.deleteOne({ pairKey: pair.pairKey }); + runInBackground(mirrors.mirrorRelationshipDeleted(pair.pairKey)); + invalidate([actorId, targetId]); + record(actorId, action, 'unavailable'); + throw new SocialError( + 409, + 'relationship_unavailable', + 'The requested relationship action is not available', + ); + } + + if (transition.kind === 'delete') { + runInBackground(mirrors.mirrorRelationshipDeleted(pair.pairKey)); + } else if (transition.kind !== 'noop') { + runInBackground(mirrors.mirrorRelationship(persisted, [actorUser, targetUser])); + } + if (transition.kind !== 'noop') { + invalidate([actorId, targetId]); + } + record(actorId, action, transition.outcome); + + return { + outcome: transition.outcome, + }; + } + + record(actorId, action, 'conflict'); + throw new SocialError( + 409, + 'relationship_conflict', + 'The relationship changed concurrently; reconcile and retry', + ); + }; + + const block = async (actorUser, targetValue) => { + const { + actorId, pair, targetId, targetUser, + } = await loadUsers(actorUser, targetValue); + const blockedAt = now(); + const blockDocument = toPlain(await blockModel.findOneAndUpdate({ + blockedId: targetId, + blockerId: actorId, + }, { + $setOnInsert: { + blockedId: targetId, + blockerId: actorId, + createdAt: blockedAt, + pairKey: pair.pairKey, + }, + }, { + new: true, + setDefaultsOnInsert: true, + upsert: true, + useFindAndModify: false, + })); + + await relationshipModel.deleteOne({ pairKey: pair.pairKey }); + runInBackground(mirrors.mirrorBlock(blockDocument, actorUser, targetUser)); + invalidate([actorId, targetId]); + record(actorId, 'block', 'blocked'); + return { outcome: 'blocked' }; + }; + + const unblock = async (actorUser, targetValue) => { + const { + actorId, targetId, + } = await loadUsers(actorUser, targetValue); + await blockModel.deleteOne({ blockerId: actorId, blockedId: targetId }); + runInBackground(mirrors.mirrorBlockDeleted(actorId, targetId)); + invalidate([actorId, targetId]); + record(actorId, 'unblock', 'unblocked'); + return { outcome: 'unblocked' }; + }; + + const list = async (actorUser) => { + const actorId = normalizeUserId(actorUser && actorUser.id); + const [relationships, blocks] = await Promise.all([ + lean(relationshipModel.find({ + $or: [{ lowUserId: actorId }, { highUserId: actorId }], + status: { $in: [RELATIONSHIP_STATUSES.ACCEPTED, RELATIONSHIP_STATUSES.PENDING] }, + })), + lean(blockModel.find({ + $or: [{ blockedId: actorId }, { blockerId: actorId }], + })), + ]); + const blockedPairKeys = new Set(blocks.map((entry) => entry.pairKey)); + const visibleRelationships = relationships.filter( + (relationship) => !blockedPairKeys.has(relationship.pairKey), + ); + const ownBlocks = blocks.filter((entry) => entry.blockerId === actorId); + const relatedUserIds = new Set(ownBlocks.map((entry) => entry.blockedId)); + visibleRelationships.forEach((relationship) => { + relatedUserIds.add( + relationship.lowUserId === actorId + ? relationship.highUserId : relationship.lowUserId, + ); + }); + const users = await lean(userModel.find({ id: { $in: [...relatedUserIds] } })); + const usersById = new Map(users.map((user) => [user.id, publicUserProjection(user)])); + const response = { + blocked: [], + friends: [], + incoming: [], + outgoing: [], + reconciledAt: now(), + }; + + visibleRelationships.forEach((relationship) => { + const otherId = relationship.lowUserId === actorId + ? relationship.highUserId : relationship.lowUserId; + const user = usersById.get(otherId); + if (!user) { + return; + } + if (relationship.status === RELATIONSHIP_STATUSES.ACCEPTED) { + response.friends.push({ + acceptedAt: relationship.stateChangedAt, + user, + }); + } else { + const request = { + requestedAt: relationship.stateChangedAt, + user, + }; + response[relationship.requestedBy === actorId ? 'outgoing' : 'incoming'].push(request); + } + }); + ownBlocks.forEach((entry) => { + const user = usersById.get(entry.blockedId); + if (user) { + response.blocked.push({ blockedAt: entry.createdAt, user }); + } + }); + + ['blocked', 'friends', 'incoming', 'outgoing'].forEach((key) => { + response[key].sort((first, second) => first.user.id - second.user.id); + }); + return response; + }; + + return { + acceptRequest: (actor, targetId) => act(actor, targetId, ACTIONS.ACCEPT), + block, + cancelRequest: (actor, targetId) => act(actor, targetId, ACTIONS.CANCEL), + declineRequest: (actor, targetId) => act(actor, targetId, ACTIONS.DECLINE), + list, + sendRequest: (actor, targetId) => act(actor, targetId, ACTIONS.SEND), + unblock, + unfriend: (actor, targetId) => act(actor, targetId, ACTIONS.UNFRIEND), + }; +}; + +module.exports = { + MAX_OUTGOING_REQUESTS, + createRelationshipService, + normalizeUserId, + normalizedPair, + ...createRelationshipService(), +}; diff --git a/server/social/relationshipService.test.js b/server/social/relationshipService.test.js new file mode 100644 index 0000000..959af81 --- /dev/null +++ b/server/social/relationshipService.test.js @@ -0,0 +1,333 @@ +/** @jest-environment node */ +/* eslint-env jest */ + +const { createRelationshipService, MAX_OUTGOING_REQUESTS } = require('./relationshipService'); + +const clone = (value) => (value ? { ...value } : value); + +const createModels = () => { + const users = new Map([ + [1, { + id: 1, name: 'One', username: 'one', email: 'private-one@example.com', preferRealName: false, + }], + [2, { + id: 2, name: 'Two', username: 'two', email: 'private-two@example.com', preferRealName: false, + }], + [3, { + id: 3, name: 'Three', username: 'three', email: 'private-three@example.com', preferRealName: false, + }], + ]); + const relationships = new Map(); + const blocks = new Map(); + let sequence = 0; + + const relationshipModel = { + countDocuments: jest.fn(async (filter) => [...relationships.values()].filter( + (relationship) => relationship.requestedBy === filter.requestedBy + && relationship.status === filter.status, + ).length), + create: jest.fn(async (relationship) => { + if (relationships.has(relationship.pairKey)) { + const err = new Error('duplicate pair'); + err.code = 11000; + throw err; + } + const timestamp = new Date('2026-07-12T12:00:00.000Z'); + const created = { + ...clone(relationship), + _id: `relationship-${sequence += 1}`, + createdAt: timestamp, + updatedAt: timestamp, + }; + relationships.set(created.pairKey, created); + return clone(created); + }), + deleteOne: jest.fn(async (filter) => { + const relationship = filter.pairKey + ? relationships.get(filter.pairKey) + : [...relationships.values()].find((entry) => entry._id === filter._id); + if (!relationship || (filter.revision !== undefined + && relationship.revision !== filter.revision)) { + return { deletedCount: 0 }; + } + relationships.delete(relationship.pairKey); + return { deletedCount: 1 }; + }), + find: jest.fn(async (filter) => [...relationships.values()].filter((relationship) => { + const belongsToUser = filter.$or.some((condition) => Object.entries(condition) + .every(([key, value]) => relationship[key] === value)); + return belongsToUser && filter.status.$in.includes(relationship.status); + }).map(clone)), + findOne: jest.fn(async ({ pairKey }) => clone(relationships.get(pairKey))), + findOneAndUpdate: jest.fn(async (filter, update) => { + const relationship = [...relationships.values()].find((entry) => entry._id === filter._id); + if (!relationship || relationship.revision !== filter.revision) { + return null; + } + const updated = { + ...relationship, + ...update.$set, + revision: relationship.revision + update.$inc.revision, + updatedAt: new Date('2026-07-12T12:00:00.000Z'), + }; + relationships.set(updated.pairKey, updated); + return clone(updated); + }), + }; + + const blockModel = { + deleteOne: jest.fn(async ({ blockerId, blockedId }) => ({ + deletedCount: blocks.delete(`${blockerId}:${blockedId}`) ? 1 : 0, + })), + exists: jest.fn(async ({ pairKey }) => [...blocks.values()] + .some((block) => block.pairKey === pairKey)), + find: jest.fn(async (filter) => [...blocks.values()].filter((block) => filter.$or + .some((condition) => Object.entries(condition) + .every(([key, value]) => block[key] === value))).map(clone)), + findOneAndUpdate: jest.fn(async (filter, update) => { + const key = `${filter.blockerId}:${filter.blockedId}`; + if (!blocks.has(key)) { + blocks.set(key, { + ...update.$setOnInsert, + _id: `block-${sequence += 1}`, + updatedAt: update.$setOnInsert.createdAt, + }); + } + return clone(blocks.get(key)); + }), + }; + + const userModel = { + find: jest.fn(async ({ id }) => id.$in.map((userId) => users.get(userId)).filter(Boolean)), + findOne: jest.fn(async ({ id }) => clone(users.get(id))), + }; + + return { + blockModel, + blocks, + relationshipModel, + relationships, + userModel, + users, + }; +}; + +const createService = () => { + const models = createModels(); + const metricRecorder = { recordSocialOutcome: jest.fn() }; + const mirrors = { + mirrorBlock: jest.fn().mockResolvedValue(null), + mirrorBlockDeleted: jest.fn().mockResolvedValue(null), + mirrorRelationship: jest.fn().mockResolvedValue(null), + mirrorRelationshipDeleted: jest.fn().mockResolvedValue(null), + }; + const notifier = jest.fn().mockResolvedValue(true); + const now = jest.fn(() => new Date('2026-07-12T12:00:00.000Z')); + const service = createRelationshipService({ + ...models, + metricRecorder, + mirrors, + notifier, + now, + }); + return { + ...models, + metricRecorder, + mirrors, + notifier, + now, + service, + }; +}; + +describe('relationship service', () => { + it('stores one unordered pair and accepts a crossed request', async () => { + const { relationships, service, users } = createService(); + + await service.sendRequest(users.get(2), 1); + await service.sendRequest(users.get(1), 2); + + expect(relationships.size).toBe(1); + expect(relationships.get('1:2')).toEqual(expect.objectContaining({ + highUserId: 2, + lowUserId: 1, + pairKey: '1:2', + requestedBy: null, + status: 'accepted', + })); + }); + + it('makes duplicate sends and concurrent accepts idempotent', async () => { + const { relationships, service, users } = createService(); + + await service.sendRequest(users.get(1), 2); + const replay = await service.sendRequest(users.get(1), 2); + const accepts = await Promise.all([ + service.acceptRequest(users.get(2), 1), + service.acceptRequest(users.get(2), 1), + ]); + + expect(replay.outcome).toBe('request_replayed'); + expect(accepts.map(({ outcome }) => outcome).sort()).toEqual([ + 'accept_replayed', + 'request_accepted', + ]); + expect(relationships.get('1:2').status).toBe('accepted'); + }); + + it('rejects self-actions, malformed ids, and unauthorized directions', async () => { + const { service, users } = createService(); + + await expect(service.sendRequest(users.get(1), 1)).rejects.toMatchObject({ + code: 'self_relationship', + statusCode: 400, + }); + await expect(service.sendRequest(users.get(1), 'not-an-id')).rejects.toMatchObject({ + code: 'invalid_user_id', + statusCode: 400, + }); + await expect(service.sendRequest(users.get(2), true)).rejects.toMatchObject({ + code: 'invalid_user_id', + statusCode: 400, + }); + await service.sendRequest(users.get(1), 2); + await expect(service.acceptRequest(users.get(1), 2)).rejects.toMatchObject({ + code: 'invalid_relationship_transition', + statusCode: 409, + }); + await expect(service.acceptRequest(users.get(3), 2)).rejects.toMatchObject({ + code: 'invalid_relationship_transition', + statusCode: 409, + }); + await expect(service.sendRequest(users.get(1), 999999)).rejects.toMatchObject({ + code: 'relationship_unavailable', + statusCode: 409, + }); + }); + + it('keeps declines idempotent and enforces the pair cooldown', async () => { + const { now, service, users } = createService(); + + await service.sendRequest(users.get(1), 2); + await service.declineRequest(users.get(2), 1); + await expect(service.declineRequest(users.get(2), 1)).resolves.toMatchObject({ + outcome: 'decline_replayed', + }); + await expect(service.sendRequest(users.get(1), 2)).rejects.toMatchObject({ + code: 'request_cooldown', + retryAfterSeconds: 24 * 60 * 60, + }); + + now.mockReturnValue(new Date('2026-07-13T12:00:01.000Z')); + await expect(service.sendRequest(users.get(1), 2)).resolves.toMatchObject({ + outcome: 'request_created', + }); + }); + + it('keeps cancellations directional, replayable, and briefly rate limited', async () => { + const { now, service, users } = createService(); + + await service.sendRequest(users.get(1), 2); + await expect(service.cancelRequest(users.get(2), 1)).rejects.toMatchObject({ + code: 'invalid_relationship_transition', + }); + await service.cancelRequest(users.get(1), 2); + await expect(service.cancelRequest(users.get(1), 2)).resolves.toMatchObject({ + outcome: 'cancel_replayed', + }); + await expect(service.sendRequest(users.get(1), 2)).rejects.toMatchObject({ + code: 'request_cooldown', + retryAfterSeconds: 60, + }); + + now.mockReturnValue(new Date('2026-07-12T12:01:01.000Z')); + await expect(service.sendRequest(users.get(1), 2)).resolves.toMatchObject({ + outcome: 'request_created', + }); + }); + + it('lets a block win, hides its direction, and does not restore friendship on unblock', async () => { + const { + relationships, service, users, + } = createService(); + + await service.sendRequest(users.get(1), 2); + await service.acceptRequest(users.get(2), 1); + await service.block(users.get(1), 2); + + expect(relationships.has('1:2')).toBe(false); + await expect(service.sendRequest(users.get(2), 1)).rejects.toMatchObject({ + code: 'relationship_unavailable', + }); + const blockerView = await service.list(users.get(1)); + const blockedView = await service.list(users.get(2)); + expect(blockerView.blocked.map(({ user }) => user.id)).toEqual([2]); + expect(blockedView.blocked).toEqual([]); + expect(blockedView.friends).toEqual([]); + + await service.unblock(users.get(1), 2); + expect((await service.list(users.get(1))).friends).toEqual([]); + await expect(service.sendRequest(users.get(2), 1)).resolves.toMatchObject({ + outcome: 'request_created', + }); + }); + + it('removes a relationship when a block wins the write race', async () => { + const { + blockModel, relationships, service, users, + } = createService(); + blockModel.exists + .mockResolvedValueOnce(false) + .mockResolvedValueOnce({ _id: 'concurrent-block' }); + + await expect(service.sendRequest(users.get(1), 2)).rejects.toMatchObject({ + code: 'relationship_unavailable', + }); + + expect(relationships.has('1:2')).toBe(false); + }); + + it('makes unfriend replayable and succeeds when PostgreSQL mirrors are disabled', async () => { + const { + mirrors, relationships, service, users, + } = createService(); + + await service.sendRequest(users.get(1), 2); + await service.acceptRequest(users.get(2), 1); + await service.unfriend(users.get(1), 2); + await expect(service.unfriend(users.get(1), 2)).resolves.toMatchObject({ + outcome: 'unfriend_replayed', + }); + + expect(relationships.has('1:2')).toBe(false); + expect(mirrors.mirrorRelationship).toHaveBeenCalled(); + expect(mirrors.mirrorRelationshipDeleted).toHaveBeenCalledWith('1:2'); + }); + + it('returns only public fields and sends generic invalidations to both users', async () => { + const { notifier, service, users } = createService(); + + await service.sendRequest(users.get(1), 2); + const snapshot = await service.list(users.get(1)); + + expect(snapshot.outgoing[0].user).toEqual({ + displayName: 'two', + id: 2, + username: 'two', + }); + expect(snapshot.outgoing[0].user).not.toHaveProperty('email'); + expect(notifier).toHaveBeenCalledWith({ userIds: [1, 2] }); + }); + + it('enforces the pending outgoing request limit', async () => { + const { + relationshipModel, service, users, + } = createService(); + relationshipModel.countDocuments.mockResolvedValue(MAX_OUTGOING_REQUESTS); + + await expect(service.sendRequest(users.get(1), 2)).rejects.toMatchObject({ + code: 'outgoing_request_limit', + statusCode: 409, + }); + }); +}); diff --git a/server/social/relationshipState.js b/server/social/relationshipState.js new file mode 100644 index 0000000..3a75540 --- /dev/null +++ b/server/social/relationshipState.js @@ -0,0 +1,184 @@ +const { RELATIONSHIP_STATUSES } = require('../models/friendRelationship'); + +const ACTIONS = Object.freeze({ + ACCEPT: 'accept', + CANCEL: 'cancel', + DECLINE: 'decline', + SEND: 'send', + UNFRIEND: 'unfriend', +}); + +const DECLINE_COOLDOWN_MS = 24 * 60 * 60 * 1000; +const CANCEL_COOLDOWN_MS = 60 * 1000; + +class SocialError extends Error { + constructor(statusCode, code, message, details = {}) { + super(message); + this.statusCode = statusCode; + this.code = code; + Object.assign(this, details); + } +} + +const failTransition = () => { + throw new SocialError( + 409, + 'invalid_relationship_transition', + 'The requested relationship transition is not available', + ); +}; + +const terminalRequest = (current, status, now, cooldownMs) => ({ + ...current, + status, + cooldownUntil: new Date(now.getTime() + cooldownMs), + stateChangedAt: now, +}); + +const transitionRelationship = ({ + action, actorId, current, now, pair, targetId, +}) => { + if (action === ACTIONS.SEND) { + if (!current) { + return { + kind: 'upsert', + outcome: 'request_created', + relationship: { + ...pair, + status: RELATIONSHIP_STATUSES.PENDING, + requestedBy: actorId, + cooldownUntil: null, + stateChangedAt: now, + }, + }; + } + + if (current.status === RELATIONSHIP_STATUSES.ACCEPTED) { + return { kind: 'noop', outcome: 'already_friends', relationship: current }; + } + + if (current.status === RELATIONSHIP_STATUSES.PENDING) { + if (current.requestedBy === actorId) { + return { kind: 'noop', outcome: 'request_replayed', relationship: current }; + } + return { + kind: 'upsert', + outcome: 'crossed_request_accepted', + relationship: { + ...current, + status: RELATIONSHIP_STATUSES.ACCEPTED, + requestedBy: null, + cooldownUntil: null, + stateChangedAt: now, + }, + }; + } + + if (current.cooldownUntil && new Date(current.cooldownUntil) > now) { + const retryAfterSeconds = Math.ceil( + (new Date(current.cooldownUntil).getTime() - now.getTime()) / 1000, + ); + throw new SocialError( + 409, + 'request_cooldown', + 'A friend request cannot be sent to this user yet', + { retryAfterSeconds }, + ); + } + + return { + kind: 'upsert', + outcome: 'request_created', + relationship: { + ...current, + status: RELATIONSHIP_STATUSES.PENDING, + requestedBy: actorId, + cooldownUntil: null, + stateChangedAt: now, + }, + }; + } + + if (action === ACTIONS.CANCEL) { + if (current && current.status === RELATIONSHIP_STATUSES.CANCELED + && current.requestedBy === actorId) { + return { kind: 'noop', outcome: 'cancel_replayed', relationship: current }; + } + if (!current || current.status !== RELATIONSHIP_STATUSES.PENDING + || current.requestedBy !== actorId) { + return failTransition(); + } + return { + kind: 'upsert', + outcome: 'request_canceled', + relationship: terminalRequest( + current, + RELATIONSHIP_STATUSES.CANCELED, + now, + CANCEL_COOLDOWN_MS, + ), + }; + } + + if (action === ACTIONS.ACCEPT) { + if (current && current.status === RELATIONSHIP_STATUSES.ACCEPTED) { + return { kind: 'noop', outcome: 'accept_replayed', relationship: current }; + } + if (!current || current.status !== RELATIONSHIP_STATUSES.PENDING + || current.requestedBy !== targetId) { + return failTransition(); + } + return { + kind: 'upsert', + outcome: 'request_accepted', + relationship: { + ...current, + status: RELATIONSHIP_STATUSES.ACCEPTED, + requestedBy: null, + cooldownUntil: null, + stateChangedAt: now, + }, + }; + } + + if (action === ACTIONS.DECLINE) { + if (current && current.status === RELATIONSHIP_STATUSES.DECLINED + && current.requestedBy === targetId) { + return { kind: 'noop', outcome: 'decline_replayed', relationship: current }; + } + if (!current || current.status !== RELATIONSHIP_STATUSES.PENDING + || current.requestedBy !== targetId) { + return failTransition(); + } + return { + kind: 'upsert', + outcome: 'request_declined', + relationship: terminalRequest( + current, + RELATIONSHIP_STATUSES.DECLINED, + now, + DECLINE_COOLDOWN_MS, + ), + }; + } + + if (action === ACTIONS.UNFRIEND) { + if (!current) { + return { kind: 'noop', outcome: 'unfriend_replayed', relationship: null }; + } + if (current.status !== RELATIONSHIP_STATUSES.ACCEPTED) { + return failTransition(); + } + return { kind: 'delete', outcome: 'unfriended', relationship: null }; + } + + throw new SocialError(400, 'invalid_action', 'Unknown relationship action'); +}; + +module.exports = { + ACTIONS, + CANCEL_COOLDOWN_MS, + DECLINE_COOLDOWN_MS, + SocialError, + transitionRelationship, +}; diff --git a/server/socket/index.js b/server/socket/index.js index dc4ebff..fefe843 100644 --- a/server/socket/index.js +++ b/server/socket/index.js @@ -12,6 +12,7 @@ const { initializePostgres, pool } = require('../postgres'); const logger = require('../logger'); const loggerMiddleware = require('./middlewares/logger'); const authenticateMiddleware = require('./middlewares/authenticate'); +const { registerSocialEventSubscriber } = require('../realtime/socialEvents'); const initRooms = require('./namespaces/rooms'); const initDefault = require('./namespaces/default'); @@ -60,11 +61,14 @@ const init = async () => { db: config.redis.db, }); const subClient = pubClient.duplicate(); + const socialSubClient = pubClient.duplicate(); pubClient.on('error', logSocketError('redis pub client')); subClient.on('error', logSocketError('redis sub client')); + socialSubClient.on('error', logSocketError('redis social sub client')); io.adapter(createAdapter(pubClient, subClient)); + registerSocialEventSubscriber(io, socialSubClient); const reportHealth = createHealthReporter({ service: 'socket', @@ -81,6 +85,7 @@ const init = async () => { }, redis: async () => pubClient.status === 'ready' && subClient.status === 'ready' + && socialSubClient.status === 'ready' && (await pubClient.ping()) === 'PONG', }, }); From bc2f365961e7c6f7ce32bb86fff1cf61a70a831f Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 20:52:51 -0700 Subject: [PATCH 14/56] Stabilize room host ownership Keep creator ownership separate from active admin handoffs and restore the creator deterministically on rejoin. Centralize role checks and document the authorization contract needed by room invitations.\n\nRefs #153 --- README.md | 3 + docs/room-ownership.md | 49 +++++++++++++ server/models/room.js | 36 ++++++---- server/models/room.test.js | 64 ++++++++++++++++- server/socket/lib/roomAuthorization.js | 10 ++- server/socket/lib/roomAuthorization.test.js | 40 ++++++++++- server/socket/namespaces/rooms.js | 12 ++-- server/socket/namespaces/rooms.test.js | 76 ++++++++++++++++++++- 8 files changed, 268 insertions(+), 22 deletions(-) create mode 100644 docs/room-ownership.md diff --git a/README.md b/README.md index 363d1e4..ed7b0c4 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,9 @@ yarn start:client For more on the internals and contributing, check out the [wiki](https://github.com/coder13/LetsCube/wiki) +The server's owner/admin handoff and reconnect guarantees are documented in +[Room ownership and administration](docs/room-ownership.md). + ## Metrics The server stores pseudonymous room and authentication events in both the diff --git a/docs/room-ownership.md b/docs/room-ownership.md new file mode 100644 index 0000000..0016709 --- /dev/null +++ b/docs/room-ownership.md @@ -0,0 +1,49 @@ +# Room ownership and administration + +Rooms keep two separate host roles: + +- `owner` is the permanent creator. It does not change when the creator leaves, + and the owner may delete the room while absent. +- `admin` is the active participant who currently has room configuration and + moderation controls. + +The active admin is selected deterministically after a membership change: + +1. The owner is admin whenever they are in the room. +2. Otherwise, the existing admin remains in control while they are in the room. +3. If that admin leaves, the first active participant in the room's stored user + order takes control. +4. An empty room has no admin. Its owner remains unchanged, and a normal room + enters the existing stale-room expiry window. + +There is no user-facing manual transfer operation. If a future operation sets +an active participant as admin while the owner is absent, membership changes +preserve that transfer until the admin leaves or the owner returns. + +## Disconnect and rejoin behavior + +A transient disconnect does not immediately change membership or admin. The +owner or current admin keeps control during the configured reconnect grace +period. Reconnecting within that period cancels departure cleanup. After the +grace period, departure uses the same admin handoff as an explicit leave. + +Presence is per user, not per socket. Closing one tab does not hand off admin +while another socket for the same user remains in the room. Explicitly leaving +from the last tab finalizes the departure immediately. If the owner later +rejoins, they reclaim admin before the join is acknowledged. + +Grand Prix rooms use the same role selection when Grand Prix is enabled. When +it is disabled, create and join requests are rejected before membership or +roles change. + +## Authorization contract + +The server refreshes canonical room state before processing room socket events. +Admin actions must authorize against the current `admin`, not a client-provided +role or an older socket snapshot. Owner-only operations authorize against the +unchanging `owner`. + +Private-room invitations and access approvals may treat the active owner or +current admin as a host, but must also independently require that host to be an +active room participant. This prevents an absent owner from granting access +solely because owner deletion authority survives departure. diff --git a/server/models/room.js b/server/models/room.js index 88881ed..fef9c78 100644 --- a/server/models/room.js +++ b/server/models/room.js @@ -316,25 +316,34 @@ Room.methods.edit = async function (options) { return this.save(); }; -Room.methods.updateAdminIfNeeded = function (cb) { - if (this.usersInRoom.length === 0) { - this.admin = null; - return this.save(); +const sameUser = (left, right) => left && right + && String(left.id) === String(right.id); + +const selectRoomAdmin = ({ usersInRoom, owner, admin }) => { + if (usersInRoom.length === 0) { + return null; } - const findOwner = this.usersInRoom.find((user) => user.id === this.owner.id); - if (findOwner && this.admin && this.admin.id !== findOwner.id) { - this.admin = findOwner; - return this.save().then(cb); + const activeOwner = usersInRoom.find((user) => sameUser(user, owner)); + if (activeOwner) { + return activeOwner; } - if (!this.admin || this.admin.id !== this.usersInRoom[0].id) { - const { usersInRoom } = this; + return usersInRoom.find((user) => sameUser(user, admin)) || usersInRoom[0]; +}; + +Room.methods.updateAdminIfNeeded = async function (cb) { + const nextAdmin = selectRoomAdmin(this); + if (sameUser(this.admin, nextAdmin) || (!this.admin && !nextAdmin)) { + return this; + } - // eslint-disable-next-line prefer-destructuring - this.admin = usersInRoom[0]; - return this.save().then(cb); + this.admin = nextAdmin; + const room = await this.save(); + if (cb) { + cb(room); } + return room; }; const collectPostgresChanges = (room) => { @@ -411,3 +420,4 @@ Room.post('save', async (room) => { module.exports.Attempt = Attempt; module.exports.collectPostgresChanges = collectPostgresChanges; module.exports.Room = Room; +module.exports.selectRoomAdmin = selectRoomAdmin; diff --git a/server/models/room.test.js b/server/models/room.test.js index c955c76..752a519 100644 --- a/server/models/room.test.js +++ b/server/models/room.test.js @@ -4,7 +4,11 @@ const bcrypt = require('bcrypt'); const mongoose = require('mongoose'); const { generateScramble } = require('letscube-scrambles'); -const { collectPostgresChanges, Room } = require('./room'); +const { + collectPostgresChanges, + Room, + selectRoomAdmin, +} = require('./room'); jest.mock('letscube-scrambles', () => ({ generateScramble: jest.fn(), @@ -166,6 +170,64 @@ describe('room security helpers', () => { expect(room.expireAt.getTime()).toBeLessThanOrEqual(Date.now() + 10 * 60 * 1000); }); + it('restores the owner even when an empty room has no current admin', () => { + const owner = { id: 101 }; + const earlierParticipant = { id: 202 }; + + expect(selectRoomAdmin({ + usersInRoom: [earlierParticipant, owner], + owner, + admin: null, + })).toBe(owner); + }); + + it('keeps an active transferred admin while the owner is absent', () => { + const earlierParticipant = { id: 202 }; + const transferredAdmin = { id: 303 }; + + expect(selectRoomAdmin({ + usersInRoom: [earlierParticipant, transferredAdmin], + owner: { id: 101 }, + admin: transferredAdmin, + })).toBe(transferredAdmin); + }); + + it('promotes an active participant when both owner and admin are absent', () => { + const nextAdmin = { id: 303 }; + + expect(selectRoomAdmin({ + usersInRoom: [nextAdmin, { id: 404 }], + owner: { id: 101 }, + admin: { id: 202 }, + })).toBe(nextAdmin); + }); + + it('clears the admin when the room becomes empty', () => { + expect(selectRoomAdmin({ + usersInRoom: [], + owner: { id: 101 }, + admin: { id: 202 }, + })).toBeNull(); + }); + + it('persists and announces the owner reclaiming admin controls', async () => { + const owner = { id: 101 }; + const room = { + usersInRoom: [owner, { id: 202 }], + owner, + admin: { id: 202 }, + save: jest.fn(), + }; + room.save.mockResolvedValue(room); + const onAdminChange = jest.fn(); + + await Room.methods.updateAdminIfNeeded.call(room, onAdminChange); + + expect(room.admin).toBe(owner); + expect(room.save).toHaveBeenCalledTimes(1); + expect(onAdminChange).toHaveBeenCalledWith(room); + }); + it('stores an optional result submission id', () => { const room = RoomModel.hydrate({ _id: '507f1f77bcf86cd799439011', diff --git a/server/socket/lib/roomAuthorization.js b/server/socket/lib/roomAuthorization.js index dd9e278..b2a935a 100644 --- a/server/socket/lib/roomAuthorization.js +++ b/server/socket/lib/roomAuthorization.js @@ -1,5 +1,11 @@ const SUPER_ADMIN_ID = 8184; +const isRoomOwner = (userId, room) => !!userId && !!room && !!room.owner + && +room.owner.id === +userId; + +const isRoomAdmin = (userId, room) => !!userId && !!room && !!room.admin + && +room.admin.id === +userId; + const canDeleteRoom = (userId, room) => { if (+userId === SUPER_ADMIN_ID) { return true; @@ -9,7 +15,7 @@ const canDeleteRoom = (userId, room) => { return false; } - return [room.owner, room.admin].some((user) => user && +user.id === +userId); + return isRoomOwner(userId, room) || isRoomAdmin(userId, room); }; const canAccessRoom = (userId, room) => { @@ -28,4 +34,6 @@ const canAccessRoom = (userId, room) => { module.exports = { canAccessRoom, canDeleteRoom, + isRoomAdmin, + isRoomOwner, }; diff --git a/server/socket/lib/roomAuthorization.test.js b/server/socket/lib/roomAuthorization.test.js index e879047..622c213 100644 --- a/server/socket/lib/roomAuthorization.test.js +++ b/server/socket/lib/roomAuthorization.test.js @@ -1,6 +1,31 @@ /* eslint-env jest */ -const { canAccessRoom, canDeleteRoom } = require('./roomAuthorization'); +const { + canAccessRoom, + canDeleteRoom, + isRoomAdmin, + isRoomOwner, +} = require('./roomAuthorization'); + +describe('room roles', () => { + const room = { + owner: { id: 101 }, + admin: { id: 202 }, + }; + + it('distinguishes the permanent owner from the current admin', () => { + expect(isRoomOwner('101', room)).toBe(true); + expect(isRoomOwner(202, room)).toBe(false); + expect(isRoomAdmin('202', room)).toBe(true); + expect(isRoomAdmin(101, room)).toBe(false); + }); + + it('rejects missing users, rooms, and role holders', () => { + expect(isRoomOwner(null, room)).toBe(false); + expect(isRoomOwner(101, null)).toBe(false); + expect(isRoomAdmin(202, { ...room, admin: null })).toBe(false); + }); +}); describe('canDeleteRoom', () => { const room = { @@ -21,6 +46,19 @@ describe('canDeleteRoom', () => { it('allows the global administrator', () => { expect(canDeleteRoom(8184, null)).toBe(true); }); + + it('keeps absent-owner deletion separate from active host authority', () => { + const roomWithAbsentOwner = { + owner: { id: 101 }, + admin: { id: 202 }, + inRoom: new Map([['101', false], ['202', true]]), + banned: new Map(), + }; + + expect(canDeleteRoom(101, roomWithAbsentOwner)).toBe(true); + expect(canAccessRoom(101, roomWithAbsentOwner)).toBe(false); + expect(isRoomAdmin(101, roomWithAbsentOwner)).toBe(false); + }); }); describe('canAccessRoom', () => { diff --git a/server/socket/namespaces/rooms.js b/server/socket/namespaces/rooms.js index 338de97..2e0cc9c 100644 --- a/server/socket/namespaces/rooms.js +++ b/server/socket/namespaces/rooms.js @@ -10,7 +10,11 @@ const { markRoomDeleted } = require('../../postgres/dualWrite'); const { Room, User } = require('../../models'); const { encodeUserRoom } = require('../utils'); const roomMap = require('../lib/roomMap'); -const { canAccessRoom, canDeleteRoom } = require('../lib/roomAuthorization'); +const { + canAccessRoom, + canDeleteRoom, + isRoomAdmin, +} = require('../lib/roomAuthorization'); const { isRoomTypeEnabled: checkRoomTypeEnabled } = require('../lib/roomAvailability'); const { removeUserFromRoomSockets } = require('../lib/roomSockets'); const { createReconnectGrace } = require('../lib/reconnectGrace'); @@ -323,7 +327,7 @@ module.exports = (io, middlewares) => { if (!isLoggedIn() || !isInRoom()) { logger.debug('Unauthenticated user or user not in room attempting to perform admin action'); return false; - } if (socket.room.admin.id !== socket.user.id) { + } if (!isRoomAdmin(socket.user.id, socket.room)) { logger.debug('Non-admin attempting to perform admin action'); socket.emit(Protocol.ERROR, { statusCode: 403, @@ -756,7 +760,7 @@ module.exports = (io, middlewares) => { } const { userId } = result; - if (userId !== socket.user.id && socket.user.id !== socket.room.admin.id) { + if (userId !== socket.user.id && !isRoomAdmin(socket.user.id, socket.room)) { socket.emit(Protocol.ERROR, { statusCode: 400, event: Protocol.SEND_EDIT_RESULT, @@ -843,7 +847,7 @@ module.exports = (io, middlewares) => { }); } - if (socket.room.admin.id !== socket.user.id) { + if (!isRoomAdmin(socket.user.id, socket.room)) { logger.debug('Non-admin attempting to edit a room'); return rejectEdit({ statusCode: 403, diff --git a/server/socket/namespaces/rooms.test.js b/server/socket/namespaces/rooms.test.js index e24cb4c..b153e5d 100644 --- a/server/socket/namespaces/rooms.test.js +++ b/server/socket/namespaces/rooms.test.js @@ -103,13 +103,14 @@ const makeSocket = ({ }; const setup = (rooms) => { + const roomChannel = { emit: jest.fn() }; const namespace = { adapter: { allRooms: jest.fn().mockResolvedValue(new Set()), sockets: jest.fn().mockResolvedValue(new Set()), }, emit: jest.fn(), - in: jest.fn(() => ({ emit: jest.fn() })), + in: jest.fn(() => roomChannel), on: jest.fn(), use: jest.fn(), }; @@ -125,10 +126,13 @@ const setup = (rooms) => { User.find.mockResolvedValue([]); initRooms(io, []); - return async (socket) => { + const connectSocket = async (socket) => { await connect(socket); return socket; }; + connectSocket.namespace = namespace; + connectSocket.roomChannel = roomChannel; + return connectSocket; }; const joinRoom = async (socket, payload) => { @@ -206,6 +210,74 @@ describe('private room namespace joins', () => { expect.objectContaining({ _id: otherRoom._id }), ); }); + + it('returns restored controls when the owner rejoins after an admin handoff', async () => { + const owner = { id: 101 }; + const room = makeRoom({ + private: false, + password: null, + owner, + admin: { id: 202 }, + inRoom: new Map([['101', false], ['202', true]]), + }); + room.addUser.mockImplementation(async (user, spectating, onAdminChange) => { + room.inRoom.set(user.id.toString(), true); + room.admin = user; + onAdminChange(room); + return room; + }); + room.edit = jest.fn().mockResolvedValue(room); + const connect = setup(new Map([[room._id, room]])); + const socket = await connect(makeSocket({ + id: 'owner-socket', + session: {}, + userId: owner.id, + })); + + const joinAcknowledgment = await joinRoom(socket, { id: room._id }); + + expect(joinAcknowledgment).toHaveBeenCalledWith( + null, + expect.objectContaining({ admin: expect.objectContaining({ id: owner.id }) }), + ); + expect(connect.roomChannel.emit).toHaveBeenCalledWith( + Protocol.UPDATE_ADMIN, + expect.objectContaining({ id: owner.id }), + ); + + const editAcknowledgment = jest.fn(); + await socket.handlers[Protocol.EDIT_ROOM]({ + name: 'Owner is back', + private: false, + type: 'normal', + }, editAcknowledgment); + + expect(room.edit).toHaveBeenCalledTimes(1); + expect(editAcknowledgment).toHaveBeenCalledWith( + null, + expect.objectContaining({ admin: expect.objectContaining({ id: owner.id }) }), + ); + }); + + it('does not change ownership when a disabled Grand Prix room is joined', async () => { + const room = makeRoom({ + type: 'grand_prix', + private: false, + password: null, + admin: { id: 202 }, + }); + const connect = setup(new Map([[room._id, room]])); + const socket = await connect(makeSocket({ id: 'grand-prix-socket', session: {} })); + + const acknowledgment = await joinRoom(socket, { id: room._id }); + + expect(acknowledgment).toHaveBeenCalledWith( + expect.objectContaining({ reason: 'grand_prix_disabled' }), + expect.objectContaining({ _id: room._id }), + ); + expect(room.addUser).not.toHaveBeenCalled(); + expect(room.admin).toEqual({ id: 202 }); + }); }); describe('room namespace edits', () => { From 1b9237990bd0b9739d9d22642e9c1b1392f47378 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 20:52:08 -0700 Subject: [PATCH 15/56] Normalize usernames for indexed lookup Centralize username validation and add a collision-aware MongoDB backfill. Mirror normalized keys through an additive PostgreSQL migration for safe friend discovery. --- README.md | 4 + client/src/components/common/Profile.jsx | 9 +- docs/username-migration.md | 59 +++++++++ scripts/backfill-normalized-usernames.js | 77 +++++++++++ server/api.js | 55 ++------ server/api.test.js | 76 +++++++++++ server/models/user.js | 26 ++++ server/models/user.test.js | 45 +++++++ server/package.json | 1 + server/postgres/dualWrite.js | 10 +- server/postgres/dualWrite.test.js | 3 + .../migration.sql | 5 + server/prisma/schema.prisma | 1 + server/username.js | 117 ++++++++++++++++ server/username.test.js | 125 ++++++++++++++++++ server/usernameBackfill.js | 106 +++++++++++++++ server/usernameBackfill.test.js | 68 ++++++++++ 17 files changed, 736 insertions(+), 51 deletions(-) create mode 100644 docs/username-migration.md create mode 100644 scripts/backfill-normalized-usernames.js create mode 100644 server/api.test.js create mode 100644 server/prisma/migrations/20260712120000_add_normalized_usernames/migration.sql create mode 100644 server/username.js create mode 100644 server/username.test.js create mode 100644 server/usernameBackfill.js create mode 100644 server/usernameBackfill.test.js diff --git a/README.md b/README.md index 557bfba..0e7ea66 100644 --- a/README.md +++ b/README.md @@ -105,3 +105,7 @@ TLS connections can set `PGSSL=true` and provide a CA with `PGSSL_CA`; certificate verification is enabled by default. PostgreSQL failures are logged but do not fail the corresponding MongoDB-backed application operation during this migration phase. + +Username lookup uses a separately normalized, uniquely indexed key while +preserving display casing. See [the normalized username migration](docs/username-migration.md) +for the required collision audit, production order, and rollback procedure. diff --git a/client/src/components/common/Profile.jsx b/client/src/components/common/Profile.jsx index 9a9bda0..dd4aa38 100644 --- a/client/src/components/common/Profile.jsx +++ b/client/src/components/common/Profile.jsx @@ -19,12 +19,9 @@ const validate = (username) => { return ''; } - if (username.indexOf(' ') > -1) { - return 'Username cannot contains spaces'; - } - - if (username.length >= 16) { - return 'Username cannot be longer than 16 characters'; + const normalizedUsername = username.normalize('NFKC').trim(); + if (normalizedUsername.length > 15 || !/^[A-Za-z0-9_-]+$/.test(normalizedUsername)) { + return 'Use 1–15 letters, numbers, underscores, or hyphens'; } return ''; diff --git a/docs/username-migration.md b/docs/username-migration.md new file mode 100644 index 0000000..1f3ae37 --- /dev/null +++ b/docs/username-migration.md @@ -0,0 +1,59 @@ +# Normalized username migration + +MongoDB remains the user source of truth. Usernames are optional and use two +fields: `username` stores trimmed display casing, while `usernameNormalized` +stores the lowercase lookup key. The normalized value is intentionally not +returned by user serialization. + +Valid new usernames contain 1–15 ASCII letters, numbers, underscores, or +hyphens. Empty input unsets both fields. Legacy invalid values are preserved but +left without a normalized key, so they are not discoverable until their owner +chooses a valid username. The privacy exception is an invalid value containing +an email marker (`@`), which is removed instead of retained. Case-insensitive +legacy collisions are also preserved and reported for explicit resolution; the +migration never picks a winner or silently renames an account. + +## Production order + +Issue #191 is a hard prerequisite. Merge and deploy its WCA email scope removal +and data purge before enabling username discovery. + +1. Back up MongoDB and schedule a short maintenance window for username writes. +2. Apply the committed PostgreSQL migrations. The nullable + `app.users.username_normalized` column and its unique index are additive, so + the previous application version continues to run. +3. From the release checkout, audit MongoDB without changing data: + + ```bash + yarn workspace letscube-server usernames:backfill + ``` + +4. Save the JSON report. Resolve every reported collision with the account + owners when practical. Invalid raw values are deliberately omitted from the + report, and email-like invalid values are removed rather than copied or + logged. +5. Apply the idempotent backfill and create or verify the sparse unique index: + + ```bash + yarn workspace letscube-server usernames:backfill --apply --create-index + ``` + +6. Run the dry-run command again. `pendingChanges` must be `0`; unresolved + collision and invalid-value reports may remain because those users are + intentionally undiscoverable. +7. Deploy the API and verify a casing-only username change, a conflicting + change (`409 USERNAME_TAKEN`), and an invalid change + (`400 INVALID_USERNAME`). PostgreSQL receives the normalized field through + the existing non-blocking dual writer whenever MongoDB users are saved. + +Do not enable the discovery endpoint from #82 until this sequence and the #191 +purge have completed. + +## Rollback + +Do not drop the MongoDB index or PostgreSQL column when rolling the application +back. Both additions are backward-compatible and the previous application +ignores them. Disable username discovery and username edits while the old code +is serving because it does not maintain `usernameNormalized`. Before rolling +forward again, rerun the dry-run and apply commands to reconcile any writes made +by the old application. diff --git a/scripts/backfill-normalized-usernames.js b/scripts/backfill-normalized-usernames.js new file mode 100644 index 0000000..a10b4f8 --- /dev/null +++ b/scripts/backfill-normalized-usernames.js @@ -0,0 +1,77 @@ +#!/usr/bin/env node +/* eslint-disable import/no-extraneous-dependencies, no-console */ +const path = require('path'); +const mongoose = require('mongoose'); + +process.env.GETCONFIG_ROOT = process.env.GETCONFIG_ROOT + || path.join(__dirname, '../server/config'); + +const config = require('../server/runtimeConfig'); +const { planUsernameBackfill } = require('../server/usernameBackfill'); + +const args = new Set(process.argv.slice(2)); +const supportedArgs = new Set(['--apply', '--create-index']); +const unknownArg = [...args].find((arg) => !supportedArgs.has(arg)); + +const duplicateNormalizedUsernames = (collection) => collection.aggregate([ + { $match: { usernameNormalized: { $exists: true } } }, + { $group: { _id: '$usernameNormalized', count: { $sum: 1 } } }, + { $match: { count: { $gt: 1 } } }, + { $limit: 1 }, +]).toArray(); + +const run = async () => { + if (unknownArg) { + throw new Error(`Unknown argument: ${unknownArg}`); + } + + const apply = args.has('--apply'); + const createIndex = args.has('--create-index'); + if (createIndex && !apply) { + throw new Error('--create-index must be used with --apply'); + } + + mongoose.set('strictQuery', false); + await mongoose.connect(config.mongodb, { autoIndex: false }); + const users = mongoose.connection.collection('users'); + const documents = await users.find({}, { + projection: { + _id: 1, + id: 1, + username: 1, + usernameNormalized: 1, + }, + }).toArray(); + const plan = planUsernameBackfill(documents); + + console.log(JSON.stringify({ + mode: apply ? 'apply' : 'dry-run', + ...plan.report, + }, null, 2)); + + if (!apply) { + return; + } + + if (plan.operations.length) { + const result = await users.bulkWrite(plan.operations, { ordered: false }); + console.log(`Updated ${result.modifiedCount} user records.`); + } + + if (createIndex) { + const duplicates = await duplicateNormalizedUsernames(users); + if (duplicates.length) { + throw new Error('Duplicate normalized usernames remain; unique index was not created'); + } + const name = await users.createIndex( + { usernameNormalized: 1 }, + { name: 'users_username_normalized_unique', sparse: true, unique: true }, + ); + console.log(`Verified MongoDB index ${name}.`); + } +}; + +run().catch((err) => { + console.error(err.message); + process.exitCode = 1; +}).finally(() => mongoose.disconnect()); diff --git a/server/api.js b/server/api.js index 7e7643a..dff3c8a 100644 --- a/server/api.js +++ b/server/api.js @@ -3,6 +3,7 @@ const express = require('express'); const router = express.Router(); const { User } = require('./models'); const auth = require('./middlewares/auth.js'); +const { updateUsername } = require('./username'); const PREFERENCE_KEYS = new Set([ 'showWCAID', @@ -14,57 +15,27 @@ const PREFERENCE_KEYS = new Set([ module.exports = () => { const sendError = (res, err) => { - res.status(err.statusCode || 500).send({ + const body = { status: err.statusCode, message: err.message || 'Error occured while retrieving data; contact Kleb', - }); + }; + if (err.code) { + body.code = err.code; + } + res.status(err.statusCode || 500).send(body); }; router.get('/me', auth, (req, res) => { res.json(req.user.toObject()); }); - router.put('/updateUsername', auth, (req, res) => { - // TODO: server side validation of username - // TODO: refactor - const { username } = req.body; - if (username === undefined) { - return sendError(res, { - statusCode: 400, - message: 'Missing username from request', - }); - } - - if (username === '') { - req.user.username = username; - return req.user.save().then((u) => { - res.json(u.toObject()); - }); + router.put('/updateUsername', auth, async (req, res) => { + try { + const user = await updateUsername(User, req.user, req.body.username); + return res.json(user.toObject()); + } catch (err) { + return sendError(res, err); } - - User.findOne({ - username: { - $regex: new RegExp(`^${username}$`, 'i'), - }, - }).then((user) => { - if (user && user.id !== req.user.id) { - sendError(res, { - statusCode: 500, - message: 'User with username already exists', - }); - return null; - } - - req.user.username = username.trim(); - return req.user.save().then((u) => { - res.json(u.toObject()); - }); - }).catch((err) => { - sendError(res, { - statusCode: 500, - message: err, - }); - }); }); router.put('/updatePreference', auth, async (req, res) => { diff --git a/server/api.test.js b/server/api.test.js new file mode 100644 index 0000000..bbc1944 --- /dev/null +++ b/server/api.test.js @@ -0,0 +1,76 @@ +/** @jest-environment node */ +/* eslint-env jest */ + +jest.mock('./models', () => ({ + User: { findOne: jest.fn() }, +})); +jest.mock('./middlewares/auth.js', () => (req, res, next) => next()); + +const { User } = require('./models'); +const router = require('./api')(); + +const updateUsernameHandler = router.stack + .find((layer) => layer.route && layer.route.path === '/updateUsername') + .route.stack[1].handle; + +const response = () => { + const res = { + json: jest.fn(), + send: jest.fn(), + status: jest.fn(), + }; + res.status.mockReturnValue(res); + return res; +}; + +describe('username API responses', () => { + beforeEach(() => jest.clearAllMocks()); + + it('returns stable 400 details for invalid input', async () => { + const req = { + body: { username: 'name@example.com' }, + user: { id: 1, save: jest.fn() }, + }; + const res = response(); + + await updateUsernameHandler(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.send).toHaveBeenCalledWith(expect.objectContaining({ + code: 'INVALID_USERNAME', + status: 400, + })); + expect(User.findOne).not.toHaveBeenCalled(); + }); + + it('returns stable 409 details for a case-insensitive conflict', async () => { + User.findOne.mockResolvedValue({ id: 2 }); + const req = { + body: { username: 'CUBER' }, + user: { id: 1, save: jest.fn() }, + }; + const res = response(); + + await updateUsernameHandler(req, res); + + expect(User.findOne).toHaveBeenCalledWith({ usernameNormalized: 'cuber' }); + expect(res.status).toHaveBeenCalledWith(409); + expect(res.send).toHaveBeenCalledWith(expect.objectContaining({ + code: 'USERNAME_TAKEN', + status: 409, + })); + expect(req.user.save).not.toHaveBeenCalled(); + }); + + it('returns stable 400 details when username is omitted', async () => { + const res = response(); + + await updateUsernameHandler({ body: {}, user: { id: 1 } }, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.send).toHaveBeenCalledWith(expect.objectContaining({ + code: 'MISSING_USERNAME', + status: 400, + })); + }); +}); diff --git a/server/models/user.js b/server/models/user.js index 053e993..c6426ff 100644 --- a/server/models/user.js +++ b/server/models/user.js @@ -1,9 +1,11 @@ const mongoose = require('mongoose'); const { mirrorUser } = require('../postgres/dualWrite'); +const { normalizeUsername } = require('../username'); const redactUser = (doc, ret) => { delete ret.email; delete ret.accessToken; + delete ret.usernameNormalized; if (!doc.showWCAID) { delete ret.wcaId; if (!doc.preferRealName) { @@ -27,6 +29,9 @@ const schema = new mongoose.Schema({ username: { type: String, }, + usernameNormalized: { + type: String, + }, wcaId: { type: String, }, @@ -68,6 +73,27 @@ const schema = new mongoose.Schema({ getters: true, transform: redactUser, }, + autoIndex: false, +}); + +schema.index( + { usernameNormalized: 1 }, + { name: 'users_username_normalized_unique', sparse: true, unique: true }, +); + +schema.pre('validate', function normalizeChangedUsername(next) { + if (!this.isNew && !this.isModified('username')) { + return next(); + } + + try { + const normalized = normalizeUsername(this.username); + this.username = normalized.username; + this.usernameNormalized = normalized.usernameNormalized; + return next(); + } catch (err) { + return next(err); + } }); schema.virtual('displayName').get(function () { diff --git a/server/models/user.test.js b/server/models/user.test.js index 874ac54..cd5fd05 100644 --- a/server/models/user.test.js +++ b/server/models/user.test.js @@ -1,6 +1,8 @@ /** @jest-environment node */ /* eslint-env jest */ +jest.mock('../postgres/dualWrite', () => ({ mirrorUser: jest.fn() })); + const mongoose = require('mongoose'); const UserSchema = require('./user'); @@ -23,3 +25,46 @@ describe('user privacy', () => { expect(user.toObject()).not.toHaveProperty('accessToken'); }); }); + +describe('user username schema', () => { + it('declares an explicit sparse unique normalized username index', () => { + expect(UserSchema.indexes()).toContainEqual([ + { usernameNormalized: 1 }, + expect.objectContaining({ + name: 'users_username_normalized_unique', + sparse: true, + unique: true, + }), + ]); + expect(UserSchema.options.autoIndex).toBe(false); + }); + + it('normalizes direct username writes through the shared validation path', async () => { + const user = new User({ + id: 1, + name: 'Test User', + accessToken: 'secret', + username: ' MixedCase_1 ', + }); + + await user.validate(); + + expect(user.username).toBe('MixedCase_1'); + expect(user.usernameNormalized).toBe('mixedcase_1'); + expect(user.toObject()).not.toHaveProperty('usernameNormalized'); + }); + + it('rejects invalid direct username writes', async () => { + const user = new User({ + id: 1, + name: 'Test User', + accessToken: 'secret', + username: 'not.an.email@example.com', + }); + + await expect(user.validate()).rejects.toMatchObject({ + code: 'INVALID_USERNAME', + statusCode: 400, + }); + }); +}); diff --git a/server/package.json b/server/package.json index 99e154c..87bbe1d 100644 --- a/server/package.json +++ b/server/package.json @@ -39,6 +39,7 @@ "postgres:schema:check": "prisma migrate diff --exit-code --from-config-datasource --to-schema prisma/schema.prisma --config prisma.config.mjs", "postgres:schema:validate": "prisma validate --config prisma.config.mjs", "privacy:purge-user-emails": "node privacy/purgeUserEmails.js", + "usernames:backfill": "node ../scripts/backfill-normalized-usernames.js", "test": "jest --passWithNoTests", "test:ci": "yarn test" }, diff --git a/server/postgres/dualWrite.js b/server/postgres/dualWrite.js index 6a5c869..02ce1bf 100644 --- a/server/postgres/dualWrite.js +++ b/server/postgres/dualWrite.js @@ -66,13 +66,14 @@ const upsertUser = async (client, user, fallbackUpdatedAt = new Date()) => { ); await client.query(` INSERT INTO app.users ( - id, wca_user_id, name, username, wca_id, preferences, avatar, - source_created_at, source_updated_at - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + id, wca_user_id, name, username, username_normalized, wca_id, + preferences, avatar, source_created_at, source_updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT (wca_user_id) DO UPDATE SET email = NULL, name = EXCLUDED.name, username = EXCLUDED.username, + username_normalized = EXCLUDED.username_normalized, wca_id = EXCLUDED.wca_id, preferences = EXCLUDED.preferences, avatar = EXCLUDED.avatar, @@ -84,12 +85,14 @@ const upsertUser = async (client, user, fallbackUpdatedAt = new Date()) => { AND ROW( app.users.name, app.users.username, + app.users.username_normalized, app.users.wca_id, app.users.preferences, app.users.avatar ) IS DISTINCT FROM ROW( EXCLUDED.name, EXCLUDED.username, + EXCLUDED.username_normalized, EXCLUDED.wca_id, EXCLUDED.preferences, EXCLUDED.avatar @@ -100,6 +103,7 @@ const upsertUser = async (client, user, fallbackUpdatedAt = new Date()) => { wcaUserId, user.name, user.username || null, + user.usernameNormalized || null, user.wcaId || null, { showWCAID: !!user.showWCAID, diff --git a/server/postgres/dualWrite.test.js b/server/postgres/dualWrite.test.js index fb26cdb..e296886 100644 --- a/server/postgres/dualWrite.test.js +++ b/server/postgres/dualWrite.test.js @@ -21,6 +21,7 @@ const user = { email: 'private@example.com', name: 'Test Solver', username: 'solver', + usernameNormalized: 'solver', wcaId: '2026TEST01', accessToken: 'must-not-be-mirrored', showWCAID: true, @@ -59,7 +60,9 @@ describe('PostgreSQL dual writer', () => { expect(values).toContain(1234); expect(client.query.mock.calls[1][0]).toContain('email = NULL'); expect(values).not.toContain('private@example.com'); + expect(values.slice(3, 5)).toEqual(['solver', 'solver']); expect(values).not.toContain('must-not-be-mirrored'); + expect(client.query.mock.calls[1][0]).toContain('username_normalized'); }); it('mirrors a room snapshot, participants, attempts, and solves', async () => { diff --git a/server/prisma/migrations/20260712120000_add_normalized_usernames/migration.sql b/server/prisma/migrations/20260712120000_add_normalized_usernames/migration.sql new file mode 100644 index 0000000..92a80fd --- /dev/null +++ b/server/prisma/migrations/20260712120000_add_normalized_usernames/migration.sql @@ -0,0 +1,5 @@ +ALTER TABLE app.users + ADD COLUMN username_normalized text; + +CREATE UNIQUE INDEX users_username_normalized_key + ON app.users (username_normalized); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index fa4c43c..9cbb555 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -10,6 +10,7 @@ model User { email String? name String username String? + usernameNormalized String? @unique(map: "users_username_normalized_key") @map("username_normalized") wcaId String? @map("wca_id") preferences Json @default("{}") @db.JsonB avatar Json @default("{}") @db.JsonB diff --git a/server/username.js b/server/username.js new file mode 100644 index 0000000..18b198b --- /dev/null +++ b/server/username.js @@ -0,0 +1,117 @@ +const USERNAME_MAX_LENGTH = 15; +const USERNAME_SEARCH_DEFAULT_LIMIT = 20; +const USERNAME_SEARCH_MAX_LIMIT = 50; +const USERNAME_PATTERN = /^[A-Za-z0-9_-]+$/; + +class UsernameError extends Error { + constructor(code, message, statusCode = 400) { + super(message); + this.name = 'UsernameError'; + this.code = code; + this.statusCode = statusCode; + } +} + +const invalidUsername = () => new UsernameError( + 'INVALID_USERNAME', + 'Username may only contain letters, numbers, underscores, and hyphens', +); + +const normalizeUsername = (value, { allowEmpty = true } = {}) => { + if (value === undefined || value === null) { + if (allowEmpty) { + return { username: undefined, usernameNormalized: undefined }; + } + throw invalidUsername(); + } + + if (typeof value !== 'string') { + throw invalidUsername(); + } + + const username = value.normalize('NFKC').trim(); + if (!username) { + if (allowEmpty) { + return { username: undefined, usernameNormalized: undefined }; + } + throw invalidUsername(); + } + + if (username.length > USERNAME_MAX_LENGTH || !USERNAME_PATTERN.test(username)) { + throw invalidUsername(); + } + + return { + username, + usernameNormalized: username.toLowerCase(), + }; +}; + +const usernameConflict = () => new UsernameError( + 'USERNAME_TAKEN', + 'Username is already in use', + 409, +); + +const isUsernameDuplicateKeyError = (err) => err && err.code === 11000 && ( + (err.keyPattern && err.keyPattern.usernameNormalized) + || (err.message && err.message.includes('users_username_normalized_unique')) +); + +const findUserByUsername = (User, value) => { + const { usernameNormalized } = normalizeUsername(value, { allowEmpty: false }); + return User.findOne({ usernameNormalized }); +}; + +const searchUsersByUsernamePrefix = (User, value, requestedLimit) => { + const { usernameNormalized } = normalizeUsername(value, { allowEmpty: false }); + const parsedLimit = Number(requestedLimit); + const limit = Number.isInteger(parsedLimit) && parsedLimit > 0 + ? Math.min(parsedLimit, USERNAME_SEARCH_MAX_LIMIT) + : USERNAME_SEARCH_DEFAULT_LIMIT; + + return User.find({ + usernameNormalized: { + $gte: usernameNormalized, + $lt: `${usernameNormalized}\uffff`, + }, + }).sort({ usernameNormalized: 1 }).limit(limit); +}; + +const updateUsername = async (User, user, value) => { + if (value === undefined) { + throw new UsernameError('MISSING_USERNAME', 'Missing username from request'); + } + + const normalized = normalizeUsername(value); + if (normalized.usernameNormalized) { + const existingUser = await findUserByUsername(User, normalized.username); + if (existingUser && existingUser.id.toString() !== user.id.toString()) { + throw usernameConflict(); + } + } + + user.username = normalized.username; + user.usernameNormalized = normalized.usernameNormalized; + + try { + return await user.save(); + } catch (err) { + if (isUsernameDuplicateKeyError(err)) { + throw usernameConflict(); + } + throw err; + } +}; + +module.exports = { + USERNAME_MAX_LENGTH, + USERNAME_SEARCH_DEFAULT_LIMIT, + USERNAME_SEARCH_MAX_LIMIT, + UsernameError, + findUserByUsername, + isUsernameDuplicateKeyError, + normalizeUsername, + searchUsersByUsernamePrefix, + updateUsername, +}; diff --git a/server/username.test.js b/server/username.test.js new file mode 100644 index 0000000..7c04f4b --- /dev/null +++ b/server/username.test.js @@ -0,0 +1,125 @@ +/** @jest-environment node */ +/* eslint-env jest */ + +const { + findUserByUsername, + normalizeUsername, + searchUsersByUsernamePrefix, + updateUsername, +} = require('./username'); + +describe('username normalization and lookup', () => { + it('trims display values, preserves casing, and creates a lowercase key', () => { + expect(normalizeUsername(' FastCuber_7 ')).toEqual({ + username: 'FastCuber_7', + usernameNormalized: 'fastcuber_7', + }); + }); + + it('represents empty optional usernames as absent fields', () => { + expect(normalizeUsername(' ')).toEqual({ + username: undefined, + usernameNormalized: undefined, + }); + }); + + it.each([ + 'cuber.name', + 'cuber+name', + 'cuber@example.com', + 'two cubers', + '1234567890123456', + ])('rejects invalid input without treating it as a lookup: %s', (username) => { + expect(() => normalizeUsername(username)).toThrow(expect.objectContaining({ + code: 'INVALID_USERNAME', + statusCode: 400, + })); + }); + + it('uses normalized equality instead of a regular expression for exact lookup', () => { + const User = { findOne: jest.fn().mockReturnValue('query') }; + + expect(findUserByUsername(User, 'Cuber_1')).toBe('query'); + expect(User.findOne).toHaveBeenCalledWith({ usernameNormalized: 'cuber_1' }); + }); + + it('uses an indexed lexical range and caps prefix results', () => { + const limit = jest.fn().mockReturnValue('query'); + const sort = jest.fn().mockReturnValue({ limit }); + const User = { find: jest.fn().mockReturnValue({ sort }) }; + + expect(searchUsersByUsernamePrefix(User, 'CuBer', 1000)).toBe('query'); + expect(User.find).toHaveBeenCalledWith({ + usernameNormalized: { + $gte: 'cuber', + $lt: 'cuber\uffff', + }, + }); + expect(sort).toHaveBeenCalledWith({ usernameNormalized: 1 }); + expect(limit).toHaveBeenCalledWith(50); + }); + + it('rejects metacharacters before issuing a prefix query', () => { + const User = { find: jest.fn() }; + + expect(() => searchUsersByUsernamePrefix(User, 'cube.*')).toThrow(expect.objectContaining({ + code: 'INVALID_USERNAME', + })); + expect(User.find).not.toHaveBeenCalled(); + }); +}); + +describe('username updates', () => { + it('saves normalized fields and allows display casing changes for the same user', async () => { + const saved = { toObject: jest.fn() }; + const user = { id: 1, save: jest.fn().mockResolvedValue(saved) }; + const User = { findOne: jest.fn().mockResolvedValue({ id: 1 }) }; + + await expect(updateUsername(User, user, ' Cuber ')).resolves.toBe(saved); + expect(user).toEqual(expect.objectContaining({ + username: 'Cuber', + usernameNormalized: 'cuber', + })); + }); + + it('returns a stable conflict before writing another user\'s username', async () => { + const user = { id: 1, save: jest.fn() }; + const User = { findOne: jest.fn().mockResolvedValue({ id: 2 }) }; + + await expect(updateUsername(User, user, 'Cuber')).rejects.toMatchObject({ + code: 'USERNAME_TAKEN', + statusCode: 409, + }); + expect(user.save).not.toHaveBeenCalled(); + }); + + it('maps a unique-index race to the same stable conflict', async () => { + const duplicate = Object.assign(new Error('users_username_normalized_unique'), { code: 11000 }); + const user = { id: 1, save: jest.fn().mockRejectedValue(duplicate) }; + const User = { findOne: jest.fn().mockResolvedValue(null) }; + + await expect(updateUsername(User, user, 'Cuber')).rejects.toMatchObject({ + code: 'USERNAME_TAKEN', + statusCode: 409, + }); + }); + + it('unsets an optional username without querying for a conflict', async () => { + const user = { id: 1, username: 'Old', usernameNormalized: 'old' }; + user.save = jest.fn().mockResolvedValue(user); + const User = { findOne: jest.fn() }; + + await updateUsername(User, user, ''); + + expect(user.username).toBeUndefined(); + expect(user.usernameNormalized).toBeUndefined(); + expect(User.findOne).not.toHaveBeenCalled(); + }); + + it('returns a stable error when the request omits username', async () => { + await expect(updateUsername({}, { id: 1 }, undefined)).rejects.toMatchObject({ + code: 'MISSING_USERNAME', + statusCode: 400, + }); + }); +}); diff --git a/server/usernameBackfill.js b/server/usernameBackfill.js new file mode 100644 index 0000000..5f11aaa --- /dev/null +++ b/server/usernameBackfill.js @@ -0,0 +1,106 @@ +const { normalizeUsername } = require('./username'); + +const identifier = (user) => (user.id === undefined ? user._id.toString() : user.id); +const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key); +const containsEmailMarker = (value) => typeof value === 'string' && value.includes('@'); + +const changedOperation = (user, set, unset) => { + const changedSet = Object.entries(set).some(([key, value]) => user[key] !== value); + const changedUnset = Object.keys(unset).some((key) => hasOwn(user, key)); + if (!changedSet && !changedUnset) { + return null; + } + + const update = {}; + if (Object.keys(set).length) { + update.$set = set; + } + if (Object.keys(unset).length) { + update.$unset = unset; + } + + return { + updateOne: { + filter: { _id: user._id }, + update, + }, + }; +}; + +const planUsernameBackfill = (users) => { + const analyzed = users.map((user) => { + try { + return { user, ...normalizeUsername(user.username) }; + } catch (err) { + return { user, error: err }; + } + }); + const grouped = new Map(); + + analyzed.forEach((entry) => { + if (!entry.usernameNormalized) { + return; + } + const matches = grouped.get(entry.usernameNormalized) || []; + matches.push(entry); + grouped.set(entry.usernameNormalized, matches); + }); + + const collisionKeys = new Set( + [...grouped.entries()].filter(([, entries]) => entries.length > 1).map(([key]) => key), + ); + const operations = []; + const invalid = []; + let empty = 0; + let valid = 0; + + analyzed.forEach((entry) => { + const { user } = entry; + let operation; + + if (entry.error) { + invalid.push({ id: identifier(user), code: entry.error.code }); + operation = changedOperation(user, {}, { + ...(containsEmailMarker(user.username) ? { username: '' } : {}), + usernameNormalized: '', + }); + } else if (!entry.usernameNormalized) { + empty += 1; + operation = changedOperation(user, {}, { username: '', usernameNormalized: '' }); + } else if (collisionKeys.has(entry.usernameNormalized)) { + operation = changedOperation(user, { username: entry.username }, { usernameNormalized: '' }); + } else { + valid += 1; + operation = changedOperation(user, { + username: entry.username, + usernameNormalized: entry.usernameNormalized, + }, {}); + } + + if (operation) { + operations.push(operation); + } + }); + + const collisions = [...collisionKeys].sort().map((usernameNormalized) => ({ + usernameNormalized, + users: grouped.get(usernameNormalized).map(({ user, username }) => ({ + id: identifier(user), + username, + })), + })); + + return { + operations, + report: { + scanned: users.length, + valid, + empty, + invalid, + collisions, + pendingChanges: operations.length, + }, + }; +}; + +module.exports = { planUsernameBackfill }; diff --git a/server/usernameBackfill.test.js b/server/usernameBackfill.test.js new file mode 100644 index 0000000..51b7b5c --- /dev/null +++ b/server/usernameBackfill.test.js @@ -0,0 +1,68 @@ +/** @jest-environment node */ +/* eslint-env jest */ + +const { planUsernameBackfill } = require('./usernameBackfill'); + +const applyOperations = (users, operations) => { + operations.forEach(({ updateOne }) => { + const user = users.find(({ _id }) => _id === updateOne.filter._id); + Object.assign(user, updateOne.update.$set || {}); + Object.keys(updateOne.update.$unset || {}).forEach((key) => delete user[key]); + }); +}; + +describe('normalized username backfill', () => { + it('plans valid, empty, invalid, and colliding legacy records safely', () => { + const users = [ + { _id: '1', id: 1, username: ' SoloCuber ' }, + { _id: '2', id: 2, username: 'FastCuber' }, + { + _id: '3', id: 3, username: 'fastcuber', usernameNormalized: 'stale', + }, + { _id: '4', id: 4, username: '' }, + { + _id: '5', id: 5, username: 'legacy.name', usernameNormalized: 'legacy.name', + }, + { _id: '6', id: 6, username: 'private@example.com' }, + ]; + + const { operations, report } = planUsernameBackfill(users); + applyOperations(users, operations); + + expect(users[0]).toMatchObject({ + username: 'SoloCuber', usernameNormalized: 'solocuber', + }); + expect(users[1].usernameNormalized).toBeUndefined(); + expect(users[2].usernameNormalized).toBeUndefined(); + expect(users[3].username).toBeUndefined(); + expect(users[4]).toMatchObject({ username: 'legacy.name' }); + expect(users[4].usernameNormalized).toBeUndefined(); + expect(users[5].username).toBeUndefined(); + expect(report.collisions).toEqual([{ + usernameNormalized: 'fastcuber', + users: [ + { id: 2, username: 'FastCuber' }, + { id: 3, username: 'fastcuber' }, + ], + }]); + expect(report.invalid).toEqual([ + { id: 5, code: 'INVALID_USERNAME' }, + { id: 6, code: 'INVALID_USERNAME' }, + ]); + expect(JSON.stringify(report.invalid)).not.toContain('private@example.com'); + }); + + it('is idempotent after applying the planned operations', () => { + const users = [ + { _id: '1', id: 1, username: ' SoloCuber ' }, + { _id: '2', id: 2, username: 'FastCuber' }, + { _id: '3', id: 3, username: 'fastcuber' }, + { _id: '4', id: 4, username: 'legacy.name' }, + { _id: '5', id: 5, username: 'private@example.com' }, + ]; + const first = planUsernameBackfill(users); + applyOperations(users, first.operations); + + expect(planUsernameBackfill(users).operations).toEqual([]); + }); +}); From 35e3198afc1293194e61f0102495edeead85c464 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 21:13:16 -0700 Subject: [PATCH 16/56] Harden normalized username rollout Reconcile MongoDB username targets into PostgreSQL and fail index creation while collisions remain. Canonicalize privacy markers and keep test authentication on the shared normalization path. --- docs/username-migration.md | 37 ++++++---- scripts/backfill-normalized-usernames.js | 59 +++++++++++++--- server/auth/index.js | 17 ++--- server/auth/testUser.js | 18 +++++ server/auth/testUser.test.js | 30 ++++++++ server/username.js | 7 +- server/username.test.js | 8 +++ server/usernameBackfill.js | 67 ++++++++++++++---- server/usernameBackfill.test.js | 50 ++++++++++++- server/usernamePostgresBackfill.js | 90 ++++++++++++++++++++++++ server/usernamePostgresBackfill.test.js | 66 +++++++++++++++++ 11 files changed, 396 insertions(+), 53 deletions(-) create mode 100644 server/auth/testUser.js create mode 100644 server/auth/testUser.test.js create mode 100644 server/usernamePostgresBackfill.js create mode 100644 server/usernamePostgresBackfill.test.js diff --git a/docs/username-migration.md b/docs/username-migration.md index 1f3ae37..396c3b3 100644 --- a/docs/username-migration.md +++ b/docs/username-migration.md @@ -9,14 +9,15 @@ Valid new usernames contain 1–15 ASCII letters, numbers, underscores, or hyphens. Empty input unsets both fields. Legacy invalid values are preserved but left without a normalized key, so they are not discoverable until their owner chooses a valid username. The privacy exception is an invalid value containing -an email marker (`@`), which is removed instead of retained. Case-insensitive -legacy collisions are also preserved and reported for explicit resolution; the +an email marker (`@`) after NFKC canonicalization, which is removed instead of +retained. This includes compatibility forms such as fullwidth and small `@`. +Case-insensitive legacy collisions are reported for explicit resolution; the migration never picks a winner or silently renames an account. ## Production order -Issue #191 is a hard prerequisite. Merge and deploy its WCA email scope removal -and data purge before enabling username discovery. +Issue #191 / PR #192 is a hard prerequisite. Its WCA email scope removal and +data purge must remain in the branch and be deployed before username discovery. 1. Back up MongoDB and schedule a short maintenance window for username writes. 2. Apply the committed PostgreSQL migrations. The nullable @@ -29,25 +30,35 @@ and data purge before enabling username discovery. ``` 4. Save the JSON report. Resolve every reported collision with the account - owners when practical. Invalid raw values are deliberately omitted from the - report, and email-like invalid values are removed rather than copied or - logged. -5. Apply the idempotent backfill and create or verify the sparse unique index: + owners before rollout. Do not deploy username writes or discovery while any + collision remains: index creation fails closed rather than leaving the name + available for a third account to claim. Invalid raw values are deliberately + omitted from the report, and email-like invalid values are removed rather + than copied or logged. +5. After the collision report is empty, apply the idempotent backfill, reconcile + existing PostgreSQL usernames by WCA user ID, and create or verify the sparse + unique MongoDB index: ```bash yarn workspace letscube-server usernames:backfill --apply --create-index ``` -6. Run the dry-run command again. `pendingChanges` must be `0`; unresolved - collision and invalid-value reports may remain because those users are - intentionally undiscoverable. + The PostgreSQL reconciliation uses MongoDB's planned target for each WCA user + and verifies exact equality without printing stored values. It clears both + PostgreSQL username fields for email-like legacy values. When PostgreSQL is + intentionally disabled with `POSTGRES_ENABLED=false`, the command records a + disabled status and skips that secondary store; rerun with PostgreSQL enabled + before bringing the mirror back into service. +6. Run the dry-run command again. `pendingChanges`, `privacyRemoved`, and the + collision report must all be empty or zero. The apply command must report + zero MongoDB private values and zero PostgreSQL mismatches. 7. Deploy the API and verify a casing-only username change, a conflicting change (`409 USERNAME_TAKEN`), and an invalid change (`400 INVALID_USERNAME`). PostgreSQL receives the normalized field through the existing non-blocking dual writer whenever MongoDB users are saved. -Do not enable the discovery endpoint from #82 until this sequence and the #191 -purge have completed. +Do not enable username writes or the discovery endpoint from #82 until this +sequence and the #191 purge have completed successfully. ## Rollback diff --git a/scripts/backfill-normalized-usernames.js b/scripts/backfill-normalized-usernames.js index a10b4f8..0a2edae 100644 --- a/scripts/backfill-normalized-usernames.js +++ b/scripts/backfill-normalized-usernames.js @@ -7,7 +7,11 @@ process.env.GETCONFIG_ROOT = process.env.GETCONFIG_ROOT || path.join(__dirname, '../server/config'); const config = require('../server/runtimeConfig'); -const { planUsernameBackfill } = require('../server/usernameBackfill'); +const { + assertUsernameRolloutReady, + planUsernameBackfill, +} = require('../server/usernameBackfill'); +const { reconcilePostgresUsernames } = require('../server/usernamePostgresBackfill'); const args = new Set(process.argv.slice(2)); const supportedArgs = new Set(['--apply', '--create-index']); @@ -20,6 +24,17 @@ const duplicateNormalizedUsernames = (collection) => collection.aggregate([ { $limit: 1 }, ]).toArray(); +const readUsers = (collection) => collection.find({}, { + projection: { + _id: 1, + id: 1, + username: 1, + usernameNormalized: 1, + }, +}).toArray(); + +let postgresPool; + const run = async () => { if (unknownArg) { throw new Error(`Unknown argument: ${unknownArg}`); @@ -34,14 +49,7 @@ const run = async () => { mongoose.set('strictQuery', false); await mongoose.connect(config.mongodb, { autoIndex: false }); const users = mongoose.connection.collection('users'); - const documents = await users.find({}, { - projection: { - _id: 1, - id: 1, - username: 1, - usernameNormalized: 1, - }, - }).toArray(); + const documents = await readUsers(users); const plan = planUsernameBackfill(documents); console.log(JSON.stringify({ @@ -58,7 +66,35 @@ const run = async () => { console.log(`Updated ${result.modifiedCount} user records.`); } + const verifiedPlan = planUsernameBackfill(await readUsers(users)); + if (verifiedPlan.report.pendingChanges || verifiedPlan.report.privacyRemoved) { + throw new Error('MongoDB username backfill verification failed'); + } + + let postgres = { status: 'disabled' }; + if (config.postgres.enabled) { + // Avoid creating a PostgreSQL pool for dry runs or explicitly disabled mirrors. + // eslint-disable-next-line global-require + ({ pool: postgresPool } = require('../server/postgres')); + postgres = { + status: 'reconciled', + ...await reconcilePostgresUsernames({ + client: postgresPool, + users: verifiedPlan.postgresUsers, + }), + }; + } + + console.log(JSON.stringify({ + verification: { + mongoPendingChanges: verifiedPlan.report.pendingChanges, + mongoPrivateValuesRemaining: verifiedPlan.report.privacyRemoved, + postgres, + }, + }, null, 2)); + if (createIndex) { + assertUsernameRolloutReady(verifiedPlan.report); const duplicates = await duplicateNormalizedUsernames(users); if (duplicates.length) { throw new Error('Duplicate normalized usernames remain; unique index was not created'); @@ -74,4 +110,7 @@ const run = async () => { run().catch((err) => { console.error(err.message); process.exitCode = 1; -}).finally(() => mongoose.disconnect()); +}).finally(() => Promise.allSettled([ + mongoose.disconnect(), + ...(postgresPool ? [postgresPool.end()] : []), +])); diff --git a/server/auth/index.js b/server/auth/index.js index 525ce30..d099bed 100644 --- a/server/auth/index.js +++ b/server/auth/index.js @@ -4,6 +4,7 @@ const { URLSearchParams } = require('url'); const { User } = require('../models'); const metrics = require('../metrics'); const { buildWcaUserUpdate } = require('./wcaProfile'); +const { upsertTestUser } = require('./testUser'); const checkStatus = async (res) => { if (res.ok) { // res.status >= 200 && res.status < 300 @@ -32,19 +33,9 @@ module.exports = (app, passport) => { if (process.env.LETSCUBE_TEST_AUTH === 'true') { try { - const user = await User.findOneAndUpdate({ - id: +(process.env.LETSCUBE_TEST_USER_ID || 990001), - }, { - id: +(process.env.LETSCUBE_TEST_USER_ID || 990001), - name: 'Cypress Test User', - username: 'cypress', - wcaId: '2026TEST01', - accessToken: `test-token-${code}`, - avatar: {}, - }, { - upsert: true, - useFindAndModify: false, - new: true, + const user = await upsertTestUser(User, { + code, + userId: +(process.env.LETSCUBE_TEST_USER_ID || 990001), }); done(null, user.toObject()); diff --git a/server/auth/testUser.js b/server/auth/testUser.js new file mode 100644 index 0000000..2f4f65c --- /dev/null +++ b/server/auth/testUser.js @@ -0,0 +1,18 @@ +const { normalizeUsername } = require('../username'); + +const upsertTestUser = (User, { code, userId }) => User.findOneAndUpdate({ + id: userId, +}, { + id: userId, + name: 'Cypress Test User', + ...normalizeUsername('cypress'), + wcaId: '2026TEST01', + accessToken: `test-token-${code}`, + avatar: {}, +}, { + upsert: true, + useFindAndModify: false, + new: true, +}); + +module.exports = { upsertTestUser }; diff --git a/server/auth/testUser.test.js b/server/auth/testUser.test.js new file mode 100644 index 0000000..7eae187 --- /dev/null +++ b/server/auth/testUser.test.js @@ -0,0 +1,30 @@ +/** @jest-environment node */ +/* eslint-env jest */ + +const { upsertTestUser } = require('./testUser'); + +describe('Cypress test authentication user', () => { + it('writes the normalized username explicitly through findOneAndUpdate', async () => { + const saved = { id: 990001 }; + const User = { findOneAndUpdate: jest.fn().mockResolvedValue(saved) }; + + await expect(upsertTestUser(User, { + code: 'test-code', + userId: 990001, + })).resolves.toBe(saved); + + expect(User.findOneAndUpdate).toHaveBeenCalledWith( + { id: 990001 }, + expect.objectContaining({ + id: 990001, + username: 'cypress', + usernameNormalized: 'cypress', + }), + { + upsert: true, + useFindAndModify: false, + new: true, + }, + ); + }); +}); diff --git a/server/username.js b/server/username.js index 18b198b..5d58835 100644 --- a/server/username.js +++ b/server/username.js @@ -17,6 +17,10 @@ const invalidUsername = () => new UsernameError( 'Username may only contain letters, numbers, underscores, and hyphens', ); +const canonicalizeUsername = (value) => ( + typeof value === 'string' ? value.normalize('NFKC').trim() : value +); + const normalizeUsername = (value, { allowEmpty = true } = {}) => { if (value === undefined || value === null) { if (allowEmpty) { @@ -29,7 +33,7 @@ const normalizeUsername = (value, { allowEmpty = true } = {}) => { throw invalidUsername(); } - const username = value.normalize('NFKC').trim(); + const username = canonicalizeUsername(value); if (!username) { if (allowEmpty) { return { username: undefined, usernameNormalized: undefined }; @@ -109,6 +113,7 @@ module.exports = { USERNAME_SEARCH_DEFAULT_LIMIT, USERNAME_SEARCH_MAX_LIMIT, UsernameError, + canonicalizeUsername, findUserByUsername, isUsernameDuplicateKeyError, normalizeUsername, diff --git a/server/username.test.js b/server/username.test.js index 7c04f4b..9dcf95b 100644 --- a/server/username.test.js +++ b/server/username.test.js @@ -2,6 +2,7 @@ /* eslint-env jest */ const { + canonicalizeUsername, findUserByUsername, normalizeUsername, searchUsersByUsernamePrefix, @@ -16,6 +17,11 @@ describe('username normalization and lookup', () => { }); }); + it('canonicalizes compatibility characters before validation', () => { + expect(canonicalizeUsername(' cuber\uFF20example.com ')).toBe('cuber@example.com'); + expect(canonicalizeUsername('cuber\uFE6Bexample.com')).toBe('cuber@example.com'); + }); + it('represents empty optional usernames as absent fields', () => { expect(normalizeUsername(' ')).toEqual({ username: undefined, @@ -27,6 +33,8 @@ describe('username normalization and lookup', () => { 'cuber.name', 'cuber+name', 'cuber@example.com', + 'cuber\uFF20example.com', + 'cuber\uFE6Bexample.com', 'two cubers', '1234567890123456', ])('rejects invalid input without treating it as a lookup: %s', (username) => { diff --git a/server/usernameBackfill.js b/server/usernameBackfill.js index 5f11aaa..5ccdd16 100644 --- a/server/usernameBackfill.js +++ b/server/usernameBackfill.js @@ -1,8 +1,10 @@ -const { normalizeUsername } = require('./username'); +const { canonicalizeUsername, normalizeUsername } = require('./username'); const identifier = (user) => (user.id === undefined ? user._id.toString() : user.id); const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key); -const containsEmailMarker = (value) => typeof value === 'string' && value.includes('@'); +const containsEmailMarker = (value) => ( + typeof value === 'string' && canonicalizeUsername(value).includes('@') +); const changedOperation = (user, set, unset) => { const changedSet = Object.entries(set).some(([key, value]) => user[key] !== value); @@ -50,36 +52,57 @@ const planUsernameBackfill = (users) => { [...grouped.entries()].filter(([, entries]) => entries.length > 1).map(([key]) => key), ); const operations = []; + const postgresUsers = []; const invalid = []; let empty = 0; + let privacyRemoved = 0; let valid = 0; analyzed.forEach((entry) => { const { user } = entry; - let operation; + let targetUsername; + let targetUsernameNormalized; if (entry.error) { invalid.push({ id: identifier(user), code: entry.error.code }); - operation = changedOperation(user, {}, { - ...(containsEmailMarker(user.username) ? { username: '' } : {}), - usernameNormalized: '', - }); + const removePrivateValue = containsEmailMarker(user.username); + if (removePrivateValue) { + privacyRemoved += 1; + } else { + targetUsername = user.username; + } } else if (!entry.usernameNormalized) { empty += 1; - operation = changedOperation(user, {}, { username: '', usernameNormalized: '' }); } else if (collisionKeys.has(entry.usernameNormalized)) { - operation = changedOperation(user, { username: entry.username }, { usernameNormalized: '' }); + targetUsername = entry.username; } else { valid += 1; - operation = changedOperation(user, { - username: entry.username, - usernameNormalized: entry.usernameNormalized, - }, {}); + targetUsername = entry.username; + targetUsernameNormalized = entry.usernameNormalized; } + const operation = changedOperation( + user, + { + ...(targetUsername === undefined ? {} : { username: targetUsername }), + ...(targetUsernameNormalized === undefined + ? {} : { usernameNormalized: targetUsernameNormalized }), + }, + { + ...(targetUsername === undefined ? { username: '' } : {}), + ...(targetUsernameNormalized === undefined ? { usernameNormalized: '' } : {}), + }, + ); + if (operation) { operations.push(operation); } + + postgresUsers.push({ + wcaUserId: user.id, + username: targetUsername, + usernameNormalized: targetUsernameNormalized, + }); }); const collisions = [...collisionKeys].sort().map((usernameNormalized) => ({ @@ -98,9 +121,25 @@ const planUsernameBackfill = (users) => { empty, invalid, collisions, + privacyRemoved, pendingChanges: operations.length, }, + postgresUsers, }; }; -module.exports = { planUsernameBackfill }; +const assertUsernameRolloutReady = (report) => { + if (report.pendingChanges) { + throw new Error('Username backfill verification still has pending changes'); + } + if (report.collisions.length) { + throw new Error( + `Cannot create username index: ${report.collisions.length} collision group(s) require resolution`, + ); + } +}; + +module.exports = { + assertUsernameRolloutReady, + planUsernameBackfill, +}; diff --git a/server/usernameBackfill.test.js b/server/usernameBackfill.test.js index 51b7b5c..67ff93e 100644 --- a/server/usernameBackfill.test.js +++ b/server/usernameBackfill.test.js @@ -1,7 +1,11 @@ /** @jest-environment node */ /* eslint-env jest */ -const { planUsernameBackfill } = require('./usernameBackfill'); +const { + assertUsernameRolloutReady, + planUsernameBackfill, +} = require('./usernameBackfill'); +const { normalizeUsername } = require('./username'); const applyOperations = (users, operations) => { operations.forEach(({ updateOne }) => { @@ -24,9 +28,13 @@ describe('normalized username backfill', () => { _id: '5', id: 5, username: 'legacy.name', usernameNormalized: 'legacy.name', }, { _id: '6', id: 6, username: 'private@example.com' }, + { _id: '7', id: 7, username: 'private\uFF20example.com' }, + { _id: '8', id: 8, username: 'private\uFE6Bexample.com' }, ]; - const { operations, report } = planUsernameBackfill(users); + const { + operations, postgresUsers, report, + } = planUsernameBackfill(users); applyOperations(users, operations); expect(users[0]).toMatchObject({ @@ -38,6 +46,8 @@ describe('normalized username backfill', () => { expect(users[4]).toMatchObject({ username: 'legacy.name' }); expect(users[4].usernameNormalized).toBeUndefined(); expect(users[5].username).toBeUndefined(); + expect(users[6].username).toBeUndefined(); + expect(users[7].username).toBeUndefined(); expect(report.collisions).toEqual([{ usernameNormalized: 'fastcuber', users: [ @@ -48,8 +58,17 @@ describe('normalized username backfill', () => { expect(report.invalid).toEqual([ { id: 5, code: 'INVALID_USERNAME' }, { id: 6, code: 'INVALID_USERNAME' }, + { id: 7, code: 'INVALID_USERNAME' }, + { id: 8, code: 'INVALID_USERNAME' }, ]); + expect(report.privacyRemoved).toBe(3); expect(JSON.stringify(report.invalid)).not.toContain('private@example.com'); + expect(postgresUsers.slice(5)).toEqual([ + { wcaUserId: 6, username: undefined, usernameNormalized: undefined }, + { wcaUserId: 7, username: undefined, usernameNormalized: undefined }, + { wcaUserId: 8, username: undefined, usernameNormalized: undefined }, + ]); + expect(JSON.stringify(postgresUsers.slice(5))).not.toContain('private'); }); it('is idempotent after applying the planned operations', () => { @@ -59,10 +78,37 @@ describe('normalized username backfill', () => { { _id: '3', id: 3, username: 'fastcuber' }, { _id: '4', id: 4, username: 'legacy.name' }, { _id: '5', id: 5, username: 'private@example.com' }, + { _id: '6', id: 6, username: 'private\uFF20example.com' }, + { _id: '7', id: 7, username: 'private\uFE6Bexample.com' }, ]; const first = planUsernameBackfill(users); applyOperations(users, first.operations); expect(planUsernameBackfill(users).operations).toEqual([]); }); + + it('fails index rollout while any valid collision remains unclaimed', () => { + const users = [ + { _id: '1', id: 1, username: 'FastCuber' }, + { _id: '2', id: 2, username: 'fastcuber' }, + ]; + const first = planUsernameBackfill(users); + applyOperations(users, first.operations); + const verified = planUsernameBackfill(users); + + expect(verified.report.pendingChanges).toBe(0); + expect(() => assertUsernameRolloutReady(verified.report)) + .toThrow('collision group(s) require resolution'); + expect(normalizeUsername('FASTCUBER').usernameNormalized) + .toBe(verified.report.collisions[0].usernameNormalized); + }); + + it('allows index rollout only after the verified plan is collision-free', () => { + const users = [{ + _id: '1', id: 1, username: 'SoloCuber', usernameNormalized: 'solocuber', + }]; + const { report } = planUsernameBackfill(users); + + expect(() => assertUsernameRolloutReady(report)).not.toThrow(); + }); }); diff --git a/server/usernamePostgresBackfill.js b/server/usernamePostgresBackfill.js new file mode 100644 index 0000000..79975a6 --- /dev/null +++ b/server/usernamePostgresBackfill.js @@ -0,0 +1,90 @@ +const DEFAULT_BATCH_SIZE = 500; + +const validUser = ({ wcaUserId }) => { + const id = Number(wcaUserId); + return Number.isSafeInteger(id) && id > 0; +}; + +const expectedValues = (users) => users.flatMap((user) => [ + Number(user.wcaUserId), + user.username || null, + user.usernameNormalized || null, +]); + +const expectedRows = (users) => users.map((user, index) => { + const offset = index * 3; + return `($${offset + 1}::bigint, $${offset + 2}::text, $${offset + 3}::text)`; +}).join(', '); + +const reconcileBatch = async (client, users) => { + const values = expectedValues(users); + const rows = expectedRows(users); + const updated = await client.query(` + WITH expected(wca_user_id, username, username_normalized) AS ( + VALUES ${rows} + ) + UPDATE app.users AS users + SET username = expected.username, + username_normalized = expected.username_normalized, + ingested_at = now() + FROM expected + WHERE users.wca_user_id = expected.wca_user_id + AND ROW(users.username, users.username_normalized) + IS DISTINCT FROM ROW(expected.username, expected.username_normalized) + `, values); + const verification = await client.query(` + WITH expected(wca_user_id, username, username_normalized) AS ( + VALUES ${rows} + ) + SELECT count(*)::int AS remaining + FROM app.users AS users + JOIN expected USING (wca_user_id) + WHERE ROW(users.username, users.username_normalized) + IS DISTINCT FROM ROW(expected.username, expected.username_normalized) + `, values); + + return { + modified: updated.rowCount, + remaining: verification.rows[0].remaining, + }; +}; + +const reconcilePostgresUsernames = async ({ + client, + users, + batchSize = DEFAULT_BATCH_SIZE, +}) => { + if (!Number.isInteger(batchSize) || batchSize <= 0) { + throw new Error('PostgreSQL username reconciliation requires a positive batch size'); + } + const eligibleUsers = users.filter(validUser); + if (eligibleUsers.length !== users.length) { + throw new Error('PostgreSQL username reconciliation requires a valid WCA user ID'); + } + const batches = Array.from( + { length: Math.ceil(eligibleUsers.length / batchSize) }, + (unused, index) => eligibleUsers.slice(index * batchSize, (index + 1) * batchSize), + ); + + const summary = await batches.reduce(async (previous, batch) => { + const accumulated = await previous; + const result = await reconcileBatch(client, batch); + return { + modified: accumulated.modified + result.modified, + remaining: accumulated.remaining + result.remaining, + }; + }, Promise.resolve({ modified: 0, remaining: 0 })); + + if (summary.remaining) { + throw new Error('PostgreSQL username reconciliation verification failed'); + } + + return { + considered: eligibleUsers.length, + ...summary, + }; +}; + +module.exports = { + reconcilePostgresUsernames, +}; diff --git a/server/usernamePostgresBackfill.test.js b/server/usernamePostgresBackfill.test.js new file mode 100644 index 0000000..9bfd667 --- /dev/null +++ b/server/usernamePostgresBackfill.test.js @@ -0,0 +1,66 @@ +/** @jest-environment node */ +/* eslint-env jest */ + +const { reconcilePostgresUsernames } = require('./usernamePostgresBackfill'); + +describe('PostgreSQL username reconciliation', () => { + it('reconciles canonical targets by WCA user ID without raw-value matching', async () => { + const client = { + query: jest.fn() + .mockResolvedValueOnce({ rowCount: 2 }) + .mockResolvedValueOnce({ rows: [{ remaining: 0 }] }), + }; + const users = [ + { wcaUserId: 1, username: 'Cuber', usernameNormalized: 'cuber' }, + { wcaUserId: 2, username: undefined, usernameNormalized: undefined }, + ]; + + await expect(reconcilePostgresUsernames({ client, users })).resolves.toEqual({ + considered: 2, + modified: 2, + remaining: 0, + }); + + expect(client.query).toHaveBeenCalledTimes(2); + expect(client.query.mock.calls[0][0]).toContain('wca_user_id'); + expect(client.query.mock.calls[0][0]).not.toContain('LIKE'); + expect(client.query.mock.calls[0][1]).toEqual([1, 'Cuber', 'cuber', 2, null, null]); + expect(client.query.mock.calls[1][0]).toContain('IS DISTINCT FROM'); + }); + + it('is idempotent when PostgreSQL already matches MongoDB targets', async () => { + const client = { + query: jest.fn() + .mockResolvedValueOnce({ rowCount: 0 }) + .mockResolvedValueOnce({ rows: [{ remaining: 0 }] }), + }; + + await expect(reconcilePostgresUsernames({ + client, + users: [{ wcaUserId: 1, username: 'Cuber', usernameNormalized: 'cuber' }], + })).resolves.toMatchObject({ modified: 0, remaining: 0 }); + }); + + it('fails closed when exact post-update verification finds a mismatch', async () => { + const client = { + query: jest.fn() + .mockResolvedValueOnce({ rowCount: 0 }) + .mockResolvedValueOnce({ rows: [{ remaining: 1 }] }), + }; + + await expect(reconcilePostgresUsernames({ + client, + users: [{ wcaUserId: 2, username: undefined, usernameNormalized: undefined }], + })).rejects.toThrow('PostgreSQL username reconciliation verification failed'); + }); + + it('fails closed instead of skipping a target without a WCA user ID', async () => { + const client = { query: jest.fn() }; + + await expect(reconcilePostgresUsernames({ + client, + users: [{ wcaUserId: undefined, username: 'orphan', usernameNormalized: 'orphan' }], + })).rejects.toThrow('requires a valid WCA user ID'); + expect(client.query).not.toHaveBeenCalled(); + }); +}); From eb0eadf72992c33e24206e2d2a1e0e3223721e2d Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 21:14:39 -0700 Subject: [PATCH 17/56] Harden friendship lifecycle ordering Gate social routes and realtime delivery behind the disabled-by-default rollout flag. Keep relationship and block tombstones revisioned for fail-closed unblock and reordered PostgreSQL mirrors, and reserve pending-request quota atomically with rollback and stale reconciliation. --- .env.example | 2 + README.md | 3 + README_DEPLOYMENT.md | 10 + compose.yml | 1 + docs/friendships.md | 37 ++- server/api.js | 13 +- server/api.test.js | 77 ++++++ server/models/friendRelationship.js | 5 +- server/models/friendRelationship.test.js | 23 ++ server/models/index.js | 6 + server/models/outgoingRequestQuota.js | 38 +++ server/models/userBlock.js | 15 + server/postgres/dualWrite.js | 37 +-- server/postgres/dualWrite.test.js | 96 +++++-- .../migration.sql | 12 +- server/prisma/schema.prisma | 3 + server/runtimeConfig.js | 3 + server/runtimeConfig.test.js | 28 ++ server/social/relationshipService.js | 258 +++++++++++++++--- server/social/relationshipService.test.js | 208 ++++++++++++-- server/social/relationshipState.js | 14 +- server/socket/index.js | 12 +- 22 files changed, 779 insertions(+), 122 deletions(-) create mode 100644 server/api.test.js create mode 100644 server/models/outgoingRequestQuota.js diff --git a/.env.example b/.env.example index 4f826cd..5b5a695 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,8 @@ SOCKETIO_PORT=9000 ROOM_RECONNECT_GRACE_MS=60000 # Grand Prix is intentionally disabled until the mode is redesigned. GRAND_PRIX_ENABLED=false +# Friend System routes remain disabled through the #188 launch gate. +SOCIAL_FEATURES_ENABLED=false # Manual Compose commands use this tag. scripts/deploy.sh overrides it with # the full deployed commit SHA. APP_IMAGE_TAG=local diff --git a/README.md b/README.md index 8839f30..cfc18ab 100644 --- a/README.md +++ b/README.md @@ -93,3 +93,6 @@ TLS connections can set `PGSSL=true` and provide a CA with `PGSSL_CA`; certificate verification is enabled by default. PostgreSQL failures are logged but do not fail the corresponding MongoDB-backed application operation during this migration phase. +Friend relationships and directional blocks use revisioned tombstone rows; +strictly older mirrors are ignored so reordered background writes cannot +resurrect a removed relationship or a deactivated block. diff --git a/README_DEPLOYMENT.md b/README_DEPLOYMENT.md index ce85f15..3b70baa 100644 --- a/README_DEPLOYMENT.md +++ b/README_DEPLOYMENT.md @@ -187,6 +187,16 @@ Keep this setting disabled until the legacy scheduler is redesigned. Setting it to `true` explicitly restores the old mode for development or controlled testing. +The Friend System server surface is also disabled by default: + +```bash +SOCIAL_FEATURES_ENABLED=false +``` + +Only set this to the exact value `true` during controlled development or after +the #188 launch-hardening gate is complete. The same switch controls the REST +routes and their realtime relay; normal room behavior is unaffected. + ### Health Checks The API and socket processes expose dependency-aware health endpoints. diff --git a/compose.yml b/compose.yml index e22418e..3c359a6 100644 --- a/compose.yml +++ b/compose.yml @@ -6,6 +6,7 @@ x-app-environment: &app-environment SOCKETIO_PORT: ${SOCKETIO_PORT:-9000} ROOM_RECONNECT_GRACE_MS: ${ROOM_RECONNECT_GRACE_MS:-60000} GRAND_PRIX_ENABLED: ${GRAND_PRIX_ENABLED:-false} + SOCIAL_FEATURES_ENABLED: ${SOCIAL_FEATURES_ENABLED:-false} MONGO_URL: ${MONGO_URL:-mongodb://mongo:27017/letscube} REDIS_URL: ${REDIS_URL:-redis://redis:6379} POSTGRES_ENABLED: ${POSTGRES_ENABLED:-true} diff --git a/docs/friendships.md b/docs/friendships.md index ac81545..edffa84 100644 --- a/docs/friendships.md +++ b/docs/friendships.md @@ -4,6 +4,14 @@ MongoDB is the source of truth for friendship state. PostgreSQL receives non-blocking, idempotent mirrors and must never be read to authorize a social action while the dual-write migration is in progress. +## Rollout gate + +The server surface is disabled unless `SOCIAL_FEATURES_ENABLED=true`. When it +is disabled, `/api/friends` returns `404 feature_disabled` and the socket +process does not subscribe to social invalidations. Existing room behavior is +unchanged. Issue #188 owns launch hardening and production operation of this +same switch; adding the foundation does not enable it. + ## Identity and privacy Social APIs accept only the authenticated session user and a numeric target @@ -34,8 +42,8 @@ Relationship transitions are: | pending by target | accept | accepted | | pending by actor | cancel | canceled for 60 seconds | | pending by target | decline | declined for 24 hours | -| accepted | unfriend | none | -| any | block | relationship removed; directional block retained | +| accepted | unfriend | removed tombstone | +| any | block | relationship tombstoned; directional block retained | | blocked by actor | unblock | directional block removed; no friendship restored | Replayed send, cancel, accept, decline, unfriend, block, and unblock operations @@ -46,12 +54,29 @@ are idempotent. An invalid direction, such as accepting an outgoing request, is Updates compare the document revision before writing. A unique-pair conflict or concurrent revision change is read again and the transition is recalculated. Both friendship writes and block writes check blocking after mutation, so a -concurrent block wins and removes the relationship. After bounded contention, +concurrent block wins and tombstones the relationship. After bounded contention, the API returns `409 relationship_conflict` and the caller should reconcile. -Each user may have at most 100 pending outgoing requests. A declined pair may -not be requested again for 24 hours, and a canceled pair may not be requested -again for 60 seconds. `request_cooldown` includes `retryAfterSeconds`. +`removed` relationships and inactive blocks are durable MongoDB tombstones, +not physical deletes. Each update increments that resource's revision. +PostgreSQL upserts accept only a strictly newer revision, so a delayed request, +friendship, or active-block mirror cannot overwrite a newer remove/unblock. +This ordering contract is database-backed and therefore works across API +processes and restarts; it does not depend on an in-process promise queue. + +Blocking activates the directional block before tombstoning the relationship, +so failures remain fail-closed. Unblocking repeats relationship cleanup before +deactivating the block. If cleanup fails, the active block remains and the +hidden relationship cannot reappear. + +Each user may have at most 100 pending outgoing requests. A MongoDB quota +document atomically reserves pair slots before relationship creation, so +concurrent sends to different users cannot pass the cap. Failed writes release +their slots. Reconciliation rebuilds missing reservations from pending +relationships and removes abandoned reservations after five minutes. A +declined pair may not be requested again for 24 hours, and a canceled pair may +not be requested again for 60 seconds. `request_cooldown` includes +`retryAfterSeconds`. ## REST API diff --git a/server/api.js b/server/api.js index c068879..12928e2 100644 --- a/server/api.js +++ b/server/api.js @@ -1,6 +1,5 @@ const express = require('express'); -const router = express.Router(); const { User } = require('./models'); const auth = require('./middlewares/auth.js'); const createFriendsRouter = require('./api/friends'); @@ -13,7 +12,8 @@ const PREFERENCE_KEYS = new Set([ 'muteTimer', ]); -module.exports = () => { +module.exports = (app) => { + const router = express.Router(); const sendError = (res, err) => { res.status(err.statusCode || 500).send({ status: err.statusCode, @@ -25,7 +25,14 @@ module.exports = () => { res.json(req.user.toObject()); }); - router.use('/friends', createFriendsRouter()); + if (app.get('config').socialFeatures.enabled) { + router.use('/friends', createFriendsRouter()); + } else { + router.use('/friends', (req, res) => res.status(404).json({ + code: 'feature_disabled', + message: 'This feature is not available', + })); + } router.put('/updateUsername', auth, (req, res) => { // TODO: server side validation of username diff --git a/server/api.test.js b/server/api.test.js new file mode 100644 index 0000000..f0cab3b --- /dev/null +++ b/server/api.test.js @@ -0,0 +1,77 @@ +/** @jest-environment node */ +/* eslint-env jest */ + +const http = require('http'); +const express = require('express'); + +jest.mock('./social/relationshipService', () => ({ + list: jest.fn().mockResolvedValue({ friends: [], reconciledAt: new Date() }), +})); + +const relationshipService = require('./social/relationshipService'); +const createApi = require('./api'); + +const get = (server, path) => new Promise((resolve, reject) => { + const req = http.get({ + headers: { authorization: 'test-session' }, + host: '127.0.0.1', + path, + port: server.address().port, + }, (res) => { + let body = ''; + res.on('data', (chunk) => { + body += chunk; + }); + res.on('end', () => resolve({ body: JSON.parse(body), statusCode: res.statusCode })); + }); + req.on('error', reject); +}); + +const startServer = (socialFeaturesEnabled) => { + const app = express(); + app.set('config', { socialFeatures: { enabled: socialFeaturesEnabled } }); + app.use((req, res, next) => { + req.isAuthenticated = () => req.headers.authorization === 'test-session'; + req.user = { id: 1 }; + next(); + }); + app.use('/api', createApi(app)); + return new Promise((resolve) => { + const server = app.listen(0, '127.0.0.1', () => resolve(server)); + }); +}; + +describe('social feature route gate', () => { + let server; + + afterEach((done) => { + relationshipService.list.mockClear(); + if (server) { + server.close(done); + } else { + done(); + } + }); + + it('returns a disabled response by default without running social handlers', async () => { + server = await startServer(false); + + await expect(get(server, '/api/friends')).resolves.toEqual({ + body: { + code: 'feature_disabled', + message: 'This feature is not available', + }, + statusCode: 404, + }); + expect(relationshipService.list).not.toHaveBeenCalled(); + }); + + it('mounts authenticated social routes when explicitly enabled', async () => { + server = await startServer(true); + + const response = await get(server, '/api/friends'); + + expect(response.statusCode).toBe(200); + expect(relationshipService.list).toHaveBeenCalledWith({ id: 1 }); + }); +}); diff --git a/server/models/friendRelationship.js b/server/models/friendRelationship.js index 06be976..a4b4a35 100644 --- a/server/models/friendRelationship.js +++ b/server/models/friendRelationship.js @@ -5,6 +5,7 @@ const RELATIONSHIP_STATUSES = Object.freeze({ CANCELED: 'canceled', DECLINED: 'declined', PENDING: 'pending', + REMOVED: 'removed', }); const FriendRelationship = new mongoose.Schema({ @@ -58,7 +59,9 @@ FriendRelationship.pre('validate', function validatePair(next) { } const requesterIsMember = this.requestedBy === this.lowUserId || this.requestedBy === this.highUserId; - if (this.status === RELATIONSHIP_STATUSES.ACCEPTED + const stateHasNoRequester = this.status === RELATIONSHIP_STATUSES.ACCEPTED + || this.status === RELATIONSHIP_STATUSES.REMOVED; + if (stateHasNoRequester ? this.requestedBy !== null && this.requestedBy !== undefined : !requesterIsMember) { next(new Error('Friend relationship requester does not match its state')); diff --git a/server/models/friendRelationship.test.js b/server/models/friendRelationship.test.js index 034ba43..ff10e3e 100644 --- a/server/models/friendRelationship.test.js +++ b/server/models/friendRelationship.test.js @@ -42,6 +42,29 @@ describe('friend relationship models', () => { await expect(invalidRequester.validate()).rejects.toThrow('requester does not match'); }); + it('accepts relationship and block tombstones with monotonic revisions', async () => { + const removed = new FriendRelationship({ + pairKey: '1:2', + lowUserId: 1, + highUserId: 2, + requestedBy: null, + revision: 4, + stateChangedAt: new Date(), + status: 'removed', + }); + const inactiveBlock = new UserBlock({ + active: false, + pairKey: '1:2', + blockerId: 1, + blockedId: 2, + revision: 2, + stateChangedAt: new Date(), + }); + + await expect(removed.validate()).resolves.toBeUndefined(); + await expect(inactiveBlock.validate()).resolves.toBeUndefined(); + }); + it('rejects self-blocks and mismatched block pair keys', async () => { const selfBlock = new UserBlock({ pairKey: '1:1', diff --git a/server/models/index.js b/server/models/index.js index 1b7fa10..dce8a31 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -2,6 +2,7 @@ const mongoose = require('mongoose'); const { FriendRelationship, RELATIONSHIP_STATUSES } = require('./friendRelationship'); const { Attempt, Room } = require('./room'); const { METRIC_EVENTS, MetricEvent } = require('./metricEvent'); +const OutgoingRequestQuota = require('./outgoingRequestQuota'); const User = require('./user'); const UserBlock = require('./userBlock'); @@ -14,6 +15,11 @@ module.exports = { 'friend_relationships', ), UserBlock: mongoose.model('UserBlock', UserBlock, 'user_blocks'), + OutgoingRequestQuota: mongoose.model( + 'OutgoingRequestQuota', + OutgoingRequestQuota, + 'outgoing_request_quotas', + ), Attempt: mongoose.model('Attempt', Attempt, 'attempts'), MetricEvent: mongoose.model('MetricEvent', MetricEvent, 'metric_events'), METRIC_EVENTS, diff --git a/server/models/outgoingRequestQuota.js b/server/models/outgoingRequestQuota.js new file mode 100644 index 0000000..64c3b48 --- /dev/null +++ b/server/models/outgoingRequestQuota.js @@ -0,0 +1,38 @@ +const mongoose = require('mongoose'); + +const Reservation = new mongoose.Schema({ + pairKey: { + type: String, + required: true, + }, + reservedAt: { + type: Date, + required: true, + }, +}, { + _id: false, +}); + +const OutgoingRequestQuota = new mongoose.Schema({ + userId: { + type: Number, + required: true, + }, + reservations: { + type: [Reservation], + default: [], + }, + revision: { + type: Number, + default: 0, + min: 0, + required: true, + }, +}, { + timestamps: true, + versionKey: false, +}); + +OutgoingRequestQuota.index({ userId: 1 }, { unique: true }); + +module.exports = OutgoingRequestQuota; diff --git a/server/models/userBlock.js b/server/models/userBlock.js index d2986e9..0bc7237 100644 --- a/server/models/userBlock.js +++ b/server/models/userBlock.js @@ -14,6 +14,20 @@ const UserBlock = new mongoose.Schema({ type: Number, required: true, }, + active: { + type: Boolean, + default: true, + required: true, + }, + stateChangedAt: { + type: Date, + required: true, + }, + revision: { + type: Number, + min: 0, + required: true, + }, }, { timestamps: true, versionKey: false, @@ -21,6 +35,7 @@ const UserBlock = new mongoose.Schema({ UserBlock.index({ blockerId: 1, blockedId: 1 }, { unique: true }); UserBlock.index({ blockedId: 1 }); +UserBlock.index({ pairKey: 1, active: 1 }); UserBlock.pre('validate', function validateBlock(next) { if (this.blockerId === this.blockedId) { diff --git a/server/postgres/dualWrite.js b/server/postgres/dualWrite.js index 666e06d..25764e5 100644 --- a/server/postgres/dualWrite.js +++ b/server/postgres/dualWrite.js @@ -134,10 +134,7 @@ const mirrorRelationship = (relationship, users) => withTransaction(async (clien id, pair_key, low_user_id, high_user_id, status, requested_by_user_id, cooldown_until, revision, state_changed_at, source_created_at, source_updated_at ) - SELECT $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 - WHERE NOT EXISTS ( - SELECT 1 FROM app.user_blocks WHERE pair_key = $2 - ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) ON CONFLICT (pair_key) DO UPDATE SET status = EXCLUDED.status, requested_by_user_id = EXCLUDED.requested_by_user_id, @@ -147,10 +144,6 @@ const mirrorRelationship = (relationship, users) => withTransaction(async (clien source_updated_at = EXCLUDED.source_updated_at, ingested_at = now() WHERE app.friend_relationships.revision < EXCLUDED.revision - OR ( - app.friend_relationships.revision = EXCLUDED.revision - AND app.friend_relationships.source_updated_at <= EXCLUDED.source_updated_at - ) `, [ stableId('friend-relationship', relationship.pairKey), relationship.pairKey, @@ -167,11 +160,6 @@ const mirrorRelationship = (relationship, users) => withTransaction(async (clien return relationship.pairKey; }); -const mirrorRelationshipDeleted = (pairKey) => query( - 'DELETE FROM app.friend_relationships WHERE pair_key = $1', - [pairKey], -); - const mirrorBlock = (block, blocker, blocked) => withTransaction(async (client) => { const updatedAt = sourceDate(block.updatedAt, block.createdAt); const blockerUserId = await upsertUser(client, blocker, updatedAt); @@ -182,32 +170,31 @@ const mirrorBlock = (block, blocker, blocked) => withTransaction(async (client) await client.query(` INSERT INTO app.user_blocks ( - id, blocker_id, blocked_id, pair_key, source_created_at, source_updated_at - ) VALUES ($1, $2, $3, $4, $5, $6) + id, blocker_id, blocked_id, pair_key, active, revision, state_changed_at, + source_created_at, source_updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (blocker_id, blocked_id) DO UPDATE SET pair_key = EXCLUDED.pair_key, + active = EXCLUDED.active, + revision = EXCLUDED.revision, + state_changed_at = EXCLUDED.state_changed_at, source_updated_at = EXCLUDED.source_updated_at, ingested_at = now() + WHERE app.user_blocks.revision < EXCLUDED.revision `, [ stableId('user-block', `${block.blockerId}:${block.blockedId}`), blockerUserId, blockedUserId, block.pairKey, + block.active, + block.revision, + block.stateChangedAt, block.createdAt || null, updatedAt, ]); - await client.query( - 'DELETE FROM app.friend_relationships WHERE pair_key = $1', - [block.pairKey], - ); return block.pairKey; }); -const mirrorBlockDeleted = (blockerId, blockedId) => query(` - DELETE FROM app.user_blocks - WHERE blocker_id = $1 AND blocked_id = $2 -`, [stableId('user', blockerId), stableId('user', blockedId)]); - const upsertRoomState = async (client, room, options = {}) => { if (!room || !room._id) { return null; @@ -644,10 +631,8 @@ const mirrorMetricEvent = (event) => query(` module.exports = { markRoomDeleted, mirrorBlock, - mirrorBlockDeleted, mirrorMetricEvent, mirrorRelationship, - mirrorRelationshipDeleted, mirrorRoom, mirrorRoomChanges, mirrorUser, diff --git a/server/postgres/dualWrite.test.js b/server/postgres/dualWrite.test.js index e0a29ed..466ee6f 100644 --- a/server/postgres/dualWrite.test.js +++ b/server/postgres/dualWrite.test.js @@ -10,10 +10,8 @@ const postgres = require('./index'); const { markRoomDeleted, mirrorBlock, - mirrorBlockDeleted, mirrorMetricEvent, mirrorRelationship, - mirrorRelationshipDeleted, mirrorRoom, mirrorRoomChanges, mirrorUser, @@ -214,9 +212,12 @@ describe('PostgreSQL dual writer', () => { updatedAt, }, [user, otherUser]); await mirrorBlock({ + active: true, blockerId: 1234, blockedId: 5678, pairKey: '1234:5678', + revision: 1, + stateChangedAt: updatedAt, createdAt: updatedAt, updatedAt, }, user, otherUser); @@ -226,26 +227,85 @@ describe('PostgreSQL dual writer', () => { .toBe(true); expect(statements.some((sql) => sql.includes('INSERT INTO app.user_blocks'))).toBe(true); expect(statements.some((sql) => sql.includes('DELETE FROM app.friend_relationships'))) - .toBe(true); + .toBe(false); + expect(statements.find((sql) => sql.includes('INSERT INTO app.friend_relationships'))) + .toContain('friend_relationships.revision < EXCLUDED.revision'); + expect(statements.find((sql) => sql.includes('INSERT INTO app.user_blocks'))) + .toContain('user_blocks.revision < EXCLUDED.revision'); }); - it('makes mirror deletion idempotent and tolerates disabled PostgreSQL', async () => { - await mirrorRelationshipDeleted('1234:5678'); - await mirrorBlockDeleted(1234, 5678); - - expect(postgres.query).toHaveBeenNthCalledWith( - 1, - 'DELETE FROM app.friend_relationships WHERE pair_key = $1', - ['1234:5678'], - ); - expect(postgres.query).toHaveBeenNthCalledWith( - 2, - expect.stringContaining('DELETE FROM app.user_blocks'), - [stableId('user', 1234), stableId('user', 5678)], - ); - + it('tolerates disabled PostgreSQL', async () => { postgres.withTransaction.mockResolvedValueOnce(null); await expect(mirrorRelationship({ pairKey: '1234:5678' }, [])) .resolves.toBeNull(); }); + + it('rejects deliberately reordered stale relationship and block mirrors', async () => { + const otherUser = { + ...user, + id: 5678, + name: 'Other Solver', + }; + const sourceState = {}; + client.query.mockImplementation(async (sql, values) => { + if (sql.includes('INSERT INTO app.friend_relationships')) { + const incoming = { revision: values[7], status: values[4] }; + if (!sourceState.relationship + || sourceState.relationship.revision < incoming.revision) { + sourceState.relationship = incoming; + } + } + if (sql.includes('INSERT INTO app.user_blocks')) { + const incoming = { active: values[4], revision: values[5] }; + if (!sourceState.block || sourceState.block.revision < incoming.revision) { + sourceState.block = incoming; + } + } + return { rows: [] }; + }); + const newerAt = new Date('2026-07-12T20:01:00.000Z'); + const olderAt = new Date('2026-07-12T20:00:00.000Z'); + + await mirrorRelationship({ + pairKey: '1234:5678', + lowUserId: 1234, + highUserId: 5678, + requestedBy: null, + status: 'removed', + revision: 3, + stateChangedAt: newerAt, + updatedAt: newerAt, + }, [user, otherUser]); + await mirrorRelationship({ + pairKey: '1234:5678', + lowUserId: 1234, + highUserId: 5678, + requestedBy: null, + status: 'accepted', + revision: 2, + stateChangedAt: olderAt, + updatedAt: olderAt, + }, [user, otherUser]); + await mirrorBlock({ + active: false, + blockerId: 1234, + blockedId: 5678, + pairKey: '1234:5678', + revision: 2, + stateChangedAt: newerAt, + updatedAt: newerAt, + }, user, otherUser); + await mirrorBlock({ + active: true, + blockerId: 1234, + blockedId: 5678, + pairKey: '1234:5678', + revision: 1, + stateChangedAt: olderAt, + updatedAt: olderAt, + }, user, otherUser); + + expect(sourceState.relationship).toEqual({ revision: 3, status: 'removed' }); + expect(sourceState.block).toEqual({ active: false, revision: 2 }); + }); }); diff --git a/server/prisma/migrations/20260712180000_friendship_lifecycle/migration.sql b/server/prisma/migrations/20260712180000_friendship_lifecycle/migration.sql index e48ead7..5f51df8 100644 --- a/server/prisma/migrations/20260712180000_friendship_lifecycle/migration.sql +++ b/server/prisma/migrations/20260712180000_friendship_lifecycle/migration.sql @@ -14,11 +14,14 @@ CREATE TABLE app.friend_relationships ( CONSTRAINT friend_relationships_pkey PRIMARY KEY (id), CONSTRAINT friend_relationships_distinct_users CHECK (low_user_id <> high_user_id), CONSTRAINT friend_relationships_status_check CHECK ( - status IN ('pending', 'accepted', 'declined', 'canceled') + status IN ('pending', 'accepted', 'declined', 'canceled', 'removed') ), CONSTRAINT friend_relationships_requester_check CHECK ( - (status = 'accepted' AND requested_by_user_id IS NULL) - OR (status <> 'accepted' AND requested_by_user_id IN (low_user_id, high_user_id)) + (status IN ('accepted', 'removed') AND requested_by_user_id IS NULL) + OR ( + status NOT IN ('accepted', 'removed') + AND requested_by_user_id IN (low_user_id, high_user_id) + ) ) ); @@ -51,6 +54,9 @@ CREATE TABLE app.user_blocks ( blocker_id uuid NOT NULL, blocked_id uuid NOT NULL, pair_key text NOT NULL, + active boolean NOT NULL, + revision integer NOT NULL, + state_changed_at timestamptz(6) NOT NULL, source_created_at timestamptz(6), source_updated_at timestamptz(6) NOT NULL, ingested_at timestamptz(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 47cb781..45cdf25 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -58,6 +58,9 @@ model UserBlock { blockerId String @map("blocker_id") @db.Uuid blockedId String @map("blocked_id") @db.Uuid pairKey String @map("pair_key") + active Boolean + revision Int + stateChangedAt DateTime @map("state_changed_at") @db.Timestamptz(6) sourceCreatedAt DateTime? @map("source_created_at") @db.Timestamptz(6) sourceUpdatedAt DateTime @map("source_updated_at") @db.Timestamptz(6) ingestedAt DateTime @default(now()) @map("ingested_at") @db.Timestamptz(6) diff --git a/server/runtimeConfig.js b/server/runtimeConfig.js index 16ed0f2..01a4862 100644 --- a/server/runtimeConfig.js +++ b/server/runtimeConfig.js @@ -97,6 +97,9 @@ module.exports = { grandPrix: { enabled: process.env.GRAND_PRIX_ENABLED === 'true', }, + socialFeatures: { + enabled: process.env.SOCIAL_FEATURES_ENABLED === 'true', + }, postgres: { enabled: process.env.POSTGRES_ENABLED !== 'false', connectionString: process.env.DATABASE_URL, diff --git a/server/runtimeConfig.test.js b/server/runtimeConfig.test.js index c18e344..0373586 100644 --- a/server/runtimeConfig.test.js +++ b/server/runtimeConfig.test.js @@ -30,3 +30,31 @@ describe('Grand Prix runtime configuration', () => { expect(loadRuntimeConfig().grandPrix.enabled).toBe(false); }); }); + +describe('social feature runtime configuration', () => { + const originalValue = process.env.SOCIAL_FEATURES_ENABLED; + + afterEach(() => { + if (originalValue === undefined) { + delete process.env.SOCIAL_FEATURES_ENABLED; + } else { + process.env.SOCIAL_FEATURES_ENABLED = originalValue; + } + jest.resetModules(); + }); + + it('is disabled by default', () => { + delete process.env.SOCIAL_FEATURES_ENABLED; + + expect(loadRuntimeConfig().socialFeatures.enabled).toBe(false); + }); + + it('is enabled only by the explicit lowercase true value', () => { + process.env.SOCIAL_FEATURES_ENABLED = 'true'; + expect(loadRuntimeConfig().socialFeatures.enabled).toBe(true); + + jest.resetModules(); + process.env.SOCIAL_FEATURES_ENABLED = 'TRUE'; + expect(loadRuntimeConfig().socialFeatures.enabled).toBe(false); + }); +}); diff --git a/server/social/relationshipService.js b/server/social/relationshipService.js index cfc02df..9737b26 100644 --- a/server/social/relationshipService.js +++ b/server/social/relationshipService.js @@ -3,15 +3,14 @@ const logger = require('../logger'); const metrics = require('../metrics'); const { FriendRelationship, + OutgoingRequestQuota, RELATIONSHIP_STATUSES, User, UserBlock, } = require('../models'); const { mirrorBlock, - mirrorBlockDeleted, mirrorRelationship, - mirrorRelationshipDeleted, } = require('../postgres/dualWrite'); const { publishSocialInvalidation } = require('../realtime/socialEvents'); const publicUserProjection = require('./publicUser'); @@ -23,6 +22,7 @@ const { const MAX_OUTGOING_REQUESTS = 100; const MAX_CAS_ATTEMPTS = 6; +const RESERVATION_STALE_AFTER_MS = 5 * 60 * 1000; const toPlain = (document) => { if (!document) { @@ -64,14 +64,13 @@ const normalizedPair = (firstUserId, secondUserId) => { const createRelationshipService = ({ blockModel = UserBlock, metricRecorder = metrics, + quotaModel = OutgoingRequestQuota, relationshipModel = FriendRelationship, socialLogger = logger, userModel = User, mirrors = { mirrorBlock, - mirrorBlockDeleted, mirrorRelationship, - mirrorRelationshipDeleted, }, notifier = publishSocialInvalidation, now = () => new Date(), @@ -111,33 +110,142 @@ const createRelationshipService = ({ }; }; - const pairIsBlocked = async (pairKey) => !!(await lean(blockModel.exists({ pairKey }))); + const pairIsBlocked = async (pairKey) => !!(await lean(blockModel.exists({ + active: true, + pairKey, + }))); - const enforceOutgoingLimit = async (actorId) => { - const count = await relationshipModel.countDocuments({ - requestedBy: actorId, - status: RELATIONSHIP_STATUSES.PENDING, - }); - if (count >= MAX_OUTGOING_REQUESTS) { - throw new SocialError( - 409, - 'outgoing_request_limit', - 'The outgoing friend request limit has been reached', - ); + const ensureQuota = async (userId) => { + try { + return toPlain(await quotaModel.findOneAndUpdate({ userId }, { + $setOnInsert: { + reservations: [], + revision: 0, + userId, + }, + }, { + new: true, + setDefaultsOnInsert: true, + upsert: true, + useFindAndModify: false, + })); + } catch (err) { + if (err.code !== 11000) { + throw err; + } + return lean(quotaModel.findOne({ userId })); } }; - const persistTransition = async (current, transition) => { - if (transition.kind === 'noop') { - return current; + const reconcileQuota = async (userId) => { + for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt += 1) { + const quota = await ensureQuota(userId); + const pendingRelationships = await lean(relationshipModel.find({ + requestedBy: userId, + status: RELATIONSHIP_STATUSES.PENDING, + })); + const pendingPairKeys = new Set(pendingRelationships.map(({ pairKey }) => pairKey)); + const staleBefore = new Date(now().getTime() - RESERVATION_STALE_AFTER_MS); + const reservations = (quota.reservations || []).filter((reservation) => ( + pendingPairKeys.has(reservation.pairKey) + || new Date(reservation.reservedAt) > staleBefore + )); + const reservedPairKeys = new Set(reservations.map(({ pairKey }) => pairKey)); + pendingPairKeys.forEach((pairKey) => { + if (!reservedPairKeys.has(pairKey)) { + reservations.push({ pairKey, reservedAt: now() }); + } + }); + + const reconciled = await quotaModel.findOneAndUpdate({ + _id: quota._id, + revision: quota.revision, + }, { + $inc: { revision: 1 }, + $set: { reservations }, + }, { + new: true, + useFindAndModify: false, + }); + if (reconciled) { + return toPlain(reconciled); + } } + throw new SocialError( + 409, + 'request_quota_conflict', + 'The outgoing friend request quota changed concurrently; retry', + ); + }; - if (transition.kind === 'delete') { - const deleted = await relationshipModel.deleteOne({ - _id: current._id, - revision: current.revision, + const reserveOutgoingRequest = async (userId, pairKey) => { + await reconcileQuota(userId); + for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt += 1) { + const quota = await lean(quotaModel.findOne({ userId })); + if ((quota.reservations || []).some((reservation) => reservation.pairKey === pairKey)) { + return quota; + } + const reserved = await quotaModel.findOneAndUpdate({ + _id: quota._id, + revision: quota.revision, + 'reservations.pairKey': { $ne: pairKey }, + $expr: { $lt: [{ $size: '$reservations' }, MAX_OUTGOING_REQUESTS] }, + }, { + $inc: { revision: 1 }, + $push: { reservations: { pairKey, reservedAt: now() } }, + }, { + new: true, + useFindAndModify: false, }); - return deleted.deletedCount === 1 ? null : undefined; + if (reserved) { + return toPlain(reserved); + } + + const latest = await lean(quotaModel.findOne({ userId })); + if ((latest.reservations || []).some((reservation) => reservation.pairKey === pairKey)) { + return latest; + } + if ((latest.reservations || []).length >= MAX_OUTGOING_REQUESTS) { + throw new SocialError( + 409, + 'outgoing_request_limit', + 'The outgoing friend request limit has been reached', + ); + } + } + throw new SocialError( + 409, + 'request_quota_conflict', + 'The outgoing friend request quota changed concurrently; retry', + ); + }; + + const releaseReservation = (userId, pairKey) => quotaModel.findOneAndUpdate({ + userId, + 'reservations.pairKey': pairKey, + }, { + $inc: { revision: 1 }, + $pull: { reservations: { pairKey } }, + }, { + new: true, + useFindAndModify: false, + }); + + const reconcilePairReservations = async (pair, relationship) => { + const pendingRequester = relationship + && relationship.status === RELATIONSHIP_STATUSES.PENDING + ? relationship.requestedBy : null; + const userIds = [pair.lowUserId, pair.highUserId]; + await Promise.all(userIds.map((userId) => ( + userId === pendingRequester + ? Promise.resolve() + : releaseReservation(userId, pair.pairKey) + ))); + }; + + const persistTransition = async (current, transition) => { + if (transition.kind === 'noop') { + return current; } if (!current) { @@ -172,6 +280,38 @@ const createRelationshipService = ({ return updated ? toPlain(updated) : undefined; }; + const tombstoneRelationship = async (pair, changedAt) => { + for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt += 1) { + const current = (await lean(relationshipModel.findOne({ pairKey: pair.pairKey }))) || null; + if (!current || current.status === RELATIONSHIP_STATUSES.REMOVED) { + return current; + } + const removed = await relationshipModel.findOneAndUpdate({ + _id: current._id, + revision: current.revision, + }, { + $inc: { revision: 1 }, + $set: { + cooldownUntil: null, + requestedBy: null, + stateChangedAt: changedAt, + status: RELATIONSHIP_STATUSES.REMOVED, + }, + }, { + new: true, + useFindAndModify: false, + }); + if (removed) { + return toPlain(removed); + } + } + throw new SocialError( + 409, + 'relationship_conflict', + 'The relationship changed concurrently; reconcile and retry', + ); + }; + const act = async (actorUser, targetValue, action) => { const { actorId, pair, targetId, targetUser, @@ -200,21 +340,31 @@ const createRelationshipService = ({ }); if (action === ACTIONS.SEND && transition.outcome === 'request_created') { - await enforceOutgoingLimit(actorId); + await reserveOutgoingRequest(actorId, pair.pairKey); } } catch (err) { record(actorId, action, err.code || 'error'); throw err; } - const persisted = await persistTransition(current, transition); + let persisted; + try { + persisted = await persistTransition(current, transition); + } catch (err) { + const latest = (await lean(relationshipModel.findOne({ pairKey: pair.pairKey }))) || null; + await reconcilePairReservations(pair, latest); + throw err; + } if (persisted === undefined) { continue; } if (await pairIsBlocked(pair.pairKey)) { - await relationshipModel.deleteOne({ pairKey: pair.pairKey }); - runInBackground(mirrors.mirrorRelationshipDeleted(pair.pairKey)); + const removed = await tombstoneRelationship(pair, now()); + await reconcilePairReservations(pair, removed); + if (removed) { + runInBackground(mirrors.mirrorRelationship(removed, [actorUser, targetUser])); + } invalidate([actorId, targetId]); record(actorId, action, 'unavailable'); throw new SocialError( @@ -224,9 +374,8 @@ const createRelationshipService = ({ ); } - if (transition.kind === 'delete') { - runInBackground(mirrors.mirrorRelationshipDeleted(pair.pairKey)); - } else if (transition.kind !== 'noop') { + await reconcilePairReservations(pair, persisted); + if (transition.kind !== 'noop') { runInBackground(mirrors.mirrorRelationship(persisted, [actorUser, targetUser])); } if (transition.kind !== 'noop') { @@ -239,6 +388,8 @@ const createRelationshipService = ({ }; } + const latest = (await lean(relationshipModel.findOne({ pairKey: pair.pairKey }))) || null; + await reconcilePairReservations(pair, latest); record(actorId, action, 'conflict'); throw new SocialError( 409, @@ -256,10 +407,14 @@ const createRelationshipService = ({ blockedId: targetId, blockerId: actorId, }, { + $inc: { revision: 1 }, + $set: { + active: true, + stateChangedAt: blockedAt, + }, $setOnInsert: { blockedId: targetId, blockerId: actorId, - createdAt: blockedAt, pairKey: pair.pairKey, }, }, { @@ -269,8 +424,12 @@ const createRelationshipService = ({ useFindAndModify: false, })); - await relationshipModel.deleteOne({ pairKey: pair.pairKey }); runInBackground(mirrors.mirrorBlock(blockDocument, actorUser, targetUser)); + const removed = await tombstoneRelationship(pair, blockedAt); + await reconcilePairReservations(pair, removed); + if (removed) { + runInBackground(mirrors.mirrorRelationship(removed, [actorUser, targetUser])); + } invalidate([actorId, targetId]); record(actorId, 'block', 'blocked'); return { outcome: 'blocked' }; @@ -278,10 +437,35 @@ const createRelationshipService = ({ const unblock = async (actorUser, targetValue) => { const { - actorId, targetId, + actorId, pair, targetId, targetUser, } = await loadUsers(actorUser, targetValue); - await blockModel.deleteOne({ blockerId: actorId, blockedId: targetId }); - runInBackground(mirrors.mirrorBlockDeleted(actorId, targetId)); + const removed = await tombstoneRelationship(pair, now()); + await reconcilePairReservations(pair, removed); + if (removed) { + runInBackground(mirrors.mirrorRelationship(removed, [actorUser, targetUser])); + } + + const deactivated = await blockModel.findOneAndUpdate({ + active: true, + blockedId: targetId, + blockerId: actorId, + }, { + $inc: { revision: 1 }, + $set: { + active: false, + stateChangedAt: now(), + }, + }, { + new: true, + useFindAndModify: false, + }); + const blockDocument = toPlain(deactivated || await lean(blockModel.findOne({ + blockedId: targetId, + blockerId: actorId, + }))); + if (blockDocument) { + runInBackground(mirrors.mirrorBlock(blockDocument, actorUser, targetUser)); + } invalidate([actorId, targetId]); record(actorId, 'unblock', 'unblocked'); return { outcome: 'unblocked' }; @@ -295,6 +479,7 @@ const createRelationshipService = ({ status: { $in: [RELATIONSHIP_STATUSES.ACCEPTED, RELATIONSHIP_STATUSES.PENDING] }, })), lean(blockModel.find({ + active: true, $or: [{ blockedId: actorId }, { blockerId: actorId }], })), ]); @@ -367,6 +552,7 @@ const createRelationshipService = ({ module.exports = { MAX_OUTGOING_REQUESTS, + RESERVATION_STALE_AFTER_MS, createRelationshipService, normalizeUserId, normalizedPair, diff --git a/server/social/relationshipService.test.js b/server/social/relationshipService.test.js index 959af81..f89f6ca 100644 --- a/server/social/relationshipService.test.js +++ b/server/social/relationshipService.test.js @@ -3,7 +3,7 @@ const { createRelationshipService, MAX_OUTGOING_REQUESTS } = require('./relationshipService'); -const clone = (value) => (value ? { ...value } : value); +const clone = (value) => (value ? JSON.parse(JSON.stringify(value)) : value); const createModels = () => { const users = new Map([ @@ -19,13 +19,10 @@ const createModels = () => { ]); const relationships = new Map(); const blocks = new Map(); + const quotas = new Map(); let sequence = 0; const relationshipModel = { - countDocuments: jest.fn(async (filter) => [...relationships.values()].filter( - (relationship) => relationship.requestedBy === filter.requestedBy - && relationship.status === filter.status, - ).length), create: jest.fn(async (relationship) => { if (relationships.has(relationship.pairKey)) { const err = new Error('duplicate pair'); @@ -54,6 +51,10 @@ const createModels = () => { return { deletedCount: 1 }; }), find: jest.fn(async (filter) => [...relationships.values()].filter((relationship) => { + if (!filter.$or) { + return relationship.requestedBy === filter.requestedBy + && relationship.status === filter.status; + } const belongsToUser = filter.$or.some((condition) => Object.entries(condition) .every(([key, value]) => relationship[key] === value)); return belongsToUser && filter.status.$in.includes(relationship.status); @@ -79,24 +80,100 @@ const createModels = () => { deleteOne: jest.fn(async ({ blockerId, blockedId }) => ({ deletedCount: blocks.delete(`${blockerId}:${blockedId}`) ? 1 : 0, })), - exists: jest.fn(async ({ pairKey }) => [...blocks.values()] - .some((block) => block.pairKey === pairKey)), - find: jest.fn(async (filter) => [...blocks.values()].filter((block) => filter.$or - .some((condition) => Object.entries(condition) - .every(([key, value]) => block[key] === value))).map(clone)), + exists: jest.fn(async ({ active, pairKey }) => [...blocks.values()] + .some((block) => block.pairKey === pairKey && block.active === active)), + find: jest.fn(async (filter) => [...blocks.values()].filter((block) => ( + filter.$or.some((condition) => Object.entries(condition) + .every(([key, value]) => block[key] === value)) + && block.active === filter.active + )).map(clone)), + findOne: jest.fn(async ({ blockerId, blockedId }) => clone( + blocks.get(`${blockerId}:${blockedId}`), + )), findOneAndUpdate: jest.fn(async (filter, update) => { const key = `${filter.blockerId}:${filter.blockedId}`; if (!blocks.has(key)) { + if (!update.$setOnInsert) { + return null; + } blocks.set(key, { ...update.$setOnInsert, _id: `block-${sequence += 1}`, - updatedAt: update.$setOnInsert.createdAt, + active: update.$set.active, + revision: update.$inc.revision, + stateChangedAt: update.$set.stateChangedAt, + createdAt: update.$set.stateChangedAt, + updatedAt: update.$set.stateChangedAt, + }); + } else { + const current = blocks.get(key); + if (filter.active !== undefined && current.active !== filter.active) { + return null; + } + blocks.set(key, { + ...current, + ...update.$set, + revision: current.revision + update.$inc.revision, + updatedAt: update.$set.stateChangedAt, }); } return clone(blocks.get(key)); }), }; + const quotaModel = { + findOne: jest.fn(async ({ userId }) => clone(quotas.get(userId))), + findOneAndUpdate: jest.fn(async (filter, update, options = {}) => { + let quota = filter.userId !== undefined + ? quotas.get(filter.userId) + : [...quotas.values()].find((entry) => entry._id === filter._id); + if (!quota && options.upsert) { + quota = { + ...update.$setOnInsert, + _id: `quota-${filter.userId}`, + }; + quotas.set(filter.userId, quota); + return clone(quota); + } + if (!quota) { + return null; + } + if (filter.revision !== undefined && quota.revision !== filter.revision) { + return null; + } + const pairFilter = filter['reservations.pairKey']; + if (pairFilter && pairFilter.$ne && quota.reservations + .some(({ pairKey }) => pairKey === pairFilter.$ne)) { + return null; + } + if (typeof pairFilter === 'string' && !quota.reservations + .some(({ pairKey }) => pairKey === pairFilter)) { + return null; + } + if (filter.$expr && quota.reservations.length >= MAX_OUTGOING_REQUESTS) { + return null; + } + + const next = clone(quota); + if (update.$set) { + Object.assign(next, clone(update.$set)); + } + if (update.$push) { + next.reservations.push(clone(update.$push.reservations)); + } + if (update.$pull) { + next.reservations = next.reservations.filter( + ({ pairKey }) => pairKey !== update.$pull.reservations.pairKey, + ); + } + if (update.$inc) { + next.revision += update.$inc.revision; + } + quotas.set(next.userId, next); + return clone(next); + }), + }; + const userModel = { find: jest.fn(async ({ id }) => id.$in.map((userId) => users.get(userId)).filter(Boolean)), findOne: jest.fn(async ({ id }) => clone(users.get(id))), @@ -105,6 +182,8 @@ const createModels = () => { return { blockModel, blocks, + quotaModel, + quotas, relationshipModel, relationships, userModel, @@ -117,9 +196,7 @@ const createService = () => { const metricRecorder = { recordSocialOutcome: jest.fn() }; const mirrors = { mirrorBlock: jest.fn().mockResolvedValue(null), - mirrorBlockDeleted: jest.fn().mockResolvedValue(null), mirrorRelationship: jest.fn().mockResolvedValue(null), - mirrorRelationshipDeleted: jest.fn().mockResolvedValue(null), }; const notifier = jest.fn().mockResolvedValue(true); const now = jest.fn(() => new Date('2026-07-12T12:00:00.000Z')); @@ -255,7 +332,7 @@ describe('relationship service', () => { await service.acceptRequest(users.get(2), 1); await service.block(users.get(1), 2); - expect(relationships.has('1:2')).toBe(false); + expect(relationships.get('1:2').status).toBe('removed'); await expect(service.sendRequest(users.get(2), 1)).rejects.toMatchObject({ code: 'relationship_unavailable', }); @@ -284,7 +361,7 @@ describe('relationship service', () => { code: 'relationship_unavailable', }); - expect(relationships.has('1:2')).toBe(false); + expect(relationships.get('1:2').status).toBe('removed'); }); it('makes unfriend replayable and succeeds when PostgreSQL mirrors are disabled', async () => { @@ -299,9 +376,12 @@ describe('relationship service', () => { outcome: 'unfriend_replayed', }); - expect(relationships.has('1:2')).toBe(false); + expect(relationships.get('1:2').status).toBe('removed'); expect(mirrors.mirrorRelationship).toHaveBeenCalled(); - expect(mirrors.mirrorRelationshipDeleted).toHaveBeenCalledWith('1:2'); + expect(mirrors.mirrorRelationship).toHaveBeenLastCalledWith( + expect.objectContaining({ pairKey: '1:2', status: 'removed' }), + expect.any(Array), + ); }); it('returns only public fields and sends generic invalidations to both users', async () => { @@ -319,15 +399,97 @@ describe('relationship service', () => { expect(notifier).toHaveBeenCalledWith({ userIds: [1, 2] }); }); - it('enforces the pending outgoing request limit', async () => { + it('atomically enforces the pending limit across different target pairs', async () => { const { - relationshipModel, service, users, + quotas, service, users, } = createService(); - relationshipModel.countDocuments.mockResolvedValue(MAX_OUTGOING_REQUESTS); + quotas.set(1, { + _id: 'quota-1', + reservations: Array.from({ length: 99 }, (_, index) => ({ + pairKey: `1:${1000 + index}`, + reservedAt: new Date('2026-07-12T12:00:00.000Z'), + })), + revision: 0, + userId: 1, + }); - await expect(service.sendRequest(users.get(1), 2)).rejects.toMatchObject({ - code: 'outgoing_request_limit', - statusCode: 409, + const results = await Promise.allSettled([ + service.sendRequest(users.get(1), 2), + service.sendRequest(users.get(1), 3), + ]); + + expect(results.filter(({ status }) => status === 'fulfilled')).toHaveLength(1); + expect(results.filter(({ status }) => status === 'rejected')).toEqual([ + expect.objectContaining({ + reason: expect.objectContaining({ code: 'outgoing_request_limit' }), + }), + ]); + expect(quotas.get(1).reservations).toHaveLength(MAX_OUTGOING_REQUESTS); + }); + + it('releases a request reservation when relationship persistence fails', async () => { + const { + quotas, relationshipModel, service, users, + } = createService(); + relationshipModel.create.mockRejectedValueOnce(new Error('mongo write failed')); + + await expect(service.sendRequest(users.get(1), 2)).rejects.toThrow('mongo write failed'); + + expect(quotas.get(1).reservations).toEqual([]); + }); + + it('reconciles abandoned quota reservations before enforcing the cap', async () => { + const { + quotas, service, users, + } = createService(); + quotas.set(1, { + _id: 'quota-1', + reservations: Array.from({ length: MAX_OUTGOING_REQUESTS }, (_, index) => ({ + pairKey: `1:${1000 + index}`, + reservedAt: new Date('2026-07-12T11:54:00.000Z'), + })), + revision: 0, + userId: 1, }); + + await expect(service.sendRequest(users.get(1), 2)).resolves.toMatchObject({ + outcome: 'request_created', + }); + + expect(quotas.get(1).reservations).toEqual([ + expect.objectContaining({ pairKey: '1:2' }), + ]); + }); + + it('keeps a block active when unblock cannot tombstone a hidden relationship', async () => { + const { + blocks, mirrors, relationshipModel, relationships, service, users, + } = createService(); + await service.sendRequest(users.get(1), 2); + await service.acceptRequest(users.get(2), 1); + relationshipModel.findOneAndUpdate.mockRejectedValueOnce(new Error('cleanup failed')); + + await expect(service.block(users.get(1), 2)).rejects.toThrow('cleanup failed'); + expect(blocks.get('1:2').active).toBe(true); + expect(relationships.get('1:2').status).toBe('accepted'); + + relationshipModel.findOneAndUpdate.mockRejectedValueOnce(new Error('cleanup still failed')); + await expect(service.unblock(users.get(1), 2)).rejects.toThrow('cleanup still failed'); + expect(blocks.get('1:2').active).toBe(true); + expect(relationships.get('1:2').status).toBe('accepted'); + expect((await service.list(users.get(1))).friends).toEqual([]); + + await service.unblock(users.get(1), 2); + expect(blocks.get('1:2').active).toBe(false); + expect(relationships.get('1:2').status).toBe('removed'); + expect(mirrors.mirrorRelationship).toHaveBeenLastCalledWith( + expect.objectContaining({ pairKey: '1:2', status: 'removed' }), + expect.any(Array), + ); + expect(mirrors.mirrorBlock).toHaveBeenLastCalledWith( + expect.objectContaining({ active: false, pairKey: '1:2' }), + expect.any(Object), + expect.any(Object), + ); }); }); diff --git a/server/social/relationshipState.js b/server/social/relationshipState.js index 3a75540..a7de1de 100644 --- a/server/social/relationshipState.js +++ b/server/social/relationshipState.js @@ -163,13 +163,23 @@ const transitionRelationship = ({ } if (action === ACTIONS.UNFRIEND) { - if (!current) { + if (!current || current.status === RELATIONSHIP_STATUSES.REMOVED) { return { kind: 'noop', outcome: 'unfriend_replayed', relationship: null }; } if (current.status !== RELATIONSHIP_STATUSES.ACCEPTED) { return failTransition(); } - return { kind: 'delete', outcome: 'unfriended', relationship: null }; + return { + kind: 'upsert', + outcome: 'unfriended', + relationship: { + ...current, + cooldownUntil: null, + requestedBy: null, + stateChangedAt: now, + status: RELATIONSHIP_STATUSES.REMOVED, + }, + }; } throw new SocialError(400, 'invalid_action', 'Unknown relationship action'); diff --git a/server/socket/index.js b/server/socket/index.js index fefe843..765b551 100644 --- a/server/socket/index.js +++ b/server/socket/index.js @@ -61,14 +61,18 @@ const init = async () => { db: config.redis.db, }); const subClient = pubClient.duplicate(); - const socialSubClient = pubClient.duplicate(); + const socialSubClient = config.socialFeatures.enabled ? pubClient.duplicate() : null; pubClient.on('error', logSocketError('redis pub client')); subClient.on('error', logSocketError('redis sub client')); - socialSubClient.on('error', logSocketError('redis social sub client')); + if (socialSubClient) { + socialSubClient.on('error', logSocketError('redis social sub client')); + } io.adapter(createAdapter(pubClient, subClient)); - registerSocialEventSubscriber(io, socialSubClient); + if (socialSubClient) { + registerSocialEventSubscriber(io, socialSubClient); + } const reportHealth = createHealthReporter({ service: 'socket', @@ -85,7 +89,7 @@ const init = async () => { }, redis: async () => pubClient.status === 'ready' && subClient.status === 'ready' - && socialSubClient.status === 'ready' + && (!socialSubClient || socialSubClient.status === 'ready') && (await pubClient.ping()) === 'PONG', }, }); From b8767591b9f2770bb3b7e722ed01695c6c7bbf57 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 21:19:50 -0700 Subject: [PATCH 18/56] Harden room departure handoffs Preserve the non-null admin socket contract, always select and persist a successor on removal, and reject self-removal moderation requests. Make explicit two-tab leaves remove their own socket membership before checking for remaining tabs so the last departure cannot strand a room.\n\nRefs #153 --- docs/room-ownership.md | 12 ++- server/models/room.js | 8 +- server/models/room.test.js | 50 ++++++++++++ server/socket/namespaces/rooms.js | 37 +++++++-- server/socket/namespaces/rooms.test.js | 108 ++++++++++++++++++++++++- 5 files changed, 202 insertions(+), 13 deletions(-) diff --git a/docs/room-ownership.md b/docs/room-ownership.md index 0016709..48e5131 100644 --- a/docs/room-ownership.md +++ b/docs/room-ownership.md @@ -29,9 +29,15 @@ grace period, departure uses the same admin handoff as an explicit leave. Presence is per user, not per socket. Closing one tab does not hand off admin while another socket for the same user remains in the room. Explicitly leaving -from the last tab finalizes the departure immediately. If the owner later +from the last tab finalizes the departure immediately: each leaving tab removes +itself from the per-user socket group before the remaining-tab check, so two +simultaneous explicit leaves cannot strand membership. If the owner later rejoins, they reclaim admin before the join is acknowledged. +An empty room persists `admin: null`, but the established `UPDATE_ADMIN` +Socket.IO event is emitted only for a non-null active admin. Spectators and +other clients therefore never receive a null admin payload. + Grand Prix rooms use the same role selection when Grand Prix is enabled. When it is disabled, create and join requests are rejected before membership or roles change. @@ -43,6 +49,10 @@ Admin actions must authorize against the current `admin`, not a client-provided role or an older socket snapshot. Owner-only operations authorize against the unchanging `owner`. +Admins cannot kick or ban themselves through raw socket events; they must use +the ordinary leave operation so membership, admin handoff, and reconnect +semantics remain coherent. + Private-room invitations and access approvals may treat the active owner or current admin as a host, but must also independently require that host to be an active room participant. This prevents an absent owner from granting access diff --git a/server/models/room.js b/server/models/room.js index fef9c78..bc4bdc3 100644 --- a/server/models/room.js +++ b/server/models/room.js @@ -211,9 +211,7 @@ Room.methods.dropUser = async function (user, updateAdmin) { this.inRoom.set(user.id.toString(), false); this.waitingFor.set(user.id.toString(), false); - if (updateAdmin) { - await this.updateAdminIfNeeded(updateAdmin); - } + await this.updateAdminIfNeeded(updateAdmin); if (this.usersInRoom.length === 0 && this.type === 'normal') { await this.updateStale(true); @@ -340,7 +338,9 @@ Room.methods.updateAdminIfNeeded = async function (cb) { this.admin = nextAdmin; const room = await this.save(); - if (cb) { + // UPDATE_ADMIN has always carried a user. Empty rooms persist null without + // notifying clients that still expect a populated admin object. + if (cb && room.admin) { cb(room); } return room; diff --git a/server/models/room.test.js b/server/models/room.test.js index 752a519..8c1dc35 100644 --- a/server/models/room.test.js +++ b/server/models/room.test.js @@ -210,6 +210,56 @@ describe('room security helpers', () => { })).toBeNull(); }); + it('hands off and persists admin after a removal without a callback', async () => { + const owner = { id: 101 }; + const nextAdmin = { id: 202 }; + const persistedAdmins = []; + const room = { + owner, + admin: owner, + inRoom: new Map([['101', true], ['202', true]]), + waitingFor: new Map([['101', true], ['202', true]]), + type: 'grand_prix', + updateAdminIfNeeded: Room.methods.updateAdminIfNeeded, + save: jest.fn(async () => { + persistedAdmins.push(room.admin && room.admin.id); + return room; + }), + }; + Object.defineProperty(room, 'usersInRoom', { + get: () => [owner, nextAdmin].filter((user) => room.inRoom.get(String(user.id))), + }); + + await Room.methods.dropUser.call(room, owner); + + expect(room.admin).toBe(nextAdmin); + expect(persistedAdmins).toContain(nextAdmin.id); + }); + + it('persists an empty room without announcing a null admin', async () => { + const owner = { id: 101 }; + const onAdminChange = jest.fn(); + const room = { + owner, + admin: owner, + inRoom: new Map([['101', true]]), + waitingFor: new Map([['101', true]]), + type: 'grand_prix', + updateAdminIfNeeded: Room.methods.updateAdminIfNeeded, + save: jest.fn().mockResolvedValue(undefined), + }; + Object.defineProperty(room, 'usersInRoom', { + get: () => (room.inRoom.get('101') ? [owner] : []), + }); + room.save.mockResolvedValue(room); + + await Room.methods.dropUser.call(room, owner, onAdminChange); + + expect(room.admin).toBeNull(); + expect(room.save).toHaveBeenCalled(); + expect(onAdminChange).not.toHaveBeenCalled(); + }); + it('persists and announces the owner reclaiming admin controls', async () => { const owner = { id: 101 }; const room = { diff --git a/server/socket/namespaces/rooms.js b/server/socket/namespaces/rooms.js index 2e0cc9c..ed6e14e 100644 --- a/server/socket/namespaces/rooms.js +++ b/server/socket/namespaces/rooms.js @@ -118,6 +118,12 @@ module.exports = (io, middlewares) => { } } + function sendAdminUpdate(room) { + if (room.admin) { + ns().in(room.accessCode).emit(Protocol.UPDATE_ADMIN, room.admin); + } + } + async function finalizeUserDeparture({ roomId, userId, connectionId, leaveReason, }) { @@ -133,9 +139,7 @@ module.exports = (io, middlewares) => { return false; } - const updatedRoom = await room.dropUser({ id: userId }, (_room) => { - ns().in(room.accessCode).emit(Protocol.UPDATE_ADMIN, _room.admin); - }); + const updatedRoom = await room.dropUser({ id: userId }, sendAdminUpdate); await metrics.endRoomVisit({ room: updatedRoom, @@ -406,9 +410,7 @@ module.exports = (io, middlewares) => { }); } - const r = await activeRoom.addUser(socket.user, spectating, (_room) => { - ns().in(_room.accessCode).emit(Protocol.UPDATE_ADMIN, _room.admin); - }); + const r = await activeRoom.addUser(socket.user, spectating, sendAdminUpdate); if (!r) { // The user is already present in persisted room state. @@ -920,6 +922,15 @@ module.exports = (io, middlewares) => { return; } + if (String(userId) === String(socket.user.id)) { + socket.emit(Protocol.ERROR, { + statusCode: 400, + event: Protocol.KICK_USER, + message: 'Cannot kick yourself; leave the room instead', + }); + return; + } + try { const room = await socket.room.dropUser({ id: userId }); @@ -954,6 +965,15 @@ module.exports = (io, middlewares) => { return; } + if (String(userId) === String(socket.user.id)) { + socket.emit(Protocol.ERROR, { + statusCode: 400, + event: Protocol.BAN_USER, + message: 'Cannot ban yourself; leave the room instead', + }); + return; + } + try { const room = await socket.room.banUser(userId); @@ -1058,8 +1078,11 @@ module.exports = (io, middlewares) => { on(Protocol.LEAVE_ROOM, async () => { if (socket.room) { if (socket.user) { - await leaveRoom('explicit'); + // Exclude this tab before checking whether another tab keeps the + // user's room membership active. Otherwise simultaneous leaves can + // each see the other socket and neither finalizes the departure. socket.leave(encodeUserRoom(socket.userId, socket.room._id)); + await leaveRoom('explicit'); } else { await metrics.endRoomVisit({ room: socket.room, diff --git a/server/socket/namespaces/rooms.test.js b/server/socket/namespaces/rooms.test.js index b153e5d..b6fbf2c 100644 --- a/server/socket/namespaces/rooms.test.js +++ b/server/socket/namespaces/rooms.test.js @@ -3,6 +3,8 @@ const Protocol = require('../../../client/src/lib/protocol.json'); const metrics = require('../../metrics'); const { Room, User } = require('../../models'); +const { encodeUserRoom } = require('../utils'); +const { createReconnectGrace } = require('../lib/reconnectGrace'); const initRooms = require('./rooms'); jest.mock('../../runtimeConfig', () => ({ @@ -33,9 +35,10 @@ jest.mock('../../postgres/dualWrite', () => ({ markRoomDeleted: jest.fn(), })); jest.mock('../lib/reconnectGrace', () => ({ - createReconnectGrace: jest.fn(() => ({ + createReconnectGrace: jest.fn(({ finalizeDeparture }) => ({ cancel: jest.fn().mockResolvedValue(false), finalize: jest.fn(), + finalizeDeparture, schedule: jest.fn(), startReconciliation: jest.fn(), })), @@ -345,3 +348,106 @@ describe('room namespace edits', () => { })); }); }); + +describe('room namespace departures and moderation', () => { + beforeEach(() => { + jest.clearAllMocks(); + metrics.endRoomVisit.mockResolvedValue(undefined); + }); + + it('does not send a null admin update to a spectator channel when a room empties', async () => { + const room = makeRoom({ + private: false, + password: null, + usersLength: 0, + inRoom: new Map([['101', true]]), + doneWithScramble: jest.fn().mockReturnValue(false), + }); + room.dropUser = jest.fn(async (user, onAdminChange) => { + room.inRoom.set(String(user.id), false); + room.admin = null; + onAdminChange(room); + return room; + }); + const connect = setup(new Map([[room._id, room]])); + await connect(makeSocket({ id: 'spectator-socket', session: {} })); + const reconnectGrace = createReconnectGrace.mock.results[0].value; + + await reconnectGrace.finalizeDeparture({ + roomId: room._id, + userId: 101, + connectionId: 'host-socket', + leaveReason: 'explicit', + }); + + expect(connect.roomChannel.emit).not.toHaveBeenCalledWith(Protocol.UPDATE_ADMIN, null); + expect(connect.roomChannel.emit).toHaveBeenCalledWith(Protocol.USER_LEFT, 101); + }); + + it('finalizes one departure when two tabs explicitly leave together', async () => { + const room = makeRoom({ + private: false, + password: null, + inRoom: new Map([['101', true]]), + }); + const connect = setup(new Map([[room._id, room]])); + const first = makeSocket({ id: 'first-tab', session: {}, userId: 101 }); + const second = makeSocket({ id: 'second-tab', session: {}, userId: 101 }); + const activeSocketIds = new Set([first.id, second.id]); + const userRoom = encodeUserRoom(101, room._id); + connect.namespace.adapter.sockets.mockImplementation(async () => new Set(activeSocketIds)); + [first, second].forEach((socket) => { + socket.room = room; + socket.roomId = room._id; + socket.leave.mockImplementation((roomName) => { + if (roomName === userRoom) { + activeSocketIds.delete(socket.id); + } + }); + }); + await connect(first); + await connect(second); + const reconnectGrace = createReconnectGrace.mock.results[0].value; + + await Promise.all([ + first.handlers[Protocol.LEAVE_ROOM](), + second.handlers[Protocol.LEAVE_ROOM](), + ]); + + expect(reconnectGrace.finalize).toHaveBeenCalledTimes(1); + expect(reconnectGrace.finalize).toHaveBeenCalledWith(expect.objectContaining({ + roomId: room._id, + userId: 101, + leaveReason: 'explicit', + })); + }); + + it('rejects raw self-kick and self-ban requests from an admin', async () => { + const room = makeRoom({ + private: false, + password: null, + admin: { id: 101 }, + dropUser: jest.fn(), + banUser: jest.fn(), + }); + const connect = setup(new Map([[room._id, room]])); + const socket = makeSocket({ id: 'admin-socket', session: {}, userId: 101 }); + socket.room = room; + socket.roomId = room._id; + await connect(socket); + + await socket.handlers[Protocol.KICK_USER](101); + await socket.handlers[Protocol.BAN_USER]('101'); + + expect(room.dropUser).not.toHaveBeenCalled(); + expect(room.banUser).not.toHaveBeenCalled(); + expect(socket.emit).toHaveBeenCalledWith(Protocol.ERROR, expect.objectContaining({ + statusCode: 400, + event: Protocol.KICK_USER, + })); + expect(socket.emit).toHaveBeenCalledWith(Protocol.ERROR, expect.objectContaining({ + statusCode: 400, + event: Protocol.BAN_USER, + })); + }); +}); From 7f606609e2d3e37208c88ab90d6ad5021e07291a Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 21:30:56 -0700 Subject: [PATCH 19/56] Harden friendship quota recovery Keep in-flight quota reservations counted until their relationship write resolves, and make quota cleanup asynchronous after durable social transitions so mirrors and invalidations are never suppressed by maintenance failures. --- docs/friendships.md | 18 ++- server/models/outgoingRequestQuota.js | 5 + server/social/relationshipService.js | 37 ++++-- server/social/relationshipService.test.js | 146 ++++++++++++++++++++++ 4 files changed, 191 insertions(+), 15 deletions(-) diff --git a/docs/friendships.md b/docs/friendships.md index edffa84..eed9dc5 100644 --- a/docs/friendships.md +++ b/docs/friendships.md @@ -71,13 +71,23 @@ hidden relationship cannot reappear. Each user may have at most 100 pending outgoing requests. A MongoDB quota document atomically reserves pair slots before relationship creation, so -concurrent sends to different users cannot pass the cap. Failed writes release -their slots. Reconciliation rebuilds missing reservations from pending -relationships and removes abandoned reservations after five minutes. A -declined pair may not be requested again for 24 hours, and a canceled pair may +concurrent sends to different users cannot pass the cap. A reservation remains +an active operation until that write either persists or explicitly fails, even +when the write is delayed beyond five minutes; reconciliation never expires an +active operation and risks admitting a 101st request. Once the relationship is +durable, reconciliation converts its reservation to a normal pending slot. +Failed writes release their slots. Reconciliation rebuilds missing reservations +from pending relationships and removes unclaimed abandoned reservations after +five minutes. A declined pair may not be requested again for 24 hours, and a canceled pair may not be requested again for 60 seconds. `request_cooldown` includes `retryAfterSeconds`. +Quota cleanup is maintenance, not part of a committed relationship transition. +After MongoDB durably changes a relationship or block, the API mirrors and +invalidates immediately; cleanup then runs in the background and logs failures +for operational follow-up. A cleanup failure therefore cannot turn a successful +social action into an error or suppress its durable update. + ## REST API All routes require an authenticated session. diff --git a/server/models/outgoingRequestQuota.js b/server/models/outgoingRequestQuota.js index 64c3b48..8371d64 100644 --- a/server/models/outgoingRequestQuota.js +++ b/server/models/outgoingRequestQuota.js @@ -1,6 +1,11 @@ const mongoose = require('mongoose'); const Reservation = new mongoose.Schema({ + activeOperation: { + type: Boolean, + default: false, + required: true, + }, pairKey: { type: String, required: true, diff --git a/server/social/relationshipService.js b/server/social/relationshipService.js index 9737b26..dde606d 100644 --- a/server/social/relationshipService.js +++ b/server/social/relationshipService.js @@ -146,14 +146,19 @@ const createRelationshipService = ({ })); const pendingPairKeys = new Set(pendingRelationships.map(({ pairKey }) => pairKey)); const staleBefore = new Date(now().getTime() - RESERVATION_STALE_AFTER_MS); - const reservations = (quota.reservations || []).filter((reservation) => ( - pendingPairKeys.has(reservation.pairKey) - || new Date(reservation.reservedAt) > staleBefore - )); + const reservations = (quota.reservations || []).flatMap((reservation) => { + if (pendingPairKeys.has(reservation.pairKey)) { + return [{ ...reservation, activeOperation: false }]; + } + if (reservation.activeOperation || new Date(reservation.reservedAt) > staleBefore) { + return [reservation]; + } + return []; + }); const reservedPairKeys = new Set(reservations.map(({ pairKey }) => pairKey)); pendingPairKeys.forEach((pairKey) => { if (!reservedPairKeys.has(pairKey)) { - reservations.push({ pairKey, reservedAt: now() }); + reservations.push({ activeOperation: false, pairKey, reservedAt: now() }); } }); @@ -192,7 +197,13 @@ const createRelationshipService = ({ $expr: { $lt: [{ $size: '$reservations' }, MAX_OUTGOING_REQUESTS] }, }, { $inc: { revision: 1 }, - $push: { reservations: { pairKey, reservedAt: now() } }, + $push: { + reservations: { + activeOperation: true, + pairKey, + reservedAt: now(), + }, + }, }, { new: true, useFindAndModify: false, @@ -243,6 +254,10 @@ const createRelationshipService = ({ ))); }; + const reconcilePairReservationsInBackground = (pair, relationship) => { + runInBackground(reconcilePairReservations(pair, relationship)); + }; + const persistTransition = async (current, transition) => { if (transition.kind === 'noop') { return current; @@ -361,11 +376,11 @@ const createRelationshipService = ({ if (await pairIsBlocked(pair.pairKey)) { const removed = await tombstoneRelationship(pair, now()); - await reconcilePairReservations(pair, removed); if (removed) { runInBackground(mirrors.mirrorRelationship(removed, [actorUser, targetUser])); } invalidate([actorId, targetId]); + reconcilePairReservationsInBackground(pair, removed); record(actorId, action, 'unavailable'); throw new SocialError( 409, @@ -374,13 +389,13 @@ const createRelationshipService = ({ ); } - await reconcilePairReservations(pair, persisted); if (transition.kind !== 'noop') { runInBackground(mirrors.mirrorRelationship(persisted, [actorUser, targetUser])); } if (transition.kind !== 'noop') { invalidate([actorId, targetId]); } + reconcilePairReservationsInBackground(pair, persisted); record(actorId, action, transition.outcome); return { @@ -424,13 +439,13 @@ const createRelationshipService = ({ useFindAndModify: false, })); - runInBackground(mirrors.mirrorBlock(blockDocument, actorUser, targetUser)); const removed = await tombstoneRelationship(pair, blockedAt); - await reconcilePairReservations(pair, removed); + runInBackground(mirrors.mirrorBlock(blockDocument, actorUser, targetUser)); if (removed) { runInBackground(mirrors.mirrorRelationship(removed, [actorUser, targetUser])); } invalidate([actorId, targetId]); + reconcilePairReservationsInBackground(pair, removed); record(actorId, 'block', 'blocked'); return { outcome: 'blocked' }; }; @@ -440,7 +455,6 @@ const createRelationshipService = ({ actorId, pair, targetId, targetUser, } = await loadUsers(actorUser, targetValue); const removed = await tombstoneRelationship(pair, now()); - await reconcilePairReservations(pair, removed); if (removed) { runInBackground(mirrors.mirrorRelationship(removed, [actorUser, targetUser])); } @@ -467,6 +481,7 @@ const createRelationshipService = ({ runInBackground(mirrors.mirrorBlock(blockDocument, actorUser, targetUser)); } invalidate([actorId, targetId]); + reconcilePairReservationsInBackground(pair, removed); record(actorId, 'unblock', 'unblocked'); return { outcome: 'unblocked' }; }; diff --git a/server/social/relationshipService.test.js b/server/social/relationshipService.test.js index f89f6ca..ec9f008 100644 --- a/server/social/relationshipService.test.js +++ b/server/social/relationshipService.test.js @@ -4,6 +4,7 @@ const { createRelationshipService, MAX_OUTGOING_REQUESTS } = require('./relationshipService'); const clone = (value) => (value ? JSON.parse(JSON.stringify(value)) : value); +const flushBackground = () => new Promise(setImmediate); const createModels = () => { const users = new Map([ @@ -200,12 +201,14 @@ const createService = () => { }; const notifier = jest.fn().mockResolvedValue(true); const now = jest.fn(() => new Date('2026-07-12T12:00:00.000Z')); + const socialLogger = { error: jest.fn() }; const service = createRelationshipService({ ...models, metricRecorder, mirrors, notifier, now, + socialLogger, }); return { ...models, @@ -213,6 +216,7 @@ const createService = () => { mirrors, notifier, now, + socialLogger, service, }; }; @@ -461,6 +465,148 @@ describe('relationship service', () => { ]); }); + it('does not expire a delayed in-flight reservation and exceed the pending limit', async () => { + const { + now, quotas, relationshipModel, relationships, service, users, + } = createService(); + const baseTime = new Date('2026-07-12T12:00:00.000Z'); + const delayedTime = new Date('2026-07-12T12:06:00.000Z'); + for (let index = 0; index < 99; index += 1) { + const pairKey = `1:${1000 + index}`; + relationships.set(pairKey, { + _id: `existing-${index}`, + highUserId: 1000 + index, + lowUserId: 1, + pairKey, + requestedBy: 1, + revision: 0, + stateChangedAt: baseTime, + status: 'pending', + }); + } + quotas.set(1, { + _id: 'quota-1', + reservations: Array.from({ length: 99 }, (_, index) => ({ + activeOperation: false, + pairKey: `1:${1000 + index}`, + reservedAt: baseTime, + })), + revision: 0, + userId: 1, + }); + + let finishPersistence; + const persistenceStarted = new Promise((resolve) => { + relationshipModel.create.mockImplementationOnce(async (relationship) => { + await new Promise((resolvePersistence) => { + finishPersistence = resolvePersistence; + resolve(); + }); + const timestamp = delayedTime; + const created = { + ...clone(relationship), + _id: 'delayed-relationship', + createdAt: timestamp, + updatedAt: timestamp, + }; + relationships.set(created.pairKey, created); + return clone(created); + }); + }); + + const delayedSend = service.sendRequest(users.get(1), 2); + await persistenceStarted; + // The request's reservation is older than the stale window, but its write has not finished. + // It must remain counted until that operation either persists or explicitly releases it. + now.mockReturnValue(delayedTime); + await expect(service.sendRequest(users.get(1), 3)).rejects.toMatchObject({ + code: 'outgoing_request_limit', + }); + + finishPersistence(); + await delayedSend; + expect([...relationships.values()].filter(({ status }) => status === 'pending')).toHaveLength(100); + }); + + it('publishes durable transitions when background quota cleanup fails', async () => { + const { + mirrors, notifier, quotaModel, relationships, service, socialLogger, users, + } = createService(); + + await service.sendRequest(users.get(1), 2); + quotaModel.findOneAndUpdate.mockRejectedValueOnce(new Error('quota cleanup failed')); + + await expect(service.acceptRequest(users.get(2), 1)).resolves.toMatchObject({ + outcome: 'request_accepted', + }); + await flushBackground(); + + expect(relationships.get('1:2')).toEqual(expect.objectContaining({ status: 'accepted' })); + expect(mirrors.mirrorRelationship).toHaveBeenLastCalledWith( + expect.objectContaining({ pairKey: '1:2', status: 'accepted' }), + expect.any(Array), + ); + expect(notifier).toHaveBeenLastCalledWith({ userIds: [2, 1] }); + expect(socialLogger.error).toHaveBeenCalledWith(expect.any(Error)); + }); + + it('does not turn a durable cancellation into an error when quota cleanup fails', async () => { + const { + mirrors, notifier, quotaModel, relationships, service, socialLogger, users, + } = createService(); + + await service.sendRequest(users.get(1), 2); + quotaModel.findOneAndUpdate.mockRejectedValueOnce(new Error('quota cleanup failed')); + + await expect(service.cancelRequest(users.get(1), 2)).resolves.toMatchObject({ + outcome: 'request_canceled', + }); + await flushBackground(); + + expect(relationships.get('1:2')).toEqual(expect.objectContaining({ status: 'canceled' })); + expect(mirrors.mirrorRelationship).toHaveBeenLastCalledWith( + expect.objectContaining({ pairKey: '1:2', status: 'canceled' }), + expect.any(Array), + ); + expect(notifier).toHaveBeenLastCalledWith({ userIds: [1, 2] }); + expect(socialLogger.error).toHaveBeenCalledWith(expect.any(Error)); + }); + + it('keeps block durable and observable when its background quota cleanup fails', async () => { + const { + blocks, mirrors, notifier, quotaModel, relationships, service, socialLogger, users, + } = createService(); + + await service.sendRequest(users.get(1), 2); + quotaModel.findOneAndUpdate.mockRejectedValueOnce(new Error('quota cleanup failed')); + + await expect(service.block(users.get(1), 2)).resolves.toMatchObject({ outcome: 'blocked' }); + await flushBackground(); + + expect(blocks.get('1:2')).toEqual(expect.objectContaining({ active: true })); + expect(relationships.get('1:2')).toEqual(expect.objectContaining({ status: 'removed' })); + expect(mirrors.mirrorBlock).toHaveBeenCalledWith( + expect.objectContaining({ active: true, pairKey: '1:2' }), + expect.any(Object), + expect.any(Object), + ); + expect(notifier).toHaveBeenLastCalledWith({ userIds: [1, 2] }); + expect(socialLogger.error).toHaveBeenCalledWith(expect.any(Error)); + + quotaModel.findOneAndUpdate.mockRejectedValueOnce(new Error('quota cleanup failed')); + await expect(service.unblock(users.get(1), 2)).resolves.toMatchObject({ outcome: 'unblocked' }); + await flushBackground(); + + expect(blocks.get('1:2')).toEqual(expect.objectContaining({ active: false })); + expect(mirrors.mirrorBlock).toHaveBeenLastCalledWith( + expect.objectContaining({ active: false, pairKey: '1:2' }), + expect.any(Object), + expect.any(Object), + ); + expect(notifier).toHaveBeenLastCalledWith({ userIds: [1, 2] }); + expect(socialLogger.error).toHaveBeenCalledTimes(2); + }); + it('keeps a block active when unblock cannot tombstone a hidden relationship', async () => { const { blocks, mirrors, relationshipModel, relationships, service, users, From 9e572b45df3fcd4f547a453ed2ce4c1b7f2af8ef Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 21:34:22 -0700 Subject: [PATCH 20/56] Make room departures cross-process safe Claim persisted room departures with a MongoDB compare-and-set on the active membership revision. This prevents competing Socket.IO processes from duplicating leave side effects or overwriting a rejoin, while retaining the reconnect grace behavior.\n\nRefs #153 --- docs/room-ownership.md | 6 ++ server/models/room.js | 104 +++++++++++++++++++++---- server/models/room.test.js | 52 +++++++++++++ server/socket/namespaces/rooms.js | 31 ++++++-- server/socket/namespaces/rooms.test.js | 60 ++++++++++++-- 5 files changed, 226 insertions(+), 27 deletions(-) diff --git a/docs/room-ownership.md b/docs/room-ownership.md index 48e5131..987a8ad 100644 --- a/docs/room-ownership.md +++ b/docs/room-ownership.md @@ -34,6 +34,12 @@ itself from the per-user socket group before the remaining-tab check, so two simultaneous explicit leaves cannot strand membership. If the owner later rejoins, they reclaim admin before the join is acknowledged. +The final persisted departure is a MongoDB compare-and-set keyed by the active +membership revision. This applies across Socket.IO processes: exactly one +process can claim a leave, emit its room events, and record its metrics. A +concurrent rejoin advances the revision, so a losing leave retries from current +state and cannot overwrite the new membership. + An empty room persists `admin: null`, but the established `UPDATE_ADMIN` Socket.IO event is emitted only for a non-null active admin. Spectators and other clients therefore never receive a null admin payload. diff --git a/server/models/room.js b/server/models/room.js index bc4bdc3..3c761ff 100644 --- a/server/models/room.js +++ b/server/models/room.js @@ -7,6 +7,22 @@ const { mirrorRoomChanges } = require('../postgres/dualWrite'); const PASSWORD_SALT_ROUNDS = 10; const STALE_ROOM_LIFETIME_MS = 10 * 60 * 1000; +const sameUser = (left, right) => left && right + && String(left.id) === String(right.id); + +const selectRoomAdmin = ({ usersInRoom, owner, admin }) => { + if (usersInRoom.length === 0) { + return null; + } + + const activeOwner = usersInRoom.find((user) => sameUser(user, owner)); + if (activeOwner) { + return activeOwner; + } + + return usersInRoom.find((user) => sameUser(user, admin)) || usersInRoom[0]; +}; + // const lengths = { // }; @@ -87,6 +103,12 @@ const Room = new mongoose.Schema({ of: Boolean, default: {}, }, + // Incremented for every join or departure so a conditional departure cannot + // overwrite a reconnect that raced it on another Socket.IO process. + membershipRevision: { + type: Number, + default: 0, + }, admin: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, owner: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, type: { @@ -187,6 +209,7 @@ Room.methods.addUser = async function (user, spectating, updateAdmin) { } this.inRoom.set(user.id.toString(), true); + this.membershipRevision = (this.membershipRevision || 0) + 1; if (this.waitingForCount === 0) { this.waitingFor.set(user.id.toString(), true); @@ -210,6 +233,7 @@ Room.methods.addUser = async function (user, spectating, updateAdmin) { Room.methods.dropUser = async function (user, updateAdmin) { this.inRoom.set(user.id.toString(), false); this.waitingFor.set(user.id.toString(), false); + this.membershipRevision = (this.membershipRevision || 0) + 1; await this.updateAdminIfNeeded(updateAdmin); @@ -220,6 +244,70 @@ Room.methods.dropUser = async function (user, updateAdmin) { return this.save(); }; +Room.methods.dropUserAtomically = async function (user) { + const userKey = user.id.toString(); + if (!this.inRoom.get(userKey)) { + return null; + } + + const usersInRoom = this.users.filter((candidate) => ( + candidate.id.toString() !== userKey + && this.inRoom.get(candidate.id.toString()) + )); + const nextAdmin = selectRoomAdmin({ + usersInRoom, + owner: this.owner, + admin: this.admin, + }); + const adminChanged = !sameUser(this.admin, nextAdmin); + const membershipRevision = this.membershipRevision || 0; + const condition = { + _id: this._id, + [`inRoom.${userKey}`]: true, + }; + if (this.updatedAt) { + condition.updatedAt = this.updatedAt; + } + if (membershipRevision > 0) { + condition.membershipRevision = membershipRevision; + } else { + condition.$or = [ + { membershipRevision: 0 }, + { membershipRevision: { $exists: false } }, + ]; + } + + const update = { + $set: { + [`inRoom.${userKey}`]: false, + [`waitingFor.${userKey}`]: false, + admin: nextAdmin ? nextAdmin._id || nextAdmin : null, + }, + $inc: { + membershipRevision: 1, + }, + }; + if (usersInRoom.length === 0 && this.type === 'normal') { + update.$set.expireAt = new Date(Date.now() + STALE_ROOM_LIFETIME_MS); + } + + const updatedRoom = await this.constructor.findOneAndUpdate(condition, update, { + new: true, + }).populate('users').populate('admin').populate('owner'); + if (!updatedRoom) { + return null; + } + + await mirrorRoomChanges(updatedRoom, { + attempts: [], + participantUserIds: [userKey], + syncAllParticipants: false, + syncRoomOwners: adminChanged, + }); + + return { room: updatedRoom, adminChanged }; +}; + Room.methods.banUser = async function (userId) { this.banned.set(userId.toString(), true); return this.dropUser({ id: userId }); @@ -314,22 +402,6 @@ Room.methods.edit = async function (options) { return this.save(); }; -const sameUser = (left, right) => left && right - && String(left.id) === String(right.id); - -const selectRoomAdmin = ({ usersInRoom, owner, admin }) => { - if (usersInRoom.length === 0) { - return null; - } - - const activeOwner = usersInRoom.find((user) => sameUser(user, owner)); - if (activeOwner) { - return activeOwner; - } - - return usersInRoom.find((user) => sameUser(user, admin)) || usersInRoom[0]; -}; - Room.methods.updateAdminIfNeeded = async function (cb) { const nextAdmin = selectRoomAdmin(this); if (sameUser(this.admin, nextAdmin) || (!this.admin && !nextAdmin)) { diff --git a/server/models/room.test.js b/server/models/room.test.js index 8c1dc35..44a6e71 100644 --- a/server/models/room.test.js +++ b/server/models/room.test.js @@ -4,6 +4,7 @@ const bcrypt = require('bcrypt'); const mongoose = require('mongoose'); const { generateScramble } = require('letscube-scrambles'); +const { mirrorRoomChanges } = require('../postgres/dualWrite'); const { collectPostgresChanges, Room, @@ -13,6 +14,9 @@ const { jest.mock('letscube-scrambles', () => ({ generateScramble: jest.fn(), })); +jest.mock('../postgres/dualWrite', () => ({ + mirrorRoomChanges: jest.fn().mockResolvedValue(undefined), +})); const RoomModel = mongoose.model('RoomPostgresChangesTest', Room); @@ -260,6 +264,54 @@ describe('room security helpers', () => { expect(onAdminChange).not.toHaveBeenCalled(); }); + it('claims a departure with the membership revision compare-and-set', async () => { + const owner = { id: 101, _id: 'owner-id' }; + const nextAdmin = { id: 202, _id: 'next-admin-id' }; + const updatedRoom = { id: 'room-one' }; + const query = { + populate: jest.fn(() => query), + then: (resolve, reject) => Promise.resolve(updatedRoom).then(resolve, reject), + }; + const model = { + findOneAndUpdate: jest.fn(() => query), + }; + const updatedAt = new Date('2026-07-13T04:30:00.000Z'); + const room = { + _id: 'room-one', + owner, + admin: owner, + users: [owner, nextAdmin], + inRoom: new Map([['101', true], ['202', true]]), + type: 'normal', + membershipRevision: 7, + updatedAt, + constructor: model, + }; + + const departure = await Room.methods.dropUserAtomically.call(room, owner); + + expect(model.findOneAndUpdate).toHaveBeenCalledWith({ + _id: room._id, + 'inRoom.101': true, + membershipRevision: 7, + updatedAt, + }, { + $set: { + 'inRoom.101': false, + 'waitingFor.101': false, + admin: nextAdmin._id, + }, + $inc: { membershipRevision: 1 }, + }, { new: true }); + expect(departure).toEqual({ room: updatedRoom, adminChanged: true }); + expect(mirrorRoomChanges).toHaveBeenCalledWith(updatedRoom, { + attempts: [], + participantUserIds: ['101'], + syncAllParticipants: false, + syncRoomOwners: true, + }); + }); + it('persists and announces the owner reclaiming admin controls', async () => { const owner = { id: 101 }; const room = { diff --git a/server/socket/namespaces/rooms.js b/server/socket/namespaces/rooms.js index ed6e14e..1249c9e 100644 --- a/server/socket/namespaces/rooms.js +++ b/server/socket/namespaces/rooms.js @@ -127,19 +127,38 @@ module.exports = (io, middlewares) => { async function finalizeUserDeparture({ roomId, userId, connectionId, leaveReason, }) { - const room = await fetchRoom(roomId); const userKey = userId.toString(); - if (!room || !room.inRoom.get(userKey)) { - return false; + // The compare-and-set in dropUserAtomically is the cross-process claim. + // A losing process re-reads so it never overwrites a concurrent rejoin. + async function claimDeparture() { + const room = await fetchRoom(roomId); + if (!room || !room.inRoom.get(userKey)) { + return null; + } + + if (leaveReason === 'disconnect' + && await hasActiveSocketsForUserRoom(userId, roomId)) { + return null; + } + + const departure = await room.dropUserAtomically({ id: userId }); + if (!departure) { + return claimDeparture(); + } + + return departure; } - if (leaveReason === 'disconnect' - && await hasActiveSocketsForUserRoom(userId, roomId)) { + const departure = await claimDeparture(); + if (!departure) { return false; } - const updatedRoom = await room.dropUser({ id: userId }, sendAdminUpdate); + const { room: updatedRoom, adminChanged } = departure; + if (adminChanged) { + sendAdminUpdate(updatedRoom); + } await metrics.endRoomVisit({ room: updatedRoom, diff --git a/server/socket/namespaces/rooms.test.js b/server/socket/namespaces/rooms.test.js index b6fbf2c..04fa4af 100644 --- a/server/socket/namespaces/rooms.test.js +++ b/server/socket/namespaces/rooms.test.js @@ -363,11 +363,12 @@ describe('room namespace departures and moderation', () => { inRoom: new Map([['101', true]]), doneWithScramble: jest.fn().mockReturnValue(false), }); - room.dropUser = jest.fn(async (user, onAdminChange) => { - room.inRoom.set(String(user.id), false); - room.admin = null; - onAdminChange(room); - return room; + room.dropUserAtomically = jest.fn().mockResolvedValue({ + room: { + ...room, + admin: null, + }, + adminChanged: true, }); const connect = setup(new Map([[room._id, room]])); await connect(makeSocket({ id: 'spectator-socket', session: {} })); @@ -384,6 +385,55 @@ describe('room namespace departures and moderation', () => { expect(connect.roomChannel.emit).toHaveBeenCalledWith(Protocol.USER_LEFT, 101); }); + it('emits metrics and leave events only for the process that claims a departure', async () => { + const room = makeRoom({ + private: false, + password: null, + inRoom: new Map([['101', true]]), + }); + const updatedRoom = { + ...room, + inRoom: new Map([['101', false]]), + usersLength: 0, + doneWithScramble: jest.fn().mockReturnValue(false), + }; + let claimed = false; + room.dropUserAtomically = jest.fn(async () => { + if (claimed) { + return null; + } + claimed = true; + return { room: updatedRoom, adminChanged: false }; + }); + const connect = setup(new Map([[room._id, room]])); + const persistedRoom = { + ...room, + inRoom: new Map([['101', false]]), + }; + Room.findById.mockImplementation(() => queryResult(claimed ? persistedRoom : room)); + const reconnectGrace = createReconnectGrace.mock.results[0].value; + + await Promise.all([ + reconnectGrace.finalizeDeparture({ + roomId: room._id, + userId: 101, + connectionId: 'socket-on-process-a', + leaveReason: 'explicit', + }), + reconnectGrace.finalizeDeparture({ + roomId: room._id, + userId: 101, + connectionId: 'socket-on-process-b', + leaveReason: 'explicit', + }), + ]); + + expect(room.dropUserAtomically).toHaveBeenCalledTimes(2); + expect(metrics.endRoomVisit).toHaveBeenCalledTimes(1); + expect(connect.roomChannel.emit).toHaveBeenCalledTimes(1); + expect(connect.roomChannel.emit).toHaveBeenCalledWith(Protocol.USER_LEFT, 101); + }); + it('finalizes one departure when two tabs explicitly leave together', async () => { const room = makeRoom({ private: false, From 7f9998703ad05e7898fa8e020b9a3a731683850b Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 21:39:49 -0700 Subject: [PATCH 21/56] Make stale departure claims terminal Carry the membership revision captured when a socket leaves through reconnect cleanup and reject a departure when that generation no longer matches. This prevents a losing old finalizer from removing a later rejoin.\n\nRefs #153 --- docs/room-ownership.md | 4 +- server/models/room.js | 12 ++--- server/models/room.test.js | 3 -- server/socket/namespaces/rooms.js | 35 ++++++-------- server/socket/namespaces/rooms.test.js | 63 +++++++++++++++----------- 5 files changed, 59 insertions(+), 58 deletions(-) diff --git a/docs/room-ownership.md b/docs/room-ownership.md index 987a8ad..dd4a3f6 100644 --- a/docs/room-ownership.md +++ b/docs/room-ownership.md @@ -37,8 +37,8 @@ rejoins, they reclaim admin before the join is acknowledged. The final persisted departure is a MongoDB compare-and-set keyed by the active membership revision. This applies across Socket.IO processes: exactly one process can claim a leave, emit its room events, and record its metrics. A -concurrent rejoin advances the revision, so a losing leave retries from current -state and cannot overwrite the new membership. +concurrent rejoin advances the revision, so a losing leave is terminal and +cannot overwrite the new membership. An empty room persists `admin: null`, but the established `UPDATE_ADMIN` Socket.IO event is emitted only for a non-null active admin. Spectators and diff --git a/server/models/room.js b/server/models/room.js index 3c761ff..6b0c22a 100644 --- a/server/models/room.js +++ b/server/models/room.js @@ -244,9 +244,13 @@ Room.methods.dropUser = async function (user, updateAdmin) { return this.save(); }; -Room.methods.dropUserAtomically = async function (user) { +Room.methods.dropUserAtomically = async function ( + user, + expectedMembershipRevision = this.membershipRevision || 0, +) { const userKey = user.id.toString(); - if (!this.inRoom.get(userKey)) { + const membershipRevision = this.membershipRevision || 0; + if (!this.inRoom.get(userKey) || expectedMembershipRevision !== membershipRevision) { return null; } @@ -260,14 +264,10 @@ Room.methods.dropUserAtomically = async function (user) { admin: this.admin, }); const adminChanged = !sameUser(this.admin, nextAdmin); - const membershipRevision = this.membershipRevision || 0; const condition = { _id: this._id, [`inRoom.${userKey}`]: true, }; - if (this.updatedAt) { - condition.updatedAt = this.updatedAt; - } if (membershipRevision > 0) { condition.membershipRevision = membershipRevision; } else { diff --git a/server/models/room.test.js b/server/models/room.test.js index 44a6e71..35d0bc8 100644 --- a/server/models/room.test.js +++ b/server/models/room.test.js @@ -275,7 +275,6 @@ describe('room security helpers', () => { const model = { findOneAndUpdate: jest.fn(() => query), }; - const updatedAt = new Date('2026-07-13T04:30:00.000Z'); const room = { _id: 'room-one', owner, @@ -284,7 +283,6 @@ describe('room security helpers', () => { inRoom: new Map([['101', true], ['202', true]]), type: 'normal', membershipRevision: 7, - updatedAt, constructor: model, }; @@ -294,7 +292,6 @@ describe('room security helpers', () => { _id: room._id, 'inRoom.101': true, membershipRevision: 7, - updatedAt, }, { $set: { 'inRoom.101': false, diff --git a/server/socket/namespaces/rooms.js b/server/socket/namespaces/rooms.js index 1249c9e..036eac6 100644 --- a/server/socket/namespaces/rooms.js +++ b/server/socket/namespaces/rooms.js @@ -87,6 +87,7 @@ module.exports = (io, middlewares) => { return rooms.flatMap((room) => room.usersInRoom.map((user) => ({ roomId: room._id, userId: user.id, + membershipRevision: room.membershipRevision, leaveReason: 'disconnect', }))); } @@ -125,32 +126,24 @@ module.exports = (io, middlewares) => { } async function finalizeUserDeparture({ - roomId, userId, connectionId, leaveReason, + roomId, userId, connectionId, leaveReason, membershipRevision, }) { const userKey = userId.toString(); - // The compare-and-set in dropUserAtomically is the cross-process claim. - // A losing process re-reads so it never overwrites a concurrent rejoin. - async function claimDeparture() { - const room = await fetchRoom(roomId); - if (!room || !room.inRoom.get(userKey)) { - return null; - } - - if (leaveReason === 'disconnect' - && await hasActiveSocketsForUserRoom(userId, roomId)) { - return null; - } - - const departure = await room.dropUserAtomically({ id: userId }); - if (!departure) { - return claimDeparture(); - } + const room = await fetchRoom(roomId); + if (!room || !room.inRoom.get(userKey) + || room.membershipRevision !== membershipRevision) { + return false; + } - return departure; + if (leaveReason === 'disconnect' + && await hasActiveSocketsForUserRoom(userId, roomId)) { + return false; } - const departure = await claimDeparture(); + // The membership revision is the departure generation. A failed claim is + // terminal: retrying it after a rejoin would remove the new membership. + const departure = await room.dropUserAtomically({ id: userId }, membershipRevision); if (!departure) { return false; } @@ -378,6 +371,7 @@ module.exports = (io, middlewares) => { roomId: socket.roomId, userId: socket.userId, connectionId: socket.id, + membershipRevision: socket.room.membershipRevision, leaveReason, }); } catch (e) { @@ -1077,6 +1071,7 @@ module.exports = (io, middlewares) => { roomId: socket.roomId, userId: socket.userId, connectionId: socket.id, + membershipRevision: socket.room.membershipRevision, leaveReason: 'disconnect', }); } else if (socket.room) { diff --git a/server/socket/namespaces/rooms.test.js b/server/socket/namespaces/rooms.test.js index 04fa4af..16148f0 100644 --- a/server/socket/namespaces/rooms.test.js +++ b/server/socket/namespaces/rooms.test.js @@ -71,6 +71,7 @@ const makeRoom = (overrides = {}) => ({ inRoom: new Map(), banned: new Map(), registered: new Map(), + membershipRevision: 7, requireRevealedIdentity: false, usersLength: 1, authenticate: jest.fn().mockResolvedValue(true), @@ -378,6 +379,7 @@ describe('room namespace departures and moderation', () => { roomId: room._id, userId: 101, connectionId: 'host-socket', + membershipRevision: room.membershipRevision, leaveReason: 'explicit', }); @@ -385,7 +387,7 @@ describe('room namespace departures and moderation', () => { expect(connect.roomChannel.emit).toHaveBeenCalledWith(Protocol.USER_LEFT, 101); }); - it('emits metrics and leave events only for the process that claims a departure', async () => { + it('does not let a losing departure remove a later rejoin', async () => { const room = makeRoom({ private: false, password: null, @@ -394,41 +396,47 @@ describe('room namespace departures and moderation', () => { const updatedRoom = { ...room, inRoom: new Map([['101', false]]), + membershipRevision: 8, usersLength: 0, doneWithScramble: jest.fn().mockReturnValue(false), }; - let claimed = false; - room.dropUserAtomically = jest.fn(async () => { - if (claimed) { - return null; - } - claimed = true; - return { room: updatedRoom, adminChanged: false }; + room.dropUserAtomically = jest.fn().mockResolvedValue({ + room: updatedRoom, + adminChanged: false, }); const connect = setup(new Map([[room._id, room]])); - const persistedRoom = { + let persistedRoom = room; + Room.findById.mockImplementation(() => queryResult(persistedRoom)); + const reconnectGrace = createReconnectGrace.mock.results[0].value; + + await expect(reconnectGrace.finalizeDeparture({ + roomId: room._id, + userId: 101, + connectionId: 'winner-socket', + membershipRevision: 7, + leaveReason: 'explicit', + })).resolves.toBe(true); + + const rejoinedRoom = { ...room, - inRoom: new Map([['101', false]]), + admin: { id: 101 }, + inRoom: new Map([['101', true]]), + membershipRevision: 9, }; - Room.findById.mockImplementation(() => queryResult(claimed ? persistedRoom : room)); - const reconnectGrace = createReconnectGrace.mock.results[0].value; + persistedRoom = rejoinedRoom; - await Promise.all([ - reconnectGrace.finalizeDeparture({ - roomId: room._id, - userId: 101, - connectionId: 'socket-on-process-a', - leaveReason: 'explicit', - }), - reconnectGrace.finalizeDeparture({ - roomId: room._id, - userId: 101, - connectionId: 'socket-on-process-b', - leaveReason: 'explicit', - }), - ]); + await expect(reconnectGrace.finalizeDeparture({ + roomId: room._id, + userId: 101, + connectionId: 'loser-socket', + membershipRevision: 7, + leaveReason: 'explicit', + })).resolves.toBe(false); - expect(room.dropUserAtomically).toHaveBeenCalledTimes(2); + expect(room.dropUserAtomically).toHaveBeenCalledTimes(1); + expect(rejoinedRoom.inRoom.get('101')).toBe(true); + expect(rejoinedRoom.admin).toEqual({ id: 101 }); + expect(rejoinedRoom.membershipRevision).toBe(updatedRoom.membershipRevision + 1); expect(metrics.endRoomVisit).toHaveBeenCalledTimes(1); expect(connect.roomChannel.emit).toHaveBeenCalledTimes(1); expect(connect.roomChannel.emit).toHaveBeenCalledWith(Protocol.USER_LEFT, 101); @@ -468,6 +476,7 @@ describe('room namespace departures and moderation', () => { expect(reconnectGrace.finalize).toHaveBeenCalledWith(expect.objectContaining({ roomId: room._id, userId: 101, + membershipRevision: room.membershipRevision, leaveReason: 'explicit', })); }); From 98d082bfd52a8b9f6133556348a63e775587e8e3 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 21:43:30 -0700 Subject: [PATCH 22/56] Replace friendship quota with Redis limiter Use an atomic, expiring Redis limiter for per-user and per-pair friend request creation so crashes cannot leak durable quota reservations or suppress relationship side effects. --- docs/friendships.md | 31 ++- server/models/index.js | 6 - server/models/outgoingRequestQuota.js | 43 ---- server/social/relationshipService.js | 168 +------------ server/social/relationshipService.test.js | 273 ++-------------------- server/social/requestRateLimiter.js | 110 +++++++++ server/social/requestRateLimiter.test.js | 113 +++++++++ 7 files changed, 263 insertions(+), 481 deletions(-) delete mode 100644 server/models/outgoingRequestQuota.js create mode 100644 server/social/requestRateLimiter.js create mode 100644 server/social/requestRateLimiter.test.js diff --git a/docs/friendships.md b/docs/friendships.md index eed9dc5..089f2e4 100644 --- a/docs/friendships.md +++ b/docs/friendships.md @@ -69,24 +69,19 @@ so failures remain fail-closed. Unblocking repeats relationship cleanup before deactivating the block. If cleanup fails, the active block remains and the hidden relationship cannot reappear. -Each user may have at most 100 pending outgoing requests. A MongoDB quota -document atomically reserves pair slots before relationship creation, so -concurrent sends to different users cannot pass the cap. A reservation remains -an active operation until that write either persists or explicitly fails, even -when the write is delayed beyond five minutes; reconciliation never expires an -active operation and risks admitting a 101st request. Once the relationship is -durable, reconciliation converts its reservation to a normal pending slot. -Failed writes release their slots. Reconciliation rebuilds missing reservations -from pending relationships and removes unclaimed abandoned reservations after -five minutes. A declined pair may not be requested again for 24 hours, and a canceled pair may -not be requested again for 60 seconds. `request_cooldown` includes -`retryAfterSeconds`. - -Quota cleanup is maintenance, not part of a committed relationship transition. -After MongoDB durably changes a relationship or block, the API mirrors and -invalidates immediately; cleanup then runs in the background and logs failures -for operational follow-up. A cleanup failure therefore cannot turn a successful -social action into an error or suppress its durable update. +New request creation is atomically rate-limited in Redis before MongoDB writes: +each actor may create 30 requests per 10-minute window, and each normalized +pair may create 3 requests per 24-hour window. One Lua operation checks both +limits before it increments either counter and gives both counters a Redis TTL. +This works across API processes, needs no reconciliation or crash cleanup, and +fails closed with `503 request_rate_limit_unavailable` if Redis cannot execute +the operation. A rate-limited request is `429 request_rate_limited` with +`retryAfterSeconds`. The limiter intentionally bounds creation attempts rather +than retaining an unbounded count of pending relationships. + +The existing per-pair resend cooldown remains: a declined pair may not be +requested again for 24 hours, and a canceled pair may not be requested again +for 60 seconds. `request_cooldown` includes `retryAfterSeconds`. ## REST API diff --git a/server/models/index.js b/server/models/index.js index dce8a31..1b7fa10 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -2,7 +2,6 @@ const mongoose = require('mongoose'); const { FriendRelationship, RELATIONSHIP_STATUSES } = require('./friendRelationship'); const { Attempt, Room } = require('./room'); const { METRIC_EVENTS, MetricEvent } = require('./metricEvent'); -const OutgoingRequestQuota = require('./outgoingRequestQuota'); const User = require('./user'); const UserBlock = require('./userBlock'); @@ -15,11 +14,6 @@ module.exports = { 'friend_relationships', ), UserBlock: mongoose.model('UserBlock', UserBlock, 'user_blocks'), - OutgoingRequestQuota: mongoose.model( - 'OutgoingRequestQuota', - OutgoingRequestQuota, - 'outgoing_request_quotas', - ), Attempt: mongoose.model('Attempt', Attempt, 'attempts'), MetricEvent: mongoose.model('MetricEvent', MetricEvent, 'metric_events'), METRIC_EVENTS, diff --git a/server/models/outgoingRequestQuota.js b/server/models/outgoingRequestQuota.js deleted file mode 100644 index 8371d64..0000000 --- a/server/models/outgoingRequestQuota.js +++ /dev/null @@ -1,43 +0,0 @@ -const mongoose = require('mongoose'); - -const Reservation = new mongoose.Schema({ - activeOperation: { - type: Boolean, - default: false, - required: true, - }, - pairKey: { - type: String, - required: true, - }, - reservedAt: { - type: Date, - required: true, - }, -}, { - _id: false, -}); - -const OutgoingRequestQuota = new mongoose.Schema({ - userId: { - type: Number, - required: true, - }, - reservations: { - type: [Reservation], - default: [], - }, - revision: { - type: Number, - default: 0, - min: 0, - required: true, - }, -}, { - timestamps: true, - versionKey: false, -}); - -OutgoingRequestQuota.index({ userId: 1 }, { unique: true }); - -module.exports = OutgoingRequestQuota; diff --git a/server/social/relationshipService.js b/server/social/relationshipService.js index dde606d..4b78688 100644 --- a/server/social/relationshipService.js +++ b/server/social/relationshipService.js @@ -3,7 +3,6 @@ const logger = require('../logger'); const metrics = require('../metrics'); const { FriendRelationship, - OutgoingRequestQuota, RELATIONSHIP_STATUSES, User, UserBlock, @@ -19,10 +18,9 @@ const { SocialError, transitionRelationship, } = require('./relationshipState'); +const { requestRateLimiter } = require('./requestRateLimiter'); -const MAX_OUTGOING_REQUESTS = 100; const MAX_CAS_ATTEMPTS = 6; -const RESERVATION_STALE_AFTER_MS = 5 * 60 * 1000; const toPlain = (document) => { if (!document) { @@ -64,7 +62,7 @@ const normalizedPair = (firstUserId, secondUserId) => { const createRelationshipService = ({ blockModel = UserBlock, metricRecorder = metrics, - quotaModel = OutgoingRequestQuota, + requestLimiter = requestRateLimiter, relationshipModel = FriendRelationship, socialLogger = logger, userModel = User, @@ -115,149 +113,6 @@ const createRelationshipService = ({ pairKey, }))); - const ensureQuota = async (userId) => { - try { - return toPlain(await quotaModel.findOneAndUpdate({ userId }, { - $setOnInsert: { - reservations: [], - revision: 0, - userId, - }, - }, { - new: true, - setDefaultsOnInsert: true, - upsert: true, - useFindAndModify: false, - })); - } catch (err) { - if (err.code !== 11000) { - throw err; - } - return lean(quotaModel.findOne({ userId })); - } - }; - - const reconcileQuota = async (userId) => { - for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt += 1) { - const quota = await ensureQuota(userId); - const pendingRelationships = await lean(relationshipModel.find({ - requestedBy: userId, - status: RELATIONSHIP_STATUSES.PENDING, - })); - const pendingPairKeys = new Set(pendingRelationships.map(({ pairKey }) => pairKey)); - const staleBefore = new Date(now().getTime() - RESERVATION_STALE_AFTER_MS); - const reservations = (quota.reservations || []).flatMap((reservation) => { - if (pendingPairKeys.has(reservation.pairKey)) { - return [{ ...reservation, activeOperation: false }]; - } - if (reservation.activeOperation || new Date(reservation.reservedAt) > staleBefore) { - return [reservation]; - } - return []; - }); - const reservedPairKeys = new Set(reservations.map(({ pairKey }) => pairKey)); - pendingPairKeys.forEach((pairKey) => { - if (!reservedPairKeys.has(pairKey)) { - reservations.push({ activeOperation: false, pairKey, reservedAt: now() }); - } - }); - - const reconciled = await quotaModel.findOneAndUpdate({ - _id: quota._id, - revision: quota.revision, - }, { - $inc: { revision: 1 }, - $set: { reservations }, - }, { - new: true, - useFindAndModify: false, - }); - if (reconciled) { - return toPlain(reconciled); - } - } - throw new SocialError( - 409, - 'request_quota_conflict', - 'The outgoing friend request quota changed concurrently; retry', - ); - }; - - const reserveOutgoingRequest = async (userId, pairKey) => { - await reconcileQuota(userId); - for (let attempt = 0; attempt < MAX_CAS_ATTEMPTS; attempt += 1) { - const quota = await lean(quotaModel.findOne({ userId })); - if ((quota.reservations || []).some((reservation) => reservation.pairKey === pairKey)) { - return quota; - } - const reserved = await quotaModel.findOneAndUpdate({ - _id: quota._id, - revision: quota.revision, - 'reservations.pairKey': { $ne: pairKey }, - $expr: { $lt: [{ $size: '$reservations' }, MAX_OUTGOING_REQUESTS] }, - }, { - $inc: { revision: 1 }, - $push: { - reservations: { - activeOperation: true, - pairKey, - reservedAt: now(), - }, - }, - }, { - new: true, - useFindAndModify: false, - }); - if (reserved) { - return toPlain(reserved); - } - - const latest = await lean(quotaModel.findOne({ userId })); - if ((latest.reservations || []).some((reservation) => reservation.pairKey === pairKey)) { - return latest; - } - if ((latest.reservations || []).length >= MAX_OUTGOING_REQUESTS) { - throw new SocialError( - 409, - 'outgoing_request_limit', - 'The outgoing friend request limit has been reached', - ); - } - } - throw new SocialError( - 409, - 'request_quota_conflict', - 'The outgoing friend request quota changed concurrently; retry', - ); - }; - - const releaseReservation = (userId, pairKey) => quotaModel.findOneAndUpdate({ - userId, - 'reservations.pairKey': pairKey, - }, { - $inc: { revision: 1 }, - $pull: { reservations: { pairKey } }, - }, { - new: true, - useFindAndModify: false, - }); - - const reconcilePairReservations = async (pair, relationship) => { - const pendingRequester = relationship - && relationship.status === RELATIONSHIP_STATUSES.PENDING - ? relationship.requestedBy : null; - const userIds = [pair.lowUserId, pair.highUserId]; - await Promise.all(userIds.map((userId) => ( - userId === pendingRequester - ? Promise.resolve() - : releaseReservation(userId, pair.pairKey) - ))); - }; - - const reconcilePairReservationsInBackground = (pair, relationship) => { - runInBackground(reconcilePairReservations(pair, relationship)); - }; - const persistTransition = async (current, transition) => { if (transition.kind === 'noop') { return current; @@ -355,21 +210,14 @@ const createRelationshipService = ({ }); if (action === ACTIONS.SEND && transition.outcome === 'request_created') { - await reserveOutgoingRequest(actorId, pair.pairKey); + await requestLimiter.consume({ actorId, pairKey: pair.pairKey }); } } catch (err) { record(actorId, action, err.code || 'error'); throw err; } - let persisted; - try { - persisted = await persistTransition(current, transition); - } catch (err) { - const latest = (await lean(relationshipModel.findOne({ pairKey: pair.pairKey }))) || null; - await reconcilePairReservations(pair, latest); - throw err; - } + const persisted = await persistTransition(current, transition); if (persisted === undefined) { continue; } @@ -380,7 +228,6 @@ const createRelationshipService = ({ runInBackground(mirrors.mirrorRelationship(removed, [actorUser, targetUser])); } invalidate([actorId, targetId]); - reconcilePairReservationsInBackground(pair, removed); record(actorId, action, 'unavailable'); throw new SocialError( 409, @@ -395,7 +242,6 @@ const createRelationshipService = ({ if (transition.kind !== 'noop') { invalidate([actorId, targetId]); } - reconcilePairReservationsInBackground(pair, persisted); record(actorId, action, transition.outcome); return { @@ -403,8 +249,6 @@ const createRelationshipService = ({ }; } - const latest = (await lean(relationshipModel.findOne({ pairKey: pair.pairKey }))) || null; - await reconcilePairReservations(pair, latest); record(actorId, action, 'conflict'); throw new SocialError( 409, @@ -445,7 +289,6 @@ const createRelationshipService = ({ runInBackground(mirrors.mirrorRelationship(removed, [actorUser, targetUser])); } invalidate([actorId, targetId]); - reconcilePairReservationsInBackground(pair, removed); record(actorId, 'block', 'blocked'); return { outcome: 'blocked' }; }; @@ -481,7 +324,6 @@ const createRelationshipService = ({ runInBackground(mirrors.mirrorBlock(blockDocument, actorUser, targetUser)); } invalidate([actorId, targetId]); - reconcilePairReservationsInBackground(pair, removed); record(actorId, 'unblock', 'unblocked'); return { outcome: 'unblocked' }; }; @@ -566,8 +408,6 @@ const createRelationshipService = ({ }; module.exports = { - MAX_OUTGOING_REQUESTS, - RESERVATION_STALE_AFTER_MS, createRelationshipService, normalizeUserId, normalizedPair, diff --git a/server/social/relationshipService.test.js b/server/social/relationshipService.test.js index ec9f008..79def39 100644 --- a/server/social/relationshipService.test.js +++ b/server/social/relationshipService.test.js @@ -1,10 +1,9 @@ /** @jest-environment node */ /* eslint-env jest */ -const { createRelationshipService, MAX_OUTGOING_REQUESTS } = require('./relationshipService'); +const { createRelationshipService } = require('./relationshipService'); const clone = (value) => (value ? JSON.parse(JSON.stringify(value)) : value); -const flushBackground = () => new Promise(setImmediate); const createModels = () => { const users = new Map([ @@ -20,7 +19,6 @@ const createModels = () => { ]); const relationships = new Map(); const blocks = new Map(); - const quotas = new Map(); let sequence = 0; const relationshipModel = { @@ -122,59 +120,6 @@ const createModels = () => { }), }; - const quotaModel = { - findOne: jest.fn(async ({ userId }) => clone(quotas.get(userId))), - findOneAndUpdate: jest.fn(async (filter, update, options = {}) => { - let quota = filter.userId !== undefined - ? quotas.get(filter.userId) - : [...quotas.values()].find((entry) => entry._id === filter._id); - if (!quota && options.upsert) { - quota = { - ...update.$setOnInsert, - _id: `quota-${filter.userId}`, - }; - quotas.set(filter.userId, quota); - return clone(quota); - } - if (!quota) { - return null; - } - if (filter.revision !== undefined && quota.revision !== filter.revision) { - return null; - } - const pairFilter = filter['reservations.pairKey']; - if (pairFilter && pairFilter.$ne && quota.reservations - .some(({ pairKey }) => pairKey === pairFilter.$ne)) { - return null; - } - if (typeof pairFilter === 'string' && !quota.reservations - .some(({ pairKey }) => pairKey === pairFilter)) { - return null; - } - if (filter.$expr && quota.reservations.length >= MAX_OUTGOING_REQUESTS) { - return null; - } - - const next = clone(quota); - if (update.$set) { - Object.assign(next, clone(update.$set)); - } - if (update.$push) { - next.reservations.push(clone(update.$push.reservations)); - } - if (update.$pull) { - next.reservations = next.reservations.filter( - ({ pairKey }) => pairKey !== update.$pull.reservations.pairKey, - ); - } - if (update.$inc) { - next.revision += update.$inc.revision; - } - quotas.set(next.userId, next); - return clone(next); - }), - }; - const userModel = { find: jest.fn(async ({ id }) => id.$in.map((userId) => users.get(userId)).filter(Boolean)), findOne: jest.fn(async ({ id }) => clone(users.get(id))), @@ -183,8 +128,6 @@ const createModels = () => { return { blockModel, blocks, - quotaModel, - quotas, relationshipModel, relationships, userModel, @@ -201,6 +144,7 @@ const createService = () => { }; const notifier = jest.fn().mockResolvedValue(true); const now = jest.fn(() => new Date('2026-07-12T12:00:00.000Z')); + const requestLimiter = { consume: jest.fn().mockResolvedValue(undefined) }; const socialLogger = { error: jest.fn() }; const service = createRelationshipService({ ...models, @@ -208,6 +152,7 @@ const createService = () => { mirrors, notifier, now, + requestLimiter, socialLogger, }); return { @@ -216,6 +161,7 @@ const createService = () => { mirrors, notifier, now, + requestLimiter, socialLogger, service, }; @@ -403,208 +349,35 @@ describe('relationship service', () => { expect(notifier).toHaveBeenCalledWith({ userIds: [1, 2] }); }); - it('atomically enforces the pending limit across different target pairs', async () => { - const { - quotas, service, users, - } = createService(); - quotas.set(1, { - _id: 'quota-1', - reservations: Array.from({ length: 99 }, (_, index) => ({ - pairKey: `1:${1000 + index}`, - reservedAt: new Date('2026-07-12T12:00:00.000Z'), - })), - revision: 0, - userId: 1, - }); - - const results = await Promise.allSettled([ - service.sendRequest(users.get(1), 2), - service.sendRequest(users.get(1), 3), - ]); - - expect(results.filter(({ status }) => status === 'fulfilled')).toHaveLength(1); - expect(results.filter(({ status }) => status === 'rejected')).toEqual([ - expect.objectContaining({ - reason: expect.objectContaining({ code: 'outgoing_request_limit' }), - }), - ]); - expect(quotas.get(1).reservations).toHaveLength(MAX_OUTGOING_REQUESTS); - }); - - it('releases a request reservation when relationship persistence fails', async () => { - const { - quotas, relationshipModel, service, users, - } = createService(); - relationshipModel.create.mockRejectedValueOnce(new Error('mongo write failed')); - - await expect(service.sendRequest(users.get(1), 2)).rejects.toThrow('mongo write failed'); - - expect(quotas.get(1).reservations).toEqual([]); - }); - - it('reconciles abandoned quota reservations before enforcing the cap', async () => { - const { - quotas, service, users, - } = createService(); - quotas.set(1, { - _id: 'quota-1', - reservations: Array.from({ length: MAX_OUTGOING_REQUESTS }, (_, index) => ({ - pairKey: `1:${1000 + index}`, - reservedAt: new Date('2026-07-12T11:54:00.000Z'), - })), - revision: 0, - userId: 1, - }); - - await expect(service.sendRequest(users.get(1), 2)).resolves.toMatchObject({ - outcome: 'request_created', - }); - - expect(quotas.get(1).reservations).toEqual([ - expect.objectContaining({ pairKey: '1:2' }), - ]); - }); - - it('does not expire a delayed in-flight reservation and exceed the pending limit', async () => { - const { - now, quotas, relationshipModel, relationships, service, users, - } = createService(); - const baseTime = new Date('2026-07-12T12:00:00.000Z'); - const delayedTime = new Date('2026-07-12T12:06:00.000Z'); - for (let index = 0; index < 99; index += 1) { - const pairKey = `1:${1000 + index}`; - relationships.set(pairKey, { - _id: `existing-${index}`, - highUserId: 1000 + index, - lowUserId: 1, - pairKey, - requestedBy: 1, - revision: 0, - stateChangedAt: baseTime, - status: 'pending', - }); - } - quotas.set(1, { - _id: 'quota-1', - reservations: Array.from({ length: 99 }, (_, index) => ({ - activeOperation: false, - pairKey: `1:${1000 + index}`, - reservedAt: baseTime, - })), - revision: 0, - userId: 1, - }); - - let finishPersistence; - const persistenceStarted = new Promise((resolve) => { - relationshipModel.create.mockImplementationOnce(async (relationship) => { - await new Promise((resolvePersistence) => { - finishPersistence = resolvePersistence; - resolve(); - }); - const timestamp = delayedTime; - const created = { - ...clone(relationship), - _id: 'delayed-relationship', - createdAt: timestamp, - updatedAt: timestamp, - }; - relationships.set(created.pairKey, created); - return clone(created); - }); - }); - - const delayedSend = service.sendRequest(users.get(1), 2); - await persistenceStarted; - // The request's reservation is older than the stale window, but its write has not finished. - // It must remain counted until that operation either persists or explicitly releases it. - now.mockReturnValue(delayedTime); - await expect(service.sendRequest(users.get(1), 3)).rejects.toMatchObject({ - code: 'outgoing_request_limit', - }); - - finishPersistence(); - await delayedSend; - expect([...relationships.values()].filter(({ status }) => status === 'pending')).toHaveLength(100); - }); - - it('publishes durable transitions when background quota cleanup fails', async () => { - const { - mirrors, notifier, quotaModel, relationships, service, socialLogger, users, - } = createService(); + it('applies the request limiter only when creating a new request', async () => { + const { requestLimiter, service, users } = createService(); await service.sendRequest(users.get(1), 2); - quotaModel.findOneAndUpdate.mockRejectedValueOnce(new Error('quota cleanup failed')); - - await expect(service.acceptRequest(users.get(2), 1)).resolves.toMatchObject({ - outcome: 'request_accepted', - }); - await flushBackground(); - - expect(relationships.get('1:2')).toEqual(expect.objectContaining({ status: 'accepted' })); - expect(mirrors.mirrorRelationship).toHaveBeenLastCalledWith( - expect.objectContaining({ pairKey: '1:2', status: 'accepted' }), - expect.any(Array), - ); - expect(notifier).toHaveBeenLastCalledWith({ userIds: [2, 1] }); - expect(socialLogger.error).toHaveBeenCalledWith(expect.any(Error)); - }); - - it('does not turn a durable cancellation into an error when quota cleanup fails', async () => { - const { - mirrors, notifier, quotaModel, relationships, service, socialLogger, users, - } = createService(); - await service.sendRequest(users.get(1), 2); - quotaModel.findOneAndUpdate.mockRejectedValueOnce(new Error('quota cleanup failed')); - - await expect(service.cancelRequest(users.get(1), 2)).resolves.toMatchObject({ - outcome: 'request_canceled', - }); - await flushBackground(); + await service.sendRequest(users.get(2), 1); - expect(relationships.get('1:2')).toEqual(expect.objectContaining({ status: 'canceled' })); - expect(mirrors.mirrorRelationship).toHaveBeenLastCalledWith( - expect.objectContaining({ pairKey: '1:2', status: 'canceled' }), - expect.any(Array), - ); - expect(notifier).toHaveBeenLastCalledWith({ userIds: [1, 2] }); - expect(socialLogger.error).toHaveBeenCalledWith(expect.any(Error)); + expect(requestLimiter.consume).toHaveBeenCalledTimes(1); + expect(requestLimiter.consume).toHaveBeenCalledWith({ actorId: 1, pairKey: '1:2' }); }); - it('keeps block durable and observable when its background quota cleanup fails', async () => { + it('fails closed before persistence when the request limiter is unavailable', async () => { const { - blocks, mirrors, notifier, quotaModel, relationships, service, socialLogger, users, + mirrors, notifier, relationships, requestLimiter, service, users, } = createService(); + const unavailable = Object.assign(new Error('Redis unavailable'), { + code: 'request_rate_limit_unavailable', + statusCode: 503, + }); + requestLimiter.consume.mockRejectedValueOnce(unavailable); - await service.sendRequest(users.get(1), 2); - quotaModel.findOneAndUpdate.mockRejectedValueOnce(new Error('quota cleanup failed')); - - await expect(service.block(users.get(1), 2)).resolves.toMatchObject({ outcome: 'blocked' }); - await flushBackground(); - - expect(blocks.get('1:2')).toEqual(expect.objectContaining({ active: true })); - expect(relationships.get('1:2')).toEqual(expect.objectContaining({ status: 'removed' })); - expect(mirrors.mirrorBlock).toHaveBeenCalledWith( - expect.objectContaining({ active: true, pairKey: '1:2' }), - expect.any(Object), - expect.any(Object), - ); - expect(notifier).toHaveBeenLastCalledWith({ userIds: [1, 2] }); - expect(socialLogger.error).toHaveBeenCalledWith(expect.any(Error)); - - quotaModel.findOneAndUpdate.mockRejectedValueOnce(new Error('quota cleanup failed')); - await expect(service.unblock(users.get(1), 2)).resolves.toMatchObject({ outcome: 'unblocked' }); - await flushBackground(); + await expect(service.sendRequest(users.get(1), 2)).rejects.toMatchObject({ + code: 'request_rate_limit_unavailable', + statusCode: 503, + }); - expect(blocks.get('1:2')).toEqual(expect.objectContaining({ active: false })); - expect(mirrors.mirrorBlock).toHaveBeenLastCalledWith( - expect.objectContaining({ active: false, pairKey: '1:2' }), - expect.any(Object), - expect.any(Object), - ); - expect(notifier).toHaveBeenLastCalledWith({ userIds: [1, 2] }); - expect(socialLogger.error).toHaveBeenCalledTimes(2); + expect(relationships).toEqual(new Map()); + expect(mirrors.mirrorRelationship).not.toHaveBeenCalled(); + expect(notifier).not.toHaveBeenCalled(); }); it('keeps a block active when unblock cannot tombstone a hidden relationship', async () => { diff --git a/server/social/requestRateLimiter.js b/server/social/requestRateLimiter.js new file mode 100644 index 0000000..3537f46 --- /dev/null +++ b/server/social/requestRateLimiter.js @@ -0,0 +1,110 @@ +const Redis = require('ioredis'); + +const config = require('../runtimeConfig'); +const logger = require('../logger'); +const { SocialError } = require('./relationshipState'); + +const PAIR_REQUEST_LIMIT = 3; +const PAIR_REQUEST_WINDOW_SECONDS = 24 * 60 * 60; +const USER_REQUEST_LIMIT = 30; +const USER_REQUEST_WINDOW_SECONDS = 10 * 60; +const KEY_PREFIX = 'letscube:social:request-creation:v1'; + +const CONSUME_REQUEST_CREATION_LIMIT = ` +local userCount = tonumber(redis.call('GET', KEYS[1]) or '0') +local pairCount = tonumber(redis.call('GET', KEYS[2]) or '0') +local userLimit = tonumber(ARGV[1]) +local pairLimit = tonumber(ARGV[2]) + +if userCount >= userLimit then + return { 0, 'user', redis.call('TTL', KEYS[1]) } +end +if pairCount >= pairLimit then + return { 0, 'pair', redis.call('TTL', KEYS[2]) } +end + +local userNext = redis.call('INCR', KEYS[1]) +if userNext == 1 or redis.call('TTL', KEYS[1]) < 0 then + redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3])) +end +local pairNext = redis.call('INCR', KEYS[2]) +if pairNext == 1 or redis.call('TTL', KEYS[2]) < 0 then + redis.call('EXPIRE', KEYS[2], tonumber(ARGV[4])) +end + +return { 1, '', 0 } +`; + +let redisClient; + +const createRedisClient = () => { + const client = config.redis.url + ? new Redis(config.redis.url, { lazyConnect: true, maxRetriesPerRequest: 1 }) + : new Redis({ + db: config.redis.db, + host: config.redis.host, + lazyConnect: true, + maxRetriesPerRequest: 1, + password: config.redis.password, + port: config.redis.port, + }); + client.on('error', (err) => logger.error(err)); + return client; +}; + +const retryAfterSeconds = (value) => { + const seconds = Number(value); + return Number.isSafeInteger(seconds) && seconds > 0 ? seconds : 1; +}; + +const createRequestRateLimiter = ({ + client, + rateLimitLogger = logger, +} = {}) => ({ + consume: async ({ actorId, pairKey }) => { + try { + const redis = client || redisClient || createRedisClient(); + if (!client && !redisClient) { + redisClient = redis; + } + const result = await redis.eval( + CONSUME_REQUEST_CREATION_LIMIT, + 2, + `${KEY_PREFIX}:user:${actorId}`, + `${KEY_PREFIX}:pair:${pairKey}`, + USER_REQUEST_LIMIT, + PAIR_REQUEST_LIMIT, + USER_REQUEST_WINDOW_SECONDS, + PAIR_REQUEST_WINDOW_SECONDS, + ); + if (!Array.isArray(result) || Number(result[0]) !== 1) { + throw new SocialError( + 429, + 'request_rate_limited', + 'Friend requests are temporarily limited', + { retryAfterSeconds: retryAfterSeconds(result && result[2]) }, + ); + } + } catch (err) { + if (err instanceof SocialError) { + throw err; + } + rateLimitLogger.error(err); + throw new SocialError( + 503, + 'request_rate_limit_unavailable', + 'Friend requests are temporarily unavailable', + ); + } + }, +}); + +module.exports = { + CONSUME_REQUEST_CREATION_LIMIT, + PAIR_REQUEST_LIMIT, + PAIR_REQUEST_WINDOW_SECONDS, + USER_REQUEST_LIMIT, + USER_REQUEST_WINDOW_SECONDS, + createRequestRateLimiter, + requestRateLimiter: createRequestRateLimiter(), +}; diff --git a/server/social/requestRateLimiter.test.js b/server/social/requestRateLimiter.test.js new file mode 100644 index 0000000..3dd4586 --- /dev/null +++ b/server/social/requestRateLimiter.test.js @@ -0,0 +1,113 @@ +/** @jest-environment node */ +/* eslint-env jest */ + +const { + PAIR_REQUEST_LIMIT, + PAIR_REQUEST_WINDOW_SECONDS, + USER_REQUEST_LIMIT, + USER_REQUEST_WINDOW_SECONDS, + createRequestRateLimiter, +} = require('./requestRateLimiter'); + +const createAtomicRedis = (getNow) => { + const entries = new Map(); + const entryFor = (key) => { + const entry = entries.get(key); + if (entry && entry.expiresAt <= getNow().getTime()) { + entries.delete(key); + return undefined; + } + return entry; + }; + const ttl = (key) => { + const entry = entryFor(key); + return entry ? Math.max(1, Math.ceil((entry.expiresAt - getNow().getTime()) / 1000)) : -2; + }; + const evalScript = jest.fn(async ( + script, + keyCount, + userKey, + pairKey, + userLimit, + pairLimit, + userWindowSeconds, + pairWindowSeconds, + ) => { + expect(keyCount).toBe(2); + expect(script).toContain("redis.call('INCR', KEYS[1])"); + const userCount = (entryFor(userKey) || { count: 0 }).count; + const pairCount = (entryFor(pairKey) || { count: 0 }).count; + if (userCount >= Number(userLimit)) { + return [0, 'user', ttl(userKey)]; + } + if (pairCount >= Number(pairLimit)) { + return [0, 'pair', ttl(pairKey)]; + } + entries.set(userKey, { + count: userCount + 1, + expiresAt: getNow().getTime() + (Number(userWindowSeconds) * 1000), + }); + entries.set(pairKey, { + count: pairCount + 1, + expiresAt: getNow().getTime() + (Number(pairWindowSeconds) * 1000), + }); + return [1, '', 0]; + }); + return { entries, eval: evalScript }; +}; + +describe('request creation rate limiter', () => { + it('atomically enforces the user window across concurrent pairs', async () => { + const now = new Date('2026-07-12T12:00:00.000Z'); + const redis = createAtomicRedis(() => now); + const limiter = createRequestRateLimiter({ client: redis }); + + const results = await Promise.allSettled(Array.from( + { length: USER_REQUEST_LIMIT + 1 }, + (_, index) => limiter.consume({ actorId: 1, pairKey: `1:${1000 + index}` }), + )); + + expect(results.filter(({ status }) => status === 'fulfilled')).toHaveLength(USER_REQUEST_LIMIT); + expect(results.filter(({ status }) => status === 'rejected')).toEqual([ + expect.objectContaining({ + reason: expect.objectContaining({ + code: 'request_rate_limited', + retryAfterSeconds: USER_REQUEST_WINDOW_SECONDS, + statusCode: 429, + }), + }), + ]); + expect(redis.eval).toHaveBeenCalledTimes(USER_REQUEST_LIMIT + 1); + }); + + it('expires the pair window without any cleanup job', async () => { + let now = new Date('2026-07-12T12:00:00.000Z'); + const redis = createAtomicRedis(() => now); + const limiter = createRequestRateLimiter({ client: redis }); + + await Promise.all(Array.from( + { length: PAIR_REQUEST_LIMIT }, + () => limiter.consume({ actorId: 1, pairKey: '1:2' }), + )); + await expect(limiter.consume({ actorId: 1, pairKey: '1:2' })).rejects.toMatchObject({ + code: 'request_rate_limited', + retryAfterSeconds: PAIR_REQUEST_WINDOW_SECONDS, + }); + + now = new Date(now.getTime() + (PAIR_REQUEST_WINDOW_SECONDS * 1000)); + await expect(limiter.consume({ actorId: 1, pairKey: '1:2' })).resolves.toBeUndefined(); + }); + + it('fails closed and logs when Redis cannot execute the atomic operation', async () => { + const error = new Error('Redis unavailable'); + const redis = { eval: jest.fn().mockRejectedValue(error) }; + const rateLimitLogger = { error: jest.fn() }; + const limiter = createRequestRateLimiter({ client: redis, rateLimitLogger }); + + await expect(limiter.consume({ actorId: 1, pairKey: '1:2' })).rejects.toMatchObject({ + code: 'request_rate_limit_unavailable', + statusCode: 503, + }); + expect(rateLimitLogger.error).toHaveBeenCalledWith(error); + }); +}); From af3602b8ff433335cb02d11037931728033ae37c Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 21:48:12 -0700 Subject: [PATCH 23/56] Fence duplicate room joins from stale leaves Advance a durable per-user presence revision before acknowledging an already-active room membership. Departure claims now require that generation, so an old leave cannot remove a newly opened tab.\n\nRefs #153 --- docs/room-ownership.md | 11 +- server/models/room.js | 63 +++++++++++- server/models/room.test.js | 31 +++++- server/socket/namespaces/rooms.js | 50 ++++++++-- server/socket/namespaces/rooms.test.js | 133 +++++++++++++++++++++---- 5 files changed, 248 insertions(+), 40 deletions(-) diff --git a/docs/room-ownership.md b/docs/room-ownership.md index dd4a3f6..3b1297e 100644 --- a/docs/room-ownership.md +++ b/docs/room-ownership.md @@ -34,11 +34,12 @@ itself from the per-user socket group before the remaining-tab check, so two simultaneous explicit leaves cannot strand membership. If the owner later rejoins, they reclaim admin before the join is acknowledged. -The final persisted departure is a MongoDB compare-and-set keyed by the active -membership revision. This applies across Socket.IO processes: exactly one -process can claim a leave, emit its room events, and record its metrics. A -concurrent rejoin advances the revision, so a losing leave is terminal and -cannot overwrite the new membership. +The final persisted departure is a MongoDB compare-and-set keyed by room +membership and the active user's presence revision. This applies across +Socket.IO processes: exactly one process can claim a leave, emit its room +events, and record its metrics. Every authenticated duplicate join advances +that user's durable presence revision before acknowledgment, so an older leave +is terminal and cannot overwrite an active new tab. An empty room persists `admin: null`, but the established `UPDATE_ADMIN` Socket.IO event is emitted only for a non-null active admin. Spectators and diff --git a/server/models/room.js b/server/models/room.js index 6b0c22a..275f102 100644 --- a/server/models/room.js +++ b/server/models/room.js @@ -23,6 +23,17 @@ const selectRoomAdmin = ({ usersInRoom, owner, admin }) => { return usersInRoom.find((user) => sameUser(user, admin)) || usersInRoom[0]; }; +const presenceRevisionFor = (room, userKey) => ( + room.presenceRevision ? room.presenceRevision.get(userKey) || 0 : 0 +); + +const advancePresenceRevisionFor = (room, userKey) => { + if (!room.presenceRevision) { + room.presenceRevision = new Map(); + } + room.presenceRevision.set(userKey, presenceRevisionFor(room, userKey) + 1); +}; + // const lengths = { // }; @@ -109,6 +120,11 @@ const Room = new mongoose.Schema({ type: Number, default: 0, }, + presenceRevision: { + type: Map, + of: Number, + default: {}, + }, admin: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, owner: { type: mongoose.Schema.Types.ObjectId, ref: 'User' }, type: { @@ -210,6 +226,7 @@ Room.methods.addUser = async function (user, spectating, updateAdmin) { this.inRoom.set(user.id.toString(), true); this.membershipRevision = (this.membershipRevision || 0) + 1; + advancePresenceRevisionFor(this, user.id.toString()); if (this.waitingForCount === 0) { this.waitingFor.set(user.id.toString(), true); @@ -234,6 +251,7 @@ Room.methods.dropUser = async function (user, updateAdmin) { this.inRoom.set(user.id.toString(), false); this.waitingFor.set(user.id.toString(), false); this.membershipRevision = (this.membershipRevision || 0) + 1; + advancePresenceRevisionFor(this, user.id.toString()); await this.updateAdminIfNeeded(updateAdmin); @@ -247,10 +265,14 @@ Room.methods.dropUser = async function (user, updateAdmin) { Room.methods.dropUserAtomically = async function ( user, expectedMembershipRevision = this.membershipRevision || 0, + expectedPresenceRevision = presenceRevisionFor(this, user.id.toString()), ) { const userKey = user.id.toString(); const membershipRevision = this.membershipRevision || 0; - if (!this.inRoom.get(userKey) || expectedMembershipRevision !== membershipRevision) { + const presenceRevision = presenceRevisionFor(this, userKey); + if (!this.inRoom.get(userKey) + || expectedMembershipRevision !== membershipRevision + || expectedPresenceRevision !== presenceRevision) { return null; } @@ -268,13 +290,29 @@ Room.methods.dropUserAtomically = async function ( _id: this._id, [`inRoom.${userKey}`]: true, }; + const revisionConditions = []; if (membershipRevision > 0) { condition.membershipRevision = membershipRevision; } else { - condition.$or = [ - { membershipRevision: 0 }, - { membershipRevision: { $exists: false } }, - ]; + revisionConditions.push({ + $or: [ + { membershipRevision: 0 }, + { membershipRevision: { $exists: false } }, + ], + }); + } + if (presenceRevision > 0) { + condition[`presenceRevision.${userKey}`] = presenceRevision; + } else { + revisionConditions.push({ + $or: [ + { [`presenceRevision.${userKey}`]: 0 }, + { [`presenceRevision.${userKey}`]: { $exists: false } }, + ], + }); + } + if (revisionConditions.length > 0) { + condition.$and = revisionConditions; } const update = { @@ -285,6 +323,7 @@ Room.methods.dropUserAtomically = async function ( }, $inc: { membershipRevision: 1, + [`presenceRevision.${userKey}`]: 1, }, }; if (usersInRoom.length === 0 && this.type === 'normal') { @@ -308,6 +347,20 @@ Room.methods.dropUserAtomically = async function ( return { room: updatedRoom, adminChanged }; }; +Room.methods.advancePresenceRevision = function (userId) { + const userKey = userId.toString(); + return this.constructor.findOneAndUpdate({ + _id: this._id, + [`inRoom.${userKey}`]: true, + }, { + $inc: { + [`presenceRevision.${userKey}`]: 1, + }, + }, { + new: true, + }).populate('users').populate('admin').populate('owner'); +}; + Room.methods.banUser = async function (userId) { this.banned.set(userId.toString(), true); return this.dropUser({ id: userId }); diff --git a/server/models/room.test.js b/server/models/room.test.js index 35d0bc8..ea83799 100644 --- a/server/models/room.test.js +++ b/server/models/room.test.js @@ -283,6 +283,7 @@ describe('room security helpers', () => { inRoom: new Map([['101', true], ['202', true]]), type: 'normal', membershipRevision: 7, + presenceRevision: new Map([['101', 7]]), constructor: model, }; @@ -292,13 +293,17 @@ describe('room security helpers', () => { _id: room._id, 'inRoom.101': true, membershipRevision: 7, + 'presenceRevision.101': 7, }, { $set: { 'inRoom.101': false, 'waitingFor.101': false, admin: nextAdmin._id, }, - $inc: { membershipRevision: 1 }, + $inc: { + membershipRevision: 1, + 'presenceRevision.101': 1, + }, }, { new: true }); expect(departure).toEqual({ room: updatedRoom, adminChanged: true }); expect(mirrorRoomChanges).toHaveBeenCalledWith(updatedRoom, { @@ -309,6 +314,30 @@ describe('room security helpers', () => { }); }); + it('advances an active user presence revision atomically', async () => { + const updatedRoom = { id: 'room-one' }; + const query = { + populate: jest.fn(() => query), + then: (resolve, reject) => Promise.resolve(updatedRoom).then(resolve, reject), + }; + const model = { + findOneAndUpdate: jest.fn(() => query), + }; + const room = { + _id: 'room-one', + constructor: model, + }; + + await expect(Room.methods.advancePresenceRevision.call(room, 101)).resolves.toBe(updatedRoom); + + expect(model.findOneAndUpdate).toHaveBeenCalledWith({ + _id: room._id, + 'inRoom.101': true, + }, { + $inc: { 'presenceRevision.101': 1 }, + }, { new: true }); + }); + it('persists and announces the owner reclaiming admin controls', async () => { const owner = { id: 101 }; const room = { diff --git a/server/socket/namespaces/rooms.js b/server/socket/namespaces/rooms.js index 036eac6..74d4207 100644 --- a/server/socket/namespaces/rooms.js +++ b/server/socket/namespaces/rooms.js @@ -88,6 +88,7 @@ module.exports = (io, middlewares) => { roomId: room._id, userId: user.id, membershipRevision: room.membershipRevision, + presenceRevision: room.presenceRevision.get(user.id.toString()), leaveReason: 'disconnect', }))); } @@ -126,13 +127,14 @@ module.exports = (io, middlewares) => { } async function finalizeUserDeparture({ - roomId, userId, connectionId, leaveReason, membershipRevision, + roomId, userId, connectionId, leaveReason, membershipRevision, presenceRevision, }) { const userKey = userId.toString(); const room = await fetchRoom(roomId); if (!room || !room.inRoom.get(userKey) - || room.membershipRevision !== membershipRevision) { + || room.membershipRevision !== membershipRevision + || room.presenceRevision.get(userKey) !== presenceRevision) { return false; } @@ -143,7 +145,11 @@ module.exports = (io, middlewares) => { // The membership revision is the departure generation. A failed claim is // terminal: retrying it after a rejoin would remove the new membership. - const departure = await room.dropUserAtomically({ id: userId }, membershipRevision); + const departure = await room.dropUserAtomically( + { id: userId }, + membershipRevision, + presenceRevision, + ); if (!departure) { return false; } @@ -372,6 +378,7 @@ module.exports = (io, middlewares) => { userId: socket.userId, connectionId: socket.id, membershipRevision: socket.room.membershipRevision, + presenceRevision: socket.room.presenceRevision.get(socket.userId.toString()), leaveReason, }); } catch (e) { @@ -423,18 +430,42 @@ module.exports = (io, middlewares) => { }); } - const r = await activeRoom.addUser(socket.user, spectating, sendAdminUpdate); + const presentRoom = await activeRoom.advancePresenceRevision(socket.userId); + if (presentRoom) { + socket.room = presentRoom; + await metrics.beginRoomVisit({ + room: presentRoom, + userId: socket.userId, + connectionId: socket.id, + activeUserCount: presentRoom.usersLength, + }); + return cb(null, joinRoomMask(presentRoom)); + } + const currentRoom = await fetchRoom(room._id); + if (!currentRoom) { + return cb({ + statusCode: 404, + message: 'Room no longer exists', + }); + } + const r = await currentRoom.addUser(socket.user, spectating, sendAdminUpdate); if (!r) { - // The user is already present in persisted room state. - socket.room = activeRoom; + const fencedRoom = await currentRoom.advancePresenceRevision(socket.userId); + if (!fencedRoom) { + return cb({ + statusCode: 409, + message: 'Room membership changed while joining', + }); + } + socket.room = fencedRoom; await metrics.beginRoomVisit({ - room: activeRoom, + room: fencedRoom, userId: socket.userId, connectionId: socket.id, - activeUserCount: activeRoom.usersLength, + activeUserCount: fencedRoom.usersLength, }); - return cb(null, joinRoomMask(activeRoom)); + return cb(null, joinRoomMask(fencedRoom)); } socket.room = r; @@ -1072,6 +1103,7 @@ module.exports = (io, middlewares) => { userId: socket.userId, connectionId: socket.id, membershipRevision: socket.room.membershipRevision, + presenceRevision: socket.room.presenceRevision.get(socket.userId.toString()), leaveReason: 'disconnect', }); } else if (socket.room) { diff --git a/server/socket/namespaces/rooms.test.js b/server/socket/namespaces/rooms.test.js index 16148f0..701165c 100644 --- a/server/socket/namespaces/rooms.test.js +++ b/server/socket/namespaces/rooms.test.js @@ -58,26 +58,34 @@ const queryResult = (value) => { return query; }; -const makeRoom = (overrides = {}) => ({ - _id: 'room-one', - accessCode: 'PRIVATE', - private: true, - password: 'bcrypt-hash-one', - type: 'normal', - owner: { id: 101 }, - admin: { id: 101 }, - users: [], - usersInRoom: [], - inRoom: new Map(), - banned: new Map(), - registered: new Map(), - membershipRevision: 7, - requireRevealedIdentity: false, - usersLength: 1, - authenticate: jest.fn().mockResolvedValue(true), - addUser: jest.fn().mockResolvedValue(false), - ...overrides, -}); +const makeRoom = (overrides = {}) => { + const room = { + _id: 'room-one', + accessCode: 'PRIVATE', + private: true, + password: 'bcrypt-hash-one', + type: 'normal', + owner: { id: 101 }, + admin: { id: 101 }, + users: [], + usersInRoom: [], + inRoom: new Map(), + banned: new Map(), + registered: new Map(), + membershipRevision: 7, + presenceRevision: new Map([['101', 7]]), + requireRevealedIdentity: false, + usersLength: 1, + authenticate: jest.fn().mockResolvedValue(true), + addUser: jest.fn().mockResolvedValue(false), + advancePresenceRevision: jest.fn(), + }; + Object.assign(room, overrides); + if (!overrides.advancePresenceRevision) { + room.advancePresenceRevision.mockResolvedValue(room); + } + return room; +}; const makeSocket = ({ id, session, userId = 202, @@ -230,6 +238,7 @@ describe('private room namespace joins', () => { onAdminChange(room); return room; }); + room.advancePresenceRevision.mockResolvedValue(null); room.edit = jest.fn().mockResolvedValue(room); const connect = setup(new Map([[room._id, room]])); const socket = await connect(makeSocket({ @@ -380,6 +389,7 @@ describe('room namespace departures and moderation', () => { userId: 101, connectionId: 'host-socket', membershipRevision: room.membershipRevision, + presenceRevision: room.presenceRevision.get('101'), leaveReason: 'explicit', }); @@ -414,6 +424,7 @@ describe('room namespace departures and moderation', () => { userId: 101, connectionId: 'winner-socket', membershipRevision: 7, + presenceRevision: 7, leaveReason: 'explicit', })).resolves.toBe(true); @@ -422,6 +433,7 @@ describe('room namespace departures and moderation', () => { admin: { id: 101 }, inRoom: new Map([['101', true]]), membershipRevision: 9, + presenceRevision: new Map([['101', 9]]), }; persistedRoom = rejoinedRoom; @@ -430,6 +442,7 @@ describe('room namespace departures and moderation', () => { userId: 101, connectionId: 'loser-socket', membershipRevision: 7, + presenceRevision: 7, leaveReason: 'explicit', })).resolves.toBe(false); @@ -442,6 +455,86 @@ describe('room namespace departures and moderation', () => { expect(connect.roomChannel.emit).toHaveBeenCalledWith(Protocol.USER_LEFT, 101); }); + it('fences an active duplicate join before an older departure can finalize', async () => { + const room = makeRoom({ + private: false, + password: null, + inRoom: new Map([['101', true]]), + }); + const fencedRoom = { + ...room, + presenceRevision: new Map([['101', 8]]), + }; + room.advancePresenceRevision.mockResolvedValue(fencedRoom); + const connect = setup(new Map([[room._id, room]])); + const socket = await connect(makeSocket({ id: 'new-tab', session: {}, userId: 101 })); + + const acknowledgment = await joinRoom(socket, { id: room._id }); + + expect(acknowledgment).toHaveBeenCalledWith( + null, + expect.objectContaining({ _id: room._id }), + ); + expect(room.advancePresenceRevision).toHaveBeenCalledWith(101); + + Room.findById.mockImplementation(() => queryResult(fencedRoom)); + const reconnectGrace = createReconnectGrace.mock.results[0].value; + + await expect(reconnectGrace.finalizeDeparture({ + roomId: room._id, + userId: 101, + connectionId: 'old-tab', + membershipRevision: 7, + presenceRevision: 7, + leaveReason: 'explicit', + })).resolves.toBe(false); + + expect(room.dropUserAtomically).toBeUndefined(); + expect(fencedRoom.inRoom.get('101')).toBe(true); + expect(fencedRoom.admin).toEqual({ id: 101 }); + }); + + it('joins normally when a departure wins before the duplicate presence fence', async () => { + const room = makeRoom({ + private: false, + password: null, + inRoom: new Map([['101', true]]), + }); + room.advancePresenceRevision.mockResolvedValue(null); + const rejoinedRoom = { + ...room, + inRoom: new Map([['101', true]]), + membershipRevision: 8, + presenceRevision: new Map([['101', 8]]), + }; + const departedRoom = { + ...room, + inRoom: new Map([['101', false]]), + addUser: jest.fn().mockResolvedValue(rejoinedRoom), + }; + const connect = setup(new Map([[room._id, room]])); + let fetchCount = 0; + Room.findById.mockImplementation(() => { + const fetchedRoom = fetchCount === 0 ? room : departedRoom; + fetchCount += 1; + return queryResult(fetchedRoom); + }); + const socket = await connect(makeSocket({ id: 'new-tab', session: {}, userId: 101 })); + + const acknowledgment = await joinRoom(socket, { id: room._id }); + + expect(departedRoom.addUser).toHaveBeenCalledWith( + socket.user, + undefined, + expect.any(Function), + ); + expect(acknowledgment).toHaveBeenCalledWith( + null, + expect.objectContaining({ _id: room._id }), + ); + expect(socket.room).toBe(rejoinedRoom); + }); + it('finalizes one departure when two tabs explicitly leave together', async () => { const room = makeRoom({ private: false, From e7d6859ed52e36653fedcd77bd65b3d4b362dfc3 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 21:53:32 -0700 Subject: [PATCH 24/56] Reauthorize recovered room joins Validate the current room state after a departure wins the duplicate-join race, preventing bans or changed access rules from being bypassed. --- server/socket/namespaces/rooms.js | 47 ++++++++++++++++++++++++-- server/socket/namespaces/rooms.test.js | 32 ++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/server/socket/namespaces/rooms.js b/server/socket/namespaces/rooms.js index 74d4207..e430b4d 100644 --- a/server/socket/namespaces/rooms.js +++ b/server/socket/namespaces/rooms.js @@ -386,7 +386,12 @@ module.exports = (io, middlewares) => { } } - async function joinRoom(room, cb, spectating) { + async function joinRoom( + room, + cb, + spectating, + { password, reauthorizeOnRecovery = false } = {}, + ) { if (socket.roomId) { if (String(socket.roomId) === String(room._id)) { socket.room = socket.room || room; @@ -449,6 +454,41 @@ module.exports = (io, middlewares) => { message: 'Room no longer exists', }); } + if (reauthorizeOnRecovery) { + const rejectRecoveryJoin = (error) => { + socket.leave(room.accessCode); + socket.leave(encodeUserRoom(socket.userId, room._id)); + delete socket.room; + delete socket.roomId; + return cb(error); + }; + const userKey = socket.userId.toString(); + if (!isRoomTypeEnabled(currentRoom.type)) { + return rejectRecoveryJoin({ + statusCode: 403, + message: 'Grand Prix rooms are disabled', + }); + } + if (currentRoom.private && (!password || !(await currentRoom.authenticate(password)))) { + return rejectRecoveryJoin({ + statusCode: 403, + message: 'Invalid password', + }); + } + if (currentRoom.banned.get(userKey)) { + return rejectRecoveryJoin({ + statusCode: 401, + message: 'Banned', + banned: true, + }); + } + if (currentRoom.requireRevealedIdentity && !socket.user.showWCAID) { + return rejectRecoveryJoin({ + statusCode: 403, + message: 'Must be showing WCA Identity to join room.', + }); + } + } const r = await currentRoom.addUser(socket.user, spectating, sendAdminUpdate); if (!r) { const fencedRoom = await currentRoom.advancePresenceRevision(socket.userId); @@ -564,7 +604,10 @@ module.exports = (io, middlewares) => { }, room); } - return await joinRoom(room, acknowledgment, spectating); + return await joinRoom(room, acknowledgment, spectating, { + password, + reauthorizeOnRecovery: true, + }); } catch (e) { logger.error(e); return rejectJoin('internal_error', { diff --git a/server/socket/namespaces/rooms.test.js b/server/socket/namespaces/rooms.test.js index 701165c..eb31f07 100644 --- a/server/socket/namespaces/rooms.test.js +++ b/server/socket/namespaces/rooms.test.js @@ -535,6 +535,38 @@ describe('room namespace departures and moderation', () => { expect(socket.room).toBe(rejoinedRoom); }); + it('does not re-add a user banned while a duplicate join recovers from departure', async () => { + const room = makeRoom({ + private: false, + password: null, + inRoom: new Map([['101', true]]), + }); + room.advancePresenceRevision.mockResolvedValue(null); + const departedAndBannedRoom = { + ...room, + inRoom: new Map([['101', false]]), + banned: new Map([['101', true]]), + addUser: jest.fn(), + }; + const connect = setup(new Map([[room._id, room]])); + let fetchCount = 0; + Room.findById.mockImplementation(() => { + const fetchedRoom = fetchCount === 0 ? room : departedAndBannedRoom; + fetchCount += 1; + return queryResult(fetchedRoom); + }); + const socket = await connect(makeSocket({ id: 'new-tab', session: {}, userId: 101 })); + + const acknowledgment = await joinRoom(socket, { id: room._id }); + + expect(departedAndBannedRoom.addUser).not.toHaveBeenCalled(); + expect(acknowledgment).toHaveBeenCalledWith(expect.objectContaining({ + statusCode: 401, + banned: true, + })); + expect(socket.roomId).toBeUndefined(); + }); + it('finalizes one departure when two tabs explicitly leave together', async () => { const room = makeRoom({ private: false, From 10400a8dba20843f5560a003ba3ff1433196247d Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 21:59:54 -0700 Subject: [PATCH 25/56] Add typed social notification inbox Persist recipient-scoped social notifications from friendship lifecycle transitions, mirror their safe references to PostgreSQL, and reconcile delivery through Socket.IO and the client inbox UI. --- .github/workflows/ci.yml | 2 +- README.md | 3 + client/src/components/Header.jsx | 53 +++- client/src/components/Navigation.jsx | 2 + .../Notifications/NotificationList.jsx | 72 +++++ client/src/components/Notifications/index.jsx | 63 +++++ .../src/components/Notifications/registry.js | 20 ++ .../components/Notifications/registry.test.js | 20 ++ client/src/lib/protocol.json | 2 + client/src/store/index.js | 2 + client/src/store/middlewares/default.js | 17 +- client/src/store/notifications/actions.js | 110 ++++++++ client/src/store/notifications/reducer.js | 74 +++++ .../src/store/notifications/reducer.test.js | 66 +++++ cypress/e2e/local_stack.cy.js | 33 +++ docs/README.md | 1 + docs/notifications.md | 44 +++ server/api.js | 6 + server/api.test.js | 17 ++ server/api/notifications.js | 36 +++ server/auth/index.js | 15 +- server/models/index.js | 2 + server/models/socialNotification.js | 67 +++++ server/postgres/dualWrite.js | 29 ++ server/postgres/dualWrite.test.js | 23 ++ .../migration.sql | 33 +++ server/prisma/schema.prisma | 21 ++ server/realtime/socialEvents.js | 65 ++++- server/realtime/socialEvents.test.js | 31 ++ server/social/notificationService.js | 267 ++++++++++++++++++ server/social/notificationService.test.js | 147 ++++++++++ server/social/notificationTypes.js | 27 ++ server/social/relationshipService.js | 17 ++ server/social/relationshipService.test.js | 12 +- 34 files changed, 1382 insertions(+), 17 deletions(-) create mode 100644 client/src/components/Notifications/NotificationList.jsx create mode 100644 client/src/components/Notifications/index.jsx create mode 100644 client/src/components/Notifications/registry.js create mode 100644 client/src/components/Notifications/registry.test.js create mode 100644 client/src/store/notifications/actions.js create mode 100644 client/src/store/notifications/reducer.js create mode 100644 client/src/store/notifications/reducer.test.js create mode 100644 docs/notifications.md create mode 100644 server/api/notifications.js create mode 100644 server/models/socialNotification.js create mode 100644 server/prisma/migrations/20260713090000_social_notifications/migration.sql create mode 100644 server/social/notificationService.js create mode 100644 server/social/notificationService.test.js create mode 100644 server/social/notificationTypes.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d76d96..df8fcd8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,7 +111,7 @@ jobs: run: docker compose -f compose.yml -f compose.dev.yml up -d mongo redis - name: Start server - run: LETSCUBE_TEST_AUTH=true yarn workspace letscube-server node index.js > server.log 2>&1 & + run: LETSCUBE_TEST_AUTH=true SOCIAL_FEATURES_ENABLED=true yarn workspace letscube-server node index.js > server.log 2>&1 & - name: Start socket server run: yarn workspace letscube-server node socket/ > socket.log 2>&1 & diff --git a/README.md b/README.md index cfc18ab..a9ca991 100644 --- a/README.md +++ b/README.md @@ -96,3 +96,6 @@ this migration phase. Friend relationships and directional blocks use revisioned tombstone rows; strictly older mirrors are ignored so reordered background writes cannot resurrect a removed relationship or a deactivated block. +Social notifications mirror only numeric recipient and actor references plus +their typed resource metadata; the notification mirror does not read or write +email addresses. diff --git a/client/src/components/Header.jsx b/client/src/components/Header.jsx index a30e337..6b48e7d 100644 --- a/client/src/components/Header.jsx +++ b/client/src/components/Header.jsx @@ -13,8 +13,10 @@ import Avatar from '@material-ui/core/Avatar'; import Menu from '@material-ui/core/Menu'; import Button from '@material-ui/core/Button'; import MenuItem from '@material-ui/core/MenuItem'; +import Badge from '@material-ui/core/Badge'; import { apiOrigin } from '../lib/fetch'; import { getNameFromId } from '../lib/events'; +import NotificationsIcon from '@material-ui/icons/Notifications'; const useStyles = makeStyles(() => ({ root: { @@ -42,10 +44,11 @@ const useStyles = makeStyles(() => ({ }, })); -function Header({ user, room }) { +function Header({ user, room, notifications }) { const loggedIn = !!user.id; const classes = useStyles(); const [anchorEl, setAnchorEl] = React.useState(null); + const [notificationAnchorEl, setNotificationAnchorEl] = React.useState(null); const open = Boolean(anchorEl); const handleMenu = (event) => { @@ -56,6 +59,8 @@ function Header({ user, room }) { setAnchorEl(null); }; + const closeNotifications = () => setNotificationAnchorEl(null); + const login = () => { localStorage.setItem('letscube.redirect_uri', `${document.location.origin}/wca-redirect`); const url = `${process.env.REACT_APP_WCA_ORIGIN}/oauth/authorize?${qs.stringify({ @@ -93,6 +98,41 @@ function Header({ user, room }) { { loggedIn ? (
+ {notifications.enabled && ( + <> + setNotificationAnchorEl(event.currentTarget)} + > + + + + + + {notifications.notifications.length === 0 && No notifications} + {notifications.notifications.slice(0, 5).map((notification) => ( + + {notification.type === 'friend_request' ? 'Friend request' : 'Notification'} + + ))} + View all notifications + + + )} @@ -136,6 +176,11 @@ Header.propTypes = { name: PropTypes.string, event: PropTypes.string, }), + notifications: PropTypes.shape({ + enabled: PropTypes.bool, + notifications: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string, type: PropTypes.string })), + unreadCount: PropTypes.number, + }), }; Header.defaultProps = { @@ -152,12 +197,18 @@ Header.defaultProps = { name: undefined, event: undefined, }, + notifications: { + enabled: false, + notifications: [], + unreadCount: 0, + }, }; const mapStateToProps = (state) => ({ room: state.room, user: state.user, roomName: state.room.name, + notifications: state.notifications, }); export default connect(mapStateToProps)(Header); diff --git a/client/src/components/Navigation.jsx b/client/src/components/Navigation.jsx index 82257d1..2586719 100644 --- a/client/src/components/Navigation.jsx +++ b/client/src/components/Navigation.jsx @@ -28,6 +28,7 @@ import Text from './Text'; const Lobby = lazy(() => import('./Lobby/index')); const Room = lazy(() => import('./Room/index')); const Profile = lazy(() => import('./common/Profile')); +const Notifications = lazy(() => import('./Notifications')); export const shouldShowGlobalPendingResult = (room, pendingResult) => ( isPendingResult(pendingResult) @@ -225,6 +226,7 @@ function Navigation({ { (!user.id || user.canJoinRoom) && } + diff --git a/client/src/components/Notifications/NotificationList.jsx b/client/src/components/Notifications/NotificationList.jsx new file mode 100644 index 0000000..60e0704 --- /dev/null +++ b/client/src/components/Notifications/NotificationList.jsx @@ -0,0 +1,72 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Button from '@material-ui/core/Button'; +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemText from '@material-ui/core/ListItemText'; +import Typography from '@material-ui/core/Typography'; +import notificationPresentation from './registry'; + +export default function NotificationList({ + actionErrors, actionPending, actionStale, notifications, onAction, +}) { + if (notifications.length === 0) { + return You have no notifications.; + } + return ( + + {notifications.map((notification) => { + const presentation = notificationPresentation(notification); + const pending = !!actionPending[notification.id]; + const stale = !!actionStale[notification.id]; + return ( + + +
+ {!stale && presentation.actions.map((action) => ( + + ))} + {actionErrors[notification.id] && ( + + {actionErrors[notification.id].message} + + )} + {stale && This request is no longer active.} +
+
+ ); + })} +
+ ); +} + +NotificationList.propTypes = { + actionErrors: PropTypes.shape(), + actionPending: PropTypes.shape(), + actionStale: PropTypes.shape(), + notifications: PropTypes.arrayOf(PropTypes.shape({ + actor: PropTypes.shape({ id: PropTypes.number }), + id: PropTypes.string.isRequired, + readAt: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + type: PropTypes.string.isRequired, + })).isRequired, + onAction: PropTypes.func.isRequired, +}; + +NotificationList.defaultProps = { + actionErrors: {}, + actionPending: {}, + actionStale: {}, +}; diff --git a/client/src/components/Notifications/index.jsx b/client/src/components/Notifications/index.jsx new file mode 100644 index 0000000..5a70084 --- /dev/null +++ b/client/src/components/Notifications/index.jsx @@ -0,0 +1,63 @@ +import React, { useEffect } from 'react'; +import { connect } from 'react-redux'; +import PropTypes from 'prop-types'; +import Button from '@material-ui/core/Button'; +import CircularProgress from '@material-ui/core/CircularProgress'; +import Container from '@material-ui/core/Container'; +import Typography from '@material-ui/core/Typography'; +import { + fetchNotifications, + markAllNotificationsRead, + runFriendNotificationAction, +} from '../../store/notifications/actions'; +import NotificationList from './NotificationList'; + +export function NotificationsPage({ + notifications, fetchPage, markAllRead, runAction, +}) { + useEffect(() => { fetchPage(); }, [fetchPage]); + if (notifications.enabled === false) { + return Notifications are unavailable.; + } + return ( + + Notifications + {notifications.fetching && notifications.notifications.length === 0 && } + {notifications.error && {notifications.error.message}} + + + {notifications.nextCursor && ( + + )} + + ); +} + +NotificationsPage.propTypes = { + fetchPage: PropTypes.func.isRequired, + markAllRead: PropTypes.func.isRequired, + notifications: PropTypes.shape().isRequired, + runAction: PropTypes.func.isRequired, +}; + +const mapStateToProps = (state) => ({ notifications: state.notifications }); +const mapDispatchToProps = (dispatch) => ({ + fetchPage: (options) => dispatch(fetchNotifications({ limit: 20, ...options })), + markAllRead: () => dispatch(markAllNotificationsRead()), + runAction: (notification, action) => dispatch(runFriendNotificationAction(notification, action)), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(NotificationsPage); diff --git a/client/src/components/Notifications/registry.js b/client/src/components/Notifications/registry.js new file mode 100644 index 0000000..d1f45cc --- /dev/null +++ b/client/src/components/Notifications/registry.js @@ -0,0 +1,20 @@ +const notificationPresentation = (notification) => { + const actorName = notification.actor + && (notification.actor.displayName || notification.actor.username) + ? notification.actor.displayName || notification.actor.username : 'A user'; + switch (notification.type) { + case 'friend_request': + return { + actions: ['accept', 'decline'], + text: `${actorName} sent you a friend request.`, + }; + case 'friend_request_accepted': + return { actions: [], text: `${actorName} accepted your friend request.` }; + case 'room_invitation': + return { actions: [], text: `${actorName} invited you to a room.` }; + default: + return { actions: [], text: 'You have a notification from Let\'s Cube.' }; + } +}; + +export default notificationPresentation; diff --git a/client/src/components/Notifications/registry.test.js b/client/src/components/Notifications/registry.test.js new file mode 100644 index 0000000..41b6fe2 --- /dev/null +++ b/client/src/components/Notifications/registry.test.js @@ -0,0 +1,20 @@ +import notificationPresentation from './registry'; + +describe('notification registry', () => { + it('maps only registered friend resources to typed actions', () => { + expect(notificationPresentation({ + actor: { username: 'solver' }, type: 'friend_request', + })).toEqual({ + actions: ['accept', 'decline'], + text: 'solver sent you a friend request.', + }); + }); + + it('renders unknown notification types without accepting server callbacks', () => { + const presentation = notificationPresentation({ type: 'unknown_future_type' }); + + expect(presentation.actions).toEqual([]); + expect(presentation).not.toHaveProperty('url'); + expect(presentation).not.toHaveProperty('callback'); + }); +}); diff --git a/client/src/lib/protocol.json b/client/src/lib/protocol.json index e0d2fcb..0cd2a2b 100644 --- a/client/src/lib/protocol.json +++ b/client/src/lib/protocol.json @@ -8,6 +8,8 @@ "HEALTH_CHECK": "health_check", "HEALTH_STATUS": "health_status", "FRIEND_STATE_INVALIDATED": "friend_state_invalidated", + "NOTIFICATION_CREATED": "notification_created", + "NOTIFICATION_UPDATED": "notification_updated", "ERROR": "errorrr", "UPDATE_ROOMS": "update_rooms", "UPDATE_ROOM": "update_room", diff --git a/client/src/store/index.js b/client/src/store/index.js index 5f4bcee..c28d0df 100644 --- a/client/src/store/index.js +++ b/client/src/store/index.js @@ -14,6 +14,7 @@ import messageReducer from './messages/reducers'; import chatReducer from './chat/reducer'; import serverReducer from './server/reducers'; import adminReducer from './admin/reducers'; +import notificationsReducer from './notifications/reducer'; import roomNamespaceMiddleware from './middlewares/rooms'; import defaultNamespaceMiddleware from './middlewares/default'; @@ -51,6 +52,7 @@ const rootReducer = combineReducers({ chat: chatReducer, server: serverReducer, admin: adminReducer, + notifications: notificationsReducer, }); const middlewares = [ diff --git a/client/src/store/middlewares/default.js b/client/src/store/middlewares/default.js index 366c97c..be89cb3 100644 --- a/client/src/store/middlewares/default.js +++ b/client/src/store/middlewares/default.js @@ -11,6 +11,7 @@ import { userCountUpdated, } from '../server/actions'; import { USER_CHANGED } from '../user/actions'; +import { fetchNotifications } from '../notifications/actions'; import { manager } from './manager'; const defaultNamespaceMiddleware = (store) => { @@ -22,6 +23,9 @@ const defaultNamespaceMiddleware = (store) => { }, onConnected: () => { store.dispatch(connected()); + if (store.getState().user.id) { + store.dispatch(fetchNotifications()); + } }, onDisconnected: () => { store.dispatch(disconnected()); @@ -30,6 +34,12 @@ const defaultNamespaceMiddleware = (store) => { [Protocol.UPDATE_USER_COUNT]: (userCount) => { store.dispatch(userCountUpdated(userCount)); }, + [Protocol.NOTIFICATION_CREATED]: () => { + store.dispatch(fetchNotifications()); + }, + [Protocol.NOTIFICATION_UPDATED]: () => { + store.dispatch(fetchNotifications()); + }, }, }); @@ -40,9 +50,10 @@ const defaultNamespaceMiddleware = (store) => { [DISCONNECT]: () => { namespace.disconnect(); }, - [USER_CHANGED]: () => { - // namespace.disconnect(); - // namespace.connect(); + [USER_CHANGED]: (action) => { + if (action.user && action.user.id) { + store.dispatch(fetchNotifications()); + } }, }; diff --git a/client/src/store/notifications/actions.js b/client/src/store/notifications/actions.js new file mode 100644 index 0000000..92cfa65 --- /dev/null +++ b/client/src/store/notifications/actions.js @@ -0,0 +1,110 @@ +import { lcFetch } from '../../lib/fetch'; + +export const NOTIFICATIONS_FETCHING = 'notifications/fetching'; +export const NOTIFICATIONS_FETCHED = 'notifications/fetched'; +export const NOTIFICATIONS_FAILED = 'notifications/failed'; +export const NOTIFICATIONS_UNAVAILABLE = 'notifications/unavailable'; +export const NOTIFICATION_ACTION_STARTED = 'notifications/action_started'; +export const NOTIFICATION_ACTION_FINISHED = 'notifications/action_finished'; +export const NOTIFICATION_ACTION_STALE = 'notifications/action_stale'; + +const requestError = async (response) => { + let body = {}; + try { + body = await response.json(); + } catch (err) { + // Keep the server status as the useful error when a proxy sends no JSON body. + } + const error = new Error(body.message || 'Notification action failed'); + error.code = body.code; + error.status = response.status; + return error; +}; + +export const fetchNotifications = ({ append = false, cursor, limit = 10 } = {}) => (dispatch) => { + dispatch({ type: NOTIFICATIONS_FETCHING, append }); + const query = new URLSearchParams({ limit: String(limit) }); + if (cursor) query.set('cursor', cursor); + return lcFetch(`/api/notifications?${query.toString()}`) + .then(async (response) => { + if (response.status === 404) { + dispatch({ type: NOTIFICATIONS_UNAVAILABLE }); + return null; + } + if (!response.ok) throw await requestError(response); + const payload = await response.json(); + dispatch({ type: NOTIFICATIONS_FETCHED, append, payload }); + return payload; + }) + .catch((error) => { + dispatch({ type: NOTIFICATIONS_FAILED, error }); + return null; + }); +}; + +export const markNotificationRead = (notificationId) => (dispatch) => { + dispatch({ notificationId, type: NOTIFICATION_ACTION_STARTED }); + return lcFetch(`/api/notifications/${notificationId}/read`, { method: 'POST' }) + .then(async (response) => { + if (!response.ok) throw await requestError(response); + dispatch({ notificationId, type: NOTIFICATION_ACTION_FINISHED }); + return response.json(); + }) + .catch((error) => { + dispatch({ error, notificationId, type: NOTIFICATION_ACTION_FINISHED }); + throw error; + }); +}; + +export const markAllNotificationsRead = () => (dispatch) => { + dispatch({ notificationId: 'all', type: NOTIFICATION_ACTION_STARTED }); + return lcFetch('/api/notifications/read-all', { method: 'POST' }) + .then(async (response) => { + if (!response.ok) throw await requestError(response); + dispatch({ notificationId: 'all', type: NOTIFICATION_ACTION_FINISHED }); + dispatch(fetchNotifications()); + }) + .catch((error) => { + dispatch({ error, notificationId: 'all', type: NOTIFICATION_ACTION_FINISHED }); + throw error; + }); +}; + +const FRIEND_ACTIONS = { + accept: 'accept', + decline: 'decline', +}; + +const isStaleFriendAction = (error) => error.status === 409 && [ + 'invalid_relationship_transition', + 'relationship_unavailable', +].includes(error.code); + +export const runFriendNotificationAction = (notification, action) => (dispatch) => { + const suffix = action === FRIEND_ACTIONS.accept ? 'accept' : 'decline'; + if (notification.type !== 'friend_request' || !notification.actor || !FRIEND_ACTIONS[action]) { + return Promise.reject(new Error('Notification action is no longer available')); + } + dispatch({ notificationId: notification.id, type: NOTIFICATION_ACTION_STARTED }); + return lcFetch(`/api/friends/requests/${notification.actor.id}/${suffix}`, { method: 'POST' }) + .then(async (response) => { + if (!response.ok) throw await requestError(response); + await dispatch(markNotificationRead(notification.id)); + dispatch({ notificationId: notification.id, type: NOTIFICATION_ACTION_FINISHED }); + dispatch(fetchNotifications()); + }) + .catch((error) => { + if (isStaleFriendAction(error)) { + return dispatch(markNotificationRead(notification.id)) + .catch(() => null) + .then(() => { + dispatch({ notificationId: notification.id, type: NOTIFICATION_ACTION_STALE }); + dispatch(fetchNotifications()); + }); + } + dispatch({ error, notificationId: notification.id, type: NOTIFICATION_ACTION_FINISHED }); + throw error; + }); +}; + +export { FRIEND_ACTIONS }; diff --git a/client/src/store/notifications/reducer.js b/client/src/store/notifications/reducer.js new file mode 100644 index 0000000..c76d97f --- /dev/null +++ b/client/src/store/notifications/reducer.js @@ -0,0 +1,74 @@ +import { + NOTIFICATION_ACTION_FINISHED, + NOTIFICATION_ACTION_STARTED, + NOTIFICATION_ACTION_STALE, + NOTIFICATIONS_FAILED, + NOTIFICATIONS_FETCHED, + NOTIFICATIONS_FETCHING, + NOTIFICATIONS_UNAVAILABLE, +} from './actions'; + +const INITIAL_STATE = { + actionErrors: {}, + actionPending: {}, + actionStale: {}, + enabled: null, + error: null, + fetching: false, + nextCursor: null, + notifications: [], + unreadCount: 0, +}; + +const byId = (notifications) => notifications.reduce((result, notification) => ({ + ...result, + [notification.id]: notification, +}), {}); + +export default function notificationsReducer(state = INITIAL_STATE, action) { + switch (action.type) { + case NOTIFICATIONS_FETCHING: + return { ...state, error: null, fetching: true }; + case NOTIFICATIONS_FETCHED: { + const merged = action.append + ? Object.values({ ...byId(state.notifications), ...byId(action.payload.notifications) }) + : action.payload.notifications; + return { + ...state, + enabled: true, + error: null, + fetching: false, + nextCursor: action.payload.nextCursor, + notifications: merged, + unreadCount: action.payload.unreadCount, + }; + } + case NOTIFICATIONS_UNAVAILABLE: + return { ...INITIAL_STATE, enabled: false }; + case NOTIFICATIONS_FAILED: + return { ...state, error: action.error, fetching: false }; + case NOTIFICATION_ACTION_STARTED: + return { + ...state, + actionErrors: { ...state.actionErrors, [action.notificationId]: null }, + actionPending: { ...state.actionPending, [action.notificationId]: true }, + actionStale: { ...state.actionStale, [action.notificationId]: false }, + }; + case NOTIFICATION_ACTION_STALE: + return { + ...state, + actionPending: { ...state.actionPending, [action.notificationId]: false }, + actionStale: { ...state.actionStale, [action.notificationId]: true }, + }; + case NOTIFICATION_ACTION_FINISHED: + return { + ...state, + actionErrors: action.error + ? { ...state.actionErrors, [action.notificationId]: action.error } + : state.actionErrors, + actionPending: { ...state.actionPending, [action.notificationId]: false }, + }; + default: + return state; + } +} diff --git a/client/src/store/notifications/reducer.test.js b/client/src/store/notifications/reducer.test.js new file mode 100644 index 0000000..2c7cbc3 --- /dev/null +++ b/client/src/store/notifications/reducer.test.js @@ -0,0 +1,66 @@ +import reducer from './reducer'; +import { + NOTIFICATION_ACTION_FINISHED, + NOTIFICATION_ACTION_STARTED, + NOTIFICATION_ACTION_STALE, + NOTIFICATIONS_FETCHED, + NOTIFICATIONS_UNAVAILABLE, +} from './actions'; + +const notification = { + actor: { id: 2, username: 'two' }, + id: 'notification-1', + readAt: null, + source: { id: 'relationship-1', type: 'friend_relationship' }, + type: 'friend_request', +}; + +describe('notification reducer', () => { + it('keeps unread counts and the recent page from the recipient API', () => { + const state = reducer(undefined, { + append: false, + payload: { nextCursor: 'cursor-2', notifications: [notification], unreadCount: 1 }, + type: NOTIFICATIONS_FETCHED, + }); + + expect(state).toMatchObject({ + enabled: true, + nextCursor: 'cursor-2', + notifications: [notification], + unreadCount: 1, + }); + }); + + it('retains an action error so stale friend requests can be retried or reconciled', () => { + const pending = reducer(undefined, { + notificationId: notification.id, + type: NOTIFICATION_ACTION_STARTED, + }); + const error = new Error('The request is no longer pending'); + const state = reducer(pending, { + error, + notificationId: notification.id, + type: NOTIFICATION_ACTION_FINISHED, + }); + + expect(state.actionPending[notification.id]).toBe(false); + expect(state.actionErrors[notification.id]).toBe(error); + }); + + it('hides the inbox after the default-off server gate responds unavailable', () => { + expect(reducer(undefined, { type: NOTIFICATIONS_UNAVAILABLE })).toMatchObject({ + enabled: false, + notifications: [], + unreadCount: 0, + }); + }); + + it('makes a stale action non-actionable after server revalidation', () => { + const state = reducer(undefined, { + notificationId: notification.id, + type: NOTIFICATION_ACTION_STALE, + }); + + expect(state.actionStale[notification.id]).toBe(true); + }); +}); diff --git a/cypress/e2e/local_stack.cy.js b/cypress/e2e/local_stack.cy.js index e711214..af1d6c6 100644 --- a/cypress/e2e/local_stack.cy.js +++ b/cypress/e2e/local_stack.cy.js @@ -6,6 +6,13 @@ describe('local app stack', () => { }); }; + const loginAs = (userId) => { + cy.request('POST', 'http://localhost:8080/auth/code', { + code: `cypress-test-user-${userId}`, + redirectUri: 'http://localhost:3000/wca-redirect', + }); + }; + it('shows the lobby without a logged-in session', () => { cy.visit('/'); @@ -85,4 +92,30 @@ describe('local app stack', () => { cy.contains(message, { timeout: 10000 }).should('be.visible'); }); + + it('delivers and acts on a friend request notification for two users', () => { + const requesterId = 990001; + const recipientId = 990002; + + loginAs(requesterId); + cy.request('POST', 'http://localhost:8080/api/friends/requests', { userId: recipientId }) + .its('status').should('eq', 201); + + loginAs(recipientId); + cy.request('http://localhost:8080/api/notifications').then((response) => { + expect(response.status).to.eq(200); + expect(response.body.notifications).to.deep.include({ + actor: { id: requesterId, displayName: `cypress-${requesterId}`, username: `cypress-${requesterId}` }, + type: 'friend_request', + }); + }); + + cy.visit('/notifications'); + cy.contains(`cypress-${requesterId} sent you a friend request.`, { timeout: 10000 }).should('be.visible'); + cy.get('button[aria-label="accept friend request"]').click(); + + loginAs(requesterId); + cy.visit('/notifications'); + cy.contains(`cypress-${recipientId} accepted your friend request.`, { timeout: 10000 }).should('be.visible'); + }); }); diff --git a/docs/README.md b/docs/README.md index 4d55c14..bc1c5be 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,3 +11,4 @@ The backend is built with NodeJS, Express and MongoDB, and they are both connect ## Feature contracts - [Friendship and blocking lifecycle](friendships.md) +- [In-app social notifications](notifications.md) diff --git a/docs/notifications.md b/docs/notifications.md new file mode 100644 index 0000000..bb06256 --- /dev/null +++ b/docs/notifications.md @@ -0,0 +1,44 @@ +# In-app social notifications + +Social notifications are a MongoDB-backed, recipient-only inbox. PostgreSQL is +a non-blocking mirror only and is never used to authorize reads or actions. +The social feature gate remains disabled unless `SOCIAL_FEATURES_ENABLED=true`. + +## Typed resource contract + +A notification contains only a recipient id, actor id, type, source type/id, +dedupe key, read timestamp, and expiry metadata. It contains no callback URL, +executable action, access token, email address, room password, or membership +list. The current notification types are `friend_request`, +`friend_request_accepted`, and the reserved `room_invitation` extension for +the room-invitation resource. + +The client owns the renderer/action registry. A `friend_request` action calls +the authenticated friendship endpoint using the actor reference; that endpoint +rechecks the current relationship and recipient. A notification never grants +authority itself. Unknown types remain display-only. + +Friend request and acceptance notifications are created from successful, +durable relationship transitions. Their dedupe key includes the relationship +document and revision, which makes replayed transitions idempotent while a +later, distinct request can create a new notification. Notifications expire +after 30 days; source resources may be stale or resolved earlier and remain +safe to display because actions always revalidate server-side. + +## API and delivery + +All notification routes require the authenticated recipient: + +| Method | Route | Purpose | +| --- | --- | --- | +| `GET` | `/api/notifications?limit=&cursor=` | Recent notifications, cursor, unread count | +| `POST` | `/api/notifications/:notificationId/read` | Mark one recipient-owned notification read | +| `POST` | `/api/notifications/read-all` | Mark that recipient's unread notifications read | + +The server relays typed `notification_created` and `notification_updated` +events only to the recipient's Socket.IO user room. The client refetches its +recent page and unread count on these events, socket connection, and reconnect, +so Redis delivery is an optimization rather than a source of truth. + +Email is not a notification identity, lookup, persistence, delivery, render, +metric, or logging field. diff --git a/server/api.js b/server/api.js index 12928e2..44d49c7 100644 --- a/server/api.js +++ b/server/api.js @@ -3,6 +3,7 @@ const express = require('express'); const { User } = require('./models'); const auth = require('./middlewares/auth.js'); const createFriendsRouter = require('./api/friends'); +const createNotificationsRouter = require('./api/notifications'); const PREFERENCE_KEYS = new Set([ 'showWCAID', @@ -27,11 +28,16 @@ module.exports = (app) => { if (app.get('config').socialFeatures.enabled) { router.use('/friends', createFriendsRouter()); + router.use('/notifications', createNotificationsRouter()); } else { router.use('/friends', (req, res) => res.status(404).json({ code: 'feature_disabled', message: 'This feature is not available', })); + router.use('/notifications', (req, res) => res.status(404).json({ + code: 'feature_disabled', + message: 'This feature is not available', + })); } router.put('/updateUsername', auth, (req, res) => { diff --git a/server/api.test.js b/server/api.test.js index f0cab3b..57cb32a 100644 --- a/server/api.test.js +++ b/server/api.test.js @@ -8,7 +8,14 @@ jest.mock('./social/relationshipService', () => ({ list: jest.fn().mockResolvedValue({ friends: [], reconciledAt: new Date() }), })); +jest.mock('./social/notificationService', () => ({ + list: jest.fn().mockResolvedValue({ notifications: [], unreadCount: 0 }), + markAllRead: jest.fn().mockResolvedValue({ updated: 0 }), + markRead: jest.fn(), +})); + const relationshipService = require('./social/relationshipService'); +const notificationService = require('./social/notificationService'); const createApi = require('./api'); const get = (server, path) => new Promise((resolve, reject) => { @@ -46,6 +53,7 @@ describe('social feature route gate', () => { afterEach((done) => { relationshipService.list.mockClear(); + notificationService.list.mockClear(); if (server) { server.close(done); } else { @@ -64,6 +72,15 @@ describe('social feature route gate', () => { statusCode: 404, }); expect(relationshipService.list).not.toHaveBeenCalled(); + expect(notificationService.list).not.toHaveBeenCalled(); + + await expect(get(server, '/api/notifications')).resolves.toEqual({ + body: { + code: 'feature_disabled', + message: 'This feature is not available', + }, + statusCode: 404, + }); }); it('mounts authenticated social routes when explicitly enabled', async () => { diff --git a/server/api/notifications.js b/server/api/notifications.js new file mode 100644 index 0000000..0f365c3 --- /dev/null +++ b/server/api/notifications.js @@ -0,0 +1,36 @@ +const express = require('express'); + +const auth = require('../middlewares/auth'); +const notificationService = require('../social/notificationService'); + +const sendError = (res, err) => res.status(err.statusCode || 500).json({ + code: err.code || 'internal_error', + message: err.statusCode ? err.message : 'The notification action could not be completed', +}); + +const asyncHandler = (handler) => async (req, res) => { + try { + await handler(req, res); + } catch (err) { + sendError(res, err); + } +}; + +const createNotificationsRouter = (service = notificationService) => { + const router = express.Router(); + router.use(auth); + + router.get('/', asyncHandler(async (req, res) => { + res.json(await service.list(req.user, req.query)); + })); + router.post('/read-all', asyncHandler(async (req, res) => { + res.json(await service.markAllRead(req.user)); + })); + router.post('/:notificationId/read', asyncHandler(async (req, res) => { + res.json(await service.markRead(req.user, req.params.notificationId)); + })); + + return router; +}; + +module.exports = createNotificationsRouter; diff --git a/server/auth/index.js b/server/auth/index.js index 91219c9..5f89fdc 100644 --- a/server/auth/index.js +++ b/server/auth/index.js @@ -31,14 +31,19 @@ module.exports = (app, passport) => { if (process.env.LETSCUBE_TEST_AUTH === 'true') { try { + const testUserMatch = /^cypress-test-user-(\d+)$/.exec(code); + const testUserId = testUserMatch + ? Number(testUserMatch[1]) : +(process.env.LETSCUBE_TEST_USER_ID || 990001); + const testUserName = testUserMatch ? `Cypress Test User ${testUserId}` : 'Cypress Test User'; + const testUsername = testUserMatch ? `cypress-${testUserId}` : 'cypress'; const user = await User.findOneAndUpdate({ - id: +(process.env.LETSCUBE_TEST_USER_ID || 990001), + id: testUserId, }, { - id: +(process.env.LETSCUBE_TEST_USER_ID || 990001), - name: 'Cypress Test User', - username: 'cypress', + id: testUserId, + name: testUserName, + username: testUsername, email: 'cypress@example.com', - wcaId: '2026TEST01', + wcaId: testUserMatch ? `2026TEST${testUserId}` : '2026TEST01', accessToken: `test-token-${code}`, avatar: {}, }, { diff --git a/server/models/index.js b/server/models/index.js index 1b7fa10..8c98991 100644 --- a/server/models/index.js +++ b/server/models/index.js @@ -2,6 +2,7 @@ const mongoose = require('mongoose'); const { FriendRelationship, RELATIONSHIP_STATUSES } = require('./friendRelationship'); const { Attempt, Room } = require('./room'); const { METRIC_EVENTS, MetricEvent } = require('./metricEvent'); +const SocialNotification = require('./socialNotification'); const User = require('./user'); const UserBlock = require('./userBlock'); @@ -16,6 +17,7 @@ module.exports = { UserBlock: mongoose.model('UserBlock', UserBlock, 'user_blocks'), Attempt: mongoose.model('Attempt', Attempt, 'attempts'), MetricEvent: mongoose.model('MetricEvent', MetricEvent, 'metric_events'), + SocialNotification: mongoose.model('SocialNotification', SocialNotification, 'social_notifications'), METRIC_EVENTS, RELATIONSHIP_STATUSES, }; diff --git a/server/models/socialNotification.js b/server/models/socialNotification.js new file mode 100644 index 0000000..9588203 --- /dev/null +++ b/server/models/socialNotification.js @@ -0,0 +1,67 @@ +const mongoose = require('mongoose'); + +const { + NOTIFICATION_SOURCE_TYPES, + NOTIFICATION_TYPES, + SOURCE_TYPE_BY_NOTIFICATION_TYPE, +} = require('../social/notificationTypes'); + +const SocialNotification = new mongoose.Schema({ + recipientId: { + type: Number, + required: true, + }, + actorId: { + type: Number, + required: true, + }, + type: { + type: String, + enum: Object.values(NOTIFICATION_TYPES), + required: true, + }, + sourceType: { + type: String, + enum: Object.values(NOTIFICATION_SOURCE_TYPES), + required: true, + }, + sourceId: { + type: String, + required: true, + }, + dedupeKey: { + type: String, + required: true, + }, + readAt: Date, + expiresAt: { + type: Date, + required: true, + }, +}, { + timestamps: true, + versionKey: false, +}); + +SocialNotification.index({ dedupeKey: 1 }, { unique: true }); +SocialNotification.index({ + recipientId: 1, readAt: 1, createdAt: -1, _id: -1, +}); +SocialNotification.index({ recipientId: 1, createdAt: -1, _id: -1 }); +SocialNotification.index({ expiresAt: 1 }, { expireAfterSeconds: 0 }); + +SocialNotification.pre('validate', function validateNotification(next) { + if (!Number.isSafeInteger(this.recipientId) || this.recipientId <= 0 + || !Number.isSafeInteger(this.actorId) || this.actorId <= 0 + || this.recipientId === this.actorId) { + next(new Error('Notification actor and recipient must be distinct valid users')); + return; + } + if (SOURCE_TYPE_BY_NOTIFICATION_TYPE[this.type] !== this.sourceType) { + next(new Error('Notification type must use its registered source type')); + return; + } + next(); +}); + +module.exports = SocialNotification; diff --git a/server/postgres/dualWrite.js b/server/postgres/dualWrite.js index 25764e5..a33c932 100644 --- a/server/postgres/dualWrite.js +++ b/server/postgres/dualWrite.js @@ -195,6 +195,34 @@ const mirrorBlock = (block, blocker, blocked) => withTransaction(async (client) return block.pairKey; }); +// Notifications intentionally mirror numeric user references only. Do not call +// upsertUser here: notifications have no email contract, including indirectly. +const mirrorNotification = (notification) => withTransaction((client) => client.query(` + INSERT INTO app.social_notifications ( + id, mongo_id, recipient_wca_user_id, actor_wca_user_id, type, source_type, + source_id, dedupe_key, read_at, expires_at, source_created_at, source_updated_at + ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (mongo_id) DO UPDATE SET + read_at = EXCLUDED.read_at, + expires_at = EXCLUDED.expires_at, + source_updated_at = EXCLUDED.source_updated_at, + ingested_at = now() + WHERE app.social_notifications.source_updated_at <= EXCLUDED.source_updated_at +`, [ + stableId('social-notification', notification._id.toString()), + notification._id.toString(), + notification.recipientId, + notification.actorId, + notification.type, + notification.sourceType, + notification.sourceId, + notification.dedupeKey, + notification.readAt || null, + notification.expiresAt, + notification.createdAt || null, + sourceDate(notification.updatedAt, notification.createdAt), +])); + const upsertRoomState = async (client, room, options = {}) => { if (!room || !room._id) { return null; @@ -632,6 +660,7 @@ module.exports = { markRoomDeleted, mirrorBlock, mirrorMetricEvent, + mirrorNotification, mirrorRelationship, mirrorRoom, mirrorRoomChanges, diff --git a/server/postgres/dualWrite.test.js b/server/postgres/dualWrite.test.js index 466ee6f..8a33dff 100644 --- a/server/postgres/dualWrite.test.js +++ b/server/postgres/dualWrite.test.js @@ -11,6 +11,7 @@ const { markRoomDeleted, mirrorBlock, mirrorMetricEvent, + mirrorNotification, mirrorRelationship, mirrorRoom, mirrorRoomChanges, @@ -240,6 +241,28 @@ describe('PostgreSQL dual writer', () => { .resolves.toBeNull(); }); + it('mirrors notification references without email or user upserts', async () => { + await mirrorNotification({ + _id: '507f1f77bcf86cd799439011', + actorId: 5678, + createdAt: new Date('2026-07-12T20:00:00.000Z'), + dedupeKey: 'friend-request:relationship-1:0', + expiresAt: new Date('2026-08-11T20:00:00.000Z'), + recipientId: 1234, + sourceId: 'relationship-1', + sourceType: 'friend_relationship', + type: 'friend_request', + updatedAt: new Date('2026-07-12T20:00:00.000Z'), + }); + + const [sql, values] = client.query.mock.calls[0]; + expect(sql).toContain('INSERT INTO app.social_notifications'); + expect(values).toContain(1234); + expect(values).toContain(5678); + expect(values).not.toContain('solver@example.com'); + expect(client.query.mock.calls.some(([statement]) => statement.includes('INSERT INTO app.users'))).toBe(false); + }); + it('rejects deliberately reordered stale relationship and block mirrors', async () => { const otherUser = { ...user, diff --git a/server/prisma/migrations/20260713090000_social_notifications/migration.sql b/server/prisma/migrations/20260713090000_social_notifications/migration.sql new file mode 100644 index 0000000..d041413 --- /dev/null +++ b/server/prisma/migrations/20260713090000_social_notifications/migration.sql @@ -0,0 +1,33 @@ +CREATE TABLE app.social_notifications ( + id uuid NOT NULL, + mongo_id text NOT NULL, + recipient_wca_user_id bigint NOT NULL, + actor_wca_user_id bigint NOT NULL, + type text NOT NULL, + source_type text NOT NULL, + source_id text NOT NULL, + dedupe_key text NOT NULL, + read_at timestamptz(6), + expires_at timestamptz(6) NOT NULL, + source_created_at timestamptz(6), + source_updated_at timestamptz(6) NOT NULL, + ingested_at timestamptz(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT social_notifications_pkey PRIMARY KEY (id), + CONSTRAINT social_notifications_distinct_users CHECK (recipient_wca_user_id <> actor_wca_user_id), + CONSTRAINT social_notifications_type_source_check CHECK ( + (type IN ('friend_request', 'friend_request_accepted') AND source_type = 'friend_relationship') + OR (type = 'room_invitation' AND source_type = 'room_invitation') + ) +); + +CREATE UNIQUE INDEX social_notifications_mongo_id_key +ON app.social_notifications(mongo_id); + +CREATE UNIQUE INDEX social_notifications_dedupe_key_key +ON app.social_notifications(dedupe_key); + +CREATE INDEX social_notifications_recipient_read_created_idx +ON app.social_notifications(recipient_wca_user_id, read_at, source_created_at DESC, mongo_id DESC); + +CREATE INDEX social_notifications_expiry_idx +ON app.social_notifications(expires_at); diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 45cdf25..0cbfa45 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -29,6 +29,27 @@ model User { @@schema("app") } +model SocialNotification { + id String @id @db.Uuid + mongoId String @unique(map: "social_notifications_mongo_id_key") @map("mongo_id") + recipientWcaUserId BigInt @map("recipient_wca_user_id") + actorWcaUserId BigInt @map("actor_wca_user_id") + type String + sourceType String @map("source_type") + sourceId String @map("source_id") + dedupeKey String @unique(map: "social_notifications_dedupe_key_key") @map("dedupe_key") + readAt DateTime? @map("read_at") @db.Timestamptz(6) + expiresAt DateTime @map("expires_at") @db.Timestamptz(6) + sourceCreatedAt DateTime? @map("source_created_at") @db.Timestamptz(6) + sourceUpdatedAt DateTime @map("source_updated_at") @db.Timestamptz(6) + ingestedAt DateTime @default(now()) @map("ingested_at") @db.Timestamptz(6) + + @@index([recipientWcaUserId, readAt, sourceCreatedAt(sort: Desc), mongoId(sort: Desc)], map: "social_notifications_recipient_read_created_idx") + @@index([expiresAt], map: "social_notifications_expiry_idx") + @@map("social_notifications") + @@schema("app") +} + model FriendRelationship { id String @id @db.Uuid pairKey String @unique(map: "friend_relationships_pair_key_key") @map("pair_key") diff --git a/server/realtime/socialEvents.js b/server/realtime/socialEvents.js index 276045b..36e2082 100644 --- a/server/realtime/socialEvents.js +++ b/server/realtime/socialEvents.js @@ -4,6 +4,7 @@ const Protocol = require('../../client/src/lib/protocol.json'); const config = require('../runtimeConfig'); const logger = require('../logger'); const { encodeUser } = require('../socket/utils'); +const { isKnownNotification } = require('../social/notificationTypes'); const SOCIAL_EVENT_CHANNEL = 'letscube:social-events:v1'; @@ -28,13 +29,44 @@ const normalizeRecipients = (userIds) => [...new Set(userIds.map(Number).filter( (userId) => Number.isSafeInteger(userId) && userId > 0, ))]; +const notificationPayload = (notification, updated = false) => { + if (!notification) { + return null; + } + if (updated) { + if (!notification.readAt) { + return null; + } + return { + ...(typeof notification.id === 'string' ? { id: notification.id } : {}), + readAt: notification.readAt, + }; + } + if (typeof notification.id !== 'string') { + return null; + } + if (!isKnownNotification({ + sourceType: notification.source && notification.source.type, + type: notification.type, + }) || !notification.actorId || !notification.createdAt || !notification.expiresAt + || !notification.source.id) { + return null; + } + return notification; +}; + const createSocialEventPublisher = ({ client, eventLogger = logger, now = () => new Date(), -} = {}) => async ({ userIds }) => { +} = {}) => async ({ notification, type = Protocol.FRIEND_STATE_INVALIDATED, userIds }) => { const recipients = normalizeRecipients(userIds || []); - if (recipients.length === 0) { + const isNotificationEvent = type === Protocol.NOTIFICATION_CREATED + || type === Protocol.NOTIFICATION_UPDATED; + const sanitizedNotification = isNotificationEvent + ? notificationPayload(notification, type === Protocol.NOTIFICATION_UPDATED) : null; + if (recipients.length === 0 || (isNotificationEvent && !sanitizedNotification) + || (!isNotificationEvent && type !== Protocol.FRIEND_STATE_INVALIDATED)) { return false; } @@ -46,7 +78,8 @@ const createSocialEventPublisher = ({ await redis.publish(SOCIAL_EVENT_CHANNEL, JSON.stringify({ occurredAt: now().toISOString(), schemaVersion: 1, - type: Protocol.FRIEND_STATE_INVALIDATED, + ...(sanitizedNotification ? { notification: sanitizedNotification } : {}), + type, userIds: recipients, })); return true; @@ -59,18 +92,28 @@ const createSocialEventPublisher = ({ const parseSocialEvent = (message) => { try { const event = JSON.parse(message); - if (event.schemaVersion !== 1 || event.type !== Protocol.FRIEND_STATE_INVALIDATED) { + const isNotificationEvent = event.type === Protocol.NOTIFICATION_CREATED + || event.type === Protocol.NOTIFICATION_UPDATED; + if (event.schemaVersion !== 1 || (event.type !== Protocol.FRIEND_STATE_INVALIDATED + && !isNotificationEvent)) { return null; } const userIds = normalizeRecipients(event.userIds || []); if (userIds.length === 0) { return null; } + const notification = isNotificationEvent + ? notificationPayload(event.notification, event.type === Protocol.NOTIFICATION_UPDATED) + : null; + if (isNotificationEvent && !notification) { + return null; + } return { - payload: { + payload: isNotificationEvent ? { notification, schemaVersion: event.schemaVersion } : { occurredAt: event.occurredAt, schemaVersion: event.schemaVersion, }, + type: event.type, userIds, }; } catch (err) { @@ -91,7 +134,7 @@ const registerSocialEventSubscriber = (io, client, eventLogger = logger) => { } event.userIds.forEach((userId) => { namespace.to(encodeUser(userId)).emit( - Protocol.FRIEND_STATE_INVALIDATED, + event.type, event.payload, ); }); @@ -104,5 +147,15 @@ module.exports = { createSocialEventPublisher, parseSocialEvent, publishSocialInvalidation: createSocialEventPublisher(), + publishNotificationCreated: ({ notification, recipientId }) => createSocialEventPublisher()({ + notification, + type: Protocol.NOTIFICATION_CREATED, + userIds: [recipientId], + }), + publishNotificationUpdated: ({ notification, recipientId }) => createSocialEventPublisher()({ + notification, + type: Protocol.NOTIFICATION_UPDATED, + userIds: [recipientId], + }), registerSocialEventSubscriber, }; diff --git a/server/realtime/socialEvents.test.js b/server/realtime/socialEvents.test.js index 7aacfe7..b1420d1 100644 --- a/server/realtime/socialEvents.test.js +++ b/server/realtime/socialEvents.test.js @@ -59,6 +59,37 @@ describe('social realtime invalidations', () => { expect(emit.mock.calls[0][1]).not.toHaveProperty('userIds'); }); + it('relays typed notification events only to the recipient user room', () => { + const handlers = {}; + const emit = jest.fn(); + const io = { of: jest.fn(() => ({ to: jest.fn(() => ({ emit })) })) }; + const client = { + on: jest.fn((event, handler) => { handlers[event] = handler; }), + subscribe: jest.fn().mockResolvedValue(1), + }; + + registerSocialEventSubscriber(io, client); + handlers.message(SOCIAL_EVENT_CHANNEL, JSON.stringify({ + notification: { + actorId: 1, + createdAt: '2026-07-12T12:00:00.000Z', + expiresAt: '2026-08-11T12:00:00.000Z', + id: '507f1f77bcf86cd799439011', + readAt: null, + source: { id: 'relationship-1', type: 'friend_relationship' }, + type: 'friend_request', + }, + schemaVersion: 1, + type: Protocol.NOTIFICATION_CREATED, + userIds: [2], + })); + + expect(emit).toHaveBeenCalledWith(Protocol.NOTIFICATION_CREATED, expect.objectContaining({ + notification: expect.objectContaining({ type: 'friend_request' }), + })); + expect(emit.mock.calls[0][1].notification).not.toHaveProperty('email'); + }); + it('ignores malformed or unknown events', () => { expect(parseSocialEvent('not-json')).toBeNull(); expect(parseSocialEvent(JSON.stringify({ diff --git a/server/social/notificationService.js b/server/social/notificationService.js new file mode 100644 index 0000000..80f06ea --- /dev/null +++ b/server/social/notificationService.js @@ -0,0 +1,267 @@ +const mongoose = require('mongoose'); + +const logger = require('../logger'); +const { SocialNotification, User } = require('../models'); +const { mirrorNotification } = require('../postgres/dualWrite'); +const { + publishNotificationCreated, + publishNotificationUpdated, +} = require('../realtime/socialEvents'); +const publicUserProjection = require('./publicUser'); +const { + isKnownNotification, + NOTIFICATION_SOURCE_TYPES, + NOTIFICATION_TYPES, +} = require('./notificationTypes'); + +const DEFAULT_RETENTION_DAYS = 30; +const DEFAULT_PAGE_SIZE = 20; +const MAX_PAGE_SIZE = 50; + +class NotificationError extends Error { + constructor(statusCode, code, message) { + super(message); + this.code = code; + this.statusCode = statusCode; + } +} + +const toPlain = (document) => ( + document && typeof document.toObject === 'function' ? document.toObject() : document +); + +const lean = async (query) => (query && typeof query.lean === 'function' ? query.lean() : query); + +const normalizeUserId = (value) => { + const userId = typeof value === 'number' ? value : Number(value); + if (!Number.isSafeInteger(userId) || userId <= 0) { + throw new NotificationError(400, 'invalid_notification', 'Invalid notification recipient'); + } + return userId; +}; + +const cursorFor = (notification) => Buffer.from(JSON.stringify({ + createdAt: new Date(notification.createdAt).toISOString(), + id: notification._id.toString(), +})).toString('base64url'); + +const parseCursor = (cursor) => { + if (!cursor) { + return null; + } + try { + const parsed = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')); + const createdAt = new Date(parsed.createdAt); + if (Number.isNaN(createdAt.getTime()) || !mongoose.Types.ObjectId.isValid(parsed.id)) { + throw new Error('invalid cursor'); + } + return { createdAt, id: new mongoose.Types.ObjectId(parsed.id) }; + } catch (err) { + throw new NotificationError(400, 'invalid_cursor', 'Invalid notification cursor'); + } +}; + +const pageSize = (value) => { + if (value === undefined) { + return DEFAULT_PAGE_SIZE; + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > MAX_PAGE_SIZE) { + throw new NotificationError(400, 'invalid_limit', 'Notification limit must be between 1 and 50'); + } + return parsed; +}; + +const socketNotification = (notification) => ({ + actorId: notification.actorId, + createdAt: notification.createdAt, + expiresAt: notification.expiresAt, + id: notification._id.toString(), + readAt: notification.readAt || null, + source: { + id: notification.sourceId, + type: notification.sourceType, + }, + type: notification.type, +}); + +const notificationDocument = ({ + actorId, dedupeKey, expiresAt, recipientId, sourceId, sourceType, type, +}) => ({ + actorId: normalizeUserId(actorId), + dedupeKey, + expiresAt, + recipientId: normalizeUserId(recipientId), + sourceId: String(sourceId), + sourceType, + type, +}); + +const createNotificationService = ({ + eventPublisher = { + publishCreated: publishNotificationCreated, + publishUpdated: publishNotificationUpdated, + }, + mirror = mirrorNotification, + notificationModel = SocialNotification, + notificationLogger = logger, + now = () => new Date(), + retentionDays = DEFAULT_RETENTION_DAYS, + userModel = User, +} = {}) => { + const runInBackground = (operation) => Promise.resolve(operation).catch( + (err) => notificationLogger.error(err), + ); + + const create = async (input) => { + const document = notificationDocument(input); + if (!isKnownNotification(document) || !document.sourceId || !document.dedupeKey + || document.actorId === document.recipientId) { + throw new NotificationError(400, 'invalid_notification', 'Invalid notification resource'); + } + + let notification; + try { + notification = toPlain(await notificationModel.create(document)); + } catch (err) { + if (err && err.code === 11000) { + return { created: false, notification: null }; + } + throw err; + } + + const payload = socketNotification(notification); + runInBackground(mirror(notification)); + runInBackground(eventPublisher.publishCreated({ + notification: payload, + recipientId: notification.recipientId, + })); + return { created: true, notification }; + }; + + const createFriendRequest = ({ actor, recipient, relationship }) => create({ + actorId: actor.id, + dedupeKey: `friend-request:${relationship._id}:${relationship.revision}`, + expiresAt: new Date(now().getTime() + retentionDays * 24 * 60 * 60 * 1000), + recipientId: recipient.id, + sourceId: relationship._id.toString(), + sourceType: NOTIFICATION_SOURCE_TYPES.FRIEND_RELATIONSHIP, + type: NOTIFICATION_TYPES.FRIEND_REQUEST, + }); + + const createFriendRequestAccepted = ({ actor, recipient, relationship }) => create({ + actorId: actor.id, + dedupeKey: `friend-request-accepted:${relationship._id}:${relationship.revision}`, + expiresAt: new Date(now().getTime() + retentionDays * 24 * 60 * 60 * 1000), + recipientId: recipient.id, + sourceId: relationship._id.toString(), + sourceType: NOTIFICATION_SOURCE_TYPES.FRIEND_RELATIONSHIP, + type: NOTIFICATION_TYPES.FRIEND_REQUEST_ACCEPTED, + }); + + const list = async (actor, { cursor, limit } = {}) => { + const recipientId = normalizeUserId(actor && actor.id); + const parsedCursor = parseCursor(cursor); + const effectiveLimit = pageSize(limit); + const filter = { + expiresAt: { $gt: now() }, + recipientId, + }; + if (parsedCursor) { + filter.$or = [ + { createdAt: { $lt: parsedCursor.createdAt } }, + { createdAt: parsedCursor.createdAt, _id: { $lt: parsedCursor.id } }, + ]; + } + + const [results, unreadCount] = await Promise.all([ + lean(notificationModel.find(filter).sort({ createdAt: -1, _id: -1 }) + .limit(effectiveLimit + 1)), + notificationModel.countDocuments({ + expiresAt: { $gt: now() }, + readAt: null, + recipientId, + }), + ]); + const notifications = results.slice(0, effectiveLimit); + const actorIds = [...new Set(notifications.map((entry) => entry.actorId))]; + const actors = actorIds.length + ? await lean(userModel.find({ id: { $in: actorIds } })) + : []; + const actorsById = new Map(actors.map((user) => [user.id, publicUserProjection(user)])); + + return { + nextCursor: results.length > effectiveLimit + ? cursorFor(notifications[notifications.length - 1]) : null, + notifications: notifications.map((notification) => ({ + ...socketNotification(notification), + actor: actorsById.get(notification.actorId) || null, + })), + unreadCount, + }; + }; + + const markRead = async (actor, notificationId) => { + const recipientId = normalizeUserId(actor && actor.id); + if (!mongoose.Types.ObjectId.isValid(notificationId)) { + throw new NotificationError(404, 'notification_not_found', 'Notification not found'); + } + const notification = toPlain(await notificationModel.findOneAndUpdate({ + _id: notificationId, + recipientId, + }, { + $set: { readAt: now() }, + }, { + new: true, + useFindAndModify: false, + })); + if (!notification) { + throw new NotificationError(404, 'notification_not_found', 'Notification not found'); + } + runInBackground(mirror(notification)); + runInBackground(eventPublisher.publishUpdated({ + notification: { + id: notification._id.toString(), + readAt: notification.readAt, + }, + recipientId, + })); + return { notification: socketNotification(notification) }; + }; + + const markAllRead = async (actor) => { + const recipientId = normalizeUserId(actor && actor.id); + const readAt = now(); + const result = await notificationModel.updateMany({ + expiresAt: { $gt: readAt }, + readAt: null, + recipientId, + }, { + $set: { readAt }, + }); + runInBackground(eventPublisher.publishUpdated({ + notification: { readAt }, + recipientId, + })); + return { + updated: result.modifiedCount === undefined ? result.nModified : result.modifiedCount, + }; + }; + + return { + create, + createFriendRequest, + createFriendRequestAccepted, + list, + markAllRead, + markRead, + }; +}; + +module.exports = { + createNotificationService, + cursorFor, + parseCursor, + socketNotification, + ...createNotificationService(), +}; diff --git a/server/social/notificationService.test.js b/server/social/notificationService.test.js new file mode 100644 index 0000000..f512aba --- /dev/null +++ b/server/social/notificationService.test.js @@ -0,0 +1,147 @@ +/** @jest-environment node */ +/* eslint-env jest */ + +const mongoose = require('mongoose'); +const { createNotificationService } = require('./notificationService'); + +const clone = (value) => JSON.parse(JSON.stringify(value)); + +const createModels = () => { + const notifications = []; + const users = new Map([ + [1, { + id: 1, name: 'One', username: 'one', preferRealName: false, + }], + [2, { + id: 2, name: 'Two', username: 'two', preferRealName: false, + }], + ]); + const notificationModel = { + countDocuments: jest.fn(async (filter) => notifications.filter((entry) => ( + entry.recipientId === filter.recipientId && !entry.readAt + )).length), + create: jest.fn(async (document) => { + if (notifications.some((entry) => entry.dedupeKey === document.dedupeKey)) { + const error = new Error('duplicate notification'); + error.code = 11000; + throw error; + } + const notification = { + ...document, + _id: new mongoose.Types.ObjectId(), + createdAt: new Date('2026-07-12T12:00:00.000Z'), + updatedAt: new Date('2026-07-12T12:00:00.000Z'), + }; + notifications.push(notification); + return clone(notification); + }), + find: jest.fn((filter) => ({ + limit: (limit) => ({ + lean: async () => notifications.filter((entry) => entry.recipientId === filter.recipientId + && entry.expiresAt > filter.expiresAt.$gt).sort((first, second) => ( + second.createdAt - first.createdAt + || second._id.toString().localeCompare(first._id.toString()) + )).slice(0, limit).map(clone), + }), + sort: () => ({ + limit: (limit) => ({ + lean: async () => notifications.filter((entry) => entry.recipientId === filter.recipientId + && entry.expiresAt > filter.expiresAt.$gt).sort((first, second) => ( + second.createdAt - first.createdAt + || second._id.toString().localeCompare(first._id.toString()) + )).slice(0, limit).map(clone), + }), + }), + })), + findOneAndUpdate: jest.fn(async (filter, update) => { + const notification = notifications.find((entry) => ( + entry._id.toString() === filter._id.toString() && entry.recipientId === filter.recipientId + )); + if (!notification) return null; + notification.readAt = update.$set.readAt; + notification.updatedAt = update.$set.readAt; + return clone(notification); + }), + updateMany: jest.fn(async (filter, update) => { + const matching = notifications.filter( + (entry) => entry.recipientId === filter.recipientId && !entry.readAt, + ); + matching.forEach((entry) => { entry.readAt = update.$set.readAt; }); + return { modifiedCount: matching.length }; + }), + }; + const userModel = { + find: jest.fn(async ({ id }) => id.$in.map((userId) => users.get(userId)).filter(Boolean)), + }; + return { notificationModel, notifications, userModel }; +}; + +describe('notification service', () => { + const now = () => new Date('2026-07-12T12:00:00.000Z'); + let events; + let mirror; + let models; + let service; + + beforeEach(() => { + models = createModels(); + events = { publishCreated: jest.fn(), publishUpdated: jest.fn() }; + mirror = jest.fn(); + service = createNotificationService({ + ...models, eventPublisher: events, mirror, notificationLogger: { error: jest.fn() }, now, + }); + }); + + const request = () => service.createFriendRequest({ + actor: { id: 1 }, recipient: { id: 2 }, relationship: { _id: 'relationship-1', revision: 0 }, + }); + + it('deduplicates a notification source and sends only typed safe socket data', async () => { + await expect(request()).resolves.toMatchObject({ created: true }); + await expect(request()).resolves.toEqual({ created: false, notification: null }); + + expect(models.notifications).toHaveLength(1); + expect(events.publishCreated).toHaveBeenCalledWith(expect.objectContaining({ + recipientId: 2, + notification: expect.objectContaining({ actorId: 1, type: 'friend_request' }), + })); + const payload = events.publishCreated.mock.calls[0][0].notification; + expect(payload).not.toHaveProperty('email'); + expect(payload).not.toHaveProperty('action'); + expect(payload).not.toHaveProperty('url'); + }); + + it('lists only the recipient records with cursor metadata and public actors', async () => { + await request(); + await service.createFriendRequestAccepted({ + actor: { id: 2 }, recipient: { id: 1 }, relationship: { _id: 'relationship-1', revision: 1 }, + }); + + const response = await service.list({ id: 2 }, { limit: 1 }); + + expect(response.notifications).toHaveLength(1); + expect(response.notifications[0]).toMatchObject({ actor: { id: 1 }, type: 'friend_request' }); + expect(response.notifications[0].actor).not.toHaveProperty('email'); + expect(response.nextCursor).toBeNull(); + expect(response.unreadCount).toBe(1); + }); + + it('cannot mark another recipient notification read and safely marks all own notifications', async () => { + await request(); + const id = models.notifications[0]._id.toString(); + + await expect(service.markRead({ id: 1 }, id)).rejects.toMatchObject({ + code: 'notification_not_found', statusCode: 404, + }); + await expect(service.markRead({ id: 2 }, id)).resolves.toMatchObject({ + notification: { id, readAt: now().toISOString() }, + }); + await expect(service.markAllRead({ id: 2 })).resolves.toEqual({ updated: 0 }); + expect(events.publishUpdated).toHaveBeenCalled(); + }); + + it('does not use PostgreSQL availability to determine notification success', async () => { + mirror.mockRejectedValue(new Error('PostgreSQL unavailable')); + await expect(request()).resolves.toMatchObject({ created: true }); + }); +}); diff --git a/server/social/notificationTypes.js b/server/social/notificationTypes.js new file mode 100644 index 0000000..e3d2e8e --- /dev/null +++ b/server/social/notificationTypes.js @@ -0,0 +1,27 @@ +const NOTIFICATION_TYPES = Object.freeze({ + FRIEND_REQUEST: 'friend_request', + FRIEND_REQUEST_ACCEPTED: 'friend_request_accepted', + ROOM_INVITATION: 'room_invitation', +}); + +const NOTIFICATION_SOURCE_TYPES = Object.freeze({ + FRIEND_RELATIONSHIP: 'friend_relationship', + ROOM_INVITATION: 'room_invitation', +}); + +const SOURCE_TYPE_BY_NOTIFICATION_TYPE = Object.freeze({ + [NOTIFICATION_TYPES.FRIEND_REQUEST]: NOTIFICATION_SOURCE_TYPES.FRIEND_RELATIONSHIP, + [NOTIFICATION_TYPES.FRIEND_REQUEST_ACCEPTED]: NOTIFICATION_SOURCE_TYPES.FRIEND_RELATIONSHIP, + [NOTIFICATION_TYPES.ROOM_INVITATION]: NOTIFICATION_SOURCE_TYPES.ROOM_INVITATION, +}); + +const isKnownNotification = ({ sourceType, type } = {}) => ( + SOURCE_TYPE_BY_NOTIFICATION_TYPE[type] === sourceType +); + +module.exports = { + isKnownNotification, + NOTIFICATION_SOURCE_TYPES, + NOTIFICATION_TYPES, + SOURCE_TYPE_BY_NOTIFICATION_TYPE, +}; diff --git a/server/social/relationshipService.js b/server/social/relationshipService.js index 4b78688..218d0df 100644 --- a/server/social/relationshipService.js +++ b/server/social/relationshipService.js @@ -12,6 +12,7 @@ const { mirrorRelationship, } = require('../postgres/dualWrite'); const { publishSocialInvalidation } = require('../realtime/socialEvents'); +const notificationService = require('./notificationService'); const publicUserProjection = require('./publicUser'); const { ACTIONS, @@ -71,6 +72,7 @@ const createRelationshipService = ({ mirrorRelationship, }, notifier = publishSocialInvalidation, + notifications = notificationService, now = () => new Date(), } = {}) => { const runInBackground = (operation) => { @@ -239,6 +241,21 @@ const createRelationshipService = ({ if (transition.kind !== 'noop') { runInBackground(mirrors.mirrorRelationship(persisted, [actorUser, targetUser])); } + if (transition.outcome === 'request_created') { + await notifications.createFriendRequest({ + actor: actorUser, + recipient: targetUser, + relationship: persisted, + }); + } + if (transition.outcome === 'request_accepted' + || transition.outcome === 'crossed_request_accepted') { + await notifications.createFriendRequestAccepted({ + actor: actorUser, + recipient: targetUser, + relationship: persisted, + }); + } if (transition.kind !== 'noop') { invalidate([actorId, targetId]); } diff --git a/server/social/relationshipService.test.js b/server/social/relationshipService.test.js index 79def39..ea05017 100644 --- a/server/social/relationshipService.test.js +++ b/server/social/relationshipService.test.js @@ -143,6 +143,10 @@ const createService = () => { mirrorRelationship: jest.fn().mockResolvedValue(null), }; const notifier = jest.fn().mockResolvedValue(true); + const notifications = { + createFriendRequest: jest.fn().mockResolvedValue({ created: true }), + createFriendRequestAccepted: jest.fn().mockResolvedValue({ created: true }), + }; const now = jest.fn(() => new Date('2026-07-12T12:00:00.000Z')); const requestLimiter = { consume: jest.fn().mockResolvedValue(undefined) }; const socialLogger = { error: jest.fn() }; @@ -151,6 +155,7 @@ const createService = () => { metricRecorder, mirrors, notifier, + notifications, now, requestLimiter, socialLogger, @@ -160,6 +165,7 @@ const createService = () => { metricRecorder, mirrors, notifier, + notifications, now, requestLimiter, socialLogger, @@ -169,7 +175,9 @@ const createService = () => { describe('relationship service', () => { it('stores one unordered pair and accepts a crossed request', async () => { - const { relationships, service, users } = createService(); + const { + notifications, relationships, service, users, + } = createService(); await service.sendRequest(users.get(2), 1); await service.sendRequest(users.get(1), 2); @@ -182,6 +190,8 @@ describe('relationship service', () => { requestedBy: null, status: 'accepted', })); + expect(notifications.createFriendRequest).toHaveBeenCalledTimes(1); + expect(notifications.createFriendRequestAccepted).toHaveBeenCalledTimes(1); }); it('makes duplicate sends and concurrent accepts idempotent', async () => { From a33836509daf788eb6fa58b1cff3720c96413a85 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 22:07:18 -0700 Subject: [PATCH 26/56] Create both users in notification Cypress flow Ensure the request recipient exists before exercising the two-user notification lifecycle. --- cypress/e2e/local_stack.cy.js | 1 + 1 file changed, 1 insertion(+) diff --git a/cypress/e2e/local_stack.cy.js b/cypress/e2e/local_stack.cy.js index af1d6c6..1f2ed0d 100644 --- a/cypress/e2e/local_stack.cy.js +++ b/cypress/e2e/local_stack.cy.js @@ -97,6 +97,7 @@ describe('local app stack', () => { const requesterId = 990001; const recipientId = 990002; + loginAs(recipientId); loginAs(requesterId); cy.request('POST', 'http://localhost:8080/api/friends/requests', { userId: recipientId }) .its('status').should('eq', 201); From ca5ac714fed9eeb478507942a007c329d34e8eb1 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 22:09:55 -0700 Subject: [PATCH 27/56] Assert notification identity instead of display casing Keep the Cypress flow focused on typed actor identity and lifecycle behavior across valid display-name preferences. --- cypress/e2e/local_stack.cy.js | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/cypress/e2e/local_stack.cy.js b/cypress/e2e/local_stack.cy.js index 1f2ed0d..32e6fc0 100644 --- a/cypress/e2e/local_stack.cy.js +++ b/cypress/e2e/local_stack.cy.js @@ -105,18 +105,16 @@ describe('local app stack', () => { loginAs(recipientId); cy.request('http://localhost:8080/api/notifications').then((response) => { expect(response.status).to.eq(200); - expect(response.body.notifications).to.deep.include({ - actor: { id: requesterId, displayName: `cypress-${requesterId}`, username: `cypress-${requesterId}` }, - type: 'friend_request', - }); + expect(response.body.notifications.some((notification) => ( + notification.type === 'friend_request' && notification.actor.id === requesterId + ))).to.eq(true); }); cy.visit('/notifications'); - cy.contains(`cypress-${requesterId} sent you a friend request.`, { timeout: 10000 }).should('be.visible'); cy.get('button[aria-label="accept friend request"]').click(); loginAs(requesterId); cy.visit('/notifications'); - cy.contains(`cypress-${recipientId} accepted your friend request.`, { timeout: 10000 }).should('be.visible'); + cy.contains('accepted your friend request.', { timeout: 10000 }).should('be.visible'); }); }); From c436f211440a35e13df2d37d407274af3ddea2fb Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 21:57:55 -0700 Subject: [PATCH 28/56] Add privacy-safe user discovery Expose authenticated, capped discovery and public profiles behind the disabled-by-default social feature gate. Preserve normalized usernames and email privacy while linking safe profile entry points and friendship actions. --- client/src/components/Lobby/UserList.jsx | 3 +- client/src/components/Navigation.jsx | 3 + client/src/components/PublicProfile.jsx | 85 ++++++++++++++ client/src/components/User.jsx | 5 +- server/api.js | 6 + server/api/users.js | 35 ++++++ server/models/user.js | 1 + server/models/user.test.js | 7 ++ server/social/discoveryRateLimiter.js | 45 ++++++++ server/social/discoveryService.js | 140 +++++++++++++++++++++++ server/social/discoveryService.test.js | 126 ++++++++++++++++++++ 11 files changed, 454 insertions(+), 2 deletions(-) create mode 100644 client/src/components/PublicProfile.jsx create mode 100644 server/api/users.js create mode 100644 server/social/discoveryRateLimiter.js create mode 100644 server/social/discoveryService.js create mode 100644 server/social/discoveryService.test.js diff --git a/client/src/components/Lobby/UserList.jsx b/client/src/components/Lobby/UserList.jsx index 02d0e0d..944ed4c 100644 --- a/client/src/components/Lobby/UserList.jsx +++ b/client/src/components/Lobby/UserList.jsx @@ -9,6 +9,7 @@ import ListItem from '@material-ui/core/ListItem'; import ListItemText from '@material-ui/core/ListItemText'; import ListItemAvatar from '@material-ui/core/ListItemAvatar'; import Avatar from '@material-ui/core/Avatar'; +import { Link } from 'react-router-dom'; const useStyles = makeStyles(() => ({ root: { @@ -34,7 +35,7 @@ function LobbyUserList({ users }) { }} > {users.map((user) => ( - + diff --git a/client/src/components/Navigation.jsx b/client/src/components/Navigation.jsx index 2586719..76f4da6 100644 --- a/client/src/components/Navigation.jsx +++ b/client/src/components/Navigation.jsx @@ -29,6 +29,7 @@ const Lobby = lazy(() => import('./Lobby/index')); const Room = lazy(() => import('./Room/index')); const Profile = lazy(() => import('./common/Profile')); const Notifications = lazy(() => import('./Notifications')); +const PublicProfile = lazy(() => import('./PublicProfile')); export const shouldShowGlobalPendingResult = (room, pendingResult) => ( isPendingResult(pendingResult) @@ -227,6 +228,8 @@ function Navigation({ { (!user.id || user.canJoinRoom) && } + + diff --git a/client/src/components/PublicProfile.jsx b/client/src/components/PublicProfile.jsx new file mode 100644 index 0000000..84147f2 --- /dev/null +++ b/client/src/components/PublicProfile.jsx @@ -0,0 +1,85 @@ +import React, { useEffect, useState } from 'react'; +import { Link, useParams } from 'react-router-dom'; +import Avatar from '@material-ui/core/Avatar'; +import Button from '@material-ui/core/Button'; +import CircularProgress from '@material-ui/core/CircularProgress'; +import Paper from '@material-ui/core/Paper'; +import TextField from '@material-ui/core/TextField'; +import Typography from '@material-ui/core/Typography'; +import { lcFetch } from '../lib/fetch'; + +const actionRequest = (action, id) => { + const encodedId = encodeURIComponent(id); + if (action === 'request') return { method: 'POST', url: '/api/friends/requests', body: { userId: id } }; + if (action === 'cancel') return { method: 'DELETE', url: `/api/friends/requests/${encodedId}` }; + if (action === 'accept' || action === 'decline') return { method: 'POST', url: `/api/friends/requests/${encodedId}/${action}` }; + if (action === 'unfriend') return { method: 'DELETE', url: `/api/friends/${encodedId}` }; + return { method: 'PUT', url: `/api/friends/blocks/${encodedId}` }; +}; + +function PublicProfile() { + const { id } = useParams(); + const [profile, setProfile] = useState(null); + const [search, setSearch] = useState(''); + const [results, setResults] = useState([]); + const [status, setStatus] = useState('loading'); + + const load = () => { + setStatus('loading'); + lcFetch(`/api/users/${encodeURIComponent(id)}`) + .then((response) => (response.ok ? response.json() : Promise.reject(response))) + .then((data) => { + setProfile(data); + setStatus('ready'); + }) + .catch(() => setStatus('unavailable')); + }; + + useEffect(load, [id]); + + const searchUsers = (event) => { + event.preventDefault(); + lcFetch(`/api/users/search?q=${encodeURIComponent(search)}`) + .then((response) => (response.ok ? response.json() : { results: [] })) + .then((data) => setResults(data.results || [])) + .catch(() => setResults([])); + }; + + const act = (action) => { + const request = actionRequest(action, id); + lcFetch(request.url, { + method: request.method, + headers: request.body ? { 'Content-Type': 'application/json' } : undefined, + body: request.body ? JSON.stringify(request.body) : undefined, + }).then(load); + }; + + return ( + +
+ setSearch(event.target.value)} + value={search} + /> + + + {results.map((user) => )} + {status === 'loading' && } + {status === 'unavailable' && This user is not available.} + {status === 'ready' && profile && ( + <> + + {profile.displayName || profile.username} + {profile.username && {`@${profile.username}`}} + {profile.wcaId && {`WCA ID: ${profile.wcaId}`}} + {profile.relationship} + {profile.actions.map((action) => )} + + )} +
+ ); +} + +export default PublicProfile; diff --git a/client/src/components/User.jsx b/client/src/components/User.jsx index 57183c5..8421f93 100644 --- a/client/src/components/User.jsx +++ b/client/src/components/User.jsx @@ -4,6 +4,7 @@ import PropTypes from 'prop-types'; import { makeStyles } from '@material-ui/core/styles'; import Popover from '@material-ui/core/Popover'; import Typography from '@material-ui/core/Typography'; +import { Link } from 'react-router-dom'; const useStyles = makeStyles((theme) => ({ root: { @@ -55,7 +56,8 @@ function User({ user, admin, className }) { aria-haspopup="true" onMouseEnter={handlePopoverOpen} onMouseLeave={handlePopoverClose} - component="span" + component={user.id ? Link : 'span'} + to={user.id ? `/users/${user.id}` : undefined} variant="subtitle2" > {user.displayName} @@ -106,6 +108,7 @@ function User({ user, admin, className }) { User.propTypes = { user: PropTypes.shape({ + id: PropTypes.number, displayName: PropTypes.string, username: PropTypes.string, wcaId: PropTypes.string, diff --git a/server/api.js b/server/api.js index 0cc24d3..e501719 100644 --- a/server/api.js +++ b/server/api.js @@ -5,6 +5,7 @@ const auth = require('./middlewares/auth.js'); const { updateUsername } = require('./username'); const createFriendsRouter = require('./api/friends'); const createNotificationsRouter = require('./api/notifications'); +const createUsersRouter = require('./api/users'); const PREFERENCE_KEYS = new Set([ 'showWCAID', @@ -43,6 +44,7 @@ module.exports = (app) => { if (app && app.get('config').socialFeatures.enabled) { router.use('/friends', createFriendsRouter()); router.use('/notifications', createNotificationsRouter()); + router.use('/users', createUsersRouter()); } else { router.use('/friends', (req, res) => res.status(404).json({ code: 'feature_disabled', @@ -52,6 +54,10 @@ module.exports = (app) => { code: 'feature_disabled', message: 'This feature is not available', })); + router.use('/users', (req, res) => res.status(404).json({ + code: 'feature_disabled', + message: 'This feature is not available', + })); } router.put('/updatePreference', auth, async (req, res) => { diff --git a/server/api/users.js b/server/api/users.js new file mode 100644 index 0000000..5dcc735 --- /dev/null +++ b/server/api/users.js @@ -0,0 +1,35 @@ +const express = require('express'); +const auth = require('../middlewares/auth'); +const { createDiscoveryService } = require('../social/discoveryService'); + +const sendError = (res, err) => res.status(err.statusCode || 500).json({ + code: err.code || 'user_unavailable', + message: err.statusCode ? err.message : 'User discovery is temporarily unavailable', + ...(err.retryAfterSeconds ? { retryAfterSeconds: err.retryAfterSeconds } : {}), +}); + +const createUsersRouter = (service = createDiscoveryService()) => { + const router = express.Router(); + router.use(auth); + + router.get('/search', async (req, res) => { + try { + res.json(await service.search(req.user, req.query.q, req.query.limit, req.query.cursor)); + } catch (err) { + sendError(res, err); + } + }); + + router.get('/:id', async (req, res) => { + try { + const profile = await service.publicProfile(req.user, req.params.id); + if (!profile) return res.status(404).json({ code: 'user_not_available', message: 'User not available' }); + return res.json(profile); + } catch (err) { + return sendError(res, err); + } + }); + return router; +}; + +module.exports = createUsersRouter; diff --git a/server/models/user.js b/server/models/user.js index c6426ff..5fcb307 100644 --- a/server/models/user.js +++ b/server/models/user.js @@ -80,6 +80,7 @@ schema.index( { usernameNormalized: 1 }, { name: 'users_username_normalized_unique', sparse: true, unique: true }, ); +schema.index({ showWCAID: 1, wcaId: 1 }, { name: 'users_visible_wca_id_lookup' }); schema.pre('validate', function normalizeChangedUsername(next) { if (!this.isNew && !this.isModified('username')) { diff --git a/server/models/user.test.js b/server/models/user.test.js index cd5fd05..8f6456c 100644 --- a/server/models/user.test.js +++ b/server/models/user.test.js @@ -39,6 +39,13 @@ describe('user username schema', () => { expect(UserSchema.options.autoIndex).toBe(false); }); + it('indexes explicit visible WCA ID lookup separately from private identity data', () => { + expect(UserSchema.indexes()).toContainEqual([ + { showWCAID: 1, wcaId: 1 }, + expect.objectContaining({ name: 'users_visible_wca_id_lookup' }), + ]); + }); + it('normalizes direct username writes through the shared validation path', async () => { const user = new User({ id: 1, diff --git a/server/social/discoveryRateLimiter.js b/server/social/discoveryRateLimiter.js new file mode 100644 index 0000000..9ad12e8 --- /dev/null +++ b/server/social/discoveryRateLimiter.js @@ -0,0 +1,45 @@ +const Redis = require('ioredis'); +const config = require('../runtimeConfig'); +const logger = require('../logger'); +const { SocialError } = require('./relationshipState'); + +const SEARCH_LIMIT = 30; +const SEARCH_WINDOW_SECONDS = 60; +const KEY_PREFIX = 'letscube:social:discovery-search:v1'; + +const createClient = () => { + const client = new Redis(config.redis.url, { lazyConnect: true, maxRetriesPerRequest: 1 }); + client.on('error', (err) => logger.error(err)); + return client; +}; + +let sharedClient; + +const createDiscoveryRateLimiter = ({ client, rateLimitLogger = logger } = {}) => ({ + consume: async ({ actorId }) => { + try { + const redis = client || sharedClient || createClient(); + if (!client) sharedClient = redis; + const key = `${KEY_PREFIX}:${actorId}`; + const count = await redis.incr(key); + if (count === 1) await redis.expire(key, SEARCH_WINDOW_SECONDS); + if (count > SEARCH_LIMIT) { + const ttl = await redis.ttl(key); + throw new SocialError(429, 'search_rate_limited', 'Search is temporarily limited', { + retryAfterSeconds: Number.isSafeInteger(ttl) && ttl > 0 ? ttl : 1, + }); + } + } catch (err) { + if (err instanceof SocialError) throw err; + rateLimitLogger.error(err); + throw new SocialError(503, 'search_rate_limit_unavailable', 'Search is temporarily unavailable'); + } + }, +}); + +module.exports = { + SEARCH_LIMIT, + SEARCH_WINDOW_SECONDS, + createDiscoveryRateLimiter, + searchRateLimiter: createDiscoveryRateLimiter(), +}; diff --git a/server/social/discoveryService.js b/server/social/discoveryService.js new file mode 100644 index 0000000..7c6ffb2 --- /dev/null +++ b/server/social/discoveryService.js @@ -0,0 +1,140 @@ +const { FriendRelationship, User, UserBlock } = require('../models'); +const { normalizeUsername } = require('../username'); +const publicUserProjection = require('./publicUser'); +const { SocialError } = require('./relationshipState'); +const { searchRateLimiter } = require('./discoveryRateLimiter'); + +const MAX_RESULTS = 20; +const WCA_ID = /^\d{4}[a-z]{4}\d{2}$/i; + +const lean = async (query) => (query && typeof query.lean === 'function' ? query.lean() : query); +const toId = (value) => (typeof value === 'string' && /^\d+$/.test(value) + ? Number(value) : value); +const validId = (value) => Number.isSafeInteger(value) && value > 0; +const pairKey = (first, second) => `${Math.min(first, second)}:${Math.max(first, second)}`; + +const decodeCursor = (value) => { + if (!value || typeof value !== 'string') return null; + try { + const parsed = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')); + return typeof parsed.usernameNormalized === 'string' && validId(parsed.id) ? parsed : null; + } catch (err) { + return null; + } +}; + +const encodeCursor = (user) => Buffer.from(JSON.stringify({ + id: user.id, + usernameNormalized: user.usernameNormalized, +})).toString('base64url'); + +const relationshipDetails = (actorId, targetId, relationship) => { + if (!relationship || relationship.status === 'removed') { + return { actions: ['request', 'block'], relationship: 'none' }; + } + if (relationship.status === 'accepted') { + return { actions: ['unfriend', 'block'], relationship: 'friends' }; + } + if (relationship.status === 'pending') { + return relationship.requestedBy === actorId + ? { actions: ['cancel', 'block'], relationship: 'outgoing' } + : { actions: ['accept', 'decline', 'block'], relationship: 'incoming' }; + } + return { actions: ['request', 'block'], relationship: 'none' }; +}; + +const createDiscoveryService = ({ + blockModel = UserBlock, + relationshipModel = FriendRelationship, + searchLimiter = searchRateLimiter, + userModel = User, +} = {}) => { + const actorId = (actor) => { + const id = toId(actor && actor.id); + if (!validId(id)) throw new SocialError(401, 'authentication_required', 'Authentication is required'); + return id; + }; + + const blockedIds = async (id) => { + const blocks = await lean(blockModel.find({ + active: true, + $or: [{ blockerId: id }, { blockedId: id }], + })); + return new Set(blocks.map((block) => ( + block.blockerId === id ? block.blockedId : block.blockerId + ))); + }; + + const publicProfile = async (actor, targetValue) => { + const viewerId = actorId(actor); + const targetId = toId(targetValue); + if (!validId(targetId)) return null; + const target = await lean(userModel.findOne({ id: targetId })); + if (!target || (await blockedIds(viewerId)).has(targetId)) return null; + const relationship = viewerId === targetId ? null : await lean( + relationshipModel.findOne({ pairKey: pairKey(viewerId, targetId) }), + ); + const details = viewerId === targetId + ? { actions: [], relationship: 'self' } + : relationshipDetails(viewerId, targetId, relationship); + return { ...publicUserProjection(target), ...details }; + }; + + const search = async (actor, queryValue, limitValue, cursorValue) => { + const viewerId = actorId(actor); + if (typeof queryValue !== 'string' || queryValue.includes('@')) { + return { nextCursor: null, results: [] }; + } + const query = queryValue.normalize('NFKC').trim(); + if (!query) return { nextCursor: null, results: [] }; + let username; + try { + username = normalizeUsername(query, { allowEmpty: false }).usernameNormalized; + } catch (err) { + if (!WCA_ID.test(query)) return { nextCursor: null, results: [] }; + } + await searchLimiter.consume({ actorId: viewerId }); + const requestedLimit = Number.parseInt(limitValue, 10) || MAX_RESULTS; + const limit = Math.min(Math.max(requestedLimit, 1), MAX_RESULTS); + const conditions = [{ id: { $ne: viewerId } }]; + if (username && !WCA_ID.test(query)) { + conditions.push({ usernameNormalized: { $gte: username, $lt: `${username}\uffff` } }); + } + if (WCA_ID.test(query)) { + const wcaId = query.toUpperCase(); + conditions.push({ + $or: [ + ...(username ? [{ usernameNormalized: { $gte: username, $lt: `${username}\uffff` } }] : []), + { showWCAID: true, wcaId }, + ], + }); + } + const cursor = decodeCursor(cursorValue); + if (cursor) { + conditions.push({ + $or: [ + { usernameNormalized: { $gt: cursor.usernameNormalized } }, + { usernameNormalized: cursor.usernameNormalized, id: { $gt: cursor.id } }, + ], + }); + } + const users = await lean(userModel.find({ $and: conditions }) + .sort({ usernameNormalized: 1, id: 1 }).limit(limit + 1)); + const blocked = await blockedIds(viewerId); + const visible = users.filter((user) => !blocked.has(user.id)); + const page = visible.slice(0, limit).map((user) => publicUserProjection(user)); + const hasMore = users.length > limit; + return { + nextCursor: hasMore && page.length + ? encodeCursor(users[Math.min(users.length, limit) - 1]) + : null, + results: page, + }; + }; + + return { publicProfile, search }; +}; + +module.exports = { + MAX_RESULTS, createDiscoveryService, decodeCursor, encodeCursor, relationshipDetails, +}; diff --git a/server/social/discoveryService.test.js b/server/social/discoveryService.test.js new file mode 100644 index 0000000..0fc5a99 --- /dev/null +++ b/server/social/discoveryService.test.js @@ -0,0 +1,126 @@ +/* eslint-env jest */ + +jest.mock('../models', () => ({ + FriendRelationship: {}, + User: {}, + UserBlock: {}, +})); +jest.mock('./relationshipState', () => ({ + SocialError: class SocialError extends Error { + constructor(statusCode, code, message, details = {}) { + super(message); + this.statusCode = statusCode; + this.code = code; + Object.assign(this, details); + } + }, +})); + +const { createDiscoveryService } = require('./discoveryService'); + +const chain = (value) => ({ + lean: jest.fn().mockResolvedValue(value), + limit: jest.fn().mockReturnThis(), + sort: jest.fn().mockReturnThis(), +}); + +const users = [ + { + id: 2, name: 'Hidden Name', username: 'Cuber', usernameNormalized: 'cuber', showWCAID: false, wcaId: '2010CUBE01', avatar: { thumb_url: 'hidden' }, + }, + { + id: 3, name: 'Visible Name', username: 'CuberTwo', usernameNormalized: 'cubertewo', showWCAID: true, wcaId: '2010CUBE02', avatar: { thumb_url: 'visible' }, + }, +]; + +const createService = ({ blocks = [], relationships = [] } = {}) => { + const userModel = { + find: jest.fn(() => chain(users)), + findOne: jest.fn(({ id }) => Promise.resolve(users.find((user) => user.id === id) || null)), + }; + const blockModel = { find: jest.fn(() => chain(blocks)) }; + const relationshipModel = { + findOne: jest.fn(({ pairKey }) => Promise.resolve( + relationships.find((entry) => entry.pairKey === pairKey) || null, + )), + }; + const searchLimiter = { consume: jest.fn().mockResolvedValue() }; + return { + service: createDiscoveryService({ + blockModel, relationshipModel, searchLimiter, userModel, + }), + userModel, + searchLimiter, + }; +}; + +describe('privacy-safe discovery', () => { + it('returns the same empty result for email-like and invalid input without querying users', async () => { + const { service, userModel, searchLimiter } = createService(); + + await expect(service.search({ id: 1 }, 'name@example.com')).resolves.toEqual({ nextCursor: null, results: [] }); + await expect(service.search({ id: 1 }, 'not valid')).resolves.toEqual({ nextCursor: null, results: [] }); + + expect(userModel.find).not.toHaveBeenCalled(); + expect(searchLimiter.consume).not.toHaveBeenCalled(); + }); + + it('projects WCA identity only when the target opted in', async () => { + const { service, userModel } = createService(); + + const result = await service.search({ id: 1 }, 'cuber'); + + expect(result.results).toEqual([ + expect.objectContaining({ id: 2, username: 'Cuber', displayName: 'Cuber' }), + expect.objectContaining({ id: 3, username: 'CuberTwo', wcaId: '2010CUBE02' }), + ]); + expect(result.results[0]).not.toHaveProperty('wcaId'); + expect(result.results[0].avatar).toBeUndefined(); + await service.search({ id: 1 }, '2010cube02'); + expect(userModel.find).toHaveBeenLastCalledWith(expect.objectContaining({ + $and: expect.arrayContaining([expect.objectContaining({ + $or: expect.arrayContaining([{ showWCAID: true, wcaId: '2010CUBE02' }]), + })]), + })); + }); + + it('caps result pages and returns an opaque cursor', async () => { + const { service } = createService(); + + const result = await service.search({ id: 1 }, 'cuber', 1); + + expect(result.results).toHaveLength(1); + expect(result.nextCursor).toEqual(expect.any(String)); + }); + + it('omits either side of a block without explaining why', async () => { + const { service } = createService({ blocks: [{ blockerId: 1, blockedId: 2, active: true }] }); + + await expect(service.search({ id: 1 }, 'cuber')).resolves.toEqual({ + nextCursor: null, + results: [expect.objectContaining({ id: 3 })], + }); + await expect(service.publicProfile({ id: 1 }, '2')).resolves.toBeNull(); + }); + + it('returns viewer-relative safe actions and no editable/private fields', async () => { + const { service } = createService({ relationships: [{ pairKey: '1:2', status: 'pending', requestedBy: 2 }] }); + + const profile = await service.publicProfile({ id: 1 }, 2); + + expect(profile).toEqual(expect.objectContaining({ + actions: ['accept', 'decline', 'block'], + relationship: 'incoming', + })); + expect(profile).not.toHaveProperty('name'); + expect(profile).not.toHaveProperty('email'); + expect(profile).not.toHaveProperty('accessToken'); + }); + + it('returns unavailable for missing or tampered ids', async () => { + const { service } = createService(); + + await expect(service.publicProfile({ id: 1 }, 'abc')).resolves.toBeNull(); + await expect(service.publicProfile({ id: 1 }, '999')).resolves.toBeNull(); + }); +}); From 5cd4e0269699fa06de2e0fb6714af5b6dad2baaf Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 22:05:24 -0700 Subject: [PATCH 29/56] Harden discovery privacy boundaries Use discoverable profile keys, suppress query logging, and sequence profile requests so discovery cannot expose or race private state. --- client/src/components/PublicProfile.jsx | 49 +++++++++++++++++++------ server/index.js | 5 ++- server/social/discoveryService.js | 35 +++++++++++------- server/social/discoveryService.test.js | 12 ++++-- server/social/publicUser.js | 1 + 5 files changed, 71 insertions(+), 31 deletions(-) diff --git a/client/src/components/PublicProfile.jsx b/client/src/components/PublicProfile.jsx index 84147f2..eefb9f9 100644 --- a/client/src/components/PublicProfile.jsx +++ b/client/src/components/PublicProfile.jsx @@ -1,4 +1,6 @@ -import React, { useEffect, useState } from 'react'; +import React, { + useCallback, useEffect, useRef, useState, +} from 'react'; import { Link, useParams } from 'react-router-dom'; import Avatar from '@material-ui/core/Avatar'; import Button from '@material-ui/core/Button'; @@ -23,19 +25,26 @@ function PublicProfile() { const [search, setSearch] = useState(''); const [results, setResults] = useState([]); const [status, setStatus] = useState('loading'); + const [acting, setActing] = useState(false); + const requestSequence = useRef(0); - const load = () => { + const load = useCallback((profileKey) => { + const requestId = requestSequence.current + 1; + requestSequence.current = requestId; setStatus('loading'); - lcFetch(`/api/users/${encodeURIComponent(id)}`) + lcFetch(`/api/users/${encodeURIComponent(profileKey)}`) .then((response) => (response.ok ? response.json() : Promise.reject(response))) .then((data) => { + if (requestSequence.current !== requestId) return; setProfile(data); setStatus('ready'); }) - .catch(() => setStatus('unavailable')); - }; + .catch(() => { + if (requestSequence.current === requestId) setStatus('unavailable'); + }); + }, []); - useEffect(load, [id]); + useEffect(() => load(id), [id, load]); const searchUsers = (event) => { event.preventDefault(); @@ -46,16 +55,21 @@ function PublicProfile() { }; const act = (action) => { - const request = actionRequest(action, id); + if (!profile || acting) return; + setActing(true); + const request = actionRequest(action, profile.id); lcFetch(request.url, { method: request.method, headers: request.body ? { 'Content-Type': 'application/json' } : undefined, body: request.body ? JSON.stringify(request.body) : undefined, - }).then(load); + }).then(() => load(profile.profileKey)).finally(() => setActing(false)); }; return ( - +
- {results.map((user) => )} + {results.map((user) => ( + + ))} {status === 'loading' && } {status === 'unavailable' && This user is not available.} {status === 'ready' && profile && ( <> - + {profile.displayName || profile.username} {profile.username && {`@${profile.username}`}} {profile.wcaId && {`WCA ID: ${profile.wcaId}`}} {profile.relationship} - {profile.actions.map((action) => )} + {profile.actions.map((action) => ( + + ))} )}
diff --git a/server/index.js b/server/index.js index 95d74fa..6edb8d2 100644 --- a/server/index.js +++ b/server/index.js @@ -49,13 +49,14 @@ const init = async () => { /* Logging */ + const isDiscoverySearch = (req) => req.path === '/api/users/search'; app.use(morgan('combined', { - skip: (req, res) => res.statusCode >= 400, + skip: (req, res) => res.statusCode >= 400 || isDiscoverySearch(req), stream: { write: (message) => logger.info(message) }, })); app.use(morgan('combined', { - skip: (req, res) => res.statusCode < 400, + skip: (req, res) => res.statusCode < 400 || isDiscoverySearch(req), stream: { write: (message) => logger.error(message) }, })); diff --git a/server/social/discoveryService.js b/server/social/discoveryService.js index 7c6ffb2..6b37f64 100644 --- a/server/social/discoveryService.js +++ b/server/social/discoveryService.js @@ -17,7 +17,7 @@ const decodeCursor = (value) => { if (!value || typeof value !== 'string') return null; try { const parsed = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')); - return typeof parsed.usernameNormalized === 'string' && validId(parsed.id) ? parsed : null; + return validId(parsed.id) ? parsed : null; } catch (err) { return null; } @@ -25,7 +25,6 @@ const decodeCursor = (value) => { const encodeCursor = (user) => Buffer.from(JSON.stringify({ id: user.id, - usernameNormalized: user.usernameNormalized, })).toString('base64url'); const relationshipDetails = (actorId, targetId, relationship) => { @@ -67,9 +66,24 @@ const createDiscoveryService = ({ const publicProfile = async (actor, targetValue) => { const viewerId = actorId(actor); - const targetId = toId(targetValue); - if (!validId(targetId)) return null; - const target = await lean(userModel.findOne({ id: targetId })); + if (typeof targetValue !== 'string' || targetValue.includes('@')) return null; + const query = targetValue.normalize('NFKC').trim(); + if (!query) return null; + let targetQuery; + if (WCA_ID.test(query)) { + targetQuery = { showWCAID: true, wcaId: query.toUpperCase() }; + } else { + try { + targetQuery = { + usernameNormalized: normalizeUsername(query, { allowEmpty: false }).usernameNormalized, + }; + } catch (err) { + return null; + } + } + await searchLimiter.consume({ actorId: viewerId }); + const target = await lean(userModel.findOne(targetQuery)); + const targetId = target && target.id; if (!target || (await blockedIds(viewerId)).has(targetId)) return null; const relationship = viewerId === targetId ? null : await lean( relationshipModel.findOne({ pairKey: pairKey(viewerId, targetId) }), @@ -111,22 +125,17 @@ const createDiscoveryService = ({ } const cursor = decodeCursor(cursorValue); if (cursor) { - conditions.push({ - $or: [ - { usernameNormalized: { $gt: cursor.usernameNormalized } }, - { usernameNormalized: cursor.usernameNormalized, id: { $gt: cursor.id } }, - ], - }); + conditions.push({ id: { $gt: cursor.id } }); } const users = await lean(userModel.find({ $and: conditions }) - .sort({ usernameNormalized: 1, id: 1 }).limit(limit + 1)); + .sort({ id: 1 }).limit(limit + 1)); const blocked = await blockedIds(viewerId); const visible = users.filter((user) => !blocked.has(user.id)); const page = visible.slice(0, limit).map((user) => publicUserProjection(user)); const hasMore = users.length > limit; return { nextCursor: hasMore && page.length - ? encodeCursor(users[Math.min(users.length, limit) - 1]) + ? encodeCursor(users[limit - 1]) : null, results: page, }; diff --git a/server/social/discoveryService.test.js b/server/social/discoveryService.test.js index 0fc5a99..9fc1b62 100644 --- a/server/social/discoveryService.test.js +++ b/server/social/discoveryService.test.js @@ -36,7 +36,10 @@ const users = [ const createService = ({ blocks = [], relationships = [] } = {}) => { const userModel = { find: jest.fn(() => chain(users)), - findOne: jest.fn(({ id }) => Promise.resolve(users.find((user) => user.id === id) || null)), + findOne: jest.fn((query) => Promise.resolve(users.find((user) => ( + (query.usernameNormalized && user.usernameNormalized === query.usernameNormalized) + || (query.showWCAID && user.showWCAID && user.wcaId === query.wcaId) + )) || null)), }; const blockModel = { find: jest.fn(() => chain(blocks)) }; const relationshipModel = { @@ -100,13 +103,13 @@ describe('privacy-safe discovery', () => { nextCursor: null, results: [expect.objectContaining({ id: 3 })], }); - await expect(service.publicProfile({ id: 1 }, '2')).resolves.toBeNull(); + await expect(service.publicProfile({ id: 1 }, 'cuber')).resolves.toBeNull(); }); it('returns viewer-relative safe actions and no editable/private fields', async () => { const { service } = createService({ relationships: [{ pairKey: '1:2', status: 'pending', requestedBy: 2 }] }); - const profile = await service.publicProfile({ id: 1 }, 2); + const profile = await service.publicProfile({ id: 1 }, 'cuber'); expect(profile).toEqual(expect.objectContaining({ actions: ['accept', 'decline', 'block'], @@ -117,10 +120,11 @@ describe('privacy-safe discovery', () => { expect(profile).not.toHaveProperty('accessToken'); }); - it('returns unavailable for missing or tampered ids', async () => { + it('returns unavailable for missing, email-like, or numeric identifiers', async () => { const { service } = createService(); await expect(service.publicProfile({ id: 1 }, 'abc')).resolves.toBeNull(); + await expect(service.publicProfile({ id: 1 }, 'name@example.com')).resolves.toBeNull(); await expect(service.publicProfile({ id: 1 }, '999')).resolves.toBeNull(); }); }); diff --git a/server/social/publicUser.js b/server/social/publicUser.js index 1466414..8fe448f 100644 --- a/server/social/publicUser.js +++ b/server/social/publicUser.js @@ -7,6 +7,7 @@ const publicUserProjection = (user) => { id: user.id, username: user.username || null, displayName: user.preferRealName ? user.name : (user.username || null), + profileKey: user.usernameNormalized || (user.showWCAID ? user.wcaId : null), }; if (user.showWCAID) { From 6add0db50fcf52c3b0dd72dfaf51a0d725528027 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 22:06:01 -0700 Subject: [PATCH 30/56] Limit public profile keys to discoverable users Avoid changing friend-list projections for users without a valid discovery identifier. --- server/social/publicUser.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/social/publicUser.js b/server/social/publicUser.js index 8fe448f..8bb46f4 100644 --- a/server/social/publicUser.js +++ b/server/social/publicUser.js @@ -3,13 +3,17 @@ const publicUserProjection = (user) => { return null; } + const profileKey = user.usernameNormalized || (user.showWCAID ? user.wcaId : null); const projection = { id: user.id, username: user.username || null, displayName: user.preferRealName ? user.name : (user.username || null), - profileKey: user.usernameNormalized || (user.showWCAID ? user.wcaId : null), }; + if (profileKey) { + projection.profileKey = profileKey; + } + if (user.showWCAID) { projection.wcaId = user.wcaId || null; projection.avatar = user.avatar || {}; From ed0bdcca3f450866e5f2628a59a4501d657fde29 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 22:19:32 -0700 Subject: [PATCH 31/56] Support distinct no-email Cypress identities Keep the legacy default fixture stable while allowing notification tests to authenticate a separate normalized user. --- server/auth/testUser.js | 33 +++++++++++++++++++-------------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/server/auth/testUser.js b/server/auth/testUser.js index f6d468c..5fd43bc 100644 --- a/server/auth/testUser.js +++ b/server/auth/testUser.js @@ -1,18 +1,23 @@ const { normalizeUsername } = require('../username'); -const upsertTestUser = (User, { code, userId }) => User.findOneAndUpdate({ - id: userId, -}, { - id: userId, - name: `Cypress Test User ${userId}`, - ...normalizeUsername(`cypress-${userId}`), - wcaId: `2026TEST${userId}`, - accessToken: `test-token-${code}`, - avatar: {}, -}, { - upsert: true, - useFindAndModify: false, - new: true, -}); +const upsertTestUser = (User, { code, userId }) => { + const isDefaultUser = userId === 990001; + const username = isDefaultUser ? 'cypress' : `cypress-${userId}`; + + return User.findOneAndUpdate({ + id: userId, + }, { + id: userId, + name: isDefaultUser ? 'Cypress Test User' : `Cypress Test User ${userId}`, + ...normalizeUsername(username), + wcaId: isDefaultUser ? '2026TEST01' : `2026TEST${userId}`, + accessToken: `test-token-${code}`, + avatar: {}, + }, { + upsert: true, + useFindAndModify: false, + new: true, + }); +}; module.exports = { upsertTestUser }; From 8cfefcbbfc6bd66a814a4ef771c973b347e9ca4c Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Sun, 12 Jul 2026 22:30:48 -0700 Subject: [PATCH 32/56] Allow Cypress API origin overrides Keep CI defaults while enabling isolated local integration stacks to run the same browser smoke flow. --- cypress/e2e/local_stack.cy.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/cypress/e2e/local_stack.cy.js b/cypress/e2e/local_stack.cy.js index 32e6fc0..fef4f7c 100644 --- a/cypress/e2e/local_stack.cy.js +++ b/cypress/e2e/local_stack.cy.js @@ -1,13 +1,15 @@ describe('local app stack', () => { + const apiOrigin = Cypress.env('apiOrigin') || 'http://localhost:8080'; + const login = () => { - cy.request('POST', 'http://localhost:8080/auth/code', { + cy.request('POST', `${apiOrigin}/auth/code`, { code: 'cypress-test-code', redirectUri: 'http://localhost:3000/wca-redirect', }); }; const loginAs = (userId) => { - cy.request('POST', 'http://localhost:8080/auth/code', { + cy.request('POST', `${apiOrigin}/auth/code`, { code: `cypress-test-user-${userId}`, redirectUri: 'http://localhost:3000/wca-redirect', }); @@ -21,7 +23,7 @@ describe('local app stack', () => { cy.contains('Create Room').should('not.exist'); cy.request({ - url: 'http://localhost:8080/api/me', + url: `${apiOrigin}/api/me`, failOnStatusCode: false, }).then((response) => { expect(response.status).to.eq(403); @@ -43,7 +45,7 @@ describe('local app stack', () => { it('logs in and exposes the test user through the API', () => { login(); - cy.request('http://localhost:8080/api/me').then((response) => { + cy.request(`${apiOrigin}/api/me`).then((response) => { expect(response.status).to.eq(200); expect(response.body).to.include({ id: 990001, canJoinRoom: true }); expect(response.body.displayName).to.be.oneOf(['Cypress Test User', 'cypress']); @@ -99,11 +101,11 @@ describe('local app stack', () => { loginAs(recipientId); loginAs(requesterId); - cy.request('POST', 'http://localhost:8080/api/friends/requests', { userId: recipientId }) + cy.request('POST', `${apiOrigin}/api/friends/requests`, { userId: recipientId }) .its('status').should('eq', 201); loginAs(recipientId); - cy.request('http://localhost:8080/api/notifications').then((response) => { + cy.request(`${apiOrigin}/api/notifications`).then((response) => { expect(response.status).to.eq(200); expect(response.body.notifications.some((notification) => ( notification.type === 'friend_request' && notification.actor.id === requesterId From 4220b9d841e10b87a2c43c556d91f8b358839ce1 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 13 Jul 2026 09:20:22 -0700 Subject: [PATCH 33/56] Refine notification surfaces Replace the header menu with a richer notification panel and align the full page styling. Cover the panel navigation in the local Cypress flow. --- client/src/components/Header.jsx | 23 +-- .../Notifications/NotificationList.jsx | 42 ++++- .../Notifications/NotificationPopover.jsx | 174 ++++++++++++++++++ client/src/components/Notifications/index.jsx | 40 +++- cypress/e2e/local_stack.cy.js | 11 +- 5 files changed, 257 insertions(+), 33 deletions(-) create mode 100644 client/src/components/Notifications/NotificationPopover.jsx diff --git a/client/src/components/Header.jsx b/client/src/components/Header.jsx index e09a494..d653427 100644 --- a/client/src/components/Header.jsx +++ b/client/src/components/Header.jsx @@ -17,6 +17,7 @@ import { apiOrigin } from '../lib/fetch'; import { getNameFromId } from '../lib/events'; import { getWcaAuthorizationUrl } from '../lib/wcaAuth'; import NotificationsIcon from '@material-ui/icons/Notifications'; +import NotificationPopover from './Notifications/NotificationPopover'; const useStyles = makeStyles(() => ({ root: { @@ -111,26 +112,12 @@ function Header({ user, room, notifications }) { - - {notifications.notifications.length === 0 && No notifications} - {notifications.notifications.slice(0, 5).map((notification) => ( - - {notification.type === 'friend_request' ? 'Friend request' : 'Notification'} - - ))} - View all notifications - + unreadCount={notifications.unreadCount} + /> )} diff --git a/client/src/components/Notifications/NotificationList.jsx b/client/src/components/Notifications/NotificationList.jsx index 60e0704..b1cb2da 100644 --- a/client/src/components/Notifications/NotificationList.jsx +++ b/client/src/components/Notifications/NotificationList.jsx @@ -5,27 +5,61 @@ import List from '@material-ui/core/List'; import ListItem from '@material-ui/core/ListItem'; import ListItemText from '@material-ui/core/ListItemText'; import Typography from '@material-ui/core/Typography'; +import { makeStyles } from '@material-ui/core/styles'; import notificationPresentation from './registry'; +const useStyles = makeStyles((theme) => ({ + list: { + marginTop: theme.spacing(1), + }, + notification: { + alignItems: 'center', + borderLeft: '3px solid transparent', + paddingBottom: theme.spacing(1.5), + paddingTop: theme.spacing(1.5), + }, + unread: { + backgroundColor: theme.palette.action.hover, + borderLeftColor: theme.palette.primary.main, + }, + actions: { + display: 'flex', + flexDirection: 'column', + marginLeft: theme.spacing(1), + minWidth: '5rem', + }, + status: { + color: theme.palette.text.secondary, + fontSize: '0.75rem', + marginTop: theme.spacing(0.5), + }, +})); + export default function NotificationList({ actionErrors, actionPending, actionStale, notifications, onAction, }) { + const classes = useStyles(); if (notifications.length === 0) { return You have no notifications.; } return ( - + {notifications.map((notification) => { const presentation = notificationPresentation(notification); const pending = !!actionPending[notification.id]; const stale = !!actionStale[notification.id]; return ( - + {notification.readAt ? 'Viewed' : 'New'}} /> -
+
{!stale && presentation.actions.map((action) => ( +
+
+ + ); +} + +NotificationPopover.propTypes = { + anchorEl: PropTypes.shape(), + notifications: PropTypes.arrayOf(PropTypes.shape({ + actor: PropTypes.shape({ + avatar: PropTypes.shape({ thumb_url: PropTypes.string }), + displayName: PropTypes.string, + username: PropTypes.string, + }), + id: PropTypes.string.isRequired, + readAt: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + type: PropTypes.string.isRequired, + })), + onClose: PropTypes.func.isRequired, + unreadCount: PropTypes.number, +}; + +NotificationPopover.defaultProps = { + anchorEl: null, + notifications: [], + unreadCount: 0, +}; + +export default NotificationPopover; diff --git a/client/src/components/Notifications/index.jsx b/client/src/components/Notifications/index.jsx index 5a70084..69fda0b 100644 --- a/client/src/components/Notifications/index.jsx +++ b/client/src/components/Notifications/index.jsx @@ -5,6 +5,7 @@ import Button from '@material-ui/core/Button'; import CircularProgress from '@material-ui/core/CircularProgress'; import Container from '@material-ui/core/Container'; import Typography from '@material-ui/core/Typography'; +import { makeStyles } from '@material-ui/core/styles'; import { fetchNotifications, markAllNotificationsRead, @@ -12,24 +13,47 @@ import { } from '../../store/notifications/actions'; import NotificationList from './NotificationList'; +const useStyles = makeStyles((theme) => ({ + root: { + paddingTop: theme.spacing(3), + }, + heading: { + alignItems: 'flex-start', + display: 'flex', + justifyContent: 'space-between', + marginBottom: theme.spacing(1), + }, + count: { + marginTop: theme.spacing(1), + }, +})); + export function NotificationsPage({ notifications, fetchPage, markAllRead, runAction, }) { + const classes = useStyles(); useEffect(() => { fetchPage(); }, [fetchPage]); if (notifications.enabled === false) { return Notifications are unavailable.; } return ( - - Notifications + +
+
+ Notifications + + {notifications.unreadCount ? `${notifications.unreadCount} unread` : 'You’re all caught up'} + +
+ +
{notifications.fetching && notifications.notifications.length === 0 && } {notifications.error && {notifications.error.message}} - { }); it('delivers and acts on a friend request notification for two users', () => { - const requesterId = 990001; - const recipientId = 990002; + const requesterId = 900000 + (Date.now() % 99999); + const recipientId = requesterId + 1; loginAs(recipientId); loginAs(requesterId); @@ -112,7 +112,12 @@ describe('local app stack', () => { ))).to.eq(true); }); - cy.visit('/notifications'); + cy.visit('/'); + cy.get('button[aria-label="Notifications"]').click(); + cy.get('[aria-label="Recent notifications"]').should('be.visible'); + cy.contains('sent you a friend request.').should('be.visible'); + cy.contains('View all notifications').click(); + cy.location('pathname').should('eq', '/notifications'); cy.get('button[aria-label="accept friend request"]').click(); loginAs(requesterId); From f6eb716e4a6fb26cd33d7c4aa1194ec99ebee433 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 13 Jul 2026 17:00:02 -0700 Subject: [PATCH 34/56] Fix public profile navigation Use public profile keys for lobby links and render unavailable profiles as a dedicated 404 page. --- client/src/components/Lobby/UserList.jsx | 8 +++++++- client/src/components/Lobby/index.jsx | 1 + client/src/components/PublicProfile.jsx | 24 +++++++++++++++++++++--- cypress/e2e/local_stack.cy.js | 12 ++++++++++++ server/socket/namespaces/rooms.js | 3 ++- 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/client/src/components/Lobby/UserList.jsx b/client/src/components/Lobby/UserList.jsx index 944ed4c..a8bd6df 100644 --- a/client/src/components/Lobby/UserList.jsx +++ b/client/src/components/Lobby/UserList.jsx @@ -35,7 +35,12 @@ function LobbyUserList({ users }) { }} > {users.map((user) => ( - + @@ -52,6 +57,7 @@ LobbyUserList.propTypes = { id: PropTypes.number, displayName: PropTypes.string, inARoom: PropTypes.boolean, + profileKey: PropTypes.string, })), }; diff --git a/client/src/components/Lobby/index.jsx b/client/src/components/Lobby/index.jsx index c49589c..d8720dc 100644 --- a/client/src/components/Lobby/index.jsx +++ b/client/src/components/Lobby/index.jsx @@ -126,6 +126,7 @@ function Lobby({ displayName: u.displayName, inARoom: !!rooms.find((room) => room.users && room.users.find((i) => i.id === u.id)), avatar: u.avatar, + profileKey: u.profileKey, })); const onCreateRoom = (options) => { diff --git a/client/src/components/PublicProfile.jsx b/client/src/components/PublicProfile.jsx index eefb9f9..1fdaabc 100644 --- a/client/src/components/PublicProfile.jsx +++ b/client/src/components/PublicProfile.jsx @@ -39,8 +39,10 @@ function PublicProfile() { setProfile(data); setStatus('ready'); }) - .catch(() => { - if (requestSequence.current === requestId) setStatus('unavailable'); + .catch((response) => { + if (requestSequence.current === requestId) { + setStatus(response && response.status === 404 ? 'not-found' : 'unavailable'); + } }); }, []); @@ -65,6 +67,22 @@ function PublicProfile() { }).then(() => load(profile.profileKey)).finally(() => setActing(false)); }; + if (status === 'not-found') { + return ( + + 404 + User not found + + This profile is unavailable or no longer public. + + + + ); + } + return ( ))} {status === 'loading' && } - {status === 'unavailable' && This user is not available.} + {status === 'unavailable' && Unable to load this profile right now.} {status === 'ready' && profile && ( <> { cy.visit('/'); cy.contains('Create Room', { timeout: 10000 }).should('be.visible'); + cy.get('a[href="/users/cypress"]', { timeout: 10000 }).click(); + cy.location('pathname').should('eq', '/users/cypress'); + cy.contains('@cypress').should('be.visible'); + }); + + it('shows a not-found page for an unavailable user profile', () => { + login(); + + cy.visit('/users/not-a-real-cuber'); + cy.contains('404').should('be.visible'); + cy.contains('User not found').should('be.visible'); + cy.get('[aria-label="Find a cuber by username or visible WCA ID"]').should('not.exist'); }); it('creates a room, reloads it directly, and lists it in the lobby', () => { diff --git a/server/socket/namespaces/rooms.js b/server/socket/namespaces/rooms.js index e430b4d..bcebef4 100644 --- a/server/socket/namespaces/rooms.js +++ b/server/socket/namespaces/rooms.js @@ -8,6 +8,7 @@ const logger = require('../../logger'); const metrics = require('../../metrics'); const { markRoomDeleted } = require('../../postgres/dualWrite'); const { Room, User } = require('../../models'); +const publicUserProjection = require('../../social/publicUser'); const { encodeUserRoom } = require('../utils'); const roomMap = require('../lib/roomMap'); const { @@ -265,7 +266,7 @@ module.exports = (io, middlewares) => { id: { $in: sockets, }, - })).map((user) => user.toJSON()).filter((user) => !!user.displayName); + })).map((user) => publicUserProjection(user)).filter((user) => !!user.displayName); ns().emit(Protocol.UPDATE_USERS_IN_LOBBY, { users, From 3ebcb31c3c22a84a6a3904e30321193aa43c4c19 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 13 Jul 2026 17:11:22 -0700 Subject: [PATCH 35/56] Separate profile and user discovery Give public profiles a focused layout and move cuber search to its own route. --- client/src/components/Header.jsx | 1 + client/src/components/Navigation.jsx | 2 + client/src/components/PublicProfile.jsx | 178 ++++++++++++++++-------- client/src/components/UserDiscovery.jsx | 88 ++++++++++++ cypress/e2e/local_stack.cy.js | 1 + 5 files changed, 210 insertions(+), 60 deletions(-) create mode 100644 client/src/components/UserDiscovery.jsx diff --git a/client/src/components/Header.jsx b/client/src/components/Header.jsx index d653427..c0fbdfb 100644 --- a/client/src/components/Header.jsx +++ b/client/src/components/Header.jsx @@ -138,6 +138,7 @@ function Header({ user, room, notifications }) { open={open} onClose={handleClose} > + Find cubers Profile Log out diff --git a/client/src/components/Navigation.jsx b/client/src/components/Navigation.jsx index 76f4da6..4daf434 100644 --- a/client/src/components/Navigation.jsx +++ b/client/src/components/Navigation.jsx @@ -30,6 +30,7 @@ const Room = lazy(() => import('./Room/index')); const Profile = lazy(() => import('./common/Profile')); const Notifications = lazy(() => import('./Notifications')); const PublicProfile = lazy(() => import('./PublicProfile')); +const UserDiscovery = lazy(() => import('./UserDiscovery')); export const shouldShowGlobalPendingResult = (room, pendingResult) => ( isPendingResult(pendingResult) @@ -229,6 +230,7 @@ function Navigation({ + diff --git a/client/src/components/PublicProfile.jsx b/client/src/components/PublicProfile.jsx index 1fdaabc..6727cc0 100644 --- a/client/src/components/PublicProfile.jsx +++ b/client/src/components/PublicProfile.jsx @@ -5,9 +5,10 @@ import { Link, useParams } from 'react-router-dom'; import Avatar from '@material-ui/core/Avatar'; import Button from '@material-ui/core/Button'; import CircularProgress from '@material-ui/core/CircularProgress'; +import Container from '@material-ui/core/Container'; import Paper from '@material-ui/core/Paper'; -import TextField from '@material-ui/core/TextField'; import Typography from '@material-ui/core/Typography'; +import { makeStyles } from '@material-ui/core/styles'; import { lcFetch } from '../lib/fetch'; const actionRequest = (action, id) => { @@ -19,11 +20,71 @@ const actionRequest = (action, id) => { return { method: 'PUT', url: `/api/friends/blocks/${encodedId}` }; }; +const actionLabel = { + accept: 'Accept request', + block: 'Block', + cancel: 'Cancel request', + decline: 'Decline', + request: 'Send friend request', + unfriend: 'Remove friend', +}; + +const relationshipLabel = { + accepted: 'Friends', + incoming: 'Friend request received', + none: 'Not connected', + outgoing: 'Friend request sent', + self: 'This is you', +}; + +const useStyles = makeStyles((theme) => ({ + root: { + paddingTop: theme.spacing(5), + }, + card: { + overflow: 'hidden', + }, + identity: { + alignItems: 'center', + backgroundColor: theme.palette.action.hover, + display: 'flex', + padding: theme.spacing(3), + }, + avatar: { + height: theme.spacing(10), + marginRight: theme.spacing(2), + width: theme.spacing(10), + }, + profileType: { + letterSpacing: '0.08em', + textTransform: 'uppercase', + }, + profileContent: { + padding: theme.spacing(3), + }, + relationship: { + marginBottom: theme.spacing(2), + }, + actions: { + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing(1), + }, + loading: { + display: 'flex', + justifyContent: 'center', + padding: theme.spacing(5), + }, + notFound: { + padding: theme.spacing(4), + textAlign: 'center', + }, +})); + function PublicProfile() { const { id } = useParams(); + const classes = useStyles(); const [profile, setProfile] = useState(null); - const [search, setSearch] = useState(''); - const [results, setResults] = useState([]); const [status, setStatus] = useState('loading'); const [acting, setActing] = useState(false); const requestSequence = useRef(0); @@ -48,14 +109,6 @@ function PublicProfile() { useEffect(() => load(id), [id, load]); - const searchUsers = (event) => { - event.preventDefault(); - lcFetch(`/api/users/search?q=${encodeURIComponent(search)}`) - .then((response) => (response.ok ? response.json() : { results: [] })) - .then((data) => setResults(data.results || [])) - .catch(() => setResults([])); - }; - const act = (action) => { if (!profile || acting) return; setActing(true); @@ -69,59 +122,64 @@ function PublicProfile() { if (status === 'not-found') { return ( - - 404 - User not found - - This profile is unavailable or no longer public. - - - + + + 404 + User not found + + This profile is unavailable or no longer public. + + + + ); } return ( - -
- setSearch(event.target.value)} - value={search} - /> - - - {results.map((user) => ( - - ))} - {status === 'loading' && } - {status === 'unavailable' && Unable to load this profile right now.} - {status === 'ready' && profile && ( - <> - - {profile.displayName || profile.username} - {profile.username && {`@${profile.username}`}} - {profile.wcaId && {`WCA ID: ${profile.wcaId}`}} - {profile.relationship} - {profile.actions.map((action) => ( - - ))} - - )} -
+ + + {status === 'loading' &&
} + {status === 'unavailable' &&
Unable to load this profile right now.
} + {status === 'ready' && profile && ( + <> +
+ +
+ Cuber profile + {profile.displayName || profile.username} + {profile.username && {`@${profile.username}`}} +
+
+
+ {profile.wcaId && {`WCA ID: ${profile.wcaId}`}} + + {relationshipLabel[profile.relationship] || 'Not connected'} + +
+ {profile.actions.map((action) => ( + + ))} + {profile.relationship === 'self' && ( + + )} +
+
+ + )} +
+
); } diff --git a/client/src/components/UserDiscovery.jsx b/client/src/components/UserDiscovery.jsx new file mode 100644 index 0000000..ac1d952 --- /dev/null +++ b/client/src/components/UserDiscovery.jsx @@ -0,0 +1,88 @@ +import React, { useState } from 'react'; +import { Link } from 'react-router-dom'; +import Button from '@material-ui/core/Button'; +import Container from '@material-ui/core/Container'; +import Paper from '@material-ui/core/Paper'; +import TextField from '@material-ui/core/TextField'; +import Typography from '@material-ui/core/Typography'; +import { makeStyles } from '@material-ui/core/styles'; +import { lcFetch } from '../lib/fetch'; + +const useStyles = makeStyles((theme) => ({ + root: { + paddingTop: theme.spacing(5), + }, + panel: { + padding: theme.spacing(3), + }, + form: { + display: 'flex', + gap: theme.spacing(1), + marginTop: theme.spacing(3), + }, + input: { + flexGrow: 1, + }, + results: { + display: 'flex', + flexDirection: 'column', + marginTop: theme.spacing(2), + }, + result: { + justifyContent: 'flex-start', + }, +})); + +function UserDiscovery() { + const classes = useStyles(); + const [search, setSearch] = useState(''); + const [results, setResults] = useState([]); + const [searched, setSearched] = useState(false); + + const searchUsers = (event) => { + event.preventDefault(); + lcFetch(`/api/users/search?q=${encodeURIComponent(search)}`) + .then((response) => (response.ok ? response.json() : { results: [] })) + .then((data) => { + setResults(data.results || []); + setSearched(true); + }) + .catch(() => { + setResults([]); + setSearched(true); + }); + }; + + return ( + + + Find cubers + + Search by username or a visible WCA ID. + +
+ setSearch(event.target.value)} + value={search} + /> + + +
+ {results.map((user) => ( + + ))} + {searched && results.length === 0 && ( + No matching cubers found. + )} +
+
+
+ ); +} + +export default UserDiscovery; diff --git a/cypress/e2e/local_stack.cy.js b/cypress/e2e/local_stack.cy.js index 716b83a..18bc724 100644 --- a/cypress/e2e/local_stack.cy.js +++ b/cypress/e2e/local_stack.cy.js @@ -56,6 +56,7 @@ describe('local app stack', () => { cy.get('a[href="/users/cypress"]', { timeout: 10000 }).click(); cy.location('pathname').should('eq', '/users/cypress'); cy.contains('@cypress').should('be.visible'); + cy.get('[aria-label="Find a cuber by username or visible WCA ID"]').should('not.exist'); }); it('shows a not-found page for an unavailable user profile', () => { From 3a3827767a18d976e67f47fa5d650148fe0f3d85 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 13 Jul 2026 17:20:57 -0700 Subject: [PATCH 36/56] Add friends management flow Provide a Friends page with request management and an Add friend discovery dialog. --- client/src/components/Friends.jsx | 305 ++++++++++++++++++++++++ client/src/components/Header.jsx | 2 +- client/src/components/Navigation.jsx | 4 +- client/src/components/UserDiscovery.jsx | 88 ------- cypress/e2e/local_stack.cy.js | 14 ++ 5 files changed, 322 insertions(+), 91 deletions(-) create mode 100644 client/src/components/Friends.jsx delete mode 100644 client/src/components/UserDiscovery.jsx diff --git a/client/src/components/Friends.jsx b/client/src/components/Friends.jsx new file mode 100644 index 0000000..15db0f9 --- /dev/null +++ b/client/src/components/Friends.jsx @@ -0,0 +1,305 @@ +import React, { + useCallback, useEffect, useState, +} from 'react'; +import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; +import Avatar from '@material-ui/core/Avatar'; +import Button from '@material-ui/core/Button'; +import CircularProgress from '@material-ui/core/CircularProgress'; +import Container from '@material-ui/core/Container'; +import Dialog from '@material-ui/core/Dialog'; +import DialogActions from '@material-ui/core/DialogActions'; +import DialogContent from '@material-ui/core/DialogContent'; +import DialogTitle from '@material-ui/core/DialogTitle'; +import Divider from '@material-ui/core/Divider'; +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemAvatar from '@material-ui/core/ListItemAvatar'; +import ListItemText from '@material-ui/core/ListItemText'; +import Paper from '@material-ui/core/Paper'; +import TextField from '@material-ui/core/TextField'; +import Typography from '@material-ui/core/Typography'; +import { makeStyles } from '@material-ui/core/styles'; +import { lcFetch } from '../lib/fetch'; + +const useStyles = makeStyles((theme) => ({ + root: { + paddingTop: theme.spacing(4), + }, + heading: { + alignItems: 'center', + display: 'flex', + justifyContent: 'space-between', + marginBottom: theme.spacing(2), + }, + section: { + padding: theme.spacing(2, 0), + }, + sectionTitle: { + padding: theme.spacing(0, 2), + }, + empty: { + padding: theme.spacing(1, 2), + }, + action: { + marginLeft: theme.spacing(1), + }, + profileLink: { + color: 'inherit', + textDecoration: 'none', + }, + search: { + display: 'flex', + gap: theme.spacing(1), + marginBottom: theme.spacing(2), + }, + searchField: { + flexGrow: 1, + }, + dialogError: { + marginBottom: theme.spacing(1), + }, +})); + +const userName = (user) => user.displayName || user.username || 'Unknown cuber'; + +const errorMessage = async (response, fallback) => { + try { + const body = await response.json(); + return body.message || fallback; + } catch (error) { + return fallback; + } +}; + +function FriendEntry({ action, entry, secondary }) { + const classes = useStyles(); + const user = entry.user; + const name = user.profileKey + ? {userName(user)} + : userName(user); + + return ( + + + + + + {action} + + ); +} + +FriendEntry.propTypes = { + action: PropTypes.node, + entry: PropTypes.shape({ + user: PropTypes.shape({ + avatar: PropTypes.shape({ thumb_url: PropTypes.string }), + displayName: PropTypes.string, + profileKey: PropTypes.string, + username: PropTypes.string, + }).isRequired, + }).isRequired, + secondary: PropTypes.string.isRequired, +}; + +FriendEntry.defaultProps = { + action: null, +}; + +function FriendsSection({ children, emptyMessage, title }) { + const classes = useStyles(); + const entries = React.Children.toArray(children).filter(Boolean); + + return ( +
+ {title} + {entries.length ? {entries} : {emptyMessage}} +
+ ); +} + +FriendsSection.propTypes = { + children: PropTypes.node, + emptyMessage: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, +}; + +FriendsSection.defaultProps = { + children: null, +}; + +function AddFriendDialog({ onClose, onRequest, open }) { + const classes = useStyles(); + const [query, setQuery] = useState(''); + const [results, setResults] = useState([]); + const [error, setError] = useState(null); + const [searching, setSearching] = useState(false); + + const search = (event) => { + event.preventDefault(); + setError(null); + setSearching(true); + lcFetch(`/api/users/search?q=${encodeURIComponent(query)}`) + .then(async (response) => { + if (!response.ok) throw new Error(await errorMessage(response, 'Unable to search for cubers')); + return response.json(); + }) + .then((data) => setResults(data.results || [])) + .catch((searchError) => setError(searchError.message)) + .finally(() => setSearching(false)); + }; + + const requestFriend = (user) => { + setError(null); + onRequest(user) + .then(() => onClose()) + .catch((requestError) => setError(requestError.message)); + }; + + return ( + + Add friend + + + Search by username or a visible WCA ID. + +
+ setQuery(event.target.value)} + value={query} + /> + + + {error && {error}} + {searching && } + {!searching && results.length > 0 && ( + + {results.map((user) => ( + + + + + + + + ))} + + )} +
+ + + +
+ ); +} + +AddFriendDialog.propTypes = { + onClose: PropTypes.func.isRequired, + onRequest: PropTypes.func.isRequired, + open: PropTypes.bool.isRequired, +}; + +function Friends() { + const classes = useStyles(); + const [data, setData] = useState(null); + const [dialogOpen, setDialogOpen] = useState(false); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + const loadFriends = useCallback(() => { + setLoading(true); + setError(null); + return lcFetch('/api/friends') + .then(async (response) => { + if (!response.ok) throw new Error(await errorMessage(response, 'Unable to load friends')); + return response.json(); + }) + .then((response) => setData(response)) + .catch((loadError) => setError(loadError.message)) + .finally(() => setLoading(false)); + }, []); + + useEffect(() => { loadFriends(); }, [loadFriends]); + + const performAction = (url, method, body) => lcFetch(url, { + method, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + }).then(async (response) => { + if (!response.ok) throw new Error(await errorMessage(response, 'Unable to update friendship')); + return loadFriends(); + }); + + const requestFriend = (user) => performAction('/api/friends/requests', 'POST', { userId: user.id }); + const handleAction = (url, method, body) => { + performAction(url, method, body).catch((actionError) => setError(actionError.message)); + }; + + return ( + +
+
+ Friends + Manage your cubing connections. +
+ +
+ + {loading &&
} + {error && {error}} + {!loading && data && ( + <> + + {data.incoming.map((entry) => ( + + + + + )} + entry={entry} + key={entry.user.id} + secondary="Wants to be friends" + /> + ))} + + + + {data.friends.map((entry) => ( + handleAction(`/api/friends/${entry.user.id}`, 'DELETE')}>Remove} + entry={entry} + key={entry.user.id} + secondary="Friends" + /> + ))} + + + + {data.outgoing.map((entry) => ( + handleAction(`/api/friends/requests/${entry.user.id}`, 'DELETE')}>Cancel} + entry={entry} + key={entry.user.id} + secondary="Waiting for a response" + /> + ))} + + + )} +
+ setDialogOpen(false)} + onRequest={requestFriend} + open={dialogOpen} + /> +
+ ); +} + +export default Friends; diff --git a/client/src/components/Header.jsx b/client/src/components/Header.jsx index c0fbdfb..29b3e46 100644 --- a/client/src/components/Header.jsx +++ b/client/src/components/Header.jsx @@ -138,7 +138,7 @@ function Header({ user, room, notifications }) { open={open} onClose={handleClose} > - Find cubers + Friends Profile Log out diff --git a/client/src/components/Navigation.jsx b/client/src/components/Navigation.jsx index 4daf434..e91ef1e 100644 --- a/client/src/components/Navigation.jsx +++ b/client/src/components/Navigation.jsx @@ -29,8 +29,8 @@ const Lobby = lazy(() => import('./Lobby/index')); const Room = lazy(() => import('./Room/index')); const Profile = lazy(() => import('./common/Profile')); const Notifications = lazy(() => import('./Notifications')); +const Friends = lazy(() => import('./Friends')); const PublicProfile = lazy(() => import('./PublicProfile')); -const UserDiscovery = lazy(() => import('./UserDiscovery')); export const shouldShowGlobalPendingResult = (room, pendingResult) => ( isPendingResult(pendingResult) @@ -230,7 +230,7 @@ function Navigation({ - + diff --git a/client/src/components/UserDiscovery.jsx b/client/src/components/UserDiscovery.jsx deleted file mode 100644 index ac1d952..0000000 --- a/client/src/components/UserDiscovery.jsx +++ /dev/null @@ -1,88 +0,0 @@ -import React, { useState } from 'react'; -import { Link } from 'react-router-dom'; -import Button from '@material-ui/core/Button'; -import Container from '@material-ui/core/Container'; -import Paper from '@material-ui/core/Paper'; -import TextField from '@material-ui/core/TextField'; -import Typography from '@material-ui/core/Typography'; -import { makeStyles } from '@material-ui/core/styles'; -import { lcFetch } from '../lib/fetch'; - -const useStyles = makeStyles((theme) => ({ - root: { - paddingTop: theme.spacing(5), - }, - panel: { - padding: theme.spacing(3), - }, - form: { - display: 'flex', - gap: theme.spacing(1), - marginTop: theme.spacing(3), - }, - input: { - flexGrow: 1, - }, - results: { - display: 'flex', - flexDirection: 'column', - marginTop: theme.spacing(2), - }, - result: { - justifyContent: 'flex-start', - }, -})); - -function UserDiscovery() { - const classes = useStyles(); - const [search, setSearch] = useState(''); - const [results, setResults] = useState([]); - const [searched, setSearched] = useState(false); - - const searchUsers = (event) => { - event.preventDefault(); - lcFetch(`/api/users/search?q=${encodeURIComponent(search)}`) - .then((response) => (response.ok ? response.json() : { results: [] })) - .then((data) => { - setResults(data.results || []); - setSearched(true); - }) - .catch(() => { - setResults([]); - setSearched(true); - }); - }; - - return ( - - - Find cubers - - Search by username or a visible WCA ID. - -
- setSearch(event.target.value)} - value={search} - /> - - -
- {results.map((user) => ( - - ))} - {searched && results.length === 0 && ( - No matching cubers found. - )} -
-
-
- ); -} - -export default UserDiscovery; diff --git a/cypress/e2e/local_stack.cy.js b/cypress/e2e/local_stack.cy.js index 18bc724..695e1aa 100644 --- a/cypress/e2e/local_stack.cy.js +++ b/cypress/e2e/local_stack.cy.js @@ -68,6 +68,20 @@ describe('local app stack', () => { cy.get('[aria-label="Find a cuber by username or visible WCA ID"]').should('not.exist'); }); + it('opens cuber discovery from the Add friend dialog', () => { + const searchUserId = 990003; + + loginAs(searchUserId); + login(); + cy.visit('/friends'); + cy.contains('Friends', { timeout: 10000 }).should('be.visible'); + cy.contains('button', 'Add friend').click(); + cy.get('[role="dialog"]').contains('Add friend').should('be.visible'); + cy.get('[role="dialog"] input[aria-label="Find a cuber by username or visible WCA ID"]').type(`cypress-${searchUserId}`); + cy.get('[role="dialog"]').contains('button', 'Search').click(); + cy.get('[role="dialog"]').contains(`cypress-${searchUserId}`).should('be.visible'); + }); + it('creates a room, reloads it directly, and lists it in the lobby', () => { const roomName = `Cypress Room ${Date.now()}`; login(); From 05e83e9b724ff74e3ca14e55861d848475e87607 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 13 Jul 2026 17:25:45 -0700 Subject: [PATCH 37/56] Improve responsive account navigation Use a descriptive desktop account panel and touch-friendly mobile bottom sheet for account destinations. --- client/src/components/AccountMenu.jsx | 182 ++++++++++++++++++++++++++ client/src/components/Header.jsx | 38 +++--- cypress/e2e/local_stack.cy.js | 10 ++ 3 files changed, 210 insertions(+), 20 deletions(-) create mode 100644 client/src/components/AccountMenu.jsx diff --git a/client/src/components/AccountMenu.jsx b/client/src/components/AccountMenu.jsx new file mode 100644 index 0000000..877f622 --- /dev/null +++ b/client/src/components/AccountMenu.jsx @@ -0,0 +1,182 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Link } from 'react-router-dom'; +import { makeStyles, useTheme } from '@material-ui/core/styles'; +import useMediaQuery from '@material-ui/core/useMediaQuery'; +import Avatar from '@material-ui/core/Avatar'; +import Divider from '@material-ui/core/Divider'; +import Drawer from '@material-ui/core/Drawer'; +import List from '@material-ui/core/List'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import ListItemText from '@material-ui/core/ListItemText'; +import Menu from '@material-ui/core/Menu'; +import MenuItem from '@material-ui/core/MenuItem'; +import Typography from '@material-ui/core/Typography'; +import ExitToAppIcon from '@material-ui/icons/ExitToApp'; +import PeopleIcon from '@material-ui/icons/People'; +import PersonIcon from '@material-ui/icons/Person'; + +const useStyles = makeStyles((theme) => ({ + desktopPaper: { + minWidth: '17rem', + }, + accountHeader: { + alignItems: 'center', + display: 'flex', + padding: theme.spacing(2), + }, + avatar: { + height: theme.spacing(5), + marginRight: theme.spacing(1.5), + width: theme.spacing(5), + }, + desktopItem: { + minHeight: theme.spacing(6), + }, + menuIcon: { + minWidth: theme.spacing(5), + }, + drawerPaper: { + borderRadius: `${theme.spacing(2)}px ${theme.spacing(2)}px 0 0`, + }, + drawerHandle: { + backgroundColor: theme.palette.divider, + borderRadius: theme.spacing(1), + height: theme.spacing(0.5), + margin: theme.spacing(1.25, 'auto', 0), + width: theme.spacing(5), + }, + drawerItem: { + minHeight: theme.spacing(7), + paddingLeft: theme.spacing(3), + paddingRight: theme.spacing(3), + }, +})); + +const accountName = (user) => user.displayName || user.name || user.username || 'Your account'; + +function AccountHeader({ user }) { + const classes = useStyles(); + const name = accountName(user); + + return ( +
+ + {name.charAt(0).toUpperCase()} + +
+ {name} + Account +
+
+ ); +} + +AccountHeader.propTypes = { + user: PropTypes.shape({ + avatar: PropTypes.shape({ thumb_url: PropTypes.string }), + displayName: PropTypes.string, + name: PropTypes.string, + username: PropTypes.string, + }).isRequired, +}; + +function AccountItems({ itemComponent: Item, itemClassName, onClose, onLogout }) { + const classes = useStyles(); + + return ( + <> + + + + + + + + + + + + + + + ); +} + +AccountItems.propTypes = { + itemClassName: PropTypes.string.isRequired, + itemComponent: PropTypes.elementType.isRequired, + onClose: PropTypes.func.isRequired, + onLogout: PropTypes.func.isRequired, +}; + +function AccountMenu({ anchorEl, onClose, onLogout, open, user }) { + const classes = useStyles(); + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + + if (isMobile) { + return ( + +
+ + + + + + + ); + } + + return ( + + + + + + ); +} + +AccountMenu.propTypes = { + anchorEl: PropTypes.shape(), + onClose: PropTypes.func.isRequired, + onLogout: PropTypes.func.isRequired, + open: PropTypes.bool.isRequired, + user: PropTypes.shape({ + avatar: PropTypes.shape({ thumb_url: PropTypes.string }), + displayName: PropTypes.string, + name: PropTypes.string, + username: PropTypes.string, + }).isRequired, +}; + +AccountMenu.defaultProps = { + anchorEl: null, +}; + +export default AccountMenu; diff --git a/client/src/components/Header.jsx b/client/src/components/Header.jsx index 29b3e46..0417f60 100644 --- a/client/src/components/Header.jsx +++ b/client/src/components/Header.jsx @@ -9,14 +9,13 @@ import Typography from '@material-ui/core/Typography'; import Toolbar from '@material-ui/core/Toolbar'; import IconButton from '@material-ui/core/IconButton'; import Avatar from '@material-ui/core/Avatar'; -import Menu from '@material-ui/core/Menu'; import Button from '@material-ui/core/Button'; -import MenuItem from '@material-ui/core/MenuItem'; import Badge from '@material-ui/core/Badge'; import { apiOrigin } from '../lib/fetch'; import { getNameFromId } from '../lib/events'; import { getWcaAuthorizationUrl } from '../lib/wcaAuth'; import NotificationsIcon from '@material-ui/icons/Notifications'; +import AccountMenu from './AccountMenu'; import NotificationPopover from './Notifications/NotificationPopover'; const useStyles = makeStyles(() => ({ @@ -120,28 +119,23 @@ function Header({ user, room, notifications }) { /> )} - + - - Friends - Profile - Log out - + onLogout={logout} + open={open} + user={user} + />
) : } @@ -154,6 +148,8 @@ Header.propTypes = { user: PropTypes.shape({ id: PropTypes.number, name: PropTypes.string, + displayName: PropTypes.string, + username: PropTypes.string, wcaId: PropTypes.string, avatar: PropTypes.shape({ thumb_url: PropTypes.string, @@ -175,6 +171,8 @@ Header.defaultProps = { user: { id: undefined, name: undefined, + displayName: undefined, + username: undefined, wcaId: undefined, avatar: { thumb_url: undefined, diff --git a/cypress/e2e/local_stack.cy.js b/cypress/e2e/local_stack.cy.js index 695e1aa..6ab21a2 100644 --- a/cypress/e2e/local_stack.cy.js +++ b/cypress/e2e/local_stack.cy.js @@ -59,6 +59,16 @@ describe('local app stack', () => { cy.get('[aria-label="Find a cuber by username or visible WCA ID"]').should('not.exist'); }); + it('opens spacious account navigation', () => { + login(); + cy.visit('/'); + + cy.get('button[aria-label="Open account menu"]').click(); + cy.get('[aria-label="Account navigation"]').contains('Friends').should('be.visible'); + cy.get('[aria-label="Account navigation"]').contains('Profile').should('be.visible'); + cy.get('[aria-label="Account navigation"]').contains('Log out').should('be.visible'); + }); + it('shows a not-found page for an unavailable user profile', () => { login(); From 8dc3add27d925b4eaa0da875e13401b4bd518274 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 13 Jul 2026 17:26:43 -0700 Subject: [PATCH 38/56] Prioritize sent friend requests Show pending outgoing requests before established friends in the Friends view. --- client/src/components/Friends.jsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/client/src/components/Friends.jsx b/client/src/components/Friends.jsx index 15db0f9..e2289d1 100644 --- a/client/src/components/Friends.jsx +++ b/client/src/components/Friends.jsx @@ -269,24 +269,24 @@ function Friends() { ))} - - {data.friends.map((entry) => ( + + {data.outgoing.map((entry) => ( handleAction(`/api/friends/${entry.user.id}`, 'DELETE')}>Remove} + action={} entry={entry} key={entry.user.id} - secondary="Friends" + secondary="Waiting for a response" /> ))} - - {data.outgoing.map((entry) => ( + + {data.friends.map((entry) => ( handleAction(`/api/friends/requests/${entry.user.id}`, 'DELETE')}>Cancel} + action={} entry={entry} key={entry.user.id} - secondary="Waiting for a response" + secondary="Friends" /> ))} From 44fc40ec0ceb209f195847f08a8a958c3f07d194 Mon Sep 17 00:00:00 2001 From: Cailyn Sinclair Date: Mon, 13 Jul 2026 17:41:28 -0700 Subject: [PATCH 39/56] Gate friends and add race invitations Add in-code friends feature gates and let accepted friends invite each other to public race rooms through typed in-app notifications. --- client/src/components/AccountMenu.jsx | 11 +++-- client/src/components/Friends.jsx | 17 ++++++- client/src/components/Navigation.jsx | 3 +- .../Notifications/NotificationList.jsx | 4 +- .../src/components/Notifications/registry.js | 2 +- .../components/Notifications/registry.test.js | 9 ++++ client/src/components/PublicProfile.jsx | 3 +- client/src/lib/features.js | 7 +++ client/src/store/middlewares/manager.js | 3 +- client/src/store/notifications/actions.js | 16 +++++++ cypress/e2e/local_stack.cy.js | 33 ++++++++++++- docs/friendships.md | 12 +++-- docs/notifications.md | 8 ++++ server/api.js | 3 +- server/features.js | 10 ++++ server/social/notificationService.js | 11 +++++ server/social/notificationService.test.js | 15 ++++++ server/social/raceInvitationService.js | 17 +++++++ server/social/raceInvitationService.test.js | 23 ++++++++++ server/social/relationshipService.js | 24 ++++++++++ server/social/relationshipService.test.js | 13 ++++++ server/socket/index.js | 4 +- server/socket/namespaces/rooms.js | 46 +++++++++++++++++++ server/socket/namespaces/rooms.test.js | 4 ++ 24 files changed, 279 insertions(+), 19 deletions(-) create mode 100644 client/src/lib/features.js create mode 100644 server/features.js create mode 100644 server/social/raceInvitationService.js create mode 100644 server/social/raceInvitationService.test.js diff --git a/client/src/components/AccountMenu.jsx b/client/src/components/AccountMenu.jsx index 877f622..ca33f62 100644 --- a/client/src/components/AccountMenu.jsx +++ b/client/src/components/AccountMenu.jsx @@ -16,6 +16,7 @@ import Typography from '@material-ui/core/Typography'; import ExitToAppIcon from '@material-ui/icons/ExitToApp'; import PeopleIcon from '@material-ui/icons/People'; import PersonIcon from '@material-ui/icons/Person'; +import { isFeatureEnabled } from '../lib/features'; const useStyles = makeStyles((theme) => ({ desktopPaper: { @@ -87,10 +88,12 @@ function AccountItems({ itemComponent: Item, itemClassName, onClose, onLogout }) return ( <> - - - - + {isFeatureEnabled('friends') && ( + + + + + )} diff --git a/client/src/components/Friends.jsx b/client/src/components/Friends.jsx index e2289d1..b15152d 100644 --- a/client/src/components/Friends.jsx +++ b/client/src/components/Friends.jsx @@ -3,6 +3,7 @@ import React, { } from 'react'; import PropTypes from 'prop-types'; import { Link } from 'react-router-dom'; +import { useDispatch } from 'react-redux'; import Avatar from '@material-ui/core/Avatar'; import Button from '@material-ui/core/Button'; import CircularProgress from '@material-ui/core/CircularProgress'; @@ -21,6 +22,7 @@ import TextField from '@material-ui/core/TextField'; import Typography from '@material-ui/core/Typography'; import { makeStyles } from '@material-ui/core/styles'; import { lcFetch } from '../lib/fetch'; +import { createRoom } from '../store/rooms/actions'; const useStyles = makeStyles((theme) => ({ root: { @@ -205,6 +207,7 @@ AddFriendDialog.propTypes = { function Friends() { const classes = useStyles(); + const dispatch = useDispatch(); const [data, setData] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); const [error, setError] = useState(null); @@ -235,6 +238,13 @@ function Friends() { }); const requestFriend = (user) => performAction('/api/friends/requests', 'POST', { userId: user.id }); + const raceWith = (user) => { + dispatch(createRoom({ + name: `Race with ${userName(user)}`, + raceWithUserId: user.id, + type: 'normal', + })); + }; const handleAction = (url, method, body) => { performAction(url, method, body).catch((actionError) => setError(actionError.message)); }; @@ -283,7 +293,12 @@ function Friends() { {data.friends.map((entry) => ( handleAction(`/api/friends/${entry.user.id}`, 'DELETE')}>Remove} + action={( + <> + + + + )} entry={entry} key={entry.user.id} secondary="Friends" diff --git a/client/src/components/Navigation.jsx b/client/src/components/Navigation.jsx index e91ef1e..4577781 100644 --- a/client/src/components/Navigation.jsx +++ b/client/src/components/Navigation.jsx @@ -24,6 +24,7 @@ import { pendingResultBelongsToUser, } from '../store/room/resultOutbox'; import Text from './Text'; +import { isFeatureEnabled } from '../lib/features'; const Lobby = lazy(() => import('./Lobby/index')); const Room = lazy(() => import('./Room/index')); @@ -230,7 +231,7 @@ function Navigation({ - + {isFeatureEnabled('friends') && } diff --git a/client/src/components/Notifications/NotificationList.jsx b/client/src/components/Notifications/NotificationList.jsx index b1cb2da..5700adf 100644 --- a/client/src/components/Notifications/NotificationList.jsx +++ b/client/src/components/Notifications/NotificationList.jsx @@ -62,8 +62,8 @@ export default function NotificationList({
{!stale && presentation.actions.map((action) => ( )}
diff --git a/client/src/components/Navigation.jsx b/client/src/components/Navigation.jsx index 4577781..1a599ea 100644 --- a/client/src/components/Navigation.jsx +++ b/client/src/components/Navigation.jsx @@ -4,13 +4,13 @@ import { Switch, Route, Redirect, useLocation, } from 'react-router-dom'; import { connect, useDispatch } from 'react-redux'; -import { push } from 'connected-react-router'; -// import Backdrop from '@material-ui/core/Backdrop'; -import CircularProgress from '@material-ui/core/CircularProgress'; -import Button from '@material-ui/core/Button'; -import Snackbar from '@material-ui/core/Snackbar'; -import Alert from '@material-ui/lab/Alert'; -import { makeStyles } from '@material-ui/core/styles'; +import history from '../lib/history'; +// import Backdrop from '@mui/material/Backdrop'; +import CircularProgress from '@mui/material/CircularProgress'; +import Button from '@mui/material/Button'; +import Snackbar from '@mui/material/Snackbar'; +import Alert from '@mui/material/Alert'; +import { makeStyles } from '@mui/styles'; import PrivateRoute from './common/PrivateRoute'; import Header from './Header'; import Footer from './Footer'; @@ -215,7 +215,7 @@ function Navigation({ atPendingRoom={atPendingRoom} error={resultSubmission.error} onDiscard={() => dispatch(discardPendingResult(pendingResult.submissionId))} - onReturn={() => dispatch(push(pendingRoomPath))} + onReturn={() => history.push(pendingRoomPath)} pendingResult={pendingResult} privateRoom={!!room.private} status={resultSubmission.status} diff --git a/client/src/components/Navigation.test.jsx b/client/src/components/Navigation.test.jsx index 984b4f0..c273c21 100644 --- a/client/src/components/Navigation.test.jsx +++ b/client/src/components/Navigation.test.jsx @@ -1,7 +1,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import Alert from '@material-ui/lab/Alert'; -import Button from '@material-ui/core/Button'; +import Alert from '@mui/material/Alert'; +import Button from '@mui/material/Button'; import { GlobalPendingResultAlert, shouldShowGlobalPendingResult, diff --git a/client/src/components/Notifications/NotificationList.jsx b/client/src/components/Notifications/NotificationList.jsx index 5700adf..aa78988 100644 --- a/client/src/components/Notifications/NotificationList.jsx +++ b/client/src/components/Notifications/NotificationList.jsx @@ -1,11 +1,11 @@ import React from 'react'; import PropTypes from 'prop-types'; -import Button from '@material-ui/core/Button'; -import List from '@material-ui/core/List'; -import ListItem from '@material-ui/core/ListItem'; -import ListItemText from '@material-ui/core/ListItemText'; -import Typography from '@material-ui/core/Typography'; -import { makeStyles } from '@material-ui/core/styles'; +import Button from '@mui/material/Button'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemText from '@mui/material/ListItemText'; +import Typography from '@mui/material/Typography'; +import { makeStyles } from '@mui/styles'; import notificationPresentation from './registry'; const useStyles = makeStyles((theme) => ({ diff --git a/client/src/components/Notifications/NotificationPopover.jsx b/client/src/components/Notifications/NotificationPopover.jsx index 7bd32e7..10af7cb 100644 --- a/client/src/components/Notifications/NotificationPopover.jsx +++ b/client/src/components/Notifications/NotificationPopover.jsx @@ -1,17 +1,17 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Link } from 'react-router-dom'; -import { makeStyles } from '@material-ui/core/styles'; -import Avatar from '@material-ui/core/Avatar'; -import Button from '@material-ui/core/Button'; -import Divider from '@material-ui/core/Divider'; -import List from '@material-ui/core/List'; -import ListItem from '@material-ui/core/ListItem'; -import ListItemAvatar from '@material-ui/core/ListItemAvatar'; -import ListItemText from '@material-ui/core/ListItemText'; -import Popover from '@material-ui/core/Popover'; -import Typography from '@material-ui/core/Typography'; -import NotificationsNoneIcon from '@material-ui/icons/NotificationsNone'; +import { makeStyles } from '@mui/styles'; +import Avatar from '@mui/material/Avatar'; +import Button from '@mui/material/Button'; +import Divider from '@mui/material/Divider'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemAvatar from '@mui/material/ListItemAvatar'; +import ListItemText from '@mui/material/ListItemText'; +import Popover from '@mui/material/Popover'; +import Typography from '@mui/material/Typography'; +import NotificationsNoneIcon from '@mui/icons-material/NotificationsNone'; import notificationPresentation from './registry'; const useStyles = makeStyles((theme) => ({ diff --git a/client/src/components/Notifications/index.jsx b/client/src/components/Notifications/index.jsx index 69fda0b..ede6833 100644 --- a/client/src/components/Notifications/index.jsx +++ b/client/src/components/Notifications/index.jsx @@ -1,11 +1,11 @@ import React, { useEffect } from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; -import Button from '@material-ui/core/Button'; -import CircularProgress from '@material-ui/core/CircularProgress'; -import Container from '@material-ui/core/Container'; -import Typography from '@material-ui/core/Typography'; -import { makeStyles } from '@material-ui/core/styles'; +import Button from '@mui/material/Button'; +import CircularProgress from '@mui/material/CircularProgress'; +import Container from '@mui/material/Container'; +import Typography from '@mui/material/Typography'; +import { makeStyles } from '@mui/styles'; import { fetchNotifications, markAllNotificationsRead, diff --git a/client/src/components/PublicProfile.jsx b/client/src/components/PublicProfile.jsx index 727d2fe..1d6fece 100644 --- a/client/src/components/PublicProfile.jsx +++ b/client/src/components/PublicProfile.jsx @@ -2,13 +2,13 @@ import React, { useCallback, useEffect, useRef, useState, } from 'react'; import { Link, useParams } from 'react-router-dom'; -import Avatar from '@material-ui/core/Avatar'; -import Button from '@material-ui/core/Button'; -import CircularProgress from '@material-ui/core/CircularProgress'; -import Container from '@material-ui/core/Container'; -import Paper from '@material-ui/core/Paper'; -import Typography from '@material-ui/core/Typography'; -import { makeStyles } from '@material-ui/core/styles'; +import Avatar from '@mui/material/Avatar'; +import Button from '@mui/material/Button'; +import CircularProgress from '@mui/material/CircularProgress'; +import Container from '@mui/material/Container'; +import Paper from '@mui/material/Paper'; +import Typography from '@mui/material/Typography'; +import { makeStyles } from '@mui/styles'; import { lcFetch } from '../lib/fetch'; import { isFeatureEnabled } from '../lib/features'; diff --git a/client/src/components/Room/Common/AdminToolbar.jsx b/client/src/components/Room/Common/AdminToolbar.jsx index 200d3a5..e0eeda3 100644 --- a/client/src/components/Room/Common/AdminToolbar.jsx +++ b/client/src/components/Room/Common/AdminToolbar.jsx @@ -1,15 +1,15 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; +import { makeStyles } from '@mui/styles'; import { connect, useDispatch } from 'react-redux'; -import Toolbar from '@material-ui/core/Toolbar'; -import Menu from '@material-ui/core/Menu'; -import MenuItem from '@material-ui/core/MenuItem'; -import Button from '@material-ui/core/Button'; -import FormGroup from '@material-ui/core/FormGroup'; -import Select from '@material-ui/core/Select'; -import ListSubheader from '@material-ui/core/ListSubheader'; -import MoreVertIcon from '@material-ui/icons/MoreVert'; +import Toolbar from '@mui/material/Toolbar'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Button from '@mui/material/Button'; +import FormGroup from '@mui/material/FormGroup'; +import Select from '@mui/material/Select'; +import ListSubheader from '@mui/material/ListSubheader'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; import { useConfirm } from 'material-ui-confirm'; import { deleteRoom, diff --git a/client/src/components/Room/Common/EditDialog.jsx b/client/src/components/Room/Common/EditDialog.jsx index 86c4bf8..fcc4756 100644 --- a/client/src/components/Room/Common/EditDialog.jsx +++ b/client/src/components/Room/Common/EditDialog.jsx @@ -1,13 +1,13 @@ import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; -import Dialog from '@material-ui/core/Dialog'; -import DialogActions from '@material-ui/core/DialogActions'; -import DialogTitle from '@material-ui/core/DialogTitle'; -import DialogContent from '@material-ui/core/DialogContent'; -import TextField from '@material-ui/core/TextField'; -import Button from '@material-ui/core/Button'; -import Checkbox from '@material-ui/core/Checkbox'; -import FormControlLabel from '@material-ui/core/FormControlLabel'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogTitle from '@mui/material/DialogTitle'; +import DialogContent from '@mui/material/DialogContent'; +import TextField from '@mui/material/TextField'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import FormControlLabel from '@mui/material/FormControlLabel'; import { formatRawTime, parseTime } from '../../../lib/utils'; function EditDialog({ diff --git a/client/src/components/Room/Common/Login.jsx b/client/src/components/Room/Common/Login.jsx index abc29d1..a460aa2 100644 --- a/client/src/components/Room/Common/Login.jsx +++ b/client/src/components/Room/Common/Login.jsx @@ -1,12 +1,12 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; +import { makeStyles } from '@mui/styles'; import { connect } from 'react-redux'; -import Paper from '@material-ui/core/Paper'; -import TextField from '@material-ui/core/TextField'; -import Button from '@material-ui/core/Button'; -import IconButton from '@material-ui/core/IconButton'; -import ArrowBackIcon from '@material-ui/icons/ArrowBack'; +import Paper from '@mui/material/Paper'; +import TextField from '@mui/material/TextField'; +import Button from '@mui/material/Button'; +import IconButton from '@mui/material/IconButton'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import { Link } from 'react-router-dom'; import { joinRoom, diff --git a/client/src/components/Room/Common/Main.jsx b/client/src/components/Room/Common/Main.jsx index 44fd4bf..7b25a6a 100644 --- a/client/src/components/Room/Common/Main.jsx +++ b/client/src/components/Room/Common/Main.jsx @@ -1,16 +1,16 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { withStyles } from '@material-ui/core/styles'; +import { withStyles } from '@mui/styles'; import { connect } from 'react-redux'; -import Grid from '@material-ui/core/Grid'; -import Paper from '@material-ui/core/Paper'; -import Divider from '@material-ui/core/Divider'; -import Typography from '@material-ui/core/Typography'; -import Button from '@material-ui/core/Button'; -import ClickAwayListener from '@material-ui/core/ClickAwayListener'; -import Alert from '@material-ui/lab/Alert'; +import Grid from '@mui/material/Grid'; +import Paper from '@mui/material/Paper'; +import Divider from '@mui/material/Divider'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import ClickAwayListener from '@mui/material/ClickAwayListener'; +import Alert from '@mui/material/Alert'; import UIfx from 'uifx'; -import { push } from 'connected-react-router'; +import history from '../../../lib/history'; import notificationAsset from '../../../assets/notification.mp3'; import calcStats from '../../../lib/stats'; import { @@ -185,7 +185,7 @@ export class Main extends React.Component { diff --git a/client/src/components/Room/Common/Main.test.jsx b/client/src/components/Room/Common/Main.test.jsx index cd61307..5d1bd35 100644 --- a/client/src/components/Room/Common/Main.test.jsx +++ b/client/src/components/Room/Common/Main.test.jsx @@ -1,7 +1,7 @@ import React from 'react'; import { shallow } from 'enzyme'; -import Alert from '@material-ui/lab/Alert'; -import Button from '@material-ui/core/Button'; +import Alert from '@mui/material/Alert'; +import Button from '@mui/material/Button'; import Timer from '../../Timer/index'; import ScramblePreview from '../../common/ScramblePreview'; import { Main } from './Main'; diff --git a/client/src/components/Room/Common/ManageUsersDialog.jsx b/client/src/components/Room/Common/ManageUsersDialog.jsx index 0434a32..994ef7b 100644 --- a/client/src/components/Room/Common/ManageUsersDialog.jsx +++ b/client/src/components/Room/Common/ManageUsersDialog.jsx @@ -2,20 +2,20 @@ import React from 'react'; import clsx from 'clsx'; import { connect, useDispatch } from 'react-redux'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; -import Button from '@material-ui/core/Button'; -import FormControlLabel from '@material-ui/core/FormControlLabel'; -import Checkbox from '@material-ui/core/Checkbox'; -import Dialog from '@material-ui/core/Dialog'; -import DialogActions from '@material-ui/core/DialogActions'; -import DialogContent from '@material-ui/core/DialogContent'; -import DialogTitle from '@material-ui/core/DialogTitle'; -import List from '@material-ui/core/List'; -import ListItem from '@material-ui/core/ListItem'; -import ListItemText from '@material-ui/core/ListItemText'; -import ListSubheader from '@material-ui/core/ListSubheader'; -import ListItemAvatar from '@material-ui/core/ListItemAvatar'; -import Avatar from '@material-ui/core/Avatar'; +import { makeStyles } from '@mui/styles'; +import Button from '@mui/material/Button'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemText from '@mui/material/ListItemText'; +import ListSubheader from '@mui/material/ListSubheader'; +import ListItemAvatar from '@mui/material/ListItemAvatar'; +import Avatar from '@mui/material/Avatar'; import { kickUser, updateUser, updateBanned } from '../../../store/room/actions'; import { getUsersInRoom, diff --git a/client/src/components/Room/Common/Panel.jsx b/client/src/components/Room/Common/Panel.jsx index 0043d9a..4953262 100644 --- a/client/src/components/Room/Common/Panel.jsx +++ b/client/src/components/Room/Common/Panel.jsx @@ -1,10 +1,10 @@ import React from 'react'; import clsx from 'clsx'; import PropTypes from 'prop-types'; -import Paper from '@material-ui/core/Paper'; -import Toolbar from '@material-ui/core/Toolbar'; -import Divider from '@material-ui/core/Divider'; -import { makeStyles } from '@material-ui/core/styles'; +import Paper from '@mui/material/Paper'; +import Toolbar from '@mui/material/Toolbar'; +import Divider from '@mui/material/Divider'; +import { makeStyles } from '@mui/styles'; const useStyles = makeStyles(() => ({ root: { diff --git a/client/src/components/Room/Common/StatsDialog.jsx b/client/src/components/Room/Common/StatsDialog.jsx index 7ec72cc..204f4cb 100644 --- a/client/src/components/Room/Common/StatsDialog.jsx +++ b/client/src/components/Room/Common/StatsDialog.jsx @@ -1,13 +1,13 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; -import Button from '@material-ui/core/Button'; -import Dialog from '@material-ui/core/Dialog'; -import DialogActions from '@material-ui/core/DialogActions'; -import DialogContent from '@material-ui/core/DialogContent'; -import DialogTitle from '@material-ui/core/DialogTitle'; -import Typography from '@material-ui/core/Typography'; -import Paper from '@material-ui/core/Paper'; +import { makeStyles } from '@mui/styles'; +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import Typography from '@mui/material/Typography'; +import Paper from '@mui/material/Paper'; import { formatTime } from '../../../lib/utils'; const useStyles = makeStyles(() => ({ diff --git a/client/src/components/Room/Common/TableStatusCell.jsx b/client/src/components/Room/Common/TableStatusCell.jsx index 23bf4f4..c26c85f 100644 --- a/client/src/components/Room/Common/TableStatusCell.jsx +++ b/client/src/components/Room/Common/TableStatusCell.jsx @@ -1,9 +1,9 @@ import React from 'react'; import clsx from 'clsx'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; -import Typography from '@material-ui/core/Typography'; -import TableCell from '@material-ui/core/TableCell'; +import { makeStyles } from '@mui/styles'; +import Typography from '@mui/material/Typography'; +import TableCell from '@mui/material/TableCell'; const useStyles = makeStyles(() => ({ root: { diff --git a/client/src/components/Room/Common/TableTimeCell.jsx b/client/src/components/Room/Common/TableTimeCell.jsx index 03939f3..f9cdabb 100644 --- a/client/src/components/Room/Common/TableTimeCell.jsx +++ b/client/src/components/Room/Common/TableTimeCell.jsx @@ -1,10 +1,10 @@ import React from 'react'; import clsx from 'clsx'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; -import Button from '@material-ui/core/Button'; -import Typography from '@material-ui/core/Typography'; -import TableCell from '@material-ui/core/TableCell'; +import { makeStyles } from '@mui/styles'; +import Button from '@mui/material/Button'; +import Typography from '@mui/material/Typography'; +import TableCell from '@mui/material/TableCell'; import { formatTime } from '../../../lib/utils'; import { useEditDialog } from './EditDialogProvider'; diff --git a/client/src/components/Room/Common/TimesTable.jsx b/client/src/components/Room/Common/TimesTable.jsx index 54d8cd2..e6e1451 100644 --- a/client/src/components/Room/Common/TimesTable.jsx +++ b/client/src/components/Room/Common/TimesTable.jsx @@ -1,14 +1,14 @@ import React, { createRef } from 'react'; import clsx from 'clsx'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; -import Typography from '@material-ui/core/Typography'; -import TableContainer from '@material-ui/core/TableContainer'; -import Table from '@material-ui/core/Table'; -import TableHead from '@material-ui/core/TableHead'; -import TableBody from '@material-ui/core/TableBody'; -import TableRow from '@material-ui/core/TableRow'; -import TableCell from '@material-ui/core/TableCell'; +import { makeStyles } from '@mui/styles'; +import Typography from '@mui/material/Typography'; +import TableContainer from '@mui/material/TableContainer'; +import Table from '@mui/material/Table'; +import TableHead from '@mui/material/TableHead'; +import TableBody from '@mui/material/TableBody'; +import TableRow from '@mui/material/TableRow'; +import TableCell from '@mui/material/TableCell'; import { useStatsDialog } from './StatsDialogProvider'; import TableCellButton from '../../common/TableCellButton'; import TableStatusCell from './TableStatusCell'; diff --git a/client/src/components/Room/Common/UserSelectorDialog.jsx b/client/src/components/Room/Common/UserSelectorDialog.jsx index 4d15637..22eb97e 100644 --- a/client/src/components/Room/Common/UserSelectorDialog.jsx +++ b/client/src/components/Room/Common/UserSelectorDialog.jsx @@ -1,18 +1,18 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; -import Button from '@material-ui/core/Button'; -import Checkbox from '@material-ui/core/Checkbox'; -import Dialog from '@material-ui/core/Dialog'; -import DialogActions from '@material-ui/core/DialogActions'; -import DialogContent from '@material-ui/core/DialogContent'; -import DialogTitle from '@material-ui/core/DialogTitle'; -import List from '@material-ui/core/List'; -import ListItem from '@material-ui/core/ListItem'; -import ListItemText from '@material-ui/core/ListItemText'; -import ListItemAvatar from '@material-ui/core/ListItemAvatar'; -import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; -import Avatar from '@material-ui/core/Avatar'; +import { makeStyles } from '@mui/styles'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemText from '@mui/material/ListItemText'; +import ListItemAvatar from '@mui/material/ListItemAvatar'; +import ListItemSecondaryAction from '@mui/material/ListItemSecondaryAction'; +import Avatar from '@mui/material/Avatar'; const useStyles = makeStyles(() => ({})); diff --git a/client/src/components/Room/Common/UserStats.jsx b/client/src/components/Room/Common/UserStats.jsx index 08e0c13..4d3379a 100644 --- a/client/src/components/Room/Common/UserStats.jsx +++ b/client/src/components/Room/Common/UserStats.jsx @@ -1,12 +1,12 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; -import Paper from '@material-ui/core/Paper'; -import Table from '@material-ui/core/Table'; -import TableHead from '@material-ui/core/TableHead'; -import TableBody from '@material-ui/core/TableBody'; -import TableRow from '@material-ui/core/TableRow'; -import TableCell from '@material-ui/core/TableCell'; +import { makeStyles } from '@mui/styles'; +import Paper from '@mui/material/Paper'; +import Table from '@mui/material/Table'; +import TableHead from '@mui/material/TableHead'; +import TableBody from '@mui/material/TableBody'; +import TableRow from '@mui/material/TableRow'; +import TableCell from '@mui/material/TableCell'; import { formatTime } from '../../../lib/utils'; const useStyles = makeStyles((theme) => ({ diff --git a/client/src/components/Room/Common/UserToolbar.jsx b/client/src/components/Room/Common/UserToolbar.jsx index 1c34885..ffae149 100644 --- a/client/src/components/Room/Common/UserToolbar.jsx +++ b/client/src/components/Room/Common/UserToolbar.jsx @@ -1,21 +1,21 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; +import { makeStyles } from '@mui/styles'; import { connect } from 'react-redux'; -import Toolbar from '@material-ui/core/Toolbar'; -import FormGroup from '@material-ui/core/FormGroup'; -import Button from '@material-ui/core/Button'; -import Dialog from '@material-ui/core/Dialog'; -import DialogActions from '@material-ui/core/DialogActions'; -import DialogContent from '@material-ui/core/DialogContent'; -import DialogTitle from '@material-ui/core/DialogTitle'; -import SettingsIcon from '@material-ui/icons/Settings'; -import FormControlLabel from '@material-ui/core/FormControlLabel'; -import FormControl from '@material-ui/core/FormControl'; -import InputLabel from '@material-ui/core/InputLabel'; -import Checkbox from '@material-ui/core/Checkbox'; -import Select from '@material-ui/core/Select'; -import MenuItem from '@material-ui/core/MenuItem'; +import Toolbar from '@mui/material/Toolbar'; +import FormGroup from '@mui/material/FormGroup'; +import Button from '@mui/material/Button'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import SettingsIcon from '@mui/icons-material/Settings'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import FormControl from '@mui/material/FormControl'; +import InputLabel from '@mui/material/InputLabel'; +import Checkbox from '@mui/material/Checkbox'; +import Select from '@mui/material/Select'; +import MenuItem from '@mui/material/MenuItem'; import { lcFetch } from '../../../lib/fetch'; import { updateCompeting } from '../../../store/room/actions'; import { updateProfile } from '../../../store/user/actions'; diff --git a/client/src/components/Room/GrandPrix/GrandPrixMain.jsx b/client/src/components/Room/GrandPrix/GrandPrixMain.jsx index d88567e..f80298d 100644 --- a/client/src/components/Room/GrandPrix/GrandPrixMain.jsx +++ b/client/src/components/Room/GrandPrix/GrandPrixMain.jsx @@ -1,14 +1,15 @@ import React, { useState, useEffect } from 'react'; import PropTypes from 'prop-types'; -import { withStyles, makeStyles, useTheme } from '@material-ui/core/styles'; +import { useTheme } from '@mui/material/styles'; +import { withStyles, makeStyles } from '@mui/styles'; import { connect, useDispatch } from 'react-redux'; -import Grid from '@material-ui/core/Grid'; -import Box from '@material-ui/core/Box'; -import Paper from '@material-ui/core/Paper'; -import Divider from '@material-ui/core/Divider'; -import Typography from '@material-ui/core/Typography'; -import ClickAwayListener from '@material-ui/core/ClickAwayListener'; -import Button from '@material-ui/core/Button'; +import Grid from '@mui/material/Grid'; +import Box from '@mui/material/Box'; +import Paper from '@mui/material/Paper'; +import Divider from '@mui/material/Divider'; +import Typography from '@mui/material/Typography'; +import ClickAwayListener from '@mui/material/ClickAwayListener'; +import Button from '@mui/material/Button'; import { formatISO9075 } from 'date-fns'; import calcStats from '../../../lib/stats'; import { diff --git a/client/src/components/Room/GrandPrix/Leaderboard.jsx b/client/src/components/Room/GrandPrix/Leaderboard.jsx index 68afe90..42ecf00 100644 --- a/client/src/components/Room/GrandPrix/Leaderboard.jsx +++ b/client/src/components/Room/GrandPrix/Leaderboard.jsx @@ -1,16 +1,16 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect, useDispatch } from 'react-redux'; -import { makeStyles } from '@material-ui/core/styles'; -import Toolbar from '@material-ui/core/Toolbar'; -import Typography from '@material-ui/core/Typography'; -import Paper from '@material-ui/core/Paper'; -import TableContainer from '@material-ui/core/TableContainer'; -import Table from '@material-ui/core/Table'; -import TableHead from '@material-ui/core/TableHead'; -import TableBody from '@material-ui/core/TableBody'; -import TableRow from '@material-ui/core/TableRow'; -import TableCell from '@material-ui/core/TableCell'; +import { makeStyles } from '@mui/styles'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; +import Paper from '@mui/material/Paper'; +import TableContainer from '@mui/material/TableContainer'; +import Table from '@mui/material/Table'; +import TableHead from '@mui/material/TableHead'; +import TableBody from '@mui/material/TableBody'; +import TableRow from '@mui/material/TableRow'; +import TableCell from '@mui/material/TableCell'; import TableStatusCell from '../Common/TableStatusCell'; import TableTimeCell from '../Common/TableTimeCell'; import { StatsDialogProvider } from '../Common/StatsDialogProvider'; diff --git a/client/src/components/Room/GrandPrix/RegisterPanel.jsx b/client/src/components/Room/GrandPrix/RegisterPanel.jsx index f426b26..f94ec7e 100644 --- a/client/src/components/Room/GrandPrix/RegisterPanel.jsx +++ b/client/src/components/Room/GrandPrix/RegisterPanel.jsx @@ -2,10 +2,10 @@ import React, { useState } from 'react'; import { connect, useDispatch } from 'react-redux'; import PropTypes from 'prop-types'; import { formatDistanceToNow, formatRFC7231 } from 'date-fns'; -import { makeStyles } from '@material-ui/core/styles'; -import Box from '@material-ui/core/Box'; -import Typography from '@material-ui/core/Typography'; -import Button from '@material-ui/core/Button'; +import { makeStyles } from '@mui/styles'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; import { updateRegistration } from '../../../store/room/actions'; import { getRegisteredUsers } from '../../../store/room/selectors'; diff --git a/client/src/components/Room/GrandPrix/index.jsx b/client/src/components/Room/GrandPrix/index.jsx index 7a1cf3e..cbf779c 100644 --- a/client/src/components/Room/GrandPrix/index.jsx +++ b/client/src/components/Room/GrandPrix/index.jsx @@ -1,21 +1,21 @@ import React, { useState } from 'react'; import clsx from 'clsx'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; +import { makeStyles } from '@mui/styles'; import { connect } from 'react-redux'; -import Grid from '@material-ui/core/Grid'; -import AppBar from '@material-ui/core/AppBar'; -import Toolbar from '@material-ui/core/Toolbar'; -import Typography from '@material-ui/core/Typography'; -import Paper from '@material-ui/core/Paper'; -import BottomNavigation from '@material-ui/core/BottomNavigation'; -import BottomNavigationAction from '@material-ui/core/BottomNavigationAction'; -import Divider from '@material-ui/core/Divider'; -import IconButton from '@material-ui/core/IconButton'; -import ChatIcon from '@material-ui/icons/Chat'; -import TimerIcon from '@material-ui/icons/Timer'; -import ArrowForwardIcon from '@material-ui/icons/ArrowForward'; -import ArrowBackIcon from '@material-ui/icons/ArrowBack'; +import Grid from '@mui/material/Grid'; +import AppBar from '@mui/material/AppBar'; +import Toolbar from '@mui/material/Toolbar'; +import Typography from '@mui/material/Typography'; +import Paper from '@mui/material/Paper'; +import BottomNavigation from '@mui/material/BottomNavigation'; +import BottomNavigationAction from '@mui/material/BottomNavigationAction'; +import Divider from '@mui/material/Divider'; +import IconButton from '@mui/material/IconButton'; +import ChatIcon from '@mui/icons-material/Chat'; +import TimerIcon from '@mui/icons-material/Timer'; +import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import Main from './GrandPrixMain'; import AdminToolbar from '../Common/AdminToolbar'; import UserToolbar from '../Common/UserToolbar'; diff --git a/client/src/components/Room/Normal/index.jsx b/client/src/components/Room/Normal/index.jsx index 1ab53d4..465bc61 100644 --- a/client/src/components/Room/Normal/index.jsx +++ b/client/src/components/Room/Normal/index.jsx @@ -1,15 +1,15 @@ import React, { useState } from 'react'; import clsx from 'clsx'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; +import { makeStyles } from '@mui/styles'; import { connect } from 'react-redux'; -import Grid from '@material-ui/core/Grid'; -import Paper from '@material-ui/core/Paper'; -import BottomNavigation from '@material-ui/core/BottomNavigation'; -import BottomNavigationAction from '@material-ui/core/BottomNavigationAction'; -import Divider from '@material-ui/core/Divider'; -import ChatIcon from '@material-ui/icons/Chat'; -import TimerIcon from '@material-ui/icons/Timer'; +import Grid from '@mui/material/Grid'; +import Paper from '@mui/material/Paper'; +import BottomNavigation from '@mui/material/BottomNavigation'; +import BottomNavigationAction from '@mui/material/BottomNavigationAction'; +import Divider from '@mui/material/Divider'; +import ChatIcon from '@mui/icons-material/Chat'; +import TimerIcon from '@mui/icons-material/Timer'; import Main from '../Common/Main'; import Chat from '../Panels/Chat'; import AdminToolbar from '../Common/AdminToolbar'; diff --git a/client/src/components/Room/Panels/Chat.jsx b/client/src/components/Room/Panels/Chat.jsx index b02a11a..d29e5a2 100644 --- a/client/src/components/Room/Panels/Chat.jsx +++ b/client/src/components/Room/Panels/Chat.jsx @@ -2,25 +2,25 @@ import React, { useState, useRef, useEffect } from 'react'; import clsx from 'clsx'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import { makeStyles } from '@material-ui/core/styles'; -import Paper from '@material-ui/core/Paper'; -import Toolbar from '@material-ui/core/Toolbar'; -import FormControl from '@material-ui/core/FormControl'; -import Input from '@material-ui/core/Input'; -import Alert from '@material-ui/lab/Alert'; -import List from '@material-ui/core/List'; -import ListItem from '@material-ui/core/ListItem'; -import ListItemText from '@material-ui/core/ListItemText'; -import ListItemAvatar from '@material-ui/core/ListItemAvatar'; -import ListItemIcon from '@material-ui/core/ListItemIcon'; -import Avatar from '@material-ui/core/Avatar'; -import Typography from '@material-ui/core/Typography'; -import IconButton from '@material-ui/core/IconButton'; -import Icon from '@material-ui/core/Icon'; -import NotificationImportantIcon from '@material-ui/icons/NotificationImportant'; -import PersonIcon from '@material-ui/icons/Person'; -import ArrowForwardIcon from '@material-ui/icons/ArrowForward'; -import ArrowBackIcon from '@material-ui/icons/ArrowBack'; +import { makeStyles } from '@mui/styles'; +import Paper from '@mui/material/Paper'; +import Toolbar from '@mui/material/Toolbar'; +import FormControl from '@mui/material/FormControl'; +import Input from '@mui/material/Input'; +import Alert from '@mui/material/Alert'; +import List from '@mui/material/List'; +import ListItem from '@mui/material/ListItem'; +import ListItemText from '@mui/material/ListItemText'; +import ListItemAvatar from '@mui/material/ListItemAvatar'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import Avatar from '@mui/material/Avatar'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import Icon from '@mui/material/Icon'; +import NotificationImportantIcon from '@mui/icons-material/NotificationImportant'; +import PersonIcon from '@mui/icons-material/Person'; +import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; +import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import { sendChat } from '../../../store/chat/actions'; import Panel from '../Common/Panel'; diff --git a/client/src/components/Room/index.jsx b/client/src/components/Room/index.jsx index 8b449a4..d9910ae 100644 --- a/client/src/components/Room/index.jsx +++ b/client/src/components/Room/index.jsx @@ -1,11 +1,11 @@ import React, { useEffect } from 'react'; import { useLocation, useParams } from 'react-router-dom'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; +import { makeStyles } from '@mui/styles'; import { connect, useDispatch } from 'react-redux'; -import Paper from '@material-ui/core/Paper'; -import Backdrop from '@material-ui/core/Backdrop'; -import CircularProgress from '@material-ui/core/CircularProgress'; +import Paper from '@mui/material/Paper'; +import Backdrop from '@mui/material/Backdrop'; +import CircularProgress from '@mui/material/CircularProgress'; import qs from 'qs'; import Login from './Common/Login'; import { diff --git a/client/src/components/RoomConfigureDialog.jsx b/client/src/components/RoomConfigureDialog.jsx index 379f7b4..321a395 100644 --- a/client/src/components/RoomConfigureDialog.jsx +++ b/client/src/components/RoomConfigureDialog.jsx @@ -2,26 +2,26 @@ import React from 'react'; import clsx from 'clsx'; import PropTypes from 'prop-types'; import { format } from 'date-fns'; -import { makeStyles } from '@material-ui/core/styles'; -import Divider from '@material-ui/core/Divider'; -import Typography from '@material-ui/core/Typography'; -import Button from '@material-ui/core/Button'; -import TextField from '@material-ui/core/TextField'; -import Switch from '@material-ui/core/Switch'; -import FormControlLabel from '@material-ui/core/FormControlLabel'; -import FormControl from '@material-ui/core/FormControl'; -import FormHelperText from '@material-ui/core/FormHelperText'; -import InputLabel from '@material-ui/core/InputLabel'; -import Select from '@material-ui/core/Select'; -import MenuItem from '@material-ui/core/MenuItem'; -import Dialog from '@material-ui/core/Dialog'; -import DialogActions from '@material-ui/core/DialogActions'; -import DialogContent from '@material-ui/core/DialogContent'; -import DialogTitle from '@material-ui/core/DialogTitle'; -import Accordion from '@material-ui/core/Accordion'; -import AccordionDetails from '@material-ui/core/AccordionDetails'; -import AccordionSummary from '@material-ui/core/AccordionSummary'; -import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import { makeStyles } from '@mui/styles'; +import Divider from '@mui/material/Divider'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import TextField from '@mui/material/TextField'; +import Switch from '@mui/material/Switch'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import FormControl from '@mui/material/FormControl'; +import FormHelperText from '@mui/material/FormHelperText'; +import InputLabel from '@mui/material/InputLabel'; +import Select from '@mui/material/Select'; +import MenuItem from '@mui/material/MenuItem'; +import Dialog from '@mui/material/Dialog'; +import DialogActions from '@mui/material/DialogActions'; +import DialogContent from '@mui/material/DialogContent'; +import DialogTitle from '@mui/material/DialogTitle'; +import Accordion from '@mui/material/Accordion'; +import AccordionDetails from '@mui/material/AccordionDetails'; +import AccordionSummary from '@mui/material/AccordionSummary'; +import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; const useStyles = makeStyles((theme) => ({ fab: { diff --git a/client/src/components/RoomConfigureDialog.test.jsx b/client/src/components/RoomConfigureDialog.test.jsx index 2c11845..64c43bb 100644 --- a/client/src/components/RoomConfigureDialog.test.jsx +++ b/client/src/components/RoomConfigureDialog.test.jsx @@ -1,8 +1,8 @@ import React from 'react'; import { shallow } from 'enzyme'; -import Button from '@material-ui/core/Button'; -import FormControlLabel from '@material-ui/core/FormControlLabel'; -import TextField from '@material-ui/core/TextField'; +import Button from '@mui/material/Button'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import TextField from '@mui/material/TextField'; import RoomConfigureDialog from './RoomConfigureDialog'; const privateRoom = { diff --git a/client/src/components/RoomListItem.jsx b/client/src/components/RoomListItem.jsx index 0dfcbf8..eeddca6 100644 --- a/client/src/components/RoomListItem.jsx +++ b/client/src/components/RoomListItem.jsx @@ -3,18 +3,18 @@ import { useHistory } from 'react-router-dom'; import { connect, useDispatch } from 'react-redux'; import PropTypes from 'prop-types'; import { formatDistanceToNow } from 'date-fns'; -import Tooltip from '@material-ui/core/Tooltip'; -import ListItem from '@material-ui/core/ListItem'; -import ListItemIcon from '@material-ui/core/ListItemIcon'; -import ListItemText from '@material-ui/core/ListItemText'; -import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; -import Typography from '@material-ui/core/Typography'; -import Menu from '@material-ui/core/Menu'; -import MenuItem from '@material-ui/core/MenuItem'; -import IconButton from '@material-ui/core/IconButton'; -import PublicIcon from '@material-ui/icons/Public'; -import PrivateIcon from '@material-ui/icons/Lock'; -import MoreVertIcon from '@material-ui/icons/MoreVert'; +import Tooltip from '@mui/material/Tooltip'; +import ListItem from '@mui/material/ListItem'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import ListItemText from '@mui/material/ListItemText'; +import ListItemSecondaryAction from '@mui/material/ListItemSecondaryAction'; +import Typography from '@mui/material/Typography'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import IconButton from '@mui/material/IconButton'; +import PublicIcon from '@mui/icons-material/Public'; +import PrivateIcon from '@mui/icons-material/Lock'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; import { useConfirm } from 'material-ui-confirm'; import { lcFetch } from '../lib/fetch'; // import ListItem from './ListItemLink'; diff --git a/client/src/components/Timer/ManualTimer.jsx b/client/src/components/Timer/ManualTimer.jsx index 4b69a6d..087a650 100644 --- a/client/src/components/Timer/ManualTimer.jsx +++ b/client/src/components/Timer/ManualTimer.jsx @@ -1,19 +1,19 @@ import React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; -import { withStyles } from '@material-ui/core/styles'; -import Box from '@material-ui/core/Box'; -import Typography from '@material-ui/core/Typography'; -import Button from '@material-ui/core/Button'; -import Input from '@material-ui/core/Input'; -import Checkbox from '@material-ui/core/Checkbox'; -import FormControlLabel from '@material-ui/core/FormControlLabel'; +import { withStyles } from '@mui/styles'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import Input from '@mui/material/Input'; +import Checkbox from '@mui/material/Checkbox'; +import FormControlLabel from '@mui/material/FormControlLabel'; import { formatTime, parseTime, setInterval, clearInterval, now, } from '../../lib/utils'; import initialStyles from './styles'; -const useStyles = withStyles((theme) => ({ +const withComponentStyles = withStyles((theme) => ({ ...initialStyles(theme), input: { maxWidth: '300px', @@ -559,4 +559,4 @@ ManualTimer.defaultProps = { onPriming: () => {}, }; -export default useStyles(ManualTimer); +export default withComponentStyles(ManualTimer); diff --git a/client/src/components/Timer/SpacebarTimer.jsx b/client/src/components/Timer/SpacebarTimer.jsx index d291dec..cf6f2ed 100644 --- a/client/src/components/Timer/SpacebarTimer.jsx +++ b/client/src/components/Timer/SpacebarTimer.jsx @@ -1,19 +1,19 @@ import React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; -import { withStyles } from '@material-ui/core/styles'; -import Box from '@material-ui/core/Box'; -import Typography from '@material-ui/core/Typography'; -import Button from '@material-ui/core/Button'; -import Checkbox from '@material-ui/core/Checkbox'; -import FormGroup from '@material-ui/core/FormGroup'; -import FormControlLabel from '@material-ui/core/FormControlLabel'; +import { withStyles } from '@mui/styles'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import FormGroup from '@mui/material/FormGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; import { formatTime, setInterval, clearInterval, now, } from '../../lib/utils'; import styles from './styles'; -const useStyles = withStyles(styles); +const withComponentStyles = withStyles(styles); /* Complicated beast. @@ -475,4 +475,4 @@ Timer.defaultProps = { onPriming: () => {}, }; -export default useStyles(Timer); +export default withComponentStyles(Timer); diff --git a/client/src/components/Timer/StackmatTimer.jsx b/client/src/components/Timer/StackmatTimer.jsx index e1b2045..a68e5cd 100644 --- a/client/src/components/Timer/StackmatTimer.jsx +++ b/client/src/components/Timer/StackmatTimer.jsx @@ -3,19 +3,19 @@ import StackmatSignalProcessor from 'stackmat-signal-processor'; import React from 'react'; import PropTypes from 'prop-types'; import clsx from 'clsx'; -import { withStyles } from '@material-ui/core/styles'; -import Box from '@material-ui/core/Box'; -import Typography from '@material-ui/core/Typography'; -import Button from '@material-ui/core/Button'; -import Checkbox from '@material-ui/core/Checkbox'; -import FormGroup from '@material-ui/core/FormGroup'; -import FormControlLabel from '@material-ui/core/FormControlLabel'; +import { withStyles } from '@mui/styles'; +import Box from '@mui/material/Box'; +import Typography from '@mui/material/Typography'; +import Button from '@mui/material/Button'; +import Checkbox from '@mui/material/Checkbox'; +import FormGroup from '@mui/material/FormGroup'; +import FormControlLabel from '@mui/material/FormControlLabel'; import { formatTime, setInterval, clearInterval, now, } from '../../lib/utils'; import styles from './styles'; -const useStyles = withStyles(styles); +const withComponentStyles = withStyles(styles); const STATUS = { RESTING: 'RESTING', @@ -431,4 +431,4 @@ Timer.defaultProps = { onPriming: () => {}, }; -export default useStyles(Timer); +export default withComponentStyles(Timer); diff --git a/client/src/components/Timer/index.jsx b/client/src/components/Timer/index.jsx index d796505..a0be36e 100644 --- a/client/src/components/Timer/index.jsx +++ b/client/src/components/Timer/index.jsx @@ -1,4 +1,4 @@ -/* eslint-disable react/jsx-props-no-spreading */ + import React from 'react'; import PropTypes from 'prop-types'; import SpacebarTimer from './SpacebarTimer'; diff --git a/client/src/components/User.jsx b/client/src/components/User.jsx index 847ce45..5e07e90 100644 --- a/client/src/components/User.jsx +++ b/client/src/components/User.jsx @@ -1,9 +1,9 @@ import React from 'react'; import clsx from 'clsx'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; -import Popover from '@material-ui/core/Popover'; -import Typography from '@material-ui/core/Typography'; +import { makeStyles } from '@mui/styles'; +import Popover from '@mui/material/Popover'; +import Typography from '@mui/material/Typography'; import { Link } from 'react-router-dom'; const useStyles = makeStyles((theme) => ({ diff --git a/client/src/components/WCARedirect.jsx b/client/src/components/WCARedirect.jsx index 52a3f06..17cad31 100644 --- a/client/src/components/WCARedirect.jsx +++ b/client/src/components/WCARedirect.jsx @@ -3,13 +3,13 @@ import PropTypes from 'prop-types'; import { connect, useDispatch } from 'react-redux'; import qs from 'qs'; import { Redirect, useLocation } from 'react-router-dom'; -import { makeStyles } from '@material-ui/core/styles'; -import Box from '@material-ui/core/Box'; -import Paper from '@material-ui/core/Paper'; -import Typography from '@material-ui/core/Typography'; -import LinearProgress from '@material-ui/core/LinearProgress'; -import Alert from '@material-ui/lab/Alert'; -import AlertTitle from '@material-ui/lab/AlertTitle'; +import { makeStyles } from '@mui/styles'; +import Box from '@mui/material/Box'; +import Paper from '@mui/material/Paper'; +import Typography from '@mui/material/Typography'; +import LinearProgress from '@mui/material/LinearProgress'; +import Alert from '@mui/material/Alert'; +import AlertTitle from '@mui/material/AlertTitle'; import { lcFetch } from '../lib/fetch'; import { userChanged } from '../store/user/actions'; diff --git a/client/src/components/common/EditableTextField.jsx b/client/src/components/common/EditableTextField.jsx index 8302b26..93a83e8 100644 --- a/client/src/components/common/EditableTextField.jsx +++ b/client/src/components/common/EditableTextField.jsx @@ -1,14 +1,14 @@ import React, { useState } from 'react'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; -import Paper from '@material-ui/core/Paper'; -import Input from '@material-ui/core/Input'; -import InputLabel from '@material-ui/core/InputLabel'; -import Button from '@material-ui/core/Button'; -import ButtonGroup from '@material-ui/core/ButtonGroup'; -import FormControl from '@material-ui/core/FormControl'; -import FormHelperText from '@material-ui/core/FormHelperText'; -import ClickAwayListener from '@material-ui/core/ClickAwayListener'; +import { makeStyles } from '@mui/styles'; +import Paper from '@mui/material/Paper'; +import Input from '@mui/material/Input'; +import InputLabel from '@mui/material/InputLabel'; +import Button from '@mui/material/Button'; +import ButtonGroup from '@mui/material/ButtonGroup'; +import FormControl from '@mui/material/FormControl'; +import FormHelperText from '@mui/material/FormHelperText'; +import ClickAwayListener from '@mui/material/ClickAwayListener'; const useStyles = makeStyles((theme) => ({ root: { diff --git a/client/src/components/common/EventListItem.jsx b/client/src/components/common/EventListItem.jsx index b953217..c990023 100644 --- a/client/src/components/common/EventListItem.jsx +++ b/client/src/components/common/EventListItem.jsx @@ -3,23 +3,24 @@ import { useHistory } from 'react-router-dom'; import { connect, useDispatch } from 'react-redux'; import PropTypes from 'prop-types'; import { formatDistanceToNow } from 'date-fns'; -import Tooltip from '@material-ui/core/Tooltip'; -import { makeStyles, useTheme } from '@material-ui/core/styles'; -import Card from '@material-ui/core/Card'; -import CardHeader from '@material-ui/core/CardHeader'; -import CardContent from '@material-ui/core/CardContent'; -import CardActions from '@material-ui/core/CardActions'; -import CardMedia from '@material-ui/core/CardMedia'; -import Alert from '@material-ui/lab/Alert'; -import Typography from '@material-ui/core/Typography'; -import Menu from '@material-ui/core/Menu'; -import MenuItem from '@material-ui/core/MenuItem'; -import IconButton from '@material-ui/core/IconButton'; -import Button from '@material-ui/core/Button'; -import PublicIcon from '@material-ui/icons/Public'; -import PrivateIcon from '@material-ui/icons/Lock'; -import MoreVertIcon from '@material-ui/icons/MoreVert'; -import ShareIcon from '@material-ui/icons/Share'; +import Tooltip from '@mui/material/Tooltip'; +import { useTheme } from '@mui/material/styles'; +import { makeStyles } from '@mui/styles'; +import Card from '@mui/material/Card'; +import CardHeader from '@mui/material/CardHeader'; +import CardContent from '@mui/material/CardContent'; +import CardActions from '@mui/material/CardActions'; +import CardMedia from '@mui/material/CardMedia'; +import Alert from '@mui/material/Alert'; +import Typography from '@mui/material/Typography'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import IconButton from '@mui/material/IconButton'; +import Button from '@mui/material/Button'; +import PublicIcon from '@mui/icons-material/Public'; +import PrivateIcon from '@mui/icons-material/Lock'; +import MoreVertIcon from '@mui/icons-material/MoreVert'; +import ShareIcon from '@mui/icons-material/Share'; import { useConfirm } from 'material-ui-confirm'; import { TwitchEmbed } from 'react-twitch-embed'; import { lcFetch } from '../../lib/fetch'; @@ -167,7 +168,7 @@ function RoomListItem({ id={`channel-${room.twitchChannel}-${room._id}`} muted withChat={false} - theme={theme.palette.type} + theme={theme.palette.mode} width="100%" /> diff --git a/client/src/components/common/HelpPopover.jsx b/client/src/components/common/HelpPopover.jsx index ba58ece..b0cd17f 100644 --- a/client/src/components/common/HelpPopover.jsx +++ b/client/src/components/common/HelpPopover.jsx @@ -1,10 +1,10 @@ import React, { useState } from 'react'; -import Popover from '@material-ui/core/Popover'; -import Typography from '@material-ui/core/Typography'; -import IconButton from '@material-ui/core/IconButton'; -import HelpIcon from '@material-ui/icons/Help'; +import Popover from '@mui/material/Popover'; +import Typography from '@mui/material/Typography'; +import IconButton from '@mui/material/IconButton'; +import HelpIcon from '@mui/icons-material/Help'; -export default () => { +const HelpPopover = () => { const [helpAnchor, setHelpAnchor] = useState(null); return ( @@ -39,3 +39,5 @@ export default () => {
); }; + +export default HelpPopover; diff --git a/client/src/components/common/ListItemLink.jsx b/client/src/components/common/ListItemLink.jsx index 26318db..74e3db7 100644 --- a/client/src/components/common/ListItemLink.jsx +++ b/client/src/components/common/ListItemLink.jsx @@ -1,7 +1,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Link } from 'react-router-dom'; -import ListItem from '@material-ui/core/ListItem'; +import ListItem from '@mui/material/ListItem'; function ListItemLink({ children, to, disabled }) { return ( diff --git a/client/src/components/common/PrivateRoute.jsx b/client/src/components/common/PrivateRoute.jsx index d1564e2..e91d6b8 100644 --- a/client/src/components/common/PrivateRoute.jsx +++ b/client/src/components/common/PrivateRoute.jsx @@ -4,7 +4,7 @@ import { Route, Redirect } from 'react-router-dom'; import { connect } from 'react-redux'; const PrivateRoute = ({ - user, children, isAdminRoute, ...rest + user, isAdminRoute, ...rest }) => { const { fetching, id } = user; const loggedIn = !fetching && id; @@ -24,16 +24,11 @@ const PrivateRoute = ({ }; PrivateRoute.propTypes = { - children: PropTypes.oneOfType([ - PropTypes.arrayOf(PropTypes.node), - PropTypes.node, - ]), isAdminRoute: PropTypes.bool, user: PropTypes.shape(), }; PrivateRoute.defaultProps = { - children: [], isAdminRoute: false, user: { }, diff --git a/client/src/components/common/Profile.jsx b/client/src/components/common/Profile.jsx index dd4aa38..b389300 100644 --- a/client/src/components/common/Profile.jsx +++ b/client/src/components/common/Profile.jsx @@ -1,15 +1,15 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import { makeStyles } from '@material-ui/core/styles'; -import Paper from '@material-ui/core/Paper'; -import Grid from '@material-ui/core/Grid'; -import Avatar from '@material-ui/core/Avatar'; -import Typography from '@material-ui/core/Typography'; -import FormControlLabel from '@material-ui/core/FormControlLabel'; -import Checkbox from '@material-ui/core/Checkbox'; -import Divider from '@material-ui/core/Divider'; -import Alert from '@material-ui/lab/Alert'; +import { makeStyles } from '@mui/styles'; +import Paper from '@mui/material/Paper'; +import Grid from '@mui/material/Grid'; +import Avatar from '@mui/material/Avatar'; +import Typography from '@mui/material/Typography'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import Checkbox from '@mui/material/Checkbox'; +import Divider from '@mui/material/Divider'; +import Alert from '@mui/material/Alert'; import EditableTextField from './EditableTextField'; import { lcFetch } from '../../lib/fetch'; import { updateProfile } from '../../store/user/actions'; diff --git a/client/src/components/common/Scramble.jsx b/client/src/components/common/Scramble.jsx index 5629e7e..6556cba 100644 --- a/client/src/components/common/Scramble.jsx +++ b/client/src/components/common/Scramble.jsx @@ -1,8 +1,8 @@ import React from 'react'; import clsx from 'clsx'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; -import Typography from '@material-ui/core/Typography'; +import { makeStyles } from '@mui/styles'; +import Typography from '@mui/material/Typography'; const useStyles = makeStyles((theme) => ({ root: { @@ -18,7 +18,7 @@ const useStyles = makeStyles((theme) => ({ disabled: { color: theme.palette.text.disabled, }, - // eslint-disable-next-line quote-props + minx: { lineHeight: '.90rem', fontSize: '.75rem', diff --git a/client/src/components/common/Scramble.test.jsx b/client/src/components/common/Scramble.test.jsx index 1fbad03..76e2ba2 100644 --- a/client/src/components/common/Scramble.test.jsx +++ b/client/src/components/common/Scramble.test.jsx @@ -1,15 +1,25 @@ import React from 'react'; -import { shallow } from 'enzyme'; +import { render, screen } from '@testing-library/react'; +import { createTheme, ThemeProvider } from '@mui/material/styles'; import Scramble from './Scramble'; const scramble = 'test'; +const theme = createTheme(); + +const renderScramble = (props) => render( + + + , +); it('renders without crashing', () => { - shallow(); + renderScramble({ scrambles: [scramble] }); + + expect(screen.getByText(scramble)).toBeInTheDocument(); }); it('renders 3x3 text correctly', () => { - const component = shallow(); + renderScramble({ event: '333', scrambles: [scramble] }); - expect(component.text()).toEqual(scramble); + expect(screen.getByText(scramble)).toHaveTextContent(scramble); }); diff --git a/client/src/components/common/ScramblePreview.jsx b/client/src/components/common/ScramblePreview.jsx index 1841570..9359b2f 100644 --- a/client/src/components/common/ScramblePreview.jsx +++ b/client/src/components/common/ScramblePreview.jsx @@ -1,7 +1,7 @@ import React, { useEffect, useRef, useState } from 'react'; import PropTypes from 'prop-types'; -import Dialog from '@material-ui/core/Dialog'; -import DialogContent from '@material-ui/core/DialogContent'; +import Dialog from '@mui/material/Dialog'; +import DialogContent from '@mui/material/DialogContent'; import loadTwistyPlayer from '../../lib/cubingTwisty'; import Scramble from './Scramble'; diff --git a/client/src/components/common/TableCellButton.jsx b/client/src/components/common/TableCellButton.jsx index bccea20..c67a799 100644 --- a/client/src/components/common/TableCellButton.jsx +++ b/client/src/components/common/TableCellButton.jsx @@ -1,9 +1,9 @@ import React from 'react'; import clsx from 'clsx'; import PropTypes from 'prop-types'; -import { makeStyles } from '@material-ui/core/styles'; -import TableCell from '@material-ui/core/TableCell'; -import Button from '@material-ui/core/Button'; +import { makeStyles } from '@mui/styles'; +import TableCell from '@mui/material/TableCell'; +import Button from '@mui/material/Button'; const useStyles = makeStyles(() => ({ root: { @@ -26,13 +26,13 @@ function TableCellButton({