From 521597a3061e70ca8ef07c42a57de0e21f19f9a1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 22:09:01 +0000 Subject: [PATCH] Add prod-to-dev database sync script Adds scripts/sync-prod-db.sh and npm run db:sync-prod to safely copy production data to the development database. After restoring, the script nulls all Discord webhook URLs and anonymizes non-admin user PII (email, name, avatar) to prevent notification leaks and reduce PII exposure on developer machines. Admin accounts are preserved intact. https://claude.ai/code/session_01EiYh5ZiuWAnpo1PND45Yi1 --- package.json | 1 + scripts/sync-prod-db.sh | 160 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100755 scripts/sync-prod-db.sh diff --git a/package.json b/package.json index 89511e2..92e0d74 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build:server": "node scripts/build-server.mjs", "db:generate": "dotenv -- drizzle-kit generate", "db:migrate": "dotenv -- drizzle-kit migrate", + "db:sync-prod": "bash scripts/sync-prod-db.sh", "dev": "NODE_OPTIONS='--import ./instrument.server.mjs' dotenv -- tsx watch server.ts", "start": "NODE_ENV=production NODE_OPTIONS='--import ./instrument.server.mjs' node dist/server.js", "start:production": "NODE_ENV=production NODE_OPTIONS='--import ./instrument.server.mjs' node dist/server.js", diff --git a/scripts/sync-prod-db.sh b/scripts/sync-prod-db.sh new file mode 100755 index 0000000..84968f5 --- /dev/null +++ b/scripts/sync-prod-db.sh @@ -0,0 +1,160 @@ +#!/usr/bin/env bash +# sync-prod-db.sh +# +# Dumps the production database and restores it to the development database, +# then sanitizes the copy to prevent outbound notifications and reduce PII exposure. +# +# Usage: +# PROD_DATABASE_URL="postgres://..." npm run db:sync-prod +# # or pass as argument: +# bash scripts/sync-prod-db.sh "postgres://..." +# +# Requirements: pg_dump, pg_restore, psql (PostgreSQL client tools) + +set -euo pipefail + +# ── Colour helpers ──────────────────────────────────────────────────────────── +RED='\033[0;31m' +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +BOLD='\033[1m' +RESET='\033[0m' + +info() { echo -e "${BOLD}$*${RESET}"; } +warn() { echo -e "${YELLOW}$*${RESET}"; } +success() { echo -e "${GREEN}$*${RESET}"; } +error() { echo -e "${RED}ERROR: $*${RESET}" >&2; exit 1; } + +# ── Load .env ───────────────────────────────────────────────────────────────── +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ENV_FILE="$SCRIPT_DIR/../.env" + +if [[ -f "$ENV_FILE" ]]; then + # Export only DATABASE_URL from .env (avoid polluting env with unrelated vars) + DATABASE_URL_FROM_ENV=$(grep -E '^DATABASE_URL=' "$ENV_FILE" | head -1 | cut -d= -f2- | sed 's/^"//' | sed 's/"$//') + if [[ -n "$DATABASE_URL_FROM_ENV" && -z "${DATABASE_URL:-}" ]]; then + export DATABASE_URL="$DATABASE_URL_FROM_ENV" + fi +fi + +# ── Resolve URLs ────────────────────────────────────────────────────────────── +# PROD_DATABASE_URL: env var or first positional argument +PROD_DB="${PROD_DATABASE_URL:-${1:-}}" +DEV_DB="${DATABASE_URL:-}" + +[[ -z "$PROD_DB" ]] && error "PROD_DATABASE_URL is not set.\nSet it as an environment variable or pass it as the first argument:\n PROD_DATABASE_URL=\"postgres://...\" npm run db:sync-prod" +[[ -z "$DEV_DB" ]] && error "DATABASE_URL is not set. Add it to your .env file." + +# Guard against accidentally syncing prod → prod +if [[ "$PROD_DB" == "$DEV_DB" ]]; then + error "PROD_DATABASE_URL and DATABASE_URL are the same. Aborting to prevent data loss." +fi + +# ── Confirmation prompt ─────────────────────────────────────────────────────── +echo "" +warn "⚠️ WARNING: This will OVERWRITE your development database with production data." +echo "" +echo -e " Source (prod): ${BOLD}${PROD_DB//:*@/:***@}${RESET}" # mask password +echo -e " Target (dev): ${BOLD}${DEV_DB//:*@/:***@}${RESET}" +echo "" +warn "After restore, the script will:" +echo " • NULL out all Discord webhook URLs (prevents notification leaks)" +echo " • Anonymize email/name/avatar for non-admin users (reduces PII exposure)" +echo " • Admin accounts are left untouched so you can still access admin pages" +echo "" +read -r -p "Type 'yes' to continue: " CONFIRM +if [[ "$CONFIRM" != "yes" ]]; then + echo "Aborted." + exit 0 +fi + +# ── Dump ────────────────────────────────────────────────────────────────────── +DUMP_FILE=$(mktemp /tmp/brackt_prod_dump_XXXXXX.dump) +# Ensure the temp file is cleaned up on exit +trap 'rm -f "$DUMP_FILE"' EXIT + +info "" +info "1/4 Dumping production database…" +pg_dump \ + --no-owner \ + --no-acl \ + --format=custom \ + "$PROD_DB" \ + --file="$DUMP_FILE" +success " Dump complete: $(du -h "$DUMP_FILE" | cut -f1)" + +# ── Drop & recreate dev DB ──────────────────────────────────────────────────── +info "" +info "2/4 Recreating development database…" + +# Extract the database name from the URL (last path component) +DEV_DB_NAME="${DEV_DB##*/}" +DEV_DB_NAME="${DEV_DB_NAME%%\?*}" # strip any query params + +# Build a connection string to the postgres maintenance DB on the same host +# by replacing the DB name with "postgres" +ADMIN_DB="${DEV_DB%/$DEV_DB_NAME*}/postgres${DEV_DB#*/$DEV_DB_NAME}" +# Simpler: just swap the last path segment +ADMIN_DB=$(echo "$DEV_DB" | sed "s|/$DEV_DB_NAME\$|/postgres|;s|/$DEV_DB_NAME?|/postgres?|") + +# Terminate existing connections, then drop and recreate +psql "$ADMIN_DB" \ + -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = '$DEV_DB_NAME' AND pid <> pg_backend_pid();" \ + -c "DROP DATABASE IF EXISTS \"$DEV_DB_NAME\";" \ + -c "CREATE DATABASE \"$DEV_DB_NAME\";" \ + --quiet +success " Database '$DEV_DB_NAME' recreated." + +# ── Restore ─────────────────────────────────────────────────────────────────── +info "" +info "3/4 Restoring production dump to development database…" +pg_restore \ + --no-owner \ + --no-acl \ + --dbname="$DEV_DB" \ + "$DUMP_FILE" +success " Restore complete." + +# ── Sanitize ────────────────────────────────────────────────────────────────── +info "" +info "4/4 Sanitizing development database…" + +SANITIZE_RESULT=$(psql "$DEV_DB" --tuples-only --no-align << 'SQL' +BEGIN; + +-- 1. Disable Discord webhook notifications for all leagues +UPDATE leagues SET discord_webhook_url = NULL; +SELECT 'discord_webhooks_nulled:' || COUNT(*) FROM leagues; + +-- 2. Anonymize PII for non-admin users only +-- (admin accounts are untouched so admin pages remain accessible) +UPDATE users SET + email = 'user_' || id || '@example.com', + first_name = 'Test', + last_name = 'User', + display_name = 'Test User', + image_url = NULL +WHERE is_admin = FALSE; +SELECT 'users_anonymized:' || COUNT(*) FROM users WHERE is_admin = FALSE; + +SELECT 'admin_users_kept:' || COUNT(*) FROM users WHERE is_admin = TRUE; + +COMMIT; +SQL +) + +LEAGUES_COUNT=$(echo "$SANITIZE_RESULT" | grep 'discord_webhooks_nulled:' | cut -d: -f2 | tr -d ' ') +ANON_COUNT=$(echo "$SANITIZE_RESULT" | grep 'users_anonymized:' | cut -d: -f2 | tr -d ' ') +ADMIN_COUNT=$(echo "$SANITIZE_RESULT" | grep 'admin_users_kept:' | cut -d: -f2 | tr -d ' ') + +echo "" +success "✓ Sync complete. Sanitization summary:" +echo " • Discord webhook URLs nulled: ${LEAGUES_COUNT:-?} leagues" +echo " • Non-admin users anonymized: ${ANON_COUNT:-?} users" +echo " • Admin accounts preserved: ${ADMIN_COUNT:-?} users" +echo "" +warn "Reminder: ensure your .env does NOT contain:" +echo " • RESEND_API_KEY (email sending)" +echo " • SENTRY_AUTH_TOKEN (error tracking)" +echo " • Production Clerk keys (use dev Clerk instance)" +echo ""