Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
NODE_ENV=prod
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
Expand All @@ -13,13 +19,29 @@ REACT_APP_WCA_CLIENT_ID=replace-me
# Server config
MONGO_URL=mongodb://mongo:27017/letscube
REDIS_URL=redis://redis:6379
POSTGRES_ENABLED=true
# DATABASE_URL can replace the individual PG* connection fields.
DATABASE_URL=
# Optional direct connection for Prisma migrations when DATABASE_URL is pooled.
DIRECT_DATABASE_URL=
PGHOST=postgres
PGPORT=5432
PGDATABASE=letscube
PGUSER=letscube
POSTGRES_PASSWORD=replace-with-a-long-random-secret
PGSSL=false
PGSSL_REJECT_UNAUTHORIZED=true
PGSSL_CA=
AUTH_SECRET=replace-with-a-long-random-secret
SESSION_SECRET=
AUTH_CALLBACK_URL=https://letscube.net/auth/callback
WCA_SOURCE=https://www.worldcubeassociation.org
WCA_CLIENT_ID=replace-me
WCA_CLIENT_SECRET=replace-me
CORS_ORIGINS=https://letscube.net,https://www.letscube.net
METRICS_ENABLED=true
METRICS_RETENTION_DAYS=90
METRICS_HASH_SECRET=replace-with-a-different-long-random-secret

# Nginx TLS mounts
LETSENCRYPT_DIR=/etc/letsencrypt
Expand Down
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,26 @@ jobs:
server:
name: Server lint and unit tests
runs-on: ubuntu-latest
env:
PGHOST: 127.0.0.1
PGPORT: 5432
PGDATABASE: letscube
PGUSER: letscube
PGPASSWORD: letscube
services:
postgres:
image: postgres:17-alpine
env:
POSTGRES_DB: letscube
POSTGRES_USER: letscube
POSTGRES_PASSWORD: letscube
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U letscube -d letscube"
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Check out repository
uses: actions/checkout@v4
Expand All @@ -24,6 +44,15 @@ jobs:
- name: Install dependencies
run: yarn install --frozen-lockfile

- name: Validate Prisma schema
run: yarn workspace letscube-server postgres:schema:validate

- name: Apply PostgreSQL migrations
run: yarn workspace letscube-server postgres:migrate

- name: Check PostgreSQL schema drift
run: yarn workspace letscube-server postgres:schema:check

- name: Run server lint
run: yarn turbo run lint --filter=letscube-server

Expand Down
5 changes: 4 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ ENV PORT=8080
ENV SOCKETIO_PORT=9000

WORKDIR /app
RUN groupadd --system letscube \
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/* \
&& groupadd --system letscube \
&& useradd --system --gid letscube --home-dir /app --shell /usr/sbin/nologin letscube

COPY --chown=letscube:letscube server ./server
Expand Down
66 changes: 62 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,21 +1,37 @@
# Let's Cube

This is a Progressive Web app written in Node.JS, Express, MongoDB, socket.io, react, and material-ui.
This is a Progressive Web app written in Node.JS, Express, MongoDB,
PostgreSQL, socket.io, React, and Material UI.

This project consists of a client and a server.

## Development:

Make sure Node.JS and Docker are installed. Docker Compose starts the local MongoDB and Redis services used by the app.
Make sure Node.JS and Docker are installed. Docker Compose starts the local
MongoDB, PostgreSQL, and Redis services used by the app.

```
git clone https://github.com/coder13/letscube.git
cd letscube
yarn install
docker compose up -d
docker compose -f docker-compose.yml up -d
```

If MongoDB or Redis are already running locally on their default ports, stop them or skip Docker Compose.
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:

```bash
yarn workspace letscube-server postgres:migrate
```

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`.

**Server**

Expand All @@ -33,3 +49,45 @@ yarn start:client
```

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.
128 changes: 123 additions & 5 deletions README_DEPLOYMENT.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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:

Expand All @@ -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
```
Expand All @@ -87,6 +94,117 @@ Deploy latest code from the server checkout:
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:
Expand Down Expand Up @@ -122,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.
1 change: 1 addition & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"@material-ui/core": "^4.11.2",
"@material-ui/icons": "^4.11.2",
"@material-ui/lab": "4.0.0-alpha.46",
"@material-ui/styles": "^4.11.5",
"clsx": "1.1.0",
"connected-react-router": "6.8.0",
"date-fns": "^2.16.1",
Expand Down
Loading
Loading