diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..362339e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,14 @@ +.git +.gitea +.agents +.codex +.svelte-kit +.vscode +build +node_modules +data +.env +.env.* +.env.keys +*.log +docs diff --git a/.env.example b/.env.example index 5acac90..56d888b 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,9 @@ TDR_DATABASE_PATH=./data/shd-build-development.db # Persistent directory for normalized user-uploaded avatars. TDR_AVATAR_STORAGE_PATH=./data/avatars +# Persistent directory for uploaded talent, brand, protocol, and skill-chip icons. +TDR_GAME_ICON_STORAGE_PATH=./data/game-icons + # Optional fixed credentials used only when initializing or explicitly resetting # the two default accounts. Leave blank to generate secure temporary passwords. DEFAULT_ADMIN_PASSWORD= @@ -20,6 +23,8 @@ DISCORD_REDIRECT_URI=https://dev.shd.build/auth/discord/callback # Canonical application origin used in emailed authentication links. APP_ORIGIN=https://dev.shd.build +# SvelteKit adapter-node canonical origin when deployed behind a reverse proxy. +ORIGIN=https://dev.shd.build # Use shd.build for both production and dev.shd.build. Use localhost only when # accessing development directly at http://localhost:5173. PASSKEY_RP_ID=shd.build diff --git a/.env.staging b/.env.staging new file mode 100644 index 0000000..d10de01 --- /dev/null +++ b/.env.staging @@ -0,0 +1,27 @@ +#/-------------------[DOTENV_PUBLIC_KEY]--------------------/ +#/ public-key encryption for .env files / +#/ [how it works](https://dotenvx.com/encryption) / +#/----------------------------------------------------------/ +DOTENV_PUBLIC_KEY_STAGING="020bab072314925eb9a9cbaf42282e3a0d5c4a8de3d88a51ed92b4b3c83d598f98" + +# .env.staging +# Staging is deployed at https://stage.shd.build. +TDR_DATABASE_PATH=encrypted:BEfWarm0yphwbJYuA9UCHZDY4pMcFw1HLHras694Tj2njTVgIuYcJZ2pJEkj8rjWbtFrCGh052GTqf0pX6KmWXTeu0WWgEanSJiRXZ8CYRt8/rif9gk/bqHOq1CUZfG01+eCIqkN1pOEGVAJEsqjkn/EbiWXzcqBtXisBOYdl40= +TDR_AVATAR_STORAGE_PATH=encrypted:BMoxUtKT0LXRmZMrj776svimltQlS7TEhv3ganVjJEbTS+4WlGDHzqxQ91+6nUexyXxeBWW7n81UeRTknZqzlENY3dFsaVtYUATv9meKfp806Ab4BVmFdNCVba+GPEMqJ57Sw2fG5ncLrn/AHniLJ0UU +TDR_GAME_ICON_STORAGE_PATH=encrypted:BAv4kKj8sR1An7Ym9+hLCIu4xSQdC4fc5dyRy+WaY00kmKLQ7Pj4pO9qc4Vzx4nNYp8124WevhHQeB6CzCoK94owCsmX/HHoEhajcoEGFSOaferE61bJfufK1pbzb7SRsQbZQjCh3yqFlTooYs1x/QHBZ5WW + +APP_ORIGIN=encrypted:BGs9cTUlSzmPJhHESxefXKdzZ9w8l192He08Sl3IR4NXcNUlUuvxrDh0qEJXYX19GHtO1XkB95oXefO6Rw1H8SnZZ5zeeCHbOn+7oW/7v8dGLWkQMGTlG8YyJ6LNVAD1GBISAxjrO3e5lEAUOm/j0rvc8u24tg== +PASSKEY_RP_ID=encrypted:BI7ozl7Nw3NR3TlJ4TycJxt0HuNu7sQwNWFldib/sU+F6L60bNgENK60E9JXfx0euz7DwtOCFGeecKCtH7Vb7vIedT6aNyqXOtjmEi4zrEP12oIJJvd1nzU/2f2uBx6Hp+YwOyvhnMxGMg== + +DISCORD_CLIENT_ID="encrypted:BIcLXUW7R3kKKwL+vPqzDzdaF9FFc/o2LLW12za5X04VROQrPgt0nf+fChDvAE9zwd/iUcBGJO0kpYuYHPPxXzjrCRwa64oj+gxKuUtjqWDuJeos5ABYzG2ZPF9M4Lbtdiq7D/PAn4/vGXhKjUxhf+M5wTo=" +DISCORD_CLIENT_SECRET="encrypted:BGANQ+E6ASvqsdW4Xvyh1hluWRQsyjtXJFJWma3pY00Ge9+kHZD7z9O9kWJKNKFGNoJP3G1bnGk1CT7x0g82R5X5jLx8YF8EETI3KjCybIctTwAGq+GepFpz7FuQIPhL3fNSteuF0eCC7kGTxTW2zrPyaLIDErF4+KCdDKRgoct/" +DISCORD_REDIRECT_URI=encrypted:BIVlGoCrhTUQE6e8j0Ol7oi/5DMHblTRfWiexZg24VZrD2t7gomiZn1ylMo0YYHNm2B7jf+TkRTlNg36wUukhkl5L+qQRGaVYuhiD57s8Hzv9Z+8zcREoqKu8jTtA8CKTYHFmaiwRfYQJAxNzWRCGNp1oAUd1TpXUEZ03hORaIgD6DJAu6OeaipofKs= + +SMTP_HOST="encrypted:BAzR0UdCJHlutOl01cw6kzPRseaot+cr+7ePUSKOcorXrGE4d2BWvQCT5A37qydjieSN98H1lV+9nykNMYUAD3PniY7/nd9DD6bDmXnG1nKACedCCF/fTPgfct0PMSN+FmgJI6UMbP45Q/l/QM3eRBJcqQ==" +SMTP_PORT="encrypted:BEfvu12wUPhrlVp1ICDAE6ohx5rKsUWGcnYnqL40VwafI8nayK9DqU5tuxag5du4E9lkHZ7QOLbn9QRw3sR3Yl74C+PlIktQWSQ5qCWMh2qbjYmulF8uTSWNXpPhIlhCyYVipA==" +SMTP_SECURE="encrypted:BHsWBT1w//boBqindVIcD3KOaDcjBfuHb+cm6ph5zP3dMT+oaFlG7msV28uPnbL5ZtOFXC6sUzVicMeqktu9O5ciQ+K9CFM+q+GStQBFqJ1SbfEq0isspV9eXWcTGPmTZAfBDxFx" +SMTP_USER="encrypted:BL5lI1+d91LAlbLyGCZ343Uqq55McVdF7Jo+e33EauaChfg+ltKB2d4lFGm7KHkQ+K9CorxlPpR82YjssS7ixYWwbEkFL7PgOgWxF8ksKQHDL8UR1zaqTmyylKseVfj/YIquaA7nS5FsjzpWCDVQFETJNw==" +SMTP_PASSWORD="encrypted:BNsDbxMZqE142HZ8PyEytL9mG7L2y79rMvM6aqDcs0ONNYtjM2ffDwKbu4ZySwkv6NS6mdKtHTVg3/tGmvdPRjbuK4f6FpRsJEnQvCwLaN0Gy9ikeCMOwrqQCOyfJSTkNTBiOvrz5e/Id0BXYjOkShk=" +SMTP_FROM="encrypted:BDsOlqmQFyAGOOJ2f+h9AS+CmV3uxEpy1mJaNzWIGFI1NMumy9s+dFgvKrunhAIImDSzod5BphAAOtUW8RyrIEO5wJvB1IJ+qoAw06hSWa4qWu1thdSu2ZEP3D+4ll7kpsOcMEU5PVFlSHQRLsPRdOS5mSGCy5VkXb2zhNAQ0A==" + +ORIGIN="encrypted:BHOwfV6ahbNXg8A7HNbApjaDrQiwVXSI5f/tBx/eeFYdBxgQJ8zQQKUOzSeICdOtx1z9BxIOWHr86+E8ZG67k0RxSeevpSLWe8bINOy+7Xf9IzXBgg2aOpY0gWxAcXercLKvP+UNJJy3TuEl9sQZqRJBjNPyhyJB" diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3981d63 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +*.sh text eol=lf +Dockerfile text eol=lf +*.yml text eol=lf +*.yaml text eol=lf diff --git a/.gitea/workflows/deploy-staging.yml b/.gitea/workflows/deploy-staging.yml new file mode 100644 index 0000000..9956210 --- /dev/null +++ b/.gitea/workflows/deploy-staging.yml @@ -0,0 +1,66 @@ +name: Validate and deploy staging + +on: + push: + branches: + - main + workflow_dispatch: + +concurrency: + group: shd-build-staging + cancel-in-progress: false + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install dependencies + run: npm ci + + - name: Validate application + run: npm run check + + - name: Build Node application + run: npm run build:app + + deploy: + needs: validate + runs-on: stage-deploy + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Install environment tooling + run: npm ci + + - name: Prepare deployment configuration + env: + DOTENV_PRIVATE_KEY_STAGING: ${{ secrets.DOTENV_PRIVATE_KEY_STAGING }} + CONFIGURED_DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }} + CONFIGURED_STAGE_PORT: ${{ vars.STAGE_PORT }} + COMMIT_SHA: ${{ gitea.sha }} + run: | + set -eu + deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-stage}" + stage_port="${CONFIGURED_STAGE_PORT:-5569}" + mkdir -p "$deployment_path" + cp deploy/compose.staging.yml deploy/deploy-staging.sh deploy/bootstrap-staging.sh "$deployment_path/" + DOTENV_PRIVATE_KEY_STAGING="$DOTENV_PRIVATE_KEY_STAGING" npx dotenvx decrypt -f .env.staging --stdout > "$deployment_path/.env.staging.runtime" + printf 'SHD_IMAGE=shd-build-stage:%s\nSHD_STAGE_PORT=%s\n' "$COMMIT_SHA" "$stage_port" > "$deployment_path/.env.deploy" + chmod 600 "$deployment_path/.env.staging.runtime" "$deployment_path/.env.deploy" + chmod 755 "$deployment_path/deploy-staging.sh" "$deployment_path/bootstrap-staging.sh" + + - name: Deploy staging + env: + CONFIGURED_DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }} + COMMIT_SHA: ${{ gitea.sha }} + run: | + deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-stage}" + "$deployment_path/deploy-staging.sh" "$deployment_path" "$GITHUB_WORKSPACE" "shd-build-stage:$COMMIT_SHA" + + - name: Remove unused application images + if: success() + run: docker image prune --force --filter "label=org.opencontainers.image.title=shd-build" diff --git a/.gitea/workflows/export-isac-data.yml b/.gitea/workflows/export-isac-data.yml new file mode 100644 index 0000000..05c57f4 --- /dev/null +++ b/.gitea/workflows/export-isac-data.yml @@ -0,0 +1,43 @@ +name: Export staging ISAC data + +on: + workflow_dispatch: + +concurrency: + group: shd-build-staging + cancel-in-progress: false + +jobs: + export: + runs-on: stage-deploy + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Export canonical ISAC data and uploaded icons + env: + CONFIGURED_DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }} + run: | + set -eu + deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-stage}" + compose_file="$deployment_path/compose.staging.yml" + deploy_environment="$deployment_path/.env.deploy" + compose() { + docker compose --env-file "$deploy_environment" -f "$compose_file" "$@" + } + restart_services() { + compose up -d --no-build app scheduler + } + trap restart_services EXIT + snapshot_name="isac-stage-${GITHUB_RUN_ID}.json" + compose stop scheduler app + compose run --rm --no-deps app node scripts/isac-data-export.mjs "/app/data/exports/$snapshot_name" + compose run --rm --no-deps -v "$GITHUB_WORKSPACE:/export" app \ + cp "/app/data/exports/$snapshot_name" /export/isac-stage.json + + - name: Publish ISAC snapshot + uses: actions/upload-artifact@v3 + with: + name: shd-stage-isac-data + path: isac-stage.json + retention-days: 14 diff --git a/.gitignore b/.gitignore index 4cc2ac3..477e971 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ Thumbs.db !.env.example !.env.test !.env.development +!.env.staging !.env.production .env.keys .env.keys.* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..627abc6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# syntax=docker/dockerfile:1 +FROM node:24-bookworm-slim AS dependencies +WORKDIR /app +COPY package.json package-lock.json .npmrc ./ +RUN npm ci + +FROM dependencies AS build +COPY vite.config.ts tsconfig.json ./ +COPY src ./src +COPY static ./static +RUN npm run build:app + +FROM node:24-bookworm-slim AS runtime-dependencies +WORKDIR /app +COPY package.json package-lock.json .npmrc ./ +RUN npm ci --omit=dev && npm cache clean --force + +FROM node:24-bookworm-slim AS runtime +LABEL org.opencontainers.image.title="shd-build" +ENV NODE_ENV=production \ + HOST=0.0.0.0 \ + PORT=3000 \ + TDR_DATABASE_PATH=/app/data/shd-build.db \ + TDR_AVATAR_STORAGE_PATH=/app/data/avatars \ + TDR_GAME_ICON_STORAGE_PATH=/app/data/game-icons +WORKDIR /app +COPY --from=runtime-dependencies --chown=node:node /app/node_modules ./node_modules +COPY --from=build --chown=node:node /app/build ./build +COPY --chown=node:node database ./database +COPY --chown=node:node scripts ./scripts +COPY --chown=node:node deploy/container-entrypoint.sh ./deploy/container-entrypoint.sh +RUN chmod 755 ./deploy/container-entrypoint.sh && mkdir -p /app/data && chown node:node /app/data +USER node +EXPOSE 3000 +VOLUME ["/app/data"] +ENTRYPOINT ["./deploy/container-entrypoint.sh"] +CMD ["serve"] diff --git a/README.md b/README.md index 677ae8c..bd057bc 100644 --- a/README.md +++ b/README.md @@ -41,16 +41,19 @@ npm run dev ## Encrypted environment files -Development and production settings live in the committed, encrypted -`.env.development` and `.env.production` files. Private decryption keys live -only in the ignored `.env.keys` file; back it up in a password manager. +Development, staging, and production settings live in the committed, encrypted +`.env.development`, `.env.staging`, and `.env.production` files. Private +decryption keys live only in the ignored `.env.keys` file; back it up in a +password manager. ```sh -npm run env:decrypt # decrypt both files for editing -npm run env:encrypt # encrypt both files again -npm run env:stage # encrypt and git-add both files +npm run env:decrypt # decrypt all environment files for editing +npm run env:encrypt # encrypt all environment files again +npm run env:stage # encrypt and git-add all environment files npm run env:decrypt:development # operate on one environment +npm run env:decrypt:staging npm run env:decrypt:production +npm run env:sync:staging-credentials # copy shared Discord/SMTP values from development ``` The repository pre-commit hook runs `env:stage`, so a commit always replaces @@ -62,6 +65,20 @@ Never commit or lose `.env.keys`. Production should receive `DOTENV_PRIVATE_KEY_PRODUCTION` through its secret manager rather than copying the keys file onto the server. +## Containers and staging deployment + +The application uses the official SvelteKit Node adapter and includes a +multi-stage `Dockerfile`. Database files, database backups, user avatars, and +uploaded game-data icons live under `/app/data`, which must be a persistent +volume. Runtime secrets are injected when the container starts and are never +baked into the image. + +The staging compose topology and Gitea Actions deployment are documented in +[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). Pushes to `main` validate the +application, build the candidate image while staging remains online, stop the +application and scheduler briefly, back up the embedded database, migrate it, +and start the new release. + ## Passwordless authentication Passkeys are the preferred sign-in method. `APP_ORIGIN` must exactly match the diff --git a/deploy/bootstrap-staging.sh b/deploy/bootstrap-staging.sh new file mode 100644 index 0000000..88c2dd1 --- /dev/null +++ b/deploy/bootstrap-staging.sh @@ -0,0 +1,29 @@ +#!/bin/sh +set -eu + +bundle_path=${1:-} +deployment_directory=${2:-/opt/shd-build-stage} +if [ -z "$bundle_path" ] || [ ! -f "$bundle_path" ]; then + echo "Usage: bootstrap-staging.sh [deployment-directory]" >&2 + exit 2 +fi + +compose_file="$deployment_directory/compose.staging.yml" +deploy_environment="$deployment_directory/.env.deploy" +compose() { + docker compose --env-file "$deploy_environment" -f "$compose_file" "$@" +} +restart_services() { + compose up -d --no-build app scheduler +} +trap restart_services EXIT + +echo "Stopping staging before the one-time database bootstrap." +compose stop scheduler app || true +compose run --rm --no-deps \ + -e ALLOW_STAGING_BOOTSTRAP=true \ + -v "$bundle_path:/import/staging-bootstrap.json:ro" \ + app node scripts/stage-bootstrap-import.mjs \ + /import/staging-bootstrap.json --confirm-empty-staging-replace + +echo "Staging bootstrap completed." diff --git a/deploy/compose.staging.yml b/deploy/compose.staging.yml new file mode 100644 index 0000000..cf13075 --- /dev/null +++ b/deploy/compose.staging.yml @@ -0,0 +1,56 @@ +name: shd-build-stage + +services: + app: + image: ${SHD_IMAGE:-shd-build-stage:local} + restart: unless-stopped + init: true + env_file: + - .env.staging.runtime + environment: + HOST: 0.0.0.0 + PORT: 3000 + BODY_SIZE_LIMIT: 6M + SKIP_DEFAULT_USERS: "true" + TDR_DATABASE_PATH: /app/data/shd-build-staging.db + TDR_AVATAR_STORAGE_PATH: /app/data/avatars + TDR_GAME_ICON_STORAGE_PATH: /app/data/game-icons + ports: + - "127.0.0.1:${SHD_STAGE_PORT:-5569}:3000" + volumes: + - shd_stage_data:/app/data + healthcheck: + test: + - CMD + - node + - -e + - fetch('http://127.0.0.1:3000/api/health/database').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1)) + interval: 15s + timeout: 5s + retries: 8 + start_period: 30s + stop_grace_period: 30s + + scheduler: + image: ${SHD_IMAGE:-shd-build-stage:local} + restart: unless-stopped + init: true + command: + - scheduler + env_file: + - .env.staging.runtime + environment: + SKIP_DEFAULT_USERS: "true" + TDR_DATABASE_PATH: /app/data/shd-build-staging.db + TDR_AVATAR_STORAGE_PATH: /app/data/avatars + TDR_GAME_ICON_STORAGE_PATH: /app/data/game-icons + volumes: + - shd_stage_data:/app/data + depends_on: + app: + condition: service_healthy + stop_grace_period: 30s + +volumes: + shd_stage_data: + name: shd-build-stage-data diff --git a/deploy/container-entrypoint.sh b/deploy/container-entrypoint.sh new file mode 100644 index 0000000..c11c63b --- /dev/null +++ b/deploy/container-entrypoint.sh @@ -0,0 +1,18 @@ +#!/bin/sh +set -eu + +if [ "${1:-}" = "serve" ]; then + node scripts/db-migrate.mjs + exec node build +fi + +if [ "${1:-}" = "scheduler" ]; then + while true; do + node scripts/system-notification-dispatch.mjs + node scripts/oversight-notification-dispatch.mjs + node scripts/notification-digest.mjs + sleep 60 + done +fi + +exec "$@" diff --git a/deploy/deploy-staging.sh b/deploy/deploy-staging.sh new file mode 100644 index 0000000..bbb972d --- /dev/null +++ b/deploy/deploy-staging.sh @@ -0,0 +1,51 @@ +#!/bin/sh +set -eu + +deployment_directory=${1:-/opt/shd-build-stage} +source_directory=${2:-} +candidate_image=${3:-} +if [ -z "$source_directory" ] || [ ! -f "$source_directory/Dockerfile" ] || [ -z "$candidate_image" ]; then + echo "Usage: deploy-staging.sh " >&2 + exit 2 +fi +compose_file="$deployment_directory/compose.staging.yml" +deploy_environment="$deployment_directory/.env.deploy" + +compose() { + docker compose --env-file "$deploy_environment" -f "$compose_file" "$@" +} + +echo "Building the candidate image while the current staging service remains online." +docker build --label org.opencontainers.image.title=shd-build -t "$candidate_image" "$source_directory" + +echo "Stopping SHD staging processes before accessing the embedded database." +compose stop scheduler app || true + +echo "Creating a timestamped database backup when a staging database exists." +compose run --rm --no-deps app sh -c \ + 'if [ -f "$TDR_DATABASE_PATH" ]; then node scripts/db-backup.mjs; else echo "No staging database exists yet; skipping backup."; fi' + +echo "Applying additive schema migrations." +compose run --rm --no-deps app node scripts/db-migrate.mjs + +echo "Starting the application and notification scheduler." +compose up -d --no-build --remove-orphans app scheduler + +container_id=$(compose ps -q app) +attempt=0 +while [ "$attempt" -lt 30 ]; do + status=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id") + if [ "$status" = "healthy" ]; then + echo "SHD staging deployment is healthy." + exit 0 + fi + if [ "$status" = "unhealthy" ] || [ "$status" = "exited" ] || [ "$status" = "dead" ]; then + break + fi + attempt=$((attempt + 1)) + sleep 2 +done + +echo "SHD staging failed its health check." +compose logs --tail=150 app +exit 1 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 0000000..b2c2766 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,198 @@ +# Container and staging deployment + +SHD.build is packaged as a Node 24 container using the official SvelteKit Node +adapter. The image contains application code, migration and notification +scripts, and native runtime dependencies. It does not contain an environment +file, database, backups, or user uploads. + +## Persistent data + +The staging compose project uses the named volume `shd-build-stage-data`, +mounted at `/app/data`. It contains: + +- `shd-build-staging.db` and its embedded-database support files; +- timestamped JSON backups under `backups/`; +- normalized user avatars under `avatars/`; +- uploaded game-data icons under `game-icons/`. + +Never remove this volume during routine deployments. `docker compose down` +without `--volumes` preserves it. Before any manual backup or restore, stop +both the `app` and `scheduler` services because both access the embedded +database. + +## Staging environment + +Staging defaults to: + +- Public origin: `https://stage.shd.build` +- Reverse-proxy upstream: `http://127.0.0.1:5569` +- Database: `/app/data/shd-build-staging.db` +- Passkey relying-party ID: `shd.build` +- Adapter canonical origin: `https://stage.shd.build` + +Edit its encrypted configuration locally: + +```sh +npm run env:decrypt:staging +# Fill in Discord and SMTP values. +npm run env:encrypt:staging +``` + +Add `https://stage.shd.build/auth/discord/callback` to the Discord application's +allowed OAuth redirect URIs. Point the staging DNS record at the deployment +host and configure the host's TLS reverse proxy to forward +`stage.shd.build` to `127.0.0.1:5569`. + +The deployment decrypts `.env.staging` on the trusted staging runner into a +mode-`0600` runtime file. The decryption key is held by Gitea Actions, not +committed or included in the container image. + +The staging container disables default-user seeding. On a brand-new staging +database, the existing bootstrap policy grants Admin to the first real account +that signs in instead of writing generated credentials into deployment logs. + +## Gitea Actions runner + +Gitea Actions requires an `act_runner`. Register a trusted runner on the +staging host from the repository's **Settings → Actions → Runners** page. The +host needs Node/npm, Docker Engine, and Docker Compose v2. The runner account +must be able to use Docker and write to the deployment directory. + +Use runner labels that provide an isolated validation job and a host deployment +job: + +```text +ubuntu-latest:docker://node:24-bookworm,stage-deploy:host +``` + +The `stage-deploy:host` label is intentional: the deployment step needs the +staging host's Docker daemon and persistent named volume. Only attach this +trusted runner to repositories allowed to deploy to that host. Gitea warns that +Docker daemon access is effectively host-level authority. + +Prepare the default deployment directory: + +```sh +sudo install -d -o act_runner -g act_runner -m 0750 /opt/shd-build-stage +sudo usermod -aG docker act_runner +``` + +Restart the runner session after adding it to the Docker group. + +## Repository Actions configuration + +In `https://git.jb.codes/jb/shd.build/settings/actions`: + +1. Add repository secret `DOTENV_PRIVATE_KEY_STAGING`, using the matching value + from the ignored local `.env.keys`. +2. Optionally add repository variable `STAGE_DEPLOY_PATH`. It defaults to + `/opt/shd-build-stage`. +3. Optionally add repository variable `STAGE_PORT`. It defaults to `5569`. +4. Ensure Actions are enabled and the `stage-deploy` runner is online. + +The workflow at `.gitea/workflows/deploy-staging.yml` runs on every push to +`main` and can also be started manually. It does not need registry credentials +because the trusted staging runner builds a commit-tagged image locally. + +## Deployment lifecycle + +The workflow: + +1. installs dependencies, runs `npm run check`, and produces the Node build; +2. builds the candidate container while the current site remains online; +3. stops only the staging application and notification scheduler; +4. creates a timestamped JSON backup if the staging database exists; +5. applies additive, repeatable migrations; +6. starts the application and scheduler with the new image; +7. waits for `/api/health/database` to report healthy; +8. retains the logs and exits unsuccessfully if health verification fails. + +The short outage is limited to backup, migration, and container startup. This +follows the database-preservation policy while minimizing data-entry downtime. +The scheduler runs automated system messages, Oversight consolidation, and +email digest checks once per minute. + +## Initial development-data bootstrap + +The first staging release can be initialized from the current development +database, including users, roles, builds, guides, proposals, audit history, +avatars, and uploaded game-data icons. This is deliberately a one-time flow: +the staging importer refuses to run after users, builds, audits, or proposals +exist in its target database. + +First stop this project's development server. The embedded database must not be +open while its complete transfer bundle is created: + +```sh +npm run stage:bootstrap:export +``` + +The command writes a timestamped, ignored JSON bundle under `data/backups/`. +It contains user records and password hashes, so transfer it over an encrypted +channel and never add it to Git. Copy it to the staging host, then run: + +```sh +/opt/shd-build-stage/bootstrap-staging.sh \ + /secure/path/shd-stage-bootstrap-YYYY-MM-DDTHH-MM-SSZ.json +``` + +The bootstrap flow stops the app and scheduler, creates a safety backup of the +target, verifies that staging is unused, imports the entire development +database and uploads, and restarts both services. Delete the transfer copy from +the staging host after verifying the imported record counts and critical +entities. The copy under the local ignored backup directory remains the +recovery source. + +Because this is a full initial clone, development session tokens and account +data also enter staging. Staging and development remain independent from that +point forward. + +## Pulling canonical ISAC data into development + +Use the manually triggered **Export staging ISAC data** workflow in Gitea +Actions. It briefly stops staging database writers, exports only canonical +builder-source tables plus uploaded game-data icons, restarts staging, and +publishes a `shd-stage-isac-data` artifact for 14 days. + +Download `isac-stage.json` from that workflow run. Then stop this project's +development server and run: + +```sh +npm run isac:import -- /path/to/isac-stage.json --confirm-replace-isac-data +``` + +The importer automatically creates both a complete development database backup +and an ISAC-only snapshot before replacing canonical data. It does **not** +replace local users, roles, sessions, builds, guides, comments, notifications, +proposals, contributor agreements, audit history, or system-message settings. +Restart development only after the command reports both backup paths and the +imported row/icon counts. + +For a local read-only snapshot without importing: + +```sh +npm run isac:export +``` + +## Manual staging operations + +From the deployment directory: + +```sh +docker compose --env-file .env.deploy -f compose.staging.yml ps +docker compose --env-file .env.deploy -f compose.staging.yml logs -f app +docker compose --env-file .env.deploy -f compose.staging.yml stop scheduler app +docker compose --env-file .env.deploy -f compose.staging.yml run --rm --no-deps app node scripts/db-backup.mjs +docker compose --env-file .env.deploy -f compose.staging.yml up -d app scheduler +``` + +Do not use `docker compose down --volumes` for staging. That deletes the named +volume containing the embedded database and uploads. + +## Production promotion + +Production should use a separate compose project, volume, database filename, +runtime environment, port, and deployment workflow. Do not point staging and +production at the same embedded database or storage volume. Once staging is +proven, the same container image can be tagged for production so promotion does +not rebuild application code. diff --git a/package-lock.json b/package-lock.json index 62e0ae2..790bd61 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ }, "devDependencies": { "@dotenvx/dotenvx": "^2.17.4", - "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/adapter-node": "^5.5.7", "@sveltejs/kit": "^2.63.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", "@types/node": "^26.1.1", @@ -1204,6 +1204,523 @@ "dev": true, "license": "MIT" }, + "node_modules/@rollup/plugin-commonjs": { + "version": "29.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-commonjs/-/plugin-commonjs-29.0.3.tgz", + "integrity": "sha512-ZaOxZceP7SOUW7Lqw5IRVweSQYWaeIPnXIGLiB690EBA3FGJTO40EEr2L5yZplJWsgTCogILRSpcAe7+U0Otdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "commondir": "^1.0.1", + "estree-walker": "^2.0.2", + "fdir": "^6.2.0", + "is-reference": "1.2.1", + "magic-string": "^0.30.3", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=16.0.0 || 14 >= 14.17" + }, + "peerDependencies": { + "rollup": "^2.68.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-commonjs/node_modules/is-reference": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-1.2.1.tgz", + "integrity": "sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@rollup/plugin-json": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@rollup/plugin-json/-/plugin-json-6.1.0.tgz", + "integrity": "sha512-EGI2te5ENk1coGeADSIwZ7G2Q8CJS2sF120T7jLw4xFw9n7wIOXHo+kIYRAoVpJAN+kmqZSoO3Fp4JtoNF4ReA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.1.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-node-resolve": { + "version": "16.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz", + "integrity": "sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "@types/resolve": "1.20.2", + "deepmerge": "^4.2.2", + "is-module": "^1.0.0", + "resolve": "^1.22.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^2.78.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/plugin-replace": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@rollup/plugin-replace/-/plugin-replace-6.0.3.tgz", + "integrity": "sha512-J4RZarRvQAm5IF0/LwUUg+obsm+xZhYnbMXmXROyoSE1ATJe3oXSb9L5MMppdxP2ylNSjv6zFBwKYjcKMucVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.0.1", + "magic-string": "^0.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.4.0.tgz", + "integrity": "sha512-MfPp06CjRLfXQ3wY0R8vJDYBy/MvVcc9OulEfR0B8Iv9ko+GCNaRZ+EpJYFl27LhKsZK0o420sYCRHCjfCgeUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@simplewebauthn/browser": { "version": "13.3.0", "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", @@ -1246,14 +1763,21 @@ "acorn": "^8.9.0" } }, - "node_modules/@sveltejs/adapter-auto": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz", - "integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==", + "node_modules/@sveltejs/adapter-node": { + "version": "5.5.7", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.7.tgz", + "integrity": "sha512-uOfc9eVlI3A37RRSaKcgrheBYPrfJwC9VMqDp8x/O6tlKdcLLvHThSWD0KNIbjQ/d+7bwLGx3vx6aowAcRfd2g==", "dev": true, "license": "MIT", + "dependencies": { + "@rollup/plugin-commonjs": "^29.0.0", + "@rollup/plugin-json": "^6.1.0", + "@rollup/plugin-node-resolve": "^16.0.0", + "@rollup/plugin-replace": "^6.0.3", + "rollup": "^4.59.0" + }, "peerDependencies": { - "@sveltejs/kit": "^2.0.0" + "@sveltejs/kit": "^2.4.0" } }, "node_modules/@sveltejs/kit": { @@ -1452,6 +1976,13 @@ "@types/node": "*" } }, + "node_modules/@types/resolve": { + "version": "1.20.2", + "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", + "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -1548,6 +2079,13 @@ "node": ">=6" } }, + "node_modules/commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", @@ -1615,6 +2153,16 @@ "dev": true, "license": "MIT" }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esm-env": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", @@ -1640,6 +2188,13 @@ } } }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1673,6 +2228,52 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-module/-/is-module-1.0.0.tgz", + "integrity": "sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==", + "dev": true, + "license": "MIT" + }, "node_modules/is-reference": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", @@ -2080,6 +2681,13 @@ "node": ">=8" } }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2167,6 +2775,28 @@ "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "license": "Apache-2.0" }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/rolldown": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", @@ -2201,6 +2831,51 @@ "@rolldown/binding-win32-x64-msvc": "1.1.5" } }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -2328,6 +3003,19 @@ "node": ">=0.10.0" } }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/svelte": { "version": "5.56.8", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz", diff --git a/package.json b/package.json index 8a97a9e..1da30e4 100644 --- a/package.json +++ b/package.json @@ -5,16 +5,25 @@ "type": "module", "scripts": { "dev": "dotenvx run -f .env.development -- vite dev --host 0.0.0.0", + "build:app": "vite build", + "build:staging": "dotenvx run -f .env.staging -- vite build", "build": "dotenvx run -f .env.production -- vite build", - "preview": "vite preview", + "start": "node build", + "preview": "node build", "db:migrate": "dotenvx run -f .env.development -- node scripts/db-migrate.mjs", + "db:migrate:staging": "dotenvx run -f .env.staging -- node scripts/db-migrate.mjs", "db:migrate:production": "dotenvx run -f .env.production -- node scripts/db-migrate.mjs", "db:seed-users": "dotenvx run -f .env.development -- node scripts/seed-initial-users.mjs", "db:seed-users:reset": "dotenvx run -f .env.development -- node scripts/seed-initial-users.mjs --reset-passwords", "db:backup": "dotenvx run -f .env.development -- node scripts/db-backup.mjs", + "db:backup:staging": "dotenvx run -f .env.staging -- node scripts/db-backup.mjs", "db:backup:production": "dotenvx run -f .env.production -- node scripts/db-backup.mjs", "db:restore": "dotenvx run -f .env.development -- node scripts/db-restore.mjs", "db:restore:production": "dotenvx run -f .env.production -- node scripts/db-restore.mjs", + "isac:export": "dotenvx run -f .env.development -- node scripts/isac-data-export.mjs", + "isac:import": "dotenvx run -f .env.development -- node scripts/isac-data-import.mjs", + "isac:export:staging": "dotenvx run -f .env.staging -- node scripts/isac-data-export.mjs", + "stage:bootstrap:export": "dotenvx run -f .env.development -- node scripts/stage-bootstrap-export.mjs", "notifications:digest": "dotenvx run -f .env.development -- node scripts/notification-digest.mjs", "notifications:digest:production": "dotenvx run -f .env.production -- node scripts/notification-digest.mjs", "notifications:oversight": "dotenvx run -f .env.development -- node scripts/oversight-notification-dispatch.mjs", @@ -27,6 +36,9 @@ "env:decrypt": "node scripts/env-files.mjs decrypt", "env:encrypt:development": "dotenvx encrypt -f .env.development", "env:decrypt:development": "dotenvx decrypt -f .env.development", + "env:encrypt:staging": "dotenvx encrypt -f .env.staging", + "env:decrypt:staging": "dotenvx decrypt -f .env.staging", + "env:sync:staging-credentials": "dotenvx run -f .env.development -- node scripts/sync-staging-credentials.mjs", "env:encrypt:production": "dotenvx encrypt -f .env.production", "env:decrypt:production": "dotenvx decrypt -f .env.production", "env:stage": "node scripts/env-files.mjs stage", @@ -36,7 +48,7 @@ }, "devDependencies": { "@dotenvx/dotenvx": "^2.17.4", - "@sveltejs/adapter-auto": "^7.0.1", + "@sveltejs/adapter-node": "^5.5.7", "@sveltejs/kit": "^2.63.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", "@types/node": "^26.1.1", diff --git a/scripts/db-migrate.mjs b/scripts/db-migrate.mjs index e36ca5c..0c99e1c 100644 --- a/scripts/db-migrate.mjs +++ b/scripts/db-migrate.mjs @@ -99,6 +99,22 @@ if (!notificationColumns.some((column) => column.name === 'actions_json')) { await database.exec('ALTER TABLE notifications ADD COLUMN actions_json TEXT'); } +const legacyProtocolEffects = await (await database.prepare( + "SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name='os_protocol_attribute_effects'" +)).get(); +if (legacyProtocolEffects) { + const legacyProtocolEffectCount = await (await database.prepare( + 'SELECT COUNT(*) AS count FROM os_protocol_attribute_effects' + )).get(); + if (Number(legacyProtocolEffectCount?.count ?? 0) > 0) { + throw new Error( + 'Legacy OS Protocol attribute effects still contain rows. Export and reconcile them before removing the retired table.' + ); + } + console.log('Removing the empty legacy OS Protocol attribute-effects table.'); + await database.exec('DROP TABLE os_protocol_attribute_effects'); +} + const proposalDefinition = await (await database.prepare( "SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'" )).get(); @@ -355,7 +371,9 @@ await database.exec('CREATE INDEX IF NOT EXISTS builds_visibility_updated_idx ON console.log(`Applied ${statements.length} schema statements to ${databasePath}`); -if (!databaseAlreadyExisted) { +if (!databaseAlreadyExisted && process.env.SKIP_DEFAULT_USERS !== 'true') { console.log('New database detected; creating default users.'); await initializeDefaultUsers(database); +} else if (!databaseAlreadyExisted) { + console.log('New database detected; default-user seeding is disabled for this environment.'); } diff --git a/scripts/env-files.mjs b/scripts/env-files.mjs index 04f5168..cc96440 100644 --- a/scripts/env-files.mjs +++ b/scripts/env-files.mjs @@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process'; import { resolve } from 'node:path'; import process from 'node:process'; -const files = ['.env.development', '.env.production']; +const files = ['.env.development', '.env.staging', '.env.production']; const action = process.argv[2]; const gitCommand = process.platform === 'win32' ? 'git.exe' : 'git'; const dotenvxCli = resolve('node_modules/@dotenvx/dotenvx/src/cli/dotenvx.js'); diff --git a/scripts/isac-data-export.mjs b/scripts/isac-data-export.mjs new file mode 100644 index 0000000..660f300 --- /dev/null +++ b/scripts/isac-data-export.mjs @@ -0,0 +1,48 @@ +import { connect } from '@tursodatabase/database'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { basename, dirname, join, resolve } from 'node:path'; +import process from 'node:process'; +import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs'; + +const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db'); +const iconDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'); +const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z'); +const outputPath = resolve(process.argv[2] || `./data/backups/isac-data-${timestamp}.json`); +const database = await connect(databasePath); +const tables = []; +let totalRows = 0; + +for (const name of ISAC_DATA_TABLES) { + const exists = await (await database.prepare( + "SELECT 1 AS present FROM sqlite_master WHERE type='table' AND name=?" + )).get(name); + if (!exists) throw new Error(`ISAC export requires missing table "${name}". Run the schema migration first.`); + const rows = await (await database.prepare(`SELECT * FROM ${quoteIdentifier(name)}`)).all(); + tables.push({ name, rowCount: rows.length, rows }); + totalRows += rows.length; +} + +const icons = []; +const iconTable = tables.find((table) => table.name === 'game_data_icons'); +for (const row of iconTable?.rows ?? []) { + if (typeof row.storage_key !== 'string' || !/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(row.storage_key)) { + throw new Error(`Invalid game-data icon storage key "${String(row.storage_key)}".`); + } + const content = await readFile(join(iconDirectory, row.storage_key)); + icons.push({ storageKey: row.storage_key, contentBase64: content.toString('base64') }); +} + +const snapshot = { + format: 'shd-build-isac-data', + version: 1, + createdAt: new Date().toISOString(), + sourceDatabase: basename(databasePath), + totalRows, + tables, + icons +}; +await mkdir(dirname(outputPath), { recursive: true }); +await writeFile(outputPath, `${JSON.stringify(snapshot, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' }); + +console.log(`Exported ${totalRows} ISAC rows and ${icons.length} uploaded icons.`); +console.log(`ISAC snapshot: ${outputPath}`); diff --git a/scripts/isac-data-import.mjs b/scripts/isac-data-import.mjs new file mode 100644 index 0000000..de67a97 --- /dev/null +++ b/scripts/isac-data-import.mjs @@ -0,0 +1,79 @@ +import { connect } from '@tursodatabase/database'; +import { spawnSync } from 'node:child_process'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { basename, join, resolve } from 'node:path'; +import process from 'node:process'; +import { ISAC_DATA_TABLES, quoteIdentifier } from './isac-data-tables.mjs'; + +const snapshotArgument = process.argv.slice(2).find((argument) => !argument.startsWith('--')); +if (!snapshotArgument || !process.argv.includes('--confirm-replace-isac-data')) { + console.error('Usage: node scripts/isac-data-import.mjs --confirm-replace-isac-data'); + process.exit(2); +} + +const snapshotPath = resolve(snapshotArgument); +const snapshot = JSON.parse(await readFile(snapshotPath, 'utf8')); +if (snapshot?.format !== 'shd-build-isac-data' || snapshot?.version !== 1 || !Array.isArray(snapshot.tables) || !Array.isArray(snapshot.icons)) { + throw new Error('The selected file is not a supported SHD.build ISAC data snapshot.'); +} +const receivedNames = snapshot.tables.map((table) => table.name); +if (receivedNames.length !== ISAC_DATA_TABLES.length || ISAC_DATA_TABLES.some((name, index) => receivedNames[index] !== name)) { + throw new Error('The ISAC snapshot table manifest is incomplete or out of order.'); +} +for (const icon of snapshot.icons) { + if (typeof icon.storageKey !== 'string' || !/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(icon.storageKey) + || typeof icon.contentBase64 !== 'string') { + throw new Error('The ISAC snapshot contains an invalid uploaded icon.'); + } +} + +const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z'); +const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-development.db'); +const backupPath = resolve(`./data/backups/${basename(databasePath).replace(/\.[^.]+$/, '')}-before-isac-import-${timestamp}.json`); +const isacBackupPath = resolve(`./data/backups/isac-data-before-import-${timestamp}.json`); +for (const [script, output] of [ + ['scripts/db-backup.mjs', backupPath], + ['scripts/isac-data-export.mjs', isacBackupPath] +]) { + const result = spawnSync(process.execPath, [script, output], { cwd: process.cwd(), stdio: 'inherit', env: process.env }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`Safety backup failed in ${script}; no ISAC data was changed.`); +} + +const database = await connect(databasePath); +let importedRows = 0; +await database.exec('PRAGMA foreign_keys=OFF'); +await database.exec('BEGIN'); +try { + for (const name of [...ISAC_DATA_TABLES].reverse()) { + await database.exec(`DELETE FROM ${quoteIdentifier(name)}`); + } + for (const table of snapshot.tables) { + for (const row of table.rows) { + const columns = Object.keys(row); + if (!columns.length) continue; + const insert = await database.prepare( + `INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')}) + VALUES (${columns.map(() => '?').join(', ')})` + ); + await insert.run(...columns.map((column) => row[column])); + importedRows++; + } + } + await database.exec('COMMIT'); +} catch (error) { + await database.exec('ROLLBACK'); + throw error; +} finally { + await database.exec('PRAGMA foreign_keys=ON'); +} + +const iconDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'); +await mkdir(iconDirectory, { recursive: true }); +for (const icon of snapshot.icons) { + await writeFile(join(iconDirectory, icon.storageKey), Buffer.from(icon.contentBase64, 'base64'), { mode: 0o600 }); +} + +console.log(`Imported ${importedRows} ISAC rows and ${snapshot.icons.length} uploaded icons from ${snapshotPath}.`); +console.log(`Full pre-import backup: ${backupPath}`); +console.log(`ISAC pre-import snapshot: ${isacBackupPath}`); diff --git a/scripts/isac-data-tables.mjs b/scripts/isac-data-tables.mjs new file mode 100644 index 0000000..c3a8460 --- /dev/null +++ b/scripts/isac-data-tables.mjs @@ -0,0 +1,54 @@ +export const ISAC_DATA_TABLES = [ + 'stat_attributes', + 'talents', + 'talent_tiers', + 'talent_triggers', + 'talent_tier_triggers', + 'talent_tier_effects', + 'talent_description_templates', + 'talent_weapon_scopes', + 'weapon_talent_slots', + 'brand_sets', + 'brand_set_effects', + 'gear', + 'gear_brand_sets', + 'gear_talents', + 'gear_tactical_augments', + 'gear_tactical_augment_costs', + 'weapons', + 'core_attribute_tier_ranges', + 'weapon_tier_ranges', + 'weapon_talent_options', + 'weapon_augmentation_effects', + 'weapon_base_attribute_bonuses', + 'os_protocols', + 'os_protocol_primary_effects', + 'os_protocol_secondary_profiles', + 'os_protocol_secondary_effects', + 'os_protocol_talents', + 'os_protocol_talent_triggers', + 'os_protocol_talent_effects', + 'skill_chip_sets', + 'skill_chip_two_piece_bonuses', + 'skill_chip_primary_options', + 'skill_chip_secondary_options', + 'skill_chip_talents', + 'skill_chip_talent_triggers', + 'skill_chip_talent_effects', + 'weapon_mods', + 'weapon_mod_compatible_categories', + 'weapon_mod_slots', + 'weapon_mod_quality_values', + 'weapon_mod_secondary_options', + 'weapon_mod_secondary_quality_values', + 'weapon_mod_exotic_attributes', + 'weapon_mod_effects', + 'weapon_mod_effect_quality_values', + 'gear_mods', + 'gear_mod_quality_values', + 'gear_mod_secondary_options', + 'gear_mod_secondary_quality_values', + 'game_data_icons' +]; + +export const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`; diff --git a/scripts/stage-bootstrap-export.mjs b/scripts/stage-bootstrap-export.mjs new file mode 100644 index 0000000..dfe08a3 --- /dev/null +++ b/scripts/stage-bootstrap-export.mjs @@ -0,0 +1,54 @@ +import { spawnSync } from 'node:child_process'; +import { mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import process from 'node:process'; + +const outputArgument = process.argv[2]; +const timestamp = new Date().toISOString().replaceAll(':', '-').replace(/\.\d{3}Z$/, 'Z'); +const outputPath = resolve(outputArgument || `./data/backups/shd-stage-bootstrap-${timestamp}.json`); +const temporaryBackupPath = `${outputPath}.database.json`; +const databaseBackup = spawnSync(process.execPath, ['scripts/db-backup.mjs', temporaryBackupPath], { + cwd: process.cwd(), + stdio: 'inherit', + env: process.env +}); +if (databaseBackup.error) throw databaseBackup.error; +if (databaseBackup.status !== 0) throw new Error('Database backup failed; no staging bootstrap bundle was created.'); + +async function collectFiles(directory, pattern) { + try { + const names = (await readdir(directory)).filter((name) => pattern.test(name)).sort(); + return await Promise.all(names.map(async (name) => ({ + name, + contentBase64: (await readFile(join(directory, name))).toString('base64') + }))); + } catch (error) { + if (error?.code === 'ENOENT') return []; + throw error; + } +} + +try { + const database = JSON.parse(await readFile(temporaryBackupPath, 'utf8')); + const avatars = await collectFiles( + resolve(process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars'), + /^[0-9a-f-]{36}\.webp$/i + ); + const gameIcons = await collectFiles( + resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'), + /^[a-z_]+-[0-9a-f-]{36}\.webp$/i + ); + const bundle = { + format: 'shd-build-stage-bootstrap', + version: 1, + createdAt: new Date().toISOString(), + database, + files: { avatars, gameIcons } + }; + await mkdir(dirname(outputPath), { recursive: true }); + await writeFile(outputPath, `${JSON.stringify(bundle, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' }); + console.log(`Staging bootstrap bundle includes ${avatars.length} avatars and ${gameIcons.length} game-data icons.`); + console.log(`Bootstrap bundle: ${outputPath}`); +} finally { + await rm(temporaryBackupPath, { force: true }); +} diff --git a/scripts/stage-bootstrap-import.mjs b/scripts/stage-bootstrap-import.mjs new file mode 100644 index 0000000..bb79c62 --- /dev/null +++ b/scripts/stage-bootstrap-import.mjs @@ -0,0 +1,109 @@ +import { connect } from '@tursodatabase/database'; +import { spawnSync } from 'node:child_process'; +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import process from 'node:process'; + +const bundleArgument = process.argv.slice(2).find((argument) => !argument.startsWith('--')); +if (!bundleArgument || !process.argv.includes('--confirm-empty-staging-replace') + || process.env.ALLOW_STAGING_BOOTSTRAP !== 'true') { + console.error('Staging bootstrap requires ALLOW_STAGING_BOOTSTRAP=true and:'); + console.error('node scripts/stage-bootstrap-import.mjs --confirm-empty-staging-replace'); + process.exit(2); +} + +const bundlePath = resolve(bundleArgument); +const bundle = JSON.parse(await readFile(bundlePath, 'utf8')); +if (bundle?.format !== 'shd-build-stage-bootstrap' || bundle?.version !== 1 + || bundle.database?.format !== 'shd-build-database-backup' || bundle.database?.version !== 1 + || !Array.isArray(bundle.database.tables) || !Array.isArray(bundle.files?.avatars) + || !Array.isArray(bundle.files?.gameIcons)) { + throw new Error('The selected file is not a supported SHD.build staging bootstrap bundle.'); +} +for (const file of bundle.files.avatars) { + if (typeof file.name !== 'string' || !/^[0-9a-f-]{36}\.webp$/i.test(file.name) || typeof file.contentBase64 !== 'string') { + throw new Error('The bootstrap bundle contains an invalid avatar file.'); + } +} +for (const file of bundle.files.gameIcons) { + if (typeof file.name !== 'string' || !/^[a-z_]+-[0-9a-f-]{36}\.webp$/i.test(file.name) || typeof file.contentBase64 !== 'string') { + throw new Error('The bootstrap bundle contains an invalid game-data icon file.'); + } +} + +const databasePath = resolve(process.env.TDR_DATABASE_PATH || './data/shd-build-staging.db'); +const safetyBackup = spawnSync(process.execPath, ['scripts/db-backup.mjs'], { + cwd: process.cwd(), + stdio: 'inherit', + env: process.env +}); +if (safetyBackup.error) throw safetyBackup.error; +if (safetyBackup.status !== 0) throw new Error('Staging safety backup failed; the target was not changed.'); + +const database = await connect(databasePath); +for (const tableName of ['users', 'builds', 'audit_log', 'data_change_proposals']) { + const row = await (await database.prepare(`SELECT COUNT(*) AS count FROM "${tableName}"`)).get(); + if (Number(row?.count ?? 0) > 0) { + throw new Error(`Staging bootstrap refused: target table "${tableName}" is not empty.`); + } +} + +const targetTables = await (await database.prepare(` + SELECT name FROM sqlite_master + WHERE type='table' AND name NOT LIKE 'sqlite_%' + ORDER BY rowid +`)).all(); +const targetNames = new Set(targetTables.map((row) => String(row.name))); +const sourceNames = new Set(bundle.database.tables.map((table) => String(table.name))); +for (const row of targetTables) { + if (!sourceNames.has(String(row.name))) { + throw new Error(`Bootstrap snapshot is missing target-schema table "${String(row.name)}".`); + } +} +const sourceOnlyTables = bundle.database.tables + .map((table) => String(table.name)) + .filter((name) => !targetNames.has(name)); +if (sourceOnlyTables.length) { + console.log(`Skipping source-only legacy tables: ${sourceOnlyTables.join(', ')}`); +} +const quoteIdentifier = (identifier) => `"${String(identifier).replaceAll('"', '""')}"`; +let restoredRows = 0; +await database.exec('PRAGMA foreign_keys=OFF'); +await database.exec('BEGIN'); +try { + for (const row of [...targetTables].reverse()) { + await database.exec(`DELETE FROM ${quoteIdentifier(row.name)}`); + } + for (const table of bundle.database.tables.filter((candidate) => targetNames.has(candidate.name))) { + for (const row of table.rows) { + const columns = Object.keys(row); + if (!columns.length) continue; + const insert = await database.prepare( + `INSERT INTO ${quoteIdentifier(table.name)} (${columns.map(quoteIdentifier).join(', ')}) + VALUES (${columns.map(() => '?').join(', ')})` + ); + await insert.run(...columns.map((column) => row[column])); + restoredRows++; + } + } + await database.exec('COMMIT'); +} catch (error) { + await database.exec('ROLLBACK'); + throw error; +} finally { + await database.exec('PRAGMA foreign_keys=ON'); +} + +const avatarDirectory = resolve(process.env.TDR_AVATAR_STORAGE_PATH || './data/avatars'); +const gameIconDirectory = resolve(process.env.TDR_GAME_ICON_STORAGE_PATH || './data/game-icons'); +await mkdir(avatarDirectory, { recursive: true }); +await mkdir(gameIconDirectory, { recursive: true }); +for (const file of bundle.files.avatars) { + await writeFile(join(avatarDirectory, file.name), Buffer.from(file.contentBase64, 'base64'), { mode: 0o600 }); +} +for (const file of bundle.files.gameIcons) { + await writeFile(join(gameIconDirectory, file.name), Buffer.from(file.contentBase64, 'base64'), { mode: 0o600 }); +} + +console.log(`Bootstrapped staging with ${restoredRows} database rows, ${bundle.files.avatars.length} avatars, and ${bundle.files.gameIcons.length} game-data icons.`); +console.log(`Source bundle: ${bundlePath}`); diff --git a/scripts/sync-staging-credentials.mjs b/scripts/sync-staging-credentials.mjs new file mode 100644 index 0000000..9dd001d --- /dev/null +++ b/scripts/sync-staging-credentials.mjs @@ -0,0 +1,46 @@ +import { spawnSync } from 'node:child_process'; +import { readFile, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import process from 'node:process'; + +const stagingFile = resolve('.env.staging'); +const dotenvxCli = resolve('node_modules/@dotenvx/dotenvx/src/cli/dotenvx.js'); +const sharedKeys = [ + 'DISCORD_CLIENT_ID', + 'DISCORD_CLIENT_SECRET', + 'SMTP_HOST', + 'SMTP_PORT', + 'SMTP_SECURE', + 'SMTP_USER', + 'SMTP_PASSWORD', + 'SMTP_FROM' +]; + +for (const key of sharedKeys) { + if (!process.env[key]) throw new Error(`Source environment is missing ${key}.`); +} + +function dotenvx(action) { + const result = spawnSync(process.execPath, [dotenvxCli, action, '-f', stagingFile], { + cwd: process.cwd(), + stdio: 'inherit' + }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`dotenvx ${action} failed.`); +} + +dotenvx('decrypt'); +try { + let contents = await readFile(stagingFile, 'utf8'); + for (const key of sharedKeys) { + const replacement = `${key}=${JSON.stringify(process.env[key])}`; + const pattern = new RegExp(`^${key}=.*$`, 'm'); + if (!pattern.test(contents)) throw new Error(`Staging environment is missing ${key}.`); + contents = contents.replace(pattern, replacement); + } + await writeFile(stagingFile, contents, 'utf8'); +} finally { + dotenvx('encrypt'); +} + +console.log('Copied shared Discord and SMTP credentials into encrypted staging configuration.'); diff --git a/vite.config.ts b/vite.config.ts index 172b314..a74eec7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,11 +1,11 @@ -import adapter from '@sveltejs/adapter-auto'; +import adapter from '@sveltejs/adapter-node'; import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; export default defineConfig({ server: { host: '0.0.0.0', - allowedHosts: ['dev.shd.build', 'shd.build'] + allowedHosts: ['dev.shd.build', 'stage.shd.build', 'shd.build'] }, plugins: [ sveltekit({ @@ -15,9 +15,6 @@ export default defineConfig({ filename.split(/[/\\]/).includes('node_modules') ? undefined : true }, - // adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list. - // If your environment is not supported, or you settled on a specific environment, switch out the adapter. - // See https://svelte.dev/docs/kit/adapters for more information about adapters. adapter: adapter() }) ]