Add containerized staging deployment
Validate and deploy staging / validate (push) Successful in 27s
Validate and deploy staging / deploy (push) Successful in 52s

This commit is contained in:
JB
2026-07-26 10:31:24 -04:00
parent 32e10c710b
commit 548d19d9ea
25 changed files with 1692 additions and 21 deletions
+14
View File
@@ -0,0 +1,14 @@
.git
.gitea
.agents
.codex
.svelte-kit
.vscode
build
node_modules
data
.env
.env.*
.env.keys
*.log
docs
+5
View File
@@ -6,6 +6,9 @@ TDR_DATABASE_PATH=./data/shd-build-development.db
# Persistent directory for normalized user-uploaded avatars. # Persistent directory for normalized user-uploaded avatars.
TDR_AVATAR_STORAGE_PATH=./data/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 # Optional fixed credentials used only when initializing or explicitly resetting
# the two default accounts. Leave blank to generate secure temporary passwords. # the two default accounts. Leave blank to generate secure temporary passwords.
DEFAULT_ADMIN_PASSWORD= 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. # Canonical application origin used in emailed authentication links.
APP_ORIGIN=https://dev.shd.build 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 # Use shd.build for both production and dev.shd.build. Use localhost only when
# accessing development directly at http://localhost:5173. # accessing development directly at http://localhost:5173.
PASSKEY_RP_ID=shd.build PASSKEY_RP_ID=shd.build
+27
View File
@@ -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"
+4
View File
@@ -0,0 +1,4 @@
*.sh text eol=lf
Dockerfile text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
+66
View File
@@ -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"
+43
View File
@@ -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
+1
View File
@@ -18,6 +18,7 @@ Thumbs.db
!.env.example !.env.example
!.env.test !.env.test
!.env.development !.env.development
!.env.staging
!.env.production !.env.production
.env.keys .env.keys
.env.keys.* .env.keys.*
+37
View File
@@ -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"]
+23 -6
View File
@@ -41,16 +41,19 @@ npm run dev
## Encrypted environment files ## Encrypted environment files
Development and production settings live in the committed, encrypted Development, staging, and production settings live in the committed, encrypted
`.env.development` and `.env.production` files. Private decryption keys live `.env.development`, `.env.staging`, and `.env.production` files. Private
only in the ignored `.env.keys` file; back it up in a password manager. decryption keys live only in the ignored `.env.keys` file; back it up in a
password manager.
```sh ```sh
npm run env:decrypt # decrypt both files for editing npm run env:decrypt # decrypt all environment files for editing
npm run env:encrypt # encrypt both files again npm run env:encrypt # encrypt all environment files again
npm run env:stage # encrypt and git-add both files npm run env:stage # encrypt and git-add all environment files
npm run env:decrypt:development # operate on one environment npm run env:decrypt:development # operate on one environment
npm run env:decrypt:staging
npm run env:decrypt:production 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 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 `DOTENV_PRIVATE_KEY_PRODUCTION` through its secret manager rather than copying
the keys file onto the server. 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 ## Passwordless authentication
Passkeys are the preferred sign-in method. `APP_ORIGIN` must exactly match the Passkeys are the preferred sign-in method. `APP_ORIGIN` must exactly match the
+29
View File
@@ -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 <bootstrap-bundle.json> [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."
+56
View File
@@ -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
+18
View File
@@ -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 "$@"
+51
View File
@@ -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 <deployment-directory> <source-directory> <candidate-image>" >&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
+198
View File
@@ -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.
+694 -6
View File
@@ -17,7 +17,7 @@
}, },
"devDependencies": { "devDependencies": {
"@dotenvx/dotenvx": "^2.17.4", "@dotenvx/dotenvx": "^2.17.4",
"@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.63.0", "@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/node": "^26.1.1", "@types/node": "^26.1.1",
@@ -1204,6 +1204,523 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/@simplewebauthn/browser": {
"version": "13.3.0", "version": "13.3.0",
"resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz", "resolved": "https://registry.npmjs.org/@simplewebauthn/browser/-/browser-13.3.0.tgz",
@@ -1246,14 +1763,21 @@
"acorn": "^8.9.0" "acorn": "^8.9.0"
} }
}, },
"node_modules/@sveltejs/adapter-auto": { "node_modules/@sveltejs/adapter-node": {
"version": "7.0.1", "version": "5.5.7",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-7.0.1.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/adapter-node/-/adapter-node-5.5.7.tgz",
"integrity": "sha512-dvuPm1E7M9NI/+canIQ6KKQDU2AkEefEZ2Dp7cY6uKoPq9Z/PhOXABe526UdW2mN986gjVkuSLkOYIBnS/M2LQ==", "integrity": "sha512-uOfc9eVlI3A37RRSaKcgrheBYPrfJwC9VMqDp8x/O6tlKdcLLvHThSWD0KNIbjQ/d+7bwLGx3vx6aowAcRfd2g==",
"dev": true, "dev": true,
"license": "MIT", "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": { "peerDependencies": {
"@sveltejs/kit": "^2.0.0" "@sveltejs/kit": "^2.4.0"
} }
}, },
"node_modules/@sveltejs/kit": { "node_modules/@sveltejs/kit": {
@@ -1452,6 +1976,13 @@
"@types/node": "*" "@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": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -1548,6 +2079,13 @@
"node": ">=6" "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": { "node_modules/cookie": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
@@ -1615,6 +2153,16 @@
"dev": true, "dev": true,
"license": "MIT" "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": { "node_modules/esm-env": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "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": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "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": "^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": { "node_modules/is-reference": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
@@ -2080,6 +2681,13 @@
"node": ">=8" "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -2167,6 +2775,28 @@
"integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==",
"license": "Apache-2.0" "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": { "node_modules/rolldown": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
@@ -2201,6 +2831,51 @@
"@rolldown/binding-win32-x64-msvc": "1.1.5" "@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": { "node_modules/sade": {
"version": "1.8.1", "version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@@ -2328,6 +3003,19 @@
"node": ">=0.10.0" "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": { "node_modules/svelte": {
"version": "5.56.8", "version": "5.56.8",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.8.tgz",
+14 -2
View File
@@ -5,16 +5,25 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "dotenvx run -f .env.development -- vite dev --host 0.0.0.0", "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", "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": "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: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": "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: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": "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: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": "dotenvx run -f .env.development -- node scripts/db-restore.mjs",
"db:restore:production": "dotenvx run -f .env.production -- 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": "dotenvx run -f .env.development -- node scripts/notification-digest.mjs",
"notifications:digest:production": "dotenvx run -f .env.production -- 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", "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:decrypt": "node scripts/env-files.mjs decrypt",
"env:encrypt:development": "dotenvx encrypt -f .env.development", "env:encrypt:development": "dotenvx encrypt -f .env.development",
"env:decrypt:development": "dotenvx decrypt -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:encrypt:production": "dotenvx encrypt -f .env.production",
"env:decrypt:production": "dotenvx decrypt -f .env.production", "env:decrypt:production": "dotenvx decrypt -f .env.production",
"env:stage": "node scripts/env-files.mjs stage", "env:stage": "node scripts/env-files.mjs stage",
@@ -36,7 +48,7 @@
}, },
"devDependencies": { "devDependencies": {
"@dotenvx/dotenvx": "^2.17.4", "@dotenvx/dotenvx": "^2.17.4",
"@sveltejs/adapter-auto": "^7.0.1", "@sveltejs/adapter-node": "^5.5.7",
"@sveltejs/kit": "^2.63.0", "@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@types/node": "^26.1.1", "@types/node": "^26.1.1",
+19 -1
View File
@@ -99,6 +99,22 @@ if (!notificationColumns.some((column) => column.name === 'actions_json')) {
await database.exec('ALTER TABLE notifications ADD COLUMN actions_json TEXT'); 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( const proposalDefinition = await (await database.prepare(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'" "SELECT sql FROM sqlite_master WHERE type='table' AND name='data_change_proposals'"
)).get(); )).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}`); 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.'); console.log('New database detected; creating default users.');
await initializeDefaultUsers(database); await initializeDefaultUsers(database);
} else if (!databaseAlreadyExisted) {
console.log('New database detected; default-user seeding is disabled for this environment.');
} }
+1 -1
View File
@@ -2,7 +2,7 @@ import { spawnSync } from 'node:child_process';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import process from 'node:process'; 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 action = process.argv[2];
const gitCommand = process.platform === 'win32' ? 'git.exe' : 'git'; const gitCommand = process.platform === 'win32' ? 'git.exe' : 'git';
const dotenvxCli = resolve('node_modules/@dotenvx/dotenvx/src/cli/dotenvx.js'); const dotenvxCli = resolve('node_modules/@dotenvx/dotenvx/src/cli/dotenvx.js');
+48
View File
@@ -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}`);
+79
View File
@@ -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 <snapshot.json> --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}`);
+54
View File
@@ -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('"', '""')}"`;
+54
View File
@@ -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 });
}
+109
View File
@@ -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 <bundle.json> --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}`);
+46
View File
@@ -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.');
+2 -5
View File
@@ -1,11 +1,11 @@
import adapter from '@sveltejs/adapter-auto'; import adapter from '@sveltejs/adapter-node';
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
server: { server: {
host: '0.0.0.0', host: '0.0.0.0',
allowedHosts: ['dev.shd.build', 'shd.build'] allowedHosts: ['dev.shd.build', 'stage.shd.build', 'shd.build']
}, },
plugins: [ plugins: [
sveltekit({ sveltekit({
@@ -15,9 +15,6 @@ export default defineConfig({
filename.split(/[/\\]/).includes('node_modules') ? undefined : true 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() adapter: adapter()
}) })
] ]