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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,4 @@ client/config/.env.development
# Local build/worktree scratch
.turbo
.worktrees
.privacy-email-cutover
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
47 changes: 47 additions & 0 deletions README_DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 8 additions & 8 deletions client/src/components/Header.jsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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: {
Expand Down Expand Up @@ -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;
};

Expand Down
12 changes: 12 additions & 0 deletions client/src/lib/wcaAuth.js
Original file line number Diff line number Diff line change
@@ -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,
})}`
);
15 changes: 15 additions & 0 deletions client/src/lib/wcaAuth.test.js
Original file line number Diff line number Diff line change
@@ -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/);
});
});
13 changes: 13 additions & 0 deletions scripts/deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions scripts/test-deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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.'
11 changes: 2 additions & 9 deletions server/auth/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: {},
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions server/auth/wcaProfile.js
Original file line number Diff line number Diff line change
@@ -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,
};
28 changes: 28 additions & 0 deletions server/auth/wcaProfile.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
2 changes: 1 addition & 1 deletion server/database.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
32 changes: 32 additions & 0 deletions server/database.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
3 changes: 0 additions & 3 deletions server/models/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,6 @@ const schema = new mongoose.Schema({
type: Number,
required: true,
},
email: {
type: String,
},
name: {
type: String,
required: true,
Expand Down
25 changes: 25 additions & 0 deletions server/models/user.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});
1 change: 1 addition & 0 deletions server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Loading
Loading