feat: migrate secrets to Infisical
Validate and deploy production / validate (push) Canceled after 0s
Validate and deploy production / deploy (push) Canceled after 0s
Validate and deploy staging / validate (push) Failing after 22s
Validate and deploy staging / deploy (push) Skipped

This commit is contained in:
jb
2026-08-02 01:19:41 -04:00
parent 9089f4a257
commit 25d9241b51
34 changed files with 2353 additions and 691 deletions
@@ -1,32 +1,53 @@
--- ---
name: replace-production-database name: replace-production-database
description: Replace the populated SHD.build production database with a local full bootstrap bundle after verified backups. Use only when the user explicitly authorizes replacement of all production data. description:
Replace the populated SHD.build production database with a local full
bootstrap bundle after verified backups. Use only when the user explicitly
authorizes replacement of all production data.
--- ---
# Replace Production Database # Replace Production Database
This procedure overwrites production users, sessions, builds, guides, audits, proposals, settings, and source data. Obtain explicit approval for a full replacement. Use the ISAC import flow when the request only concerns canonical game data. This procedure overwrites production users, sessions, builds, guides, audits,
proposals, settings, and source data. Obtain explicit approval for a full
replacement. Use the ISAC import flow when the request only concerns canonical
game data.
## Export ## Export
1. Stop only this repository's local development server. The embedded database must be closed during export. 1. Stop only this repository's local development server. The embedded database
2. Run `npm run production:bootstrap:export`. Record the generated bundle beneath `data/backups/`; it includes private account data and uploaded assets. must be closed during export.
3. Transfer the bundle to `f302` over SSH. Keep it outside Git and confirm the remote copy is nonempty. 2. Run `npm run production:bootstrap:export`. Record the generated bundle
beneath `data/backups/`; it includes private account data and uploaded
assets.
3. Transfer the bundle to `f302` over SSH. Keep it outside Git and confirm the
remote copy is nonempty.
## Replace ## Replace
1. Confirm the current production deployment is healthy and no Gitea production deployment is running. 1. Confirm the current production deployment is healthy and no Gitea production
2. On `f302`, stop only the production `app` and `scheduler` services from `/opt/shd-build-production`. deployment is running.
3. Run `node scripts/db-backup.mjs` through the production Compose service. Record and retain the JSON backup path before any destructive work. 2. On `f302`, stop only the production `app` and `scheduler` services from
4. Reset the production database file and its embedded-database support files. Keep the persistent volume and existing upload directories; the import writes the local avatar and managed-image files without deleting retained files. `/opt/shd-build-production`.
5. Run `node scripts/db-migrate.mjs` through the production Compose service to create the current empty schema. 3. Run `node scripts/db-backup.mjs` through the production Compose service.
6. Run `environment-bootstrap-import.mjs` with `ALLOW_PRODUCTION_BOOTSTRAP=true`, the copied bundle mounted read-only, `--target production`, and `--confirm-empty-target-replace`. Record and retain the JSON backup path before any destructive work.
7. Start `app` and `scheduler`, wait for app health, then remove the remote transfer bundle. 4. Reset the production database file and its embedded-database support files.
Keep the persistent volume and existing upload directories; the import writes
the local avatar and managed-image files without deleting retained files.
5. Run `node scripts/db-migrate.mjs` through the production Compose service to
create the current empty schema.
6. Run `/opt/shd-build-production/bootstrap-production.sh <bundle-path>`. The
script stages the bundle in the private data volume, assigns it to the
runtime UID, runs `environment-bootstrap-import.mjs` with the required
confirmation, removes the staged copy, and restarts both services.
7. Wait for app health, then remove the remote transfer bundle.
## Verify ## Verify
1. Confirm the importer reports the expected row, avatar, and managed-image counts. 1. Confirm the importer reports the expected row, avatar, and managed-image
2. Confirm `https://shd.build/api/health` returns `ready: true` with an `ok` database check. counts.
2. Confirm `https://shd.build/api/health` returns `ready: true` with an `ok`
database check.
3. Confirm the production app container is healthy and its scheduler is running. 3. Confirm the production app container is healthy and its scheduler is running.
4. Restart the local development server and verify it accepts an HTTP request. 4. Restart the local development server and verify it accepts an HTTP request.
@@ -35,4 +56,5 @@ This procedure overwrites production users, sessions, builds, guides, audits, pr
- Never replace a populated production database without explicit user approval. - Never replace a populated production database without explicit user approval.
- Never delete the production Compose volume. - Never delete the production Compose volume.
- Do not replace the database while the app or scheduler can write to it. - Do not replace the database while the app or scheduler can write to it.
- Keep the verified pre-replacement backup. It is the recovery source if the import or restart fails. - Keep the verified pre-replacement backup. It is the recovery source if the
import or restart fails.
-32
View File
@@ -1,32 +0,0 @@
#/-------------------[DOTENV_PUBLIC_KEY]--------------------/
#/ public-key encryption for .env files /
#/ [how it works](https://dotenvx.com/encryption) /
#/----------------------------------------------------------/
DOTENV_PUBLIC_KEY_DEVELOPMENT="038fa4f9f7abd799fabebc69cb4d6a42a2ea361c3ec799580ddd213bcafac5c167"
# .env.development
# Server-only OpenAI access for the permission-gated ISAC mechanic authoring
# assistant. Use a project-scoped, Responses-only restricted key.
OPENAI_MECHANIC_API_KEY=encrypted:BGRKl6pMDudJdr4yvye51GWuIsyjPfgarjsyxFnDs/HdgcRPdttk7nNz/40TEdfHidyaWHvag6HkM46zG/37VEhFpZ5N3iPPJmKV1a43NaAZNUeOP0dy6ct8UmOd6gEjDK9U6wzrjczRijgAffZ3GDyKyxkeDrn+8PHjng/0sXL78tpPr4p040cTEfSyet/XRsIUqeiV/6euS60afbnyzfOMUJ5liPdXK4VoJh6iLCsVrpmoNZ+rB5FMd65vpdSXOy6MKoG25aGq/1gtC5JOBiknrSE4vYGnuMriczklJviEBma+4rKMTSLTBOnYnHzpsuDC7+Tcft9VcluvNgcy0McGDExm
OPENAI_MECHANIC_MODEL=encrypted:BCCIuv04UwV44nzKVT3X7PpzbUJofJXPj/X2EYqErcSxedHh7IDAszLKgrBL+uzAT3xLOYS8lhOYfaFl/GaI12oOWAiZV2pijxRjpOE5Zv6X9wFJ7c+su44IRa7GIXF8wwnfWnSwSAg1S6kdS98=
OPENAI_MECHANIC_REASONING_EFFORT=encrypted:BDOTlCFCvs30g+to1IJD9aFhfPqrVPTccpRJKiUyywAm0ojSihpUL12guVnvjLG9NR2MrzkV1icqviDSR4yqsPC6Zz+Llf3KId4IBMXCa+OIUd+KOCM1AN3adoU9ZcBOIxuKZf6XTg==
TDR_DATABASE_PATH=encrypted:BHzbwoU8Nc6yeHEwxLKFt08YVpz0+IRDBaRRu8mZlKtoi3R7qDOriv1+mIwLxkuZg+n8k7hGf9XhEipvfhbhx/N2sMcvsnVcrZfLIWItcNfrq9O8XmxE5oQpIQiYz/Osm9vclOeu06C6MwykowCtksIQD2xtCLLHInFPa8pJ0M0=
DISCORD_CLIENT_ID=encrypted:BAUbUqppB9HUQocELaV74nB1Ot6blLFNZ+640X8QLX5VWDzhABsw8JluD34L0ooYZve794HZH8ZFSn/zcvOsgSbGOdte9RQAa0YZpSz3Ih+TAmCvNUWg59A7iE2O1jCB5IEsCrRGbg4UslnP+I9ZHgnjRKo=
DISCORD_CLIENT_SECRET=encrypted:BA6jiQ2ws/e+y08nRwx2hyH2IlKBOjYVi+kk4gcYH6YyEvGw7HwIMRJ/n5eQx/IQB3KNDnklwL7x9XNGaq2Ht2UOHfrV+HFjPrTy/Kwt6retmMkfrA3PYJ7PdI+SlxE+hzvhYLvBJz6q1IcpRH09gz/x4jYeJPqD/LvPSKU2RlQ9
DISCORD_REDIRECT_URI=encrypted:BJl92lm9C5zv7guW8O5faHE5t53c5orIMLte3TOOaMFMGcWlybqNuQEf6SmLfgwQ/AoAviJZDxOQRhRaKI5OR4s1r54Fbe2oHQBSlgcCyIKmACukOY9dg9nlpx/EkkvIY77hekCJhh/RfD3P5Os2OfSok7uiZxEGcagkA8H3/2KbpRUZSjWVEqNZjyM=
# Passwordless authentication
APP_ORIGIN=encrypted:BBjQBgg8fXVjAjSrfzhAmuDuVRpvOlrKZTGqInncSwhJENhUs1EN/mBOlD769tAWEiNQ19mdDhIN1Dsiuu9Ps2VksDNdXjVNGwMYj3TRlX6v9wmzgODoCm6fvlseNJUQqXWSqEGgnrBzg90eg8bRMOzQMBByrA==
PASSKEY_RP_ID=encrypted:BM8t3iboOCY37Mg3UfYG1b17shBtNvM1QhkPAQPEq6gHIZz5nWeLVIqkZiLdMP81fmyEjmpD4cpnHNffy81xMyqSvzYZfIQ1UhNAM5vJMc6aDB535fRNVRYyf4+zMbiRJtyc+010RN3kCw==
# Shared SMTP account for magic-link and email-verification delivery.
# Implicit TLS/SSL normally uses port 465. For STARTTLS on port 587, set
# SMTP_SECURE=false.
SMTP_HOST=encrypted:BE8dBQOkKsQuDh9SDx3CTyUOzQ8uuoDMlw4bCxQBs7apyg6P5FqZJIvEgnzCRHCo3dC7skgi/KDUDZI1stLX5A9tvlLvasCf2vGiMnn7NgG0e1nAYD1PiAnbQDmqfUcYBMLIWJibqIF96Yv1icnEcfNwkw==
SMTP_PORT=encrypted:BKt0uerEl2/lqwev72twYoeI0lUZdXgONWB+Xm14ftBG/6vY8E2UiH+/+6Zrf2b8Y1D11PzsZqEGaCYCJ8v1PyDnAQrE0pC4WjiYCl9kCkqsFvtZQcWys3rm0Nu6zrV8s0pYPQ==
SMTP_SECURE=encrypted:BFj5YB/gxpOwKdCrgUsydheEDV5aYAvtNMtLFSslYgsEkPLWe6nA1deQDXjVEwZxLC6iAgOgzxErRbem8nK5BNFfwpMuwwily+NQWZgPYMu7gD+BPmT9BMNvJkJvCwHEPj6Q6AT1
SMTP_USER=encrypted:BC7+7c4vy1aS9mkuOaYZbxOdgZunja8UCz3P+ap+ZMK6qqV5KXvp+DPVvtJvVyCZejisS15LFsL+1VCeiD8hYyFKBrE2YJ+kx1hDwNezVMr1Dza548b9dXeMLh7Is6MjN5XBLLUjk2HcqgeXZ8WOmx01Dg==
SMTP_PASSWORD=encrypted:BOgcw3hrxXlMuQ92RNr+3M+myxlpd8lAtieebwXMsRQKiqLAKSDz3x0VXetvC2foMJ/KIEzTqM5scqkUA7dnm1/tQe0N3dG3ZZFzYIMz90Q0I5/XPWzli6cGtexoujswpkfnAa4EeGlvNEAhOYQrciU=
SMTP_FROM=encrypted:BGrMEhbploGfX29mvT+8XjgxvXK/QNjzr4CbAywkDyJmZ2xlscDbLC3+UoRSanGMdbVHvbDbYx6wG8M3MQ2QeIknf2AB75LpeeIVquSWa+u6OQBHmiRgEXWRuQJ7leTahd+B+E87gCpVtC3yFcHUtW3STyV1ZyidQJVwsI4HmQ==
REMOVE_BG_API_KEY=encrypted:BFeraOe9tiw3wCooTZ2ZE0qG9dDwqlpzq3HpEA0J+2LGckZSUxYP4f1Po1LNqWWihX+TzACONzHDhgjSaNq9XTMdjitYw7bEFehQQxsVi5iiJ/C6ZsN9M6vNIRP0FH+SfcYvFI41KJbSNOBFIX4zm5Y8hxG0aFJKkA==
+13 -3
View File
@@ -1,3 +1,6 @@
# Infisical secret inventory. This file contains names and safe defaults only;
# the application does not load it. Store values under /app unless noted.
# Server-only OpenAI access for the permission-gated ISAC mechanic and # Server-only OpenAI access for the permission-gated ISAC mechanic and
# calculation authoring assistants. Use a project-scoped, Responses-only restricted key. # calculation authoring assistants. Use a project-scoped, Responses-only restricted key.
OPENAI_MECHANIC_API_KEY= OPENAI_MECHANIC_API_KEY=
@@ -13,7 +16,8 @@ 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. # Persistent directory for game-data icons and managed library images, including
# weapons, gear, mods, talents, brands, protocols, Skill Chips, and Specializations.
TDR_GAME_ICON_STORAGE_PATH=./data/game-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
@@ -25,6 +29,7 @@ DEFAULT_USER_PASSWORD=
DISCORD_CLIENT_ID= DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET= DISCORD_CLIENT_SECRET=
# Proxied development: https://dev.shd.build/auth/discord/callback # Proxied development: https://dev.shd.build/auth/discord/callback
# Staging: https://stage.shd.build/auth/discord/callback
# Production: https://shd.build/auth/discord/callback # Production: https://shd.build/auth/discord/callback
DISCORD_REDIRECT_URI=https://dev.shd.build/auth/discord/callback DISCORD_REDIRECT_URI=https://dev.shd.build/auth/discord/callback
@@ -45,9 +50,14 @@ SMTP_USER=
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_FROM=SHD.build <no-reply@shd.build> SMTP_FROM=SHD.build <no-reply@shd.build>
# Long random bearer token used only by the internal container scheduler. # Cloudflare Turnstile challenge keys for anonymous Guide activity.
SCHEDULER_TOKEN= TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=
REMOVE_BG_API_KEY= REMOVE_BG_API_KEY=
REMOVE_BG_LOW_CREDIT_THRESHOLD=5 REMOVE_BG_LOW_CREDIT_THRESHOLD=5
REMOVE_BG_LOW_FREE_CALL_THRESHOLD=10 REMOVE_BG_LOW_FREE_CALL_THRESHOLD=10
REMOVE_BG_CREDIT_CHECK_HOURS=6 REMOVE_BG_CREDIT_CHECK_HOURS=6
# /shared/scheduler
SCHEDULER_TOKEN=
-32
View File
@@ -1,32 +0,0 @@
#/-------------------[DOTENV_PUBLIC_KEY]--------------------/
#/ public-key encryption for .env files /
#/ [how it works](https://dotenvx.com/encryption) /
#/----------------------------------------------------------/
DOTENV_PUBLIC_KEY_PRODUCTION="034d799df86cb16852742f1b2a42c69dde86396ddce96448c429c6ba68034a331d"
# .env.production
# Server-only OpenAI access for the permission-gated ISAC mechanic authoring
# assistant. Use a project-scoped, Responses-only restricted key.
OPENAI_MECHANIC_API_KEY=encrypted:BIQtxqHheOrBdpeabt1p2Y1+TCHQDIxp5DHpQMUsS5N8aqukt/IELJSKWo9LXaFXbExHFB/X7Z2WvuWSyLiSA1QyZeUnUwMxnaJiYCpPlM3gTGvvlw/6U9uPxPBO7dLwchTMtqX9Sfx+GSWFzojfPOsVNaUVBRjBy5VTSOLwxMw0LMI+oYGVDEbjR3k/74sHlGXQazN0mThk8NClPJg6BSkKuKKFQLBjc4RyWUm94SE9KZwqiZVkfQaMmn+a9zLvbfd4yZaG4Ae/sneIU1T+cYjqdYAT05kBf8DikJkaezC2dRvcZ8I7jsp08pt+xHj3UC4XgbDLK2IVyLO4C8ELP5g7vQVy
OPENAI_MECHANIC_MODEL=encrypted:BLZffo6IeKN95zCneOq+FrdTjcjPF+vRkagV7pWAVgdusAK0OWR6B2bFqbqBuRVmos32uEWS4aP2GLrGpOr7gPlGDQTPu2EBAv4eT1OD5ewMC3lJFuqrXnr6j12bJOG8BUM3l+pMxwFBAXrDUew=
OPENAI_MECHANIC_REASONING_EFFORT=encrypted:BOrPunzMR1atHilHR6MEXaUDEMyPI7gkhyF4bSO1byh2DqGGEyKcGxLf6A2vIOcFM38/k9g9QCxuth54B4Zs5mHE5MUmvj6g8nwDSfO/ae5lAmYLSCqj4MLaDQ2LcJ+vYDrJ8aLpfA==
TDR_DATABASE_PATH=encrypted:BL1wSNLW9BwOyzX89x73UjkxbJIUsOgOGkvLUfBRCqAAL2T4OVccEzh9aTWmLNaEICj6L795Tyd3Up+OZQ34NxqASKKk3o7piwQj8hiRU6W89fLVlSSZxdgLGq2S5YUdm3a/h9Loa1H4iDkg0TxO+MEQ2Qu53GD8zxOYWSQuBQ==
DISCORD_CLIENT_ID=encrypted:BB7n8VWz0GAV16Fkm7eJIfxI0a/rgHP1YakJ9XeiAvflWdYmndF5dvboOli+yLtuGCe+VqKUnzyLeQ7tk2YPQmMlkDXe75bhRkrLyPPvU0eg1DzhZ9ptU/qmmJZfrCQtT5o+/R61umVmfGkDB8nhpPkzqE8=
DISCORD_CLIENT_SECRET=encrypted:BJbCD14+3ZJ9T9+y90Uh3ZgRn4zyOqA0ckN/H+A9JfyWGgM2R5PBP2bmOPPGwRZk1Lg2GOWsowox4nrZEmINuUYirLRaszm9sqVejYXWsJPsTiXbukW+4rgtDpW7ifVkajbu/QGb8NqQR0tPUzJMYM0U5at5NM7TrqKWuu+4cLHf
DISCORD_REDIRECT_URI=encrypted:BKRD6GzZg6Qkt/ydrqkta4pUNlXNQzI4Kdq1ymn6qJH4O7NYVGE4J7MNO5biVsWPQuNU2ZG5qFatxzbbZMWT50x0o+A9wqmQ5XXt1kln8puHZsVOvOqSAI1z7mvWA+nuewv3vRDu0fchSsgSgFRjL1xwpp5tNac8DnvNaw0K/NlWHDdgZFIajQ==
# Passwordless authentication
APP_ORIGIN=encrypted:BCDOpIDmpg3cipvTGTD/2XFxazXZBJLLf0/Y/sUDdRoKTEaF3m1dh7H3Ep9gyvRSLcbXxVnu3vPBowE5L7/pB6S3KRkjfdwvEImgUmvpfFfkLXM1/AiWO3M1drkfB+0lx5x5ktbtuNpQ22bckMraFzyx
PASSKEY_RP_ID=encrypted:BFyFsPV8oZCPkKROL0Lo8aDmKK+ay8jy5Z4HeYdkYvfF8e6eaaS29doIyXqEV0eeHEF/iFf5UspARhlf8To/lB1TE8KHXBiHAgfTVhur+tCN/TUzocH1jr1jnw77qykey5FXcoTKqD0+VQ==
# Shared SMTP account for magic-link and email-verification delivery.
# Implicit TLS/SSL normally uses port 465. For STARTTLS on port 587, set
# SMTP_SECURE=false.
SMTP_HOST=encrypted:BFenO3Hh63xJz6dcJrEmCM40VGjPV7Y+F0TVgRRWAGMeBbrYZddhoJi7hhHZXpIX6B3nrSvq7dWIL+0aPKPzyWMgBsj3AnlXx8qi19CejStRQBHTQPyGnlpAZJK4SyBv24RwpwUs19T/TcK8b1DCX3tHYg==
SMTP_PORT=encrypted:BBxjsH6/dJcBap/sCS97SUPjuhuYSrFR1mqDvzaifCP52n/8jZ0+EYdkK0FcsU8ZhgHAWZ6vRkBjxr3nM39YXLEyATzbNF/tFZg29WGOk3kOxRhgemry0+kK2/UmZ9KUBTn3dQ==
SMTP_SECURE=encrypted:BAnU6hfdVX2xiPtP183dGjofqmz50/lB4MBeuWoer4KjwOsQXh1b/jOrGACUY7IKD2ngi5IMNdAtcqIkxdQGEfXj9GvPnkOIMNSwFwKNafIsZpS137UGmZCW1XopXU5cID28iSY7
SMTP_USER=encrypted:BMREkzkhV9Aa4E22SskfpcTt3zbGSPwhvRqTmfXlclta6FZtQD7Oy8rDifhNaMf78V3hJkoaXPk9bcZ8zZn2NnG72NPbRXxukn6LiLePhb78z24g4UiiHON2MP7jIz4pvhPCSET08N/Km5kfiMLvp8otQA==
SMTP_PASSWORD=encrypted:BG39vFySsPvtm4AsuZZk5UbNaxHOOjxd8yffgsZebGNjxLY8aluuLf3/XMFquOz3bb59ZcyYSPv0Knp/N5c0vSHkl/ByC8WuQFTeLYH6BzpUADiVlGPE9Wg5Oa6dCOmianfRtHBUVrG1DC/BztBVYRg=
SMTP_FROM=encrypted:BMGyxQY0VdtmVfyiRl7NoO6dz8Q2pqvx8CLXDX/ZS9FsE98SUynl2MeOrOU8Vjz55V+KcLfFUooZoYT5Ke46YfRGMmE0ke2OaDZxFCBnD0rv6Mm9sRqXdyTn0vUxhjXmqLFkef32hN6VvBNN+xlppwwwbgYTg6pwtx6LKBQ1eg==
REMOVE_BG_API_KEY=encrypted:BLrlul9iafui2MD8NcqLYgwc2crtcC2HbXIX1QNDHy5sX2PMitvRCYOefEKzT6JmNXT2xvD0/uOKB7WTdKG1+qv7jVWl3IBK9wc2ftAtAWgESsUPy2mLKomXe8N04wz682om7EgwJ942294NWMWPLrWGYZCH1vOtog==
-35
View File
@@ -1,35 +0,0 @@
#/-------------------[DOTENV_PUBLIC_KEY]--------------------/
#/ public-key encryption for .env files /
#/ [how it works](https://dotenvx.com/encryption) /
#/----------------------------------------------------------/
DOTENV_PUBLIC_KEY_STAGING="020bab072314925eb9a9cbaf42282e3a0d5c4a8de3d88a51ed92b4b3c83d598f98"
# .env.staging
# Server-only OpenAI access for the permission-gated ISAC mechanic authoring
# assistant. Use a project-scoped, Responses-only restricted key.
OPENAI_MECHANIC_API_KEY=encrypted:BL4P5f594pMu3zF6onW128E+17uT7Vh7CwnpcAndHTNLAW3Vx3w99MawmLYZ17iiWgu6GF6ZzqavHpGgtTdYa1mU2zSwwfm5f0/gQP9lNM7KETdG8EIQfa167d3XixJu2EeW7Z17dTUEbEOOJ8xiI2HwsIZ/X37KEqYHZ0GJIzU0rXo4i6G17rHRs2OiByZL80ujQNsjU5lPHGnDikmAaSSczqtR0Q4/QmSd9EFDrRLIIGtDwqpCG8C4lV6JxRaU6SrfH+4GtUxSHyvITnsAAAzVVQ9yUmddmPkkfAdmAjz6XlrX5mdNnrjAJI9Z95RGTeCrwXCMI9ffulH84xxpXyXG+qez
OPENAI_MECHANIC_MODEL=encrypted:BLyNUZ2WPB+jMJOqLoV/xAbd31RsrXbGkV2EChn0vy8rh5yVoqbUX2aoqsTU6mv7oP1NT3CAsC/YkfiJFJ2FEiH9GT8Ddrs43QFMBvU2c3AUlZGZJeQDEIoqjWqmt7I6EYeM5gFiaR4o/i2NEHk=
OPENAI_MECHANIC_REASONING_EFFORT=encrypted:BHU/0x0Ez9jvwYPhmTCPh4ciVPYyVQTfjfCSb8zV2iNwH6c6LNkEs3omWXvA4wpUhNwub8z79/w4N2QGEnxOy/n8jFnhXqf+mI++niqmRXp8nuFXY0NUGF+THiZlssiKzIAyFXXeCQ==
# Staging is deployed at https://stage.shd.build.
TDR_DATABASE_PATH=encrypted:BAh41Twl5JvpTKOaxlfw2J8dMBqRy5ontNeIPV8XFqrzQBTdQ7EmLn4gv+OOal4pUGlRQ2onC8TFN8vD0Xr83n1AQRYe48ovlDIRGeWFJoCimNSXqymrT+avXhNwRrMEjIu2E8YW0W8JWDXGWB1c4G8epRLy49Plq6Gzd7P+/RM=
TDR_AVATAR_STORAGE_PATH=encrypted:BH3DJiNNFUPnK83cby/HVCk0DHoBh2sNrnV1Txoz2Bh5EMPFSVpXdZaAz6C/LkdD5a2mKcDHFZb21Wy/GVJc9N3XNCJQz7GtvWrPNMv4oDFDJjDyeWHFHhPZmee6h3izWVO9mQDQTioamt7r50swMtP2
TDR_GAME_ICON_STORAGE_PATH=encrypted:BKNWYaPZsXRaUr9gXAFfS5ULOqQPCBaO8fzsfpPMdh9L7dhMPgiIsqNzZlLUr5Ywl7JzKc9EFbdeuVYP2MFpeta0x/lwGnJFy+hjcBHlanXPUyLIYFEs0h2MZdwB4b24un9kZo6igsF478qWRfP2PLPCZ4eq
APP_ORIGIN=encrypted:BAN8bBXyHn0fLmn/HqlK3cXfess/XF0HGYRAKxh89hyV26VHETLKgARQogdzjaAU4IRoTB6Lxr6pXzhuVRtQhsn4SiloTQ+t9Ecg/B7IbJ60F2TlcM5/aMq9QodpDGV2jZKVT7MsDl7yQuOyMca8BDJE6YiYGA==
PASSKEY_RP_ID=encrypted:BMF+2RmswtWB5Z8eaebDFdK306JvBeu8fqPLK7DmfObq980Tc57a8t0fSOUt13k+f16CnvRc0EeHORq+R2vN0jQW3korDKptBa4LHRJr68u8ITXKqU+gkzIzGWLOdfmkMdXKPN4AGeEryw==
DISCORD_CLIENT_ID="encrypted:BE1JPS3brhFpQ7qvCqmAi9PoQxFKnLZGBe/IrZQ4atx6s0byUQ6RrRW4cRK+XG08FCikCbzW0BaDSjy3z/6I+0dNvCZEMcsfU7v/ZFlmrOpoPF+sxrQNHfbDHaYT0MnOSlvtpnPGAm/FoT4SVuS0XdTyY2M="
DISCORD_CLIENT_SECRET="encrypted:BDEjA2trC6RqGqDkwq2R8pGdD8xWqnhyFhkS9hxxUt4GJpTtTSUteU1zwHodGc+Xj7UUXAd5o3+MVc31N2yPyVLVHkdN3tkEb87nuUOdxHhtbBW7fPAszq/n5jMJfIfEI9/lvCB4pq/yVKxDkHJt+sWoZhdqUuLgtRFOE0IPZwyW"
DISCORD_REDIRECT_URI=encrypted:BNM4ROa+P+E06QttCNhxzIwzTKmZb7IJLaK8gIH1n07gadfrNfAzbvFWCOZHnj4mcTvRnWym6761KnkL4FIgGOcLTx8eoGKmFM6WRAgA1u5MAz2k0fhyuNxAU/h0J+VdGLdFa3WpkjEY1TjD1A+6dhDv62A7XF8wCJtZx5mTqzjPjRdalxA/3GwgKRQ=
SMTP_HOST="encrypted:BMjOo3Z/HyXK4c2fiVIqyud3bT83eBuD3uCEs97j2ymE0OAvoEqYaux8fMnSFull6kqvBLnfsxRR7BJaH6B15/Y8bhWmMIzPx1nfmkQTgbHY/esF+LSRZ18WeAIk7NOuvPRWacpY2CtadMZZ9hIe7AIhYg=="
SMTP_PORT="encrypted:BCaQ3+9VxAash3YSjHRQ/pt1RLy6xjVpzMA4MSDuyFlfnMjE20ywt5ZtnqRrmKzXQa2KELF0mm92v1VehREwUZReD+W8J3p9sSxpG+D4SjDRkXfVEAfXuYml9E+vqwirianL/g=="
SMTP_SECURE="encrypted:BAyezwpS+qNxZB0JmXJcQyiAS5Lua5XrURUnW1JUobBzTraqafs+2MpNJt3/LzVPayVv9nfuuQlUYfLJFnF6yRLQehqZobkaC6Hh+Q6AiWD3ZgkG+Sn6ON9c/Db+fltzt9D5lqbS"
SMTP_USER="encrypted:BHnngZLsV6cbONJ10YvT/46wYXA8zZQB4W50VodF/6E3MyRcY9GiUmW+vaCFsrq71KA8HMKQaJ1P+3dB5u/7kaWPzMW8UDUWkBtaBnr0CJLCxN3bff2SA2+iCcHvjgx0bcLJS83JhnKsdo6Fb42IkuEGgw=="
SMTP_PASSWORD="encrypted:BJs+zmP95i0SCm5h228DpILK5Q8BLX3wpsUx+5qEQgFakGV2b5h1x59q4TJcvX1wkTcbEHG+8ZYI1B2NJCgNgKFxRV4+uBnybIwP9rpHUQGL+kycYaM+hatvLzt9rA0eY+Gbm4AwhbiQ14rWxGqHnD4="
SMTP_FROM="encrypted:BCrtD05OXCA8xlrFHvRLN/w16m43/S4da2xYWfYsvPIWeDWN56KURl9J6jljUpi5KzPutOfut5azWyKMssNdxAZqlRYQefFBBbrLK+lZjqccGEw2mKwLAibllYqPhiL5hK3bW5fUjojYqAL6Kb1UvN5KtkGzjIe2YW1jsc9eCg=="
ORIGIN="encrypted:BMh6IEGoG+8iLK4a8vDpqduz3bGJ7ZI+X3oBnmmM0sbff3MaU/u3bdPuBtKcxxzMI8+Auc9TVlHOyDwM5pq40ny96CT/ZiMBQH+qFOjN797Vi7GO27eK58Z7iQuN99eQep1Pwxo84JruTUjhEuyYpVyqoQHl/ibN"
SCHEDULER_TOKEN="encrypted:BAUdHe+C2fE9hU+uzbpQM36hNC7ZY01smAcCuE8uUA8n2QhhVAD6z3Meb6k+IokarpxUrua7W1S7knQx7hdDgZfDPqWsbJW0GYP9Ym6VNe1chJ/F2ceaNxPAPp0jsZ55Vj22rQ1ljni5vD1oSBE2gTkrsqt95KS/qiaRMTRxEkQ0lmXZscmkXhmeNy0="
REMOVE_BG_API_KEY=encrypted:BFcytS+vbpSp2AhQ7nnRZvGbQCCuDCcp5D6irWwodBVnKB1zosiXapU3Nw6lTwHepl6t+j8hakxF3mKyJmB+6I5hZlzH/v6VUYUQnlYaPE43dYYYbGrseg4ZYK0W9O7SWlOyHHgIv7Ktkx3nqBIwa6PS50Bjoq+8SA==
+54 -17
View File
@@ -7,7 +7,7 @@ on:
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
group: shd-build-production group: shd-build-host-maintenance
cancel-in-progress: false cancel-in-progress: false
jobs: jobs:
@@ -21,7 +21,7 @@ jobs:
run: npm ci run: npm ci
- name: Validate application - name: Validate application
run: npm run check run: npm run check && npm test
- name: Build Node application - name: Build Node application
run: npm run build:app run: npm run build:app
@@ -33,30 +33,63 @@ jobs:
- name: Check out repository - name: Check out repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install environment tooling
run: npm ci
- name: Prepare production configuration - name: Prepare production configuration
env: env:
DOTENV_PRIVATE_KEY_PRODUCTION: ${{ secrets.DOTENV_PRIVATE_KEY_PRODUCTION }} INFISICAL_APP_CLIENT_ID:
SCHEDULER_TOKEN_PRODUCTION: ${{ secrets.SCHEDULER_TOKEN_PRODUCTION }} ${{ vars.INFISICAL_PRODUCTION_APP_CLIENT_ID }}
INFISICAL_APP_CLIENT_SECRET:
${{ secrets.INFISICAL_PRODUCTION_APP_CLIENT_SECRET }}
INFISICAL_SCHEDULER_CLIENT_ID:
${{ vars.INFISICAL_PRODUCTION_SCHEDULER_CLIENT_ID }}
INFISICAL_SCHEDULER_CLIENT_SECRET:
${{ secrets.INFISICAL_PRODUCTION_SCHEDULER_CLIENT_SECRET }}
CONFIGURED_DEPLOY_PATH: ${{ vars.PRODUCTION_DEPLOY_PATH }} CONFIGURED_DEPLOY_PATH: ${{ vars.PRODUCTION_DEPLOY_PATH }}
CONFIGURED_PRODUCTION_PORT: ${{ vars.PRODUCTION_PORT }} CONFIGURED_PRODUCTION_PORT: ${{ vars.PRODUCTION_PORT }}
COMMIT_SHA: ${{ gitea.sha }} COMMIT_SHA: ${{ gitea.sha }}
BUILD_NUMBER: ${{ gitea.run_number }} BUILD_NUMBER: ${{ gitea.run_number }}
run: | run: |
set -eu set -eu
: "${DOTENV_PRIVATE_KEY_PRODUCTION:?DOTENV_PRIVATE_KEY_PRODUCTION is required}" umask 077
: "${SCHEDULER_TOKEN_PRODUCTION:?SCHEDULER_TOKEN_PRODUCTION is required}" : "${INFISICAL_APP_CLIENT_ID:?INFISICAL_PRODUCTION_APP_CLIENT_ID is required}"
: "${INFISICAL_APP_CLIENT_SECRET:?INFISICAL_PRODUCTION_APP_CLIENT_SECRET is required}"
: "${INFISICAL_SCHEDULER_CLIENT_ID:?INFISICAL_PRODUCTION_SCHEDULER_CLIENT_ID is required}"
: "${INFISICAL_SCHEDULER_CLIENT_SECRET:?INFISICAL_PRODUCTION_SCHEDULER_CLIENT_SECRET is required}"
deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-production}" deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-production}"
production_port="${CONFIGURED_PRODUCTION_PORT:-3102}" production_port="${CONFIGURED_PRODUCTION_PORT:-3102}"
case "$deployment_path" in
/opt/shd-build-*) ;;
*) echo "PRODUCTION_DEPLOY_PATH must be beneath /opt/shd-build-." >&2; exit 1 ;;
esac
case "$production_port" in
''|*[!0-9]*) echo "PRODUCTION_PORT must be an integer." >&2; exit 1 ;;
esac
if [ "$production_port" -lt 1 ] || [ "$production_port" -gt 65535 ]; then
echo "PRODUCTION_PORT must be from 1 through 65535." >&2
exit 1
fi
mkdir -p "$deployment_path" mkdir -p "$deployment_path"
cp deploy/compose.production.yml deploy/deploy-production.sh deploy/bootstrap-production.sh deploy/sync-production-isac-to-staging.sh "$deployment_path/" release_id="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}"
DOTENV_PRIVATE_KEY_PRODUCTION="$DOTENV_PRIVATE_KEY_PRODUCTION" npx dotenvx decrypt -f .env.production --stdout > "$deployment_path/.env.production.runtime" candidate_path="$deployment_path/candidate-$release_id"
printf '\nSCHEDULER_TOKEN=%s\n' "$SCHEDULER_TOKEN_PRODUCTION" >> "$deployment_path/.env.production.runtime" rm -rf "$candidate_path"
printf 'SHD_IMAGE=shd-build-production:%s\nSHD_PRODUCTION_PORT=%s\nSHD_BUILD_NUMBER=%s\n' "$COMMIT_SHA" "$production_port" "$BUILD_NUMBER" > "$deployment_path/.env.deploy" mkdir -p "$candidate_path"
chmod 600 "$deployment_path/.env.production.runtime" "$deployment_path/.env.deploy" cp deploy/compose.production.yml deploy/deploy-production.sh deploy/bootstrap-production.sh deploy/sync-production-isac-to-staging.sh "$candidate_path/"
chmod 755 "$deployment_path/deploy-production.sh" "$deployment_path/bootstrap-production.sh" "$deployment_path/sync-production-isac-to-staging.sh" write_credential() {
target="$1"
value="$2"
temporary_file=$(mktemp "${target%/*}/.infisical.XXXXXX")
printf '%s' "$value" > "$temporary_file"
chmod 600 "$temporary_file"
mv "$temporary_file" "$target"
}
write_credential "$candidate_path/.infisical.app.client-id" "$INFISICAL_APP_CLIENT_ID"
write_credential "$candidate_path/.infisical.app.client-secret" "$INFISICAL_APP_CLIENT_SECRET"
write_credential "$candidate_path/.infisical.scheduler.client-id" "$INFISICAL_SCHEDULER_CLIENT_ID"
write_credential "$candidate_path/.infisical.scheduler.client-secret" "$INFISICAL_SCHEDULER_CLIENT_SECRET"
temporary_deploy_environment=$(mktemp "$candidate_path/.env.deploy.XXXXXX")
printf 'SHD_IMAGE=shd-build-production:%s-%s\nSHD_PRODUCTION_PORT=%s\nSHD_BUILD_NUMBER=%s\nSHD_COMMIT_SHA=%s\n' "$COMMIT_SHA" "$release_id" "$production_port" "$BUILD_NUMBER" "$COMMIT_SHA" > "$temporary_deploy_environment"
chmod 600 "$temporary_deploy_environment"
mv "$temporary_deploy_environment" "$candidate_path/.env.deploy"
chmod 755 "$candidate_path/deploy-production.sh" "$candidate_path/bootstrap-production.sh" "$candidate_path/sync-production-isac-to-staging.sh"
- name: Deploy production - name: Deploy production
env: env:
@@ -64,8 +97,12 @@ jobs:
COMMIT_SHA: ${{ gitea.sha }} COMMIT_SHA: ${{ gitea.sha }}
run: | run: |
deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-production}" deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-production}"
"$deployment_path/deploy-production.sh" "$deployment_path" "$GITHUB_WORKSPACE" "shd-build-production:$COMMIT_SHA" release_id="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}"
candidate_path="$deployment_path/candidate-$release_id"
"$candidate_path/deploy-production.sh" "$deployment_path" "$GITHUB_WORKSPACE" "shd-build-production:$COMMIT_SHA-$release_id" "$candidate_path"
- name: Remove unused application images - name: Remove unused application images
if: success() if: success()
run: docker image prune --force --filter "label=org.opencontainers.image.title=shd-build" run:
docker image prune --force --filter
"label=org.opencontainers.image.title=shd-build"
+53 -13
View File
@@ -7,7 +7,7 @@ on:
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
group: shd-build-staging group: shd-build-host-maintenance
cancel-in-progress: false cancel-in-progress: false
jobs: jobs:
@@ -21,7 +21,7 @@ jobs:
run: npm ci run: npm ci
- name: Validate application - name: Validate application
run: npm run check run: npm run check && npm test
- name: Build Node application - name: Build Node application
run: npm run build:app run: npm run build:app
@@ -33,26 +33,62 @@ jobs:
- name: Check out repository - name: Check out repository
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install environment tooling
run: npm ci
- name: Prepare deployment configuration - name: Prepare deployment configuration
env: env:
DOTENV_PRIVATE_KEY_STAGING: ${{ secrets.DOTENV_PRIVATE_KEY_STAGING }} INFISICAL_APP_CLIENT_ID: ${{ vars.INFISICAL_STAGING_APP_CLIENT_ID }}
INFISICAL_APP_CLIENT_SECRET:
${{ secrets.INFISICAL_STAGING_APP_CLIENT_SECRET }}
INFISICAL_SCHEDULER_CLIENT_ID:
${{ vars.INFISICAL_STAGING_SCHEDULER_CLIENT_ID }}
INFISICAL_SCHEDULER_CLIENT_SECRET:
${{ secrets.INFISICAL_STAGING_SCHEDULER_CLIENT_SECRET }}
CONFIGURED_DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }} CONFIGURED_DEPLOY_PATH: ${{ vars.STAGE_DEPLOY_PATH }}
CONFIGURED_STAGE_PORT: ${{ vars.STAGE_PORT }} CONFIGURED_STAGE_PORT: ${{ vars.STAGE_PORT }}
COMMIT_SHA: ${{ gitea.sha }} COMMIT_SHA: ${{ gitea.sha }}
BUILD_NUMBER: ${{ gitea.run_number }} BUILD_NUMBER: ${{ gitea.run_number }}
run: | run: |
set -eu set -eu
umask 077
: "${INFISICAL_APP_CLIENT_ID:?INFISICAL_STAGING_APP_CLIENT_ID is required}"
: "${INFISICAL_APP_CLIENT_SECRET:?INFISICAL_STAGING_APP_CLIENT_SECRET is required}"
: "${INFISICAL_SCHEDULER_CLIENT_ID:?INFISICAL_STAGING_SCHEDULER_CLIENT_ID is required}"
: "${INFISICAL_SCHEDULER_CLIENT_SECRET:?INFISICAL_STAGING_SCHEDULER_CLIENT_SECRET is required}"
deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-stage}" deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-stage}"
stage_port="${CONFIGURED_STAGE_PORT:-5569}" stage_port="${CONFIGURED_STAGE_PORT:-5569}"
case "$deployment_path" in
/opt/shd-build-*) ;;
*) echo "STAGE_DEPLOY_PATH must be beneath /opt/shd-build-." >&2; exit 1 ;;
esac
case "$stage_port" in
''|*[!0-9]*) echo "STAGE_PORT must be an integer." >&2; exit 1 ;;
esac
if [ "$stage_port" -lt 1 ] || [ "$stage_port" -gt 65535 ]; then
echo "STAGE_PORT must be from 1 through 65535." >&2
exit 1
fi
mkdir -p "$deployment_path" mkdir -p "$deployment_path"
cp deploy/compose.staging.yml deploy/deploy-staging.sh deploy/bootstrap-staging.sh "$deployment_path/" release_id="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}"
DOTENV_PRIVATE_KEY_STAGING="$DOTENV_PRIVATE_KEY_STAGING" npx dotenvx decrypt -f .env.staging --stdout > "$deployment_path/.env.staging.runtime" candidate_path="$deployment_path/candidate-$release_id"
printf 'SHD_IMAGE=shd-build-stage:%s\nSHD_STAGE_PORT=%s\nSHD_BUILD_NUMBER=%s\n' "$COMMIT_SHA" "$stage_port" "$BUILD_NUMBER" > "$deployment_path/.env.deploy" rm -rf "$candidate_path"
chmod 600 "$deployment_path/.env.staging.runtime" "$deployment_path/.env.deploy" mkdir -p "$candidate_path"
chmod 755 "$deployment_path/deploy-staging.sh" "$deployment_path/bootstrap-staging.sh" cp deploy/compose.staging.yml deploy/deploy-staging.sh deploy/bootstrap-staging.sh "$candidate_path/"
write_credential() {
target="$1"
value="$2"
temporary_file=$(mktemp "${target%/*}/.infisical.XXXXXX")
printf '%s' "$value" > "$temporary_file"
chmod 600 "$temporary_file"
mv "$temporary_file" "$target"
}
write_credential "$candidate_path/.infisical.app.client-id" "$INFISICAL_APP_CLIENT_ID"
write_credential "$candidate_path/.infisical.app.client-secret" "$INFISICAL_APP_CLIENT_SECRET"
write_credential "$candidate_path/.infisical.scheduler.client-id" "$INFISICAL_SCHEDULER_CLIENT_ID"
write_credential "$candidate_path/.infisical.scheduler.client-secret" "$INFISICAL_SCHEDULER_CLIENT_SECRET"
temporary_deploy_environment=$(mktemp "$candidate_path/.env.deploy.XXXXXX")
printf 'SHD_IMAGE=shd-build-stage:%s-%s\nSHD_STAGE_PORT=%s\nSHD_BUILD_NUMBER=%s\nSHD_COMMIT_SHA=%s\n' "$COMMIT_SHA" "$release_id" "$stage_port" "$BUILD_NUMBER" "$COMMIT_SHA" > "$temporary_deploy_environment"
chmod 600 "$temporary_deploy_environment"
mv "$temporary_deploy_environment" "$candidate_path/.env.deploy"
chmod 755 "$candidate_path/deploy-staging.sh" "$candidate_path/bootstrap-staging.sh"
- name: Deploy staging - name: Deploy staging
env: env:
@@ -60,8 +96,12 @@ jobs:
COMMIT_SHA: ${{ gitea.sha }} COMMIT_SHA: ${{ gitea.sha }}
run: | run: |
deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-stage}" deployment_path="${CONFIGURED_DEPLOY_PATH:-/opt/shd-build-stage}"
"$deployment_path/deploy-staging.sh" "$deployment_path" "$GITHUB_WORKSPACE" "shd-build-stage:$COMMIT_SHA" release_id="${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT:-1}"
candidate_path="$deployment_path/candidate-$release_id"
"$candidate_path/deploy-staging.sh" "$deployment_path" "$GITHUB_WORKSPACE" "shd-build-stage:$COMMIT_SHA-$release_id" "$candidate_path"
- name: Remove unused application images - name: Remove unused application images
if: success() if: success()
run: docker image prune --force --filter "label=org.opencontainers.image.title=shd-build" run:
docker image prune --force --filter
"label=org.opencontainers.image.title=shd-build"
+25 -8
View File
@@ -4,7 +4,7 @@ on:
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
group: shd-build-staging group: shd-build-host-maintenance
cancel-in-progress: false cancel-in-progress: false
jobs: jobs:
@@ -25,15 +25,32 @@ jobs:
compose() { compose() {
docker compose --env-file "$deploy_environment" -f "$compose_file" "$@" 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" snapshot_name="isac-stage-${GITHUB_RUN_ID}.json"
volume_snapshot="/app/data/exports/$snapshot_name"
remove_volume_snapshot=false
cleanup() {
exit_status=$?
trap - EXIT
if [ "$remove_volume_snapshot" = true ]; then
compose run --rm --no-deps app rm -f "$volume_snapshot" || true
fi
if ! compose up -d --no-build app scheduler; then
if [ "$exit_status" -eq 0 ]; then exit_status=1; fi
fi
exit "$exit_status"
}
trap cleanup EXIT
compose stop scheduler app compose stop scheduler app
compose run --rm --no-deps app node scripts/isac-data-export.mjs "/app/data/exports/$snapshot_name" app_container="$(compose ps --all --quiet app)"
compose run --rm --no-deps -v "$GITHUB_WORKSPACE:/export" app \ if [ -z "$app_container" ]; then
cp "/app/data/exports/$snapshot_name" /export/isac-stage.json echo "The stopped staging app container could not be found." >&2
exit 1
fi
remove_volume_snapshot=true
compose run --rm --no-deps app node scripts/isac-data-export.mjs "$volume_snapshot"
docker cp "$app_container:$volume_snapshot" "$GITHUB_WORKSPACE/isac-stage.json"
compose run --rm --no-deps app rm -f "$volume_snapshot"
remove_volume_snapshot=false
- name: Publish ISAC snapshot - name: Publish ISAC snapshot
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
@@ -4,7 +4,7 @@ on:
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
group: shd-build-production group: shd-build-host-maintenance
cancel-in-progress: false cancel-in-progress: false
jobs: jobs:
@@ -25,15 +25,32 @@ jobs:
compose() { compose() {
docker compose --env-file "$deploy_environment" -f "$compose_file" "$@" 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-production-${GITHUB_RUN_ID}.json" snapshot_name="isac-production-${GITHUB_RUN_ID}.json"
volume_snapshot="/app/data/exports/$snapshot_name"
remove_volume_snapshot=false
cleanup() {
exit_status=$?
trap - EXIT
if [ "$remove_volume_snapshot" = true ]; then
compose run --rm --no-deps app rm -f "$volume_snapshot" || true
fi
if ! compose up -d --no-build app scheduler; then
if [ "$exit_status" -eq 0 ]; then exit_status=1; fi
fi
exit "$exit_status"
}
trap cleanup EXIT
compose stop scheduler app compose stop scheduler app
compose run --rm --no-deps app node scripts/isac-data-export.mjs "/app/data/exports/$snapshot_name" app_container="$(compose ps --all --quiet app)"
compose run --rm --no-deps -v "$GITHUB_WORKSPACE:/export" app \ if [ -z "$app_container" ]; then
cp "/app/data/exports/$snapshot_name" /export/isac-production.json echo "The stopped production app container could not be found." >&2
exit 1
fi
remove_volume_snapshot=true
compose run --rm --no-deps app node scripts/isac-data-export.mjs "$volume_snapshot"
docker cp "$app_container:$volume_snapshot" "$GITHUB_WORKSPACE/isac-production.json"
compose run --rm --no-deps app rm -f "$volume_snapshot"
remove_volume_snapshot=false
- name: Publish production ISAC snapshot - name: Publish production ISAC snapshot
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
@@ -4,7 +4,7 @@ on:
workflow_dispatch: workflow_dispatch:
concurrency: concurrency:
group: shd-build-production-to-staging group: shd-build-host-maintenance
cancel-in-progress: false cancel-in-progress: false
jobs: jobs:
+2 -3
View File
@@ -17,11 +17,10 @@ Thumbs.db
.env.* .env.*
!.env.example !.env.example
!.env.test !.env.test
!.env.development
!.env.staging
!.env.production
.env.keys .env.keys
.env.keys.* .env.keys.*
.infisical.*.client-id
.infisical.*.client-secret
# Embedded Turso database files # Embedded Turso database files
/data/*.db /data/*.db
+6
View File
@@ -0,0 +1,6 @@
{
"workspaceId": "2ee94b27-e599-4194-9797-3372d42e53f6",
"defaultEnvironment": "dev",
"defaultSecretPath": "/app",
"domain": "https://secrets.jb.codes"
}
+176 -65
View File
@@ -2,39 +2,70 @@
## Parallel task execution ## Parallel task execution
For substantial changes, use subagents as a standard practice when the work can be divided into concrete, independent tasks that provide real parallel progress. For substantial changes, use subagents as a standard practice when the work can
be divided into concrete, independent tasks that provide real parallel progress.
1. Delegate bounded mechanical work to faster, lower-reasoning agents (focused code discovery, repetitive call-site updates, documentation, isolated test coverage). 1. Delegate bounded mechanical work to faster, lower-reasoning agents (focused
2. Keep architecture, security-sensitive behavior, database migrations, shared data-model decisions, and complex debugging with a suitably capable agent. code discovery, repetitive call-site updates, documentation, isolated test
3. Assign explicit file or subsystem ownership before parallel edits. Avoid having multiple agents edit the same files unless their work is deliberately sequenced. coverage).
4. Do not delegate tightly coupled work merely to increase agent count. Keep it with the primary agent when coordination would cost more than it saves. 2. Keep architecture, security-sensitive behavior, database migrations, shared
5. The primary agent remains responsible for integrating results, reviewing diffs, resolving cross-cutting concerns, and running the final required validation. data-model decisions, and complex debugging with a suitably capable agent.
6. Subagents must follow every repository rule that applies to their assigned work, including design-system maintenance, audit logging, and database preservation. 3. Assign explicit file or subsystem ownership before parallel edits. Avoid
having multiple agents edit the same files unless their work is deliberately
sequenced.
4. Do not delegate tightly coupled work merely to increase agent count. Keep it
with the primary agent when coordination would cost more than it saves.
5. The primary agent remains responsible for integrating results, reviewing
diffs, resolving cross-cutting concerns, and running the final required
validation.
6. Subagents must follow every repository rule that applies to their assigned
work, including design-system maintenance, audit logging, and database
preservation.
## Documentation prose ## Documentation prose
Use the `stop-slop` skill whenever an agent creates or changes project documentation. Apply its review to the complete affected document, including sections that the change did not introduce. Preserve technical meaning, commands, paths, formulas, links, policy strength, and product terminology. Remove filler, vague claims, formulaic contrasts, manufactured emphasis, false agency, and repetition. Prefer active voice and specific subjects. Do not weaken terms such as `must`, `never`, or `always` when they express a real requirement. Use the `stop-slop` skill whenever an agent creates or changes project
documentation. Apply its review to the complete affected document, including
sections that the change did not introduce. Preserve technical meaning,
commands, paths, formulas, links, policy strength, and product terminology.
Remove filler, vague claims, formulaic contrasts, manufactured emphasis, false
agency, and repetition. Prefer active voice and specific subjects. Do not weaken
terms such as `must`, `never`, or `always` when they express a real requirement.
## Dependency source research ## Dependency source research
Use `npx opensrc` before inspecting an npm dependency's implementation. It resolves the package version from this workspace's lockfile, shallow-clones the matching source revision, and caches it outside the repository at `~/.opensrc/`. Use `npx opensrc` before inspecting an npm dependency's implementation. It
resolves the package version from this workspace's lockfile, shallow-clones the
matching source revision, and caches it outside the repository at `~/.opensrc/`.
- Do not guess at dependency behavior or implementation details. Identify the needed function, type, option, or control flow in the cached source and use that evidence when designing a change. - Do not guess at dependency behavior or implementation details. Identify the
- Search the smallest relevant source area first. Use the package entry point, exports, types, and targeted code search to locate the behavior instead of reading unrelated files. needed function, type, option, or control flow in the cached source and use
- Fetch source before a dependency investigation: `npx opensrc fetch --cwd . <package>`. that evidence when designing a change.
- Print the cached source path for use with repository search and file tools: `npx opensrc path --cwd . <package>`. - Search the smallest relevant source area first. Use the package entry point,
- Fetch several declared packages in one command: `npx opensrc fetch --cwd . <package-a> <package-b>`. exports, types, and targeted code search to locate the behavior instead of
- After adding or upgrading a direct dependency, fetch its source at the locked version. Do not commit `~/.opensrc/` or copy dependency source into this repository. reading unrelated files.
- Use `npx opensrc list` to inspect the cache. Use `npx opensrc remove <package>` or `npx opensrc clean` only when cache removal is intended. - Fetch source before a dependency investigation:
`npx opensrc fetch --cwd . <package>`.
- Print the cached source path for use with repository search and file tools:
`npx opensrc path --cwd . <package>`.
- Fetch several declared packages in one command:
`npx opensrc fetch --cwd . <package-a> <package-b>`.
- After adding or upgrading a direct dependency, fetch its source at the locked
version. Do not commit `~/.opensrc/` or copy dependency source into this
repository.
- Use `npx opensrc list` to inspect the cache. Use
`npx opensrc remove <package>` or `npx opensrc clean` only when cache removal
is intended.
## Available skills ## Available skills
The `.agents/skills/` directory contains repo-specific skill files. Key skills: The `.agents/skills/` directory contains repo-specific skill files. Key skills:
- `stop-slop` — apply to documentation prose changes - `stop-slop`: apply to documentation prose changes
- `frontend-design` / `impeccable` — UI design, refinement, and polish - `frontend-design` / `impeccable`: UI design, refinement, and polish
- `import-talent-screenshot` / `import-weapon-screenshot` — extract Division Resurgence game data from screenshots and enter through ISAC - `import-talent-screenshot` / `import-weapon-screenshot`: extract Division
- `webapp-testing` — Playwright-based local web app testing Resurgence game data from screenshots and enter through ISAC
- `webapp-testing`: Playwright-based local web app testing
Use `skill` tool to load one when the task matches. Use `skill` tool to load one when the task matches.
@@ -44,103 +75,183 @@ Run in order after source changes:
``` ```
npm run check # svelte-kit sync + svelte-check (typecheck) npm run check # svelte-kit sync + svelte-check (typecheck)
npm run build # dotenvx production env + vite build npm run build # secretless Vite production build
``` ```
`npm run check` runs `svelte-kit sync` (generates `.svelte-kit/tsconfig.json`) then `svelte-check --tsconfig ./tsconfig.json`. `npm test` runs all vitest tests; there is no separate vitest config (vitest inherits from `vite.config.ts`). `npm run check` runs `svelte-kit sync` (generates `.svelte-kit/tsconfig.json`)
then `svelte-check --tsconfig ./tsconfig.json`. `npm test` runs all vitest
tests; there is no separate vitest config (vitest inherits from
`vite.config.ts`).
Test files (`*.test.ts`) live adjacent to the code they test, mostly in `src/lib/`. Most server-unit tests mock `$lib/server/database` via `vi.mock(...)` — unnecessary for unit tests against pure logic. Integration tests that need database state use the embedded Turso engine. Test files (`*.test.ts`) live adjacent to the code they test, mostly in
`src/lib/`. Most server-unit tests mock `$lib/server/database` via
`vi.mock(...)`; pure logic tests do not need that mock. Integration tests that
need database state use the embedded Turso engine.
## Framework and toolchain quirks ## Framework and toolchain quirks
- **SvelteKit 2 / Svelte 5** with forced runes mode (configured in `vite.config.ts` via plugin `compilerOptions.runes`). There is no `svelte.config.js` — SvelteKit config lives in `vite.config.ts`. - **SvelteKit 2 / Svelte 5** with forced runes mode (configured in
- **Node adapter** (`@sveltejs/adapter-node`). Production entry point is `node build`. The container runs migrations on startup, then serves. `vite.config.ts` via plugin `compilerOptions.runes`). There is no
- **Embedded Turso** (`@tursodatabase/database`) — local SQLite, no Turso Cloud, no libSQL HTTP, no replicas. The dev database path is `./data/shd-build-development.db` (set in `.env.development`). `svelte.config.js`; SvelteKit config lives in `vite.config.ts`.
- **Passkey auth** (`@simplewebauthn`) is preferred. `APP_ORIGIN` and `PASSKEY_RP_ID` must match the public origin. - **Node adapter** (`@sveltejs/adapter-node`). Production entry point is
- **Environment files** are encrypted with `dotenvx`. Committed `.env.development`, `.env.staging`, `.env.production` are encrypted; `.env.keys` is git-ignored. The pre-commit hook runs `npm run env:stage` (encrypts and git-adds). To edit: `npm run env:decrypt`, make changes, `npm run env:stage`. `node build`. The container runs migrations on startup, then serves.
- **Embedded Turso** (`@tursodatabase/database`): local SQLite, no Turso Cloud,
no libSQL HTTP, no replicas. The Infisical `dev` environment sets the database
path to `./data/shd-build-development.db`.
- **Passkey auth** (`@simplewebauthn`) is preferred. `APP_ORIGIN` must match the
public origin. `PASSKEY_RP_ID` must be that hostname or a registrable parent
domain; hosted environments use `shd.build`.
- **Infisical secrets** live in project `2ee94b27-e599-4194-9797-3372d42e53f6`
at `https://secrets.jb.codes`. Application values use `/app`;
`SCHEDULER_TOKEN` uses `/shared/scheduler`. Local commands use the
authenticated Infisical CLI and the `dev` environment. Deployed app and
scheduler containers use separate Universal Auth bootstrap credentials mounted
through Compose secrets. The root entrypoint exchanges those credentials for
one-shot JSON exports, deletes the export files, and starts the workload as
the dedicated `shd-runtime` UID 10001 without Infisical credentials or tokens.
The self-hosted Free plan cannot scope Viewer identities by environment or
path.
- **Argon2id** password hashing with server-side sessions and CSRF protection. - **Argon2id** password hashing with server-side sessions and CSRF protection.
- **ISAC namespace**: `/isac` is the admin console; `/admin` redirects there via 308. - **ISAC namespace**: `/isac` is the admin console; `/admin` redirects there
via 308.
## Development server lifecycle ## Development server lifecycle
After changing source code, styles, configuration, dependencies, or routes, restart the development server before handing work back. After changing source code, styles, configuration, dependencies, or routes,
restart the development server before handing work back.
1. Inspect running processes and stop only this project's development-server process tree. 1. Inspect running processes and stop only this project's development-server
2. Start one hidden `npm run dev` process from the repository root. Do not leave duplicate project development servers running. process tree.
2. Start one hidden `npm run dev` process from the repository root. Do not leave
duplicate project development servers running.
3. Confirm the server accepts an HTTP request before completing the task. 3. Confirm the server accepts an HTTP request before completing the task.
4. Leave the verified development server running so the project owner can review the changes. 4. Leave the verified development server running so the project owner can review
the changes.
Database maintenance may require the server to remain stopped while the embedded database is locked. Restart and verify the server after maintenance completes. Database maintenance may require the server to remain stopped while the embedded
database is locked. Restart and verify the server after maintenance completes.
## SHD design-system maintenance ## SHD design-system maintenance
Treat `docs/DESIGN_SYSTEM.md` and the shared tokens in `src/app.css` as maintained parts of the product. When a change makes a significant visual or interaction-design decision, update the design system in the same change. Treat `docs/DESIGN_SYSTEM.md` and the shared tokens in `src/app.css` as
maintained parts of the product. When a change makes a significant visual or
interaction-design decision, update the design system in the same change.
For significant design changes: reuse or extend CSS custom properties from `src/app.css`; update `docs/DESIGN_SYSTEM.md`; refactor repeated UI into a reusable component; verify hover, pressed, focus-visible, pending, disabled, error, success, and reduced-motion behavior. Run `npm run check && npm run build` before completing. For significant design changes: reuse or extend CSS custom properties from
`src/app.css`; update `docs/DESIGN_SYSTEM.md`; refactor repeated UI into a
reusable component; verify hover, pressed, focus-visible, pending, disabled,
error, success, and reduced-motion behavior. Run
`npm run check && npm run build` before completing.
Design principles: tactical but restrained, data-first, square and engineered, orange reserved for action/emphasis, explicit textual state indicators. Design principles: tactical but restrained, data-first, square and engineered,
orange reserved for action/emphasis, explicit textual state indicators.
Minor alignment fixes, isolated bug fixes, and corrections that do not introduce a reusable design decision do not require a documentation update. Minor alignment fixes, isolated bug fixes, and corrections that do not introduce
a reusable design decision do not require a documentation update.
## Reusable interface patterns ## Reusable interface patterns
Favor shared components from `src/lib/components`. Before adding page-local markup, check if a shared component covers the need. When introducing a reusable pattern, migrate the clearest existing duplicate in the same change. Favor shared components from `src/lib/components`. Before adding page-local
markup, check if a shared component covers the need. When introducing a reusable
pattern, migrate the clearest existing duplicate in the same change.
For small, finite option sets, prefer the shared segmented-choice pattern over a dropdown. Use radio semantics for exactly one choice and checkbox semantics for additive independent choices. Reserve dropdowns for long, dynamic, or search-oriented option sets. For small, finite option sets, prefer the shared segmented-choice pattern over a
dropdown. Use radio semantics for exactly one choice and checkbox semantics for
additive independent choices. Reserve dropdowns for long, dynamic, or
search-oriented option sets.
## ISAC editor lifecycle ## ISAC editor lifecycle
All ISAC data-management editors must follow the same successful-save behavior: All ISAC data-management editors must follow the same successful-save behavior:
1. Saving an edit to an existing record closes the editor and returns the user to the registry. 1. Saving an edit to an existing record closes the editor and returns the user
to the registry.
2. Saving a newly added record keeps the editor open with a fresh blank record. 2. Saving a newly added record keeps the editor open with a fresh blank record.
3. Validation errors keep the editor open and preserve the entered values. 3. Validation errors keep the editor open and preserve the entered values.
4. Server actions must return the add/edit mode and a unique result token; client-side reactive handling must consume each token only once to avoid repeated state updates. 4. Server actions must return the add/edit mode and a unique result token;
5. When a successful add keeps an icon-enabled editor open, increment the shared upload reset token so the file input, preview, filename, drag/paste state, validation message, and removal state are fresh for the next record. client-side reactive handling must consume each token only once to avoid
repeated state updates.
5. When a successful add keeps an icon-enabled editor open, increment the shared
upload reset token so the file input, preview, filename, drag/paste state,
validation message, and removal state are fresh for the next record.
Apply this convention whenever an ISAC editor is added or materially changed. Apply this convention whenever an ISAC editor is added or materially changed.
## ISAC audit logging ## ISAC audit logging
Every successful server-side ISAC mutation must create an append-only audit event. Follow these rules: Every successful server-side ISAC mutation must create an append-only audit
event. Follow these rules:
1. Use the shared audit utilities in `src/lib/server/audit.ts`; do not write directly to `audit_log`. 1. Use the shared audit utilities in `src/lib/server/audit.ts`; do not write
2. Record the authenticated operator ID, stable category, specific action (namespaced past tense, e.g. `weapon.updated`), target entity ID, timestamp, and useful before/after snapshots. Categories include `game_data`, `user_access`, `user_moderation`, `identity`, `role_management`. directly to `audit_log`.
3. Capture changes to the primary row and its relationships. Include meaningful previous and resulting sets for linked records. 2. Record the authenticated operator ID, stable category, specific action
4. Create audit records only after validation succeeds. Rejected or failed requests must not appear as successful mutation events. (namespaced past tense, e.g. `weapon.updated`), target entity ID, timestamp,
5. Never include password hashes, session tokens, magic-link tokens, OAuth state, passkey public-key bytes, SMTP credentials, or environment secrets in audit snapshots. and useful before/after snapshots. Categories include `game_data`,
6. Audit visibility is controlled by `audit.view`. The read-only `Auditor` role grants `isac.access` and `audit.view` without granting mutation permissions. `user_access`, `user_moderation`, `identity`, `role_management`.
7. Audit reversion is a compensating action, never a history rewrite. Require `audit.revert`, confirmation, current-state comparison, relationship validation, and a new `audit.reverted` event. 3. Capture changes to the primary row and its relationships. Include meaningful
8. Tests for an ISAC mutation must confirm its audit event contains the correct actor, category, action, target, and change evidence. previous and resulting sets for linked records.
4. Create audit records only after validation succeeds. Rejected or failed
requests must not appear as successful mutation events.
5. Never include password hashes, session tokens, magic-link tokens, OAuth
state, passkey public-key bytes, SMTP credentials, or environment secrets in
audit snapshots.
6. Audit visibility is controlled by `audit.view`. The read-only `Auditor` role
grants `isac.access` and `audit.view` without granting mutation permissions.
7. Audit reversion is a compensating action, never a history rewrite. Require
`audit.revert`, confirmation, current-state comparison, relationship
validation, and a new `audit.reverted` event.
8. Tests for an ISAC mutation must confirm its audit event contains the correct
actor, category, action, target, and change evidence.
## Prototype compatibility ## Prototype compatibility
Treat application data and client payloads as prototype formats until the project owner declares a production-stable format. Treat application data and client payloads as prototype formats until the
project owner declares a production-stable format.
1. Prefer one current validated schema over compatibility readers, repair branches, dual writes, or fallback parsing for obsolete formats. 1. Prefer one current validated schema over compatibility readers, repair
2. Remove incompatible local drafts, saved builds, calibration snapshots, and other derived records through a backed-up migration when the current model cannot represent them safely. branches, dual writes, or fallback parsing for obsolete formats.
3. Preserve authored game data whenever the new model can retain it without guessing. 2. Remove incompatible local drafts, saved builds, calibration snapshots, and
4. Follow the database-preservation rules before removing or reshaping persistent data. other derived records through a backed-up migration when the current model
5. Add compatibility support only when the project owner requires it for a production-stable format. cannot represent them safely.
3. Preserve authored game data whenever the new model can retain it without
guessing.
4. Follow the database-preservation rules before removing or reshaping
persistent data.
5. Add compatibility support only when the project owner requires it for a
production-stable format.
## Talent mechanic conventions ## Talent mechanic conventions
Before importing a Talent or changing its mechanic program, read `docs/TALENT_MECHANIC_RULES.md`. Treat the confirmed lifecycle, trigger, cooldown, and targeting rules in that document as authoritative game behavior. Do not mark a mechanic `requires_assumptions` for behavior that the document resolves. When the project owner clarifies another Talent mechanic, record the answer in `docs/TALENT_MECHANIC_RULES.md`. Before importing a Talent or changing its mechanic program, read
`docs/TALENT_MECHANIC_RULES.md`. Treat the confirmed lifecycle, trigger,
cooldown, and targeting rules in that document as authoritative game behavior.
Do not mark a mechanic `requires_assumptions` for behavior that the document
resolves. When the project owner clarifies another Talent mechanic, record the
answer in `docs/TALENT_MECHANIC_RULES.md`.
## Database preservation ## Database preservation
Development data is valuable. Treat embedded database files as persistent user data, not disposable build artifacts. Treat embedded database files as persistent user data, not disposable build
artifacts.
Before destructive work on the database, stop the dev server, then create a verified timestamped JSON backup with `npm run db:backup`. Keep backups out of Git (they can contain user records). Prefer additive, repeatable schema migrations over database recreation. Report the backup path when completing database-destructive work. Before destructive work on the database, stop the dev server, then create a
verified timestamped JSON backup with `npm run db:backup`. Keep backups out of
Git (they can contain user records). Prefer additive, repeatable schema
migrations over database recreation. Report the backup path when completing
database-destructive work.
Use `npm run db:restore -- <backup-file>` only with an empty, migrated target database — it refuses to merge into a populated database. Use `npm run db:restore -- <backup-file>` only with an empty, migrated target
database. The command refuses to merge into a populated database.
## Development database maintenance ## Development database maintenance
1. Stop only this project's development-server process tree. 1. Stop only this project's development-server process tree.
2. Run and verify `npm run db:backup`. Retain and report the backup path. 2. Run and verify `npm run db:backup`. Retain and report the backup path.
3. Apply the migration or maintenance operation. Verify schema state, record counts, and critical entities. 3. Apply the migration or maintenance operation. Verify schema state, record
4. If an operation fails, preserve logs and the verified backup rather than improvising against the database. counts, and critical entities.
4. If an operation fails, preserve logs and the verified backup rather than
improvising against the database.
5. Restart the dev server, then confirm a basic health or page check. 5. Restart the dev server, then confirm a basic health or page check.
Production and staging backups and deployment are defined in `docs/DEPLOYMENT.md`. Production and staging backups and deployment are defined in
`docs/DEPLOYMENT.md`.
+21 -4
View File
@@ -3,7 +3,7 @@ FROM node:24-bookworm-slim AS dependencies
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json .npmrc ./ COPY package.json package-lock.json .npmrc ./
COPY scripts/prepare.mjs ./scripts/prepare.mjs COPY scripts/prepare.mjs ./scripts/prepare.mjs
RUN npm ci RUN npm ci --ignore-scripts && npm rebuild argon2
FROM dependencies AS build FROM dependencies AS build
COPY vite.config.ts tsconfig.json ./ COPY vite.config.ts tsconfig.json ./
@@ -15,24 +15,41 @@ FROM node:24-bookworm-slim AS runtime-dependencies
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json .npmrc ./ COPY package.json package-lock.json .npmrc ./
COPY scripts/prepare.mjs ./scripts/prepare.mjs COPY scripts/prepare.mjs ./scripts/prepare.mjs
RUN npm ci --omit=dev && npm cache clean --force RUN npm ci --omit=dev --ignore-scripts && npm rebuild argon2 && npm cache clean --force
FROM node:24-bookworm-slim AS runtime FROM node:24-bookworm-slim AS runtime
ARG TARGETARCH=amd64
ARG INFISICAL_CLI_VERSION=0.43.116
LABEL org.opencontainers.image.title="shd-build" LABEL org.opencontainers.image.title="shd-build"
ENV NODE_ENV=production \ ENV NODE_ENV=production \
HOST=0.0.0.0 \ HOST=0.0.0.0 \
PORT=3000 \ PORT=3000 \
INFISICAL_DOMAIN=https://secrets.jb.codes \
INFISICAL_DISABLE_UPDATE_CHECK=true \
TDR_DATABASE_PATH=/app/data/shd-build.db \ TDR_DATABASE_PATH=/app/data/shd-build.db \
TDR_AVATAR_STORAGE_PATH=/app/data/avatars \ TDR_AVATAR_STORAGE_PATH=/app/data/avatars \
TDR_GAME_ICON_STORAGE_PATH=/app/data/game-icons TDR_GAME_ICON_STORAGE_PATH=/app/data/game-icons
WORKDIR /app WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl gosu \
&& useradd --uid 10001 --gid node --no-create-home --shell /usr/sbin/nologin shd-runtime \
&& case "$TARGETARCH" in \
amd64) infisical_sha256=53abc96a3bc872ade81efe156275bd30c53f9ee8b52965b0c9cb320a9c814fbc ;; \
arm64) infisical_sha256=0111d8e7daa5f3c498a8cc6cb9852bde2c5c21e53c935d4738ddd80c764554cc ;; \
*) echo "Unsupported Infisical CLI architecture: $TARGETARCH" >&2; exit 1 ;; \
esac \
&& curl -fsSL "https://github.com/Infisical/cli/releases/download/v${INFISICAL_CLI_VERSION}/cli_${INFISICAL_CLI_VERSION}_linux_${TARGETARCH}.tar.gz" -o /tmp/infisical.tar.gz \
&& echo "$infisical_sha256 /tmp/infisical.tar.gz" | sha256sum -c - \
&& tar -xzf /tmp/infisical.tar.gz -C /usr/local/bin infisical \
&& chmod 755 /usr/local/bin/infisical \
&& rm -f /tmp/infisical.tar.gz \
&& rm -rf /var/lib/apt/lists/*
COPY --from=runtime-dependencies --chown=node:node /app/node_modules ./node_modules COPY --from=runtime-dependencies --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/build ./build COPY --from=build --chown=node:node /app/build ./build
COPY --chown=node:node database ./database COPY --chown=node:node database ./database
COPY --chown=node:node scripts ./scripts COPY --chown=node:node scripts ./scripts
COPY --chown=node:node deploy/container-entrypoint.sh ./deploy/container-entrypoint.sh 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 RUN chmod 755 ./deploy/container-entrypoint.sh ./scripts/runtime-launcher.mjs && mkdir -p /app/data && chown node:node /app/data && chmod 0770 /app/data
USER node
EXPOSE 3000 EXPOSE 3000
VOLUME ["/app/data"] VOLUME ["/app/data"]
ENTRYPOINT ["./deploy/container-entrypoint.sh"] ENTRYPOINT ["./deploy/container-entrypoint.sh"]
+33 -73
View File
@@ -17,8 +17,8 @@ Create or update the local database:
npm run db:migrate npm run db:migrate
``` ```
The default file is `data/shd-build.db`. Copy `.env.example` to `.env` and set a The Infisical `dev` environment sets the local file to
different location if needed. `./data/shd-build-development.db`.
Store custom avatars as metadata-free 256 × 256 WebP files. Set Store custom avatars as metadata-free 256 × 256 WebP files. Set
`TDR_AVATAR_STORAGE_PATH` to a persistent, writable directory in production; it `TDR_AVATAR_STORAGE_PATH` to a persistent, writable directory in production; it
@@ -30,9 +30,10 @@ Seed the initial local accounts once:
npm run db:seed-users npm run db:seed-users
``` ```
The seed creates administrator `Aewyn` and standard user `Quartermaster`. It The seed creates `Aewyn` and `Quartermaster` with the standard user role. It
generates and displays strong temporary passwords once, then stores only salted generates and displays strong temporary passwords once, then stores only salted
Argon2id hashes. Argon2id hashes. The separate bootstrap policy promotes the first authenticated
account when no administrator exists.
## Develop ## Develop
@@ -40,30 +41,28 @@ Argon2id hashes.
npm run dev npm run dev
``` ```
## Encrypted environment files ## Infisical secrets
Keep development, staging, and production settings in the committed, encrypted The committed `.infisical.json` connects the project to the self-hosted
`.env.development`, `.env.staging`, and `.env.production` files. Keep private Infisical instance at `https://secrets.jb.codes`. Authenticate the local CLI
decryption keys only in the ignored `.env.keys` file and back it up in a once. The CLI uses its configured system vault and can fall back to an encrypted
password manager. file vault when the operating-system keyring is unavailable.
```sh ```sh
npm run env:decrypt # decrypt all environment files for editing npx infisical login --domain=https://secrets.jb.codes
npm run env:encrypt # encrypt all environment files again npm run dev
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` and replaces staged plaintext Local commands use the `dev` environment. Application configuration lives under
environment files with encrypted versions. Git has no pre-add hook. Use `/app`; the scheduler bearer token lives under `/shared/scheduler`. Staging and
`npm run env:stage` instead of directly adding these two files. production commands select the `staging` and `prod` environments explicitly.
`npm run check`, `npm test`, and `npm run build` do not contact Infisical.
Keep `.env.keys` out of Git and retain a backup. Production deployments receive Staging and production containers authenticate at startup with separate
`DOTENV_PRIVATE_KEY_PRODUCTION` through their secret manager; do not copy the Universal Auth identities. Gitea stores each client secret, while repository
keys file to the server. variables hold the matching non-secret client IDs. The containers fetch current
values before migration or startup and stop if authentication or validation
fails.
## Containers and staging deployment ## Containers and staging deployment
@@ -72,23 +71,25 @@ The application uses the official SvelteKit Node adapter and a multi-stage
game-data icons under `/app/data` on a persistent volume. Inject runtime secrets game-data icons under `/app/data` on a persistent volume. Inject runtime secrets
when the container starts; keep them out of the image. when the container starts; keep them out of the image.
[docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) documents the staging Compose topology [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) documents the staging and production
and Gitea Actions deployment. A push to `main` validates the application, builds Compose topologies. A push to `main` triggers both deployment workflows. Each
the candidate image while staging stays online, stops the application and workflow validates Infisical access and the candidate image while the current
scheduler for the database backup and migration, then starts the release. service stays online, then stops its application and scheduler for backup and
migration.
## Passwordless authentication ## Passwordless authentication
Use passkeys as the preferred sign-in method. Set `APP_ORIGIN` to the exact Use passkeys as the preferred sign-in method. Set `APP_ORIGIN` to the exact
public origin for registration and authentication. Set `PASSKEY_RP_ID` to that public origin for registration and authentication. Set `PASSKEY_RP_ID` to that
hostname or a registrable parent domain. Hosted environments use `shd.build`, hostname or a registrable parent domain. Hosted environments use `shd.build`,
which permits credentials on `shd.build` and `dev.shd.build`. Use `localhost` which permits credentials on `shd.build`, `stage.shd.build`, and
for direct local development. `dev.shd.build`. Use `localhost` for direct local development.
Magic links require `SMTP_HOST`, `SMTP_PORT`, `SMTP_SECURE`, `SMTP_USER`, Magic links require `SMTP_HOST` and `SMTP_FROM`. `SMTP_PORT` defaults to `587`.
`SMTP_PASSWORD`, and `SMTP_FROM`. Use `SMTP_SECURE=true` on port 465. Use Set `SMTP_SECURE=true` for implicit TLS, normally on port 465; the default
`false` on port 587 and upgrade with STARTTLS. An account owner activates an `false` uses STARTTLS. If the SMTP server requires authentication, set both
email identity by following a 15-minute, single-use verification link. `SMTP_USER` and `SMTP_PASSWORD`. An account owner activates an email identity by
following a 15-minute, single-use verification link.
## Validate ## Validate
@@ -97,47 +98,6 @@ npm run check
npm run build npm run build
``` ```
[`sv`](https://github.com/sveltejs/cli) creates and configures Svelte projects.
## Creating a project
```sh
# create a new project
npx sv create my-app
```
To recreate this project with the same configuration:
```sh
# recreate this project
npx sv@0.16.5 create --template minimal --types ts --install npm .
```
## Developing
Install dependencies with `npm install` (or `pnpm install` or `yarn`), then
start a development server:
```sh
npm run dev
# or start the server and open the app in a new browser tab
npm run dev -- --open
```
## Building
Build a production version:
```sh
npm run build
```
Preview the production build with `npm run preview`.
> Deployments may require an [adapter](https://svelte.dev/docs/kit/adapters) for
> the target environment.
The shared visual language and dashboard conventions are documented in The shared visual language and dashboard conventions are documented in
[docs/DESIGN_SYSTEM.md](docs/DESIGN_SYSTEM.md). [docs/DESIGN_SYSTEM.md](docs/DESIGN_SYSTEM.md).
+28 -6
View File
@@ -7,23 +7,45 @@ if [ -z "$bundle_path" ] || [ ! -f "$bundle_path" ]; then
echo "Usage: bootstrap-production.sh <bootstrap-bundle.json> [deployment-directory]" >&2 echo "Usage: bootstrap-production.sh <bootstrap-bundle.json> [deployment-directory]" >&2
exit 2 exit 2
fi fi
bundle_path=$(readlink -f "$bundle_path")
compose_file="$deployment_directory/compose.production.yml" compose_file="$deployment_directory/compose.production.yml"
deploy_environment="$deployment_directory/.env.deploy" deploy_environment="$deployment_directory/.env.deploy"
volume_bundle_path=/app/data/.production-bootstrap-import.json
remove_volume_bundle=false
compose() { compose() {
docker compose --env-file "$deploy_environment" -f "$compose_file" "$@" docker compose --env-file "$deploy_environment" -f "$compose_file" "$@"
} }
restart_services() { cleanup() {
compose up -d --no-build app scheduler status=$?
trap - EXIT HUP INT TERM
set +e
if [ "$remove_volume_bundle" = true ]; then
compose run --rm --no-deps --entrypoint rm --user root app -f "$volume_bundle_path"
fi
if ! compose up -d --no-build app scheduler; then status=1; fi
exit "$status"
} }
trap restart_services EXIT trap cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
echo "Stopping production before the one-time database bootstrap." echo "Stopping production before the one-time database bootstrap."
compose stop scheduler app || true compose stop scheduler app
compose create app >/dev/null
app_container=$(compose ps --all --quiet app)
if [ -z "$app_container" ]; then
echo "The stopped production app container could not be found." >&2
exit 1
fi
docker cp "$bundle_path" "$app_container:$volume_bundle_path"
remove_volume_bundle=true
compose run --rm --no-deps --entrypoint sh --user root app -c \
'chown shd-runtime:node "$1" && chmod 600 "$1"' sh "$volume_bundle_path"
compose run --rm --no-deps \ compose run --rm --no-deps \
-e ALLOW_PRODUCTION_BOOTSTRAP=true \ -e ALLOW_PRODUCTION_BOOTSTRAP=true \
-v "$bundle_path:/import/production-bootstrap.json:ro" \
app node scripts/environment-bootstrap-import.mjs \ app node scripts/environment-bootstrap-import.mjs \
/import/production-bootstrap.json --target production --confirm-empty-target-replace "$volume_bundle_path" --target production --confirm-empty-target-replace
echo "Production bootstrap completed." echo "Production bootstrap completed."
+28 -6
View File
@@ -7,23 +7,45 @@ if [ -z "$bundle_path" ] || [ ! -f "$bundle_path" ]; then
echo "Usage: bootstrap-staging.sh <bootstrap-bundle.json> [deployment-directory]" >&2 echo "Usage: bootstrap-staging.sh <bootstrap-bundle.json> [deployment-directory]" >&2
exit 2 exit 2
fi fi
bundle_path=$(readlink -f "$bundle_path")
compose_file="$deployment_directory/compose.staging.yml" compose_file="$deployment_directory/compose.staging.yml"
deploy_environment="$deployment_directory/.env.deploy" deploy_environment="$deployment_directory/.env.deploy"
volume_bundle_path=/app/data/.staging-bootstrap-import.json
remove_volume_bundle=false
compose() { compose() {
docker compose --env-file "$deploy_environment" -f "$compose_file" "$@" docker compose --env-file "$deploy_environment" -f "$compose_file" "$@"
} }
restart_services() { cleanup() {
compose up -d --no-build app scheduler status=$?
trap - EXIT HUP INT TERM
set +e
if [ "$remove_volume_bundle" = true ]; then
compose run --rm --no-deps --entrypoint rm --user root app -f "$volume_bundle_path"
fi
if ! compose up -d --no-build app scheduler; then status=1; fi
exit "$status"
} }
trap restart_services EXIT trap cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
echo "Stopping staging before the one-time database bootstrap." echo "Stopping staging before the one-time database bootstrap."
compose stop scheduler app || true compose stop scheduler app
compose create app >/dev/null
app_container=$(compose ps --all --quiet app)
if [ -z "$app_container" ]; then
echo "The stopped staging app container could not be found." >&2
exit 1
fi
docker cp "$bundle_path" "$app_container:$volume_bundle_path"
remove_volume_bundle=true
compose run --rm --no-deps --entrypoint sh --user root app -c \
'chown shd-runtime:node "$1" && chmod 600 "$1"' sh "$volume_bundle_path"
compose run --rm --no-deps \ compose run --rm --no-deps \
-e ALLOW_STAGING_BOOTSTRAP=true \ -e ALLOW_STAGING_BOOTSTRAP=true \
-v "$bundle_path:/import/staging-bootstrap.json:ro" \
app node scripts/stage-bootstrap-import.mjs \ app node scripts/stage-bootstrap-import.mjs \
/import/staging-bootstrap.json --confirm-empty-staging-replace "$volume_bundle_path" --confirm-empty-staging-replace
echo "Staging bootstrap completed." echo "Staging bootstrap completed."
+43 -17
View File
@@ -5,26 +5,29 @@ services:
image: ${SHD_IMAGE:-shd-build-production:local} image: ${SHD_IMAGE:-shd-build-production:local}
restart: unless-stopped restart: unless-stopped
init: true init: true
env_file:
- .env.production.runtime
environment: environment:
HOST: 0.0.0.0 INFISICAL_PROJECT_ID: 2ee94b27-e599-4194-9797-3372d42e53f6
PORT: 3000 INFISICAL_ENVIRONMENT: prod
BODY_SIZE_LIMIT: 6M INFISICAL_RUNTIME_SCOPE: app
SKIP_DEFAULT_USERS: 'true' INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE: /run/secrets/infisical_client_id
TDR_DATABASE_PATH: /app/data/shd-build-production.db INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE: /run/secrets/infisical_client_secret
TDR_AVATAR_STORAGE_PATH: /app/data/avatars
TDR_GAME_ICON_STORAGE_PATH: /app/data/game-icons
SHD_BUILD_NUMBER: ${SHD_BUILD_NUMBER:-0} SHD_BUILD_NUMBER: ${SHD_BUILD_NUMBER:-0}
APP_ORIGIN: https://shd.build SHD_COMMIT_SHA: ${SHD_COMMIT_SHA:-}
ORIGIN: https://shd.build
PASSKEY_RP_ID: shd.build
DISCORD_REDIRECT_URI: https://shd.build/auth/discord/callback
UBISOFT_NEWS_IMPORT_ENABLED: 'true'
ports: ports:
- '0.0.0.0:${SHD_PRODUCTION_PORT:-3102}:3000' - '0.0.0.0:${SHD_PRODUCTION_PORT:-3102}:3000'
volumes: volumes:
- shd_production_data:/app/data - shd_production_data:/app/data
secrets:
- source: infisical_app_client_id
target: infisical_client_id
uid: '0'
gid: '0'
mode: 0400
- source: infisical_app_client_secret
target: infisical_client_secret
uid: '0'
gid: '0'
mode: 0400
healthcheck: healthcheck:
test: test:
- CMD - CMD
@@ -43,10 +46,23 @@ services:
init: true init: true
command: command:
- scheduler - scheduler
env_file:
- .env.production.runtime
environment: environment:
SCHEDULER_URL: http://app:3000/api/internal/scheduler INFISICAL_PROJECT_ID: 2ee94b27-e599-4194-9797-3372d42e53f6
INFISICAL_ENVIRONMENT: prod
INFISICAL_RUNTIME_SCOPE: scheduler
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE: /run/secrets/infisical_client_id
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE: /run/secrets/infisical_client_secret
secrets:
- source: infisical_scheduler_client_id
target: infisical_client_id
uid: '0'
gid: '0'
mode: 0400
- source: infisical_scheduler_client_secret
target: infisical_client_secret
uid: '0'
gid: '0'
mode: 0400
depends_on: depends_on:
app: app:
condition: service_healthy condition: service_healthy
@@ -55,3 +71,13 @@ services:
volumes: volumes:
shd_production_data: shd_production_data:
name: shd-build-production-data name: shd-build-production-data
secrets:
infisical_app_client_id:
file: .infisical.app.client-id
infisical_app_client_secret:
file: .infisical.app.client-secret
infisical_scheduler_client_id:
file: .infisical.scheduler.client-id
infisical_scheduler_client_secret:
file: .infisical.scheduler.client-secret
+43 -13
View File
@@ -5,22 +5,29 @@ services:
image: ${SHD_IMAGE:-shd-build-stage:local} image: ${SHD_IMAGE:-shd-build-stage:local}
restart: unless-stopped restart: unless-stopped
init: true init: true
env_file:
- .env.staging.runtime
environment: environment:
HOST: 0.0.0.0 INFISICAL_PROJECT_ID: 2ee94b27-e599-4194-9797-3372d42e53f6
PORT: 3000 INFISICAL_ENVIRONMENT: staging
BODY_SIZE_LIMIT: 6M INFISICAL_RUNTIME_SCOPE: app
SKIP_DEFAULT_USERS: 'true' INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE: /run/secrets/infisical_client_id
TDR_DATABASE_PATH: /app/data/shd-build-staging.db INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE: /run/secrets/infisical_client_secret
TDR_AVATAR_STORAGE_PATH: /app/data/avatars
TDR_GAME_ICON_STORAGE_PATH: /app/data/game-icons
SHD_BUILD_NUMBER: ${SHD_BUILD_NUMBER:-0} SHD_BUILD_NUMBER: ${SHD_BUILD_NUMBER:-0}
UBISOFT_NEWS_IMPORT_ENABLED: 'false' SHD_COMMIT_SHA: ${SHD_COMMIT_SHA:-}
ports: ports:
- '0.0.0.0:${SHD_STAGE_PORT:-5569}:3000' - '0.0.0.0:${SHD_STAGE_PORT:-5569}:3000'
volumes: volumes:
- shd_stage_data:/app/data - shd_stage_data:/app/data
secrets:
- source: infisical_app_client_id
target: infisical_client_id
uid: '0'
gid: '0'
mode: 0400
- source: infisical_app_client_secret
target: infisical_client_secret
uid: '0'
gid: '0'
mode: 0400
healthcheck: healthcheck:
test: test:
- CMD - CMD
@@ -39,10 +46,23 @@ services:
init: true init: true
command: command:
- scheduler - scheduler
env_file:
- .env.staging.runtime
environment: environment:
SCHEDULER_URL: http://app:3000/api/internal/scheduler INFISICAL_PROJECT_ID: 2ee94b27-e599-4194-9797-3372d42e53f6
INFISICAL_ENVIRONMENT: staging
INFISICAL_RUNTIME_SCOPE: scheduler
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE: /run/secrets/infisical_client_id
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE: /run/secrets/infisical_client_secret
secrets:
- source: infisical_scheduler_client_id
target: infisical_client_id
uid: '0'
gid: '0'
mode: 0400
- source: infisical_scheduler_client_secret
target: infisical_client_secret
uid: '0'
gid: '0'
mode: 0400
depends_on: depends_on:
app: app:
condition: service_healthy condition: service_healthy
@@ -51,3 +71,13 @@ services:
volumes: volumes:
shd_stage_data: shd_stage_data:
name: shd-build-stage-data name: shd-build-stage-data
secrets:
infisical_app_client_id:
file: .infisical.app.client-id
infisical_app_client_secret:
file: .infisical.app.client-secret
infisical_scheduler_client_id:
file: .infisical.scheduler.client-id
infisical_scheduler_client_secret:
file: .infisical.scheduler.client-secret
+111 -6
View File
@@ -1,13 +1,118 @@
#!/bin/sh #!/bin/sh
set -eu set -eu
if [ "${1:-}" = "serve" ]; then project_id=${INFISICAL_PROJECT_ID:?INFISICAL_PROJECT_ID is required}
node scripts/db-migrate.mjs environment=${INFISICAL_ENVIRONMENT:?INFISICAL_ENVIRONMENT is required}
exec node build runtime_scope=${INFISICAL_RUNTIME_SCOPE:?INFISICAL_RUNTIME_SCOPE is required}
client_id_file=${INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE:?INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE is required}
client_secret_file=${INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE:?INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE is required}
build_number=${SHD_BUILD_NUMBER:-0}
commit_sha=${SHD_COMMIT_SHA:-}
secret_file=
shared_secret_file=
cleanup() {
if [ -n "$secret_file" ]; then rm -f "$secret_file"; fi
if [ -n "$shared_secret_file" ]; then rm -f "$shared_secret_file"; fi
}
trap cleanup EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
if [ ! -r "$client_id_file" ] || [ ! -r "$client_secret_file" ]; then
echo "Infisical Universal Auth credential files are not readable." >&2
exit 1
fi fi
if [ "${1:-}" = "scheduler" ]; then if [ "$(id -u)" -eq 0 ]; then
exec node scripts/scheduler-client.mjs runtime_uid=$(id -u shd-runtime)
for credential_file in "$client_id_file" "$client_secret_file"; do
credential_uid=$(stat -c '%u' "$credential_file")
credential_mode=$(stat -c '%a' "$credential_file")
if [ "$credential_uid" -eq "$runtime_uid" ] || [ $((credential_mode % 100)) -ne 0 ]; then
echo "Infisical credential files must not be readable by the application runtime user." >&2
exit 1
fi
done
if [ "$runtime_scope" = app ] && ! { [ "${1:-}" = node ] && [ "${2:-}" = scripts/validate-runtime-environment.mjs ]; }; then
chgrp -R node /app/data
chmod -R g+rwX /app/data
fi
fi fi
exec "$@" client_id=$(cat "$client_id_file")
client_secret=$(cat "$client_secret_file")
if [ -z "$client_id" ] || [ -z "$client_secret" ]; then
echo "Infisical Universal Auth credential files must not be empty." >&2
exit 1
fi
unset INFISICAL_TOKEN INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN TOKEN
access_token=$(
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID="$client_id" \
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET="$client_secret" \
infisical login --method=universal-auth --silent --plain
)
unset client_id client_secret
if [ -z "$access_token" ]; then
echo "Infisical Universal Auth returned an empty access token." >&2
exit 1
fi
umask 077
secret_file=$(mktemp /tmp/shd-infisical-app.XXXXXX)
case "$runtime_scope" in
app)
INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN="$access_token" infisical export \
--projectId="$project_id" \
--env="$environment" \
--path=/app \
--format=json \
--output-file="$secret_file" \
--silent
shared_secret_file=$(mktemp /tmp/shd-infisical-shared.XXXXXX)
INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN="$access_token" infisical export \
--projectId="$project_id" \
--env="$environment" \
--path=/shared/scheduler \
--format=json \
--output-file="$shared_secret_file" \
--silent
;;
scheduler)
INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN="$access_token" infisical export \
--projectId="$project_id" \
--env="$environment" \
--path=/shared/scheduler \
--format=json \
--output-file="$secret_file" \
--silent
;;
*)
echo "Unsupported INFISICAL_RUNTIME_SCOPE: $runtime_scope" >&2
exit 2
;;
esac
unset access_token \
INFISICAL_TOKEN \
INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN \
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID \
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET
if [ "$runtime_scope" = app ]; then
if [ "$(id -u)" -eq 0 ]; then
chown shd-runtime:node "$secret_file" "$shared_secret_file"
exec gosu shd-runtime node scripts/runtime-launcher.mjs app "$environment" "$build_number" "$commit_sha" "$secret_file" "$shared_secret_file" -- "$@"
fi
exec node scripts/runtime-launcher.mjs app "$environment" "$build_number" "$commit_sha" "$secret_file" "$shared_secret_file" -- "$@"
fi
if [ "$(id -u)" -eq 0 ]; then
chown shd-runtime:node "$secret_file"
exec gosu shd-runtime node scripts/runtime-launcher.mjs scheduler "$environment" "$build_number" "$commit_sha" "$secret_file" -- "$@"
fi
exec node scripts/runtime-launcher.mjs scheduler "$environment" "$build_number" "$commit_sha" "$secret_file" -- "$@"
+131 -25
View File
@@ -4,48 +4,154 @@ set -eu
deployment_directory=${1:-/opt/shd-build-production} deployment_directory=${1:-/opt/shd-build-production}
source_directory=${2:-} source_directory=${2:-}
candidate_image=${3:-} candidate_image=${3:-}
if [ -z "$source_directory" ] || [ ! -f "$source_directory/Dockerfile" ] || [ -z "$candidate_image" ]; then candidate_directory=${4:-}
echo "Usage: deploy-production.sh <deployment-directory> <source-directory> <candidate-image>" >&2 if [ -z "$source_directory" ] || [ ! -f "$source_directory/Dockerfile" ] || [ -z "$candidate_image" ] || [ ! -f "$candidate_directory/compose.production.yml" ] || [ ! -f "$candidate_directory/.env.deploy" ]; then
echo "Usage: deploy-production.sh <deployment-directory> <source-directory> <candidate-image> <candidate-directory>" >&2
exit 2 exit 2
fi fi
compose_file="$deployment_directory/compose.production.yml" case "$candidate_directory" in
deploy_environment="$deployment_directory/.env.deploy" "$deployment_directory"/candidate-*) ;;
*) echo "Candidate directory must be a candidate-* child of the deployment directory." >&2; exit 2 ;;
esac
candidate_compose_file="$candidate_directory/compose.production.yml"
candidate_environment="$candidate_directory/.env.deploy"
canonical_compose_file="$deployment_directory/compose.production.yml"
canonical_environment="$deployment_directory/.env.deploy"
previous_directory="$deployment_directory/.previous-release"
healthy_marker="$deployment_directory/.deployment-healthy"
release_files="compose.production.yml .env.deploy .infisical.app.client-id .infisical.app.client-secret .infisical.scheduler.client-id .infisical.scheduler.client-secret deploy-production.sh bootstrap-production.sh sync-production-isac-to-staging.sh"
rollback_required=false
candidate_promoted=false
had_previous_release=false
compose() { candidate_compose() {
docker compose --env-file "$deploy_environment" -f "$compose_file" "$@" docker compose --env-file "$candidate_environment" -f "$candidate_compose_file" "$@"
} }
canonical_compose() {
docker compose --env-file "$canonical_environment" -f "$canonical_compose_file" "$@"
}
release_is_healthy() {
health_environment=$1
health_compose_file=$2
health_attempt=0
while [ "$health_attempt" -lt 30 ]; do
health_app_container=$(docker compose --env-file "$health_environment" -f "$health_compose_file" ps -q app)
health_scheduler_container=$(docker compose --env-file "$health_environment" -f "$health_compose_file" ps -q scheduler)
health_app_status=missing
health_scheduler_status=missing
if [ -n "$health_app_container" ]; then
health_app_status=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$health_app_container" 2>/dev/null || echo missing)
fi
if [ -n "$health_scheduler_container" ]; then
health_scheduler_status=$(docker inspect --format '{{.State.Status}}' "$health_scheduler_container" 2>/dev/null || echo missing)
fi
if [ "$health_app_status" = healthy ] && [ "$health_scheduler_status" = running ]; then return 0; fi
if [ "$health_app_status" = unhealthy ] || [ "$health_app_status" = exited ] || [ "$health_app_status" = dead ] || [ "$health_scheduler_status" = exited ] || [ "$health_scheduler_status" = dead ]; then return 1; fi
health_attempt=$((health_attempt + 1))
sleep 2
done
return 1
}
copy_release() {
copy_source=$1
copy_target=$2
copy_status=0
for release_file in $release_files; do
if [ -f "$copy_source/$release_file" ]; then
copy_temporary=$(mktemp "$copy_target/.release.XXXXXX") || return 1
if cp -p "$copy_source/$release_file" "$copy_temporary"; then
mv "$copy_temporary" "$copy_target/$release_file" || copy_status=1
else
rm -f "$copy_temporary"
copy_status=1
fi
else
rm -f "$copy_target/$release_file" || copy_status=1
fi
done
return "$copy_status"
}
cleanup_candidate() {
case "$candidate_directory" in
"$deployment_directory"/candidate-*) rm -rf "$candidate_directory" ;;
esac
}
rollback() {
rollback_exit_status=$?
trap - EXIT HUP INT TERM
set +e
if [ "$rollback_required" = true ]; then
echo "Restoring the previous healthy production release." >&2
restore_status=0
candidate_compose stop scheduler app >/dev/null 2>&1 || restore_status=1
if [ "$candidate_promoted" = true ]; then copy_release "$previous_directory" "$deployment_directory" || restore_status=1; fi
if [ "$had_previous_release" = true ]; then
canonical_compose up -d --no-build app scheduler || restore_status=1
if [ "$restore_status" -eq 0 ] && ! release_is_healthy "$canonical_environment" "$canonical_compose_file"; then restore_status=1; fi
else
echo "No previous production release is available." >&2
restore_status=1
fi
if [ "$restore_status" -ne 0 ]; then
echo "The previous production release could not be restored." >&2
if [ "$rollback_exit_status" -eq 0 ]; then rollback_exit_status=1; fi
fi
fi
cleanup_candidate
exit "$rollback_exit_status"
}
trap rollback EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
echo "Building the candidate image while production remains online." echo "Building the candidate image while production remains online."
docker build --label org.opencontainers.image.title=shd-build -t "$candidate_image" "$source_directory" docker build --label org.opencontainers.image.title=shd-build -t "$candidate_image" "$source_directory"
echo "Validating Infisical access and the candidate runtime configuration."
candidate_compose run --rm --no-deps app node scripts/validate-runtime-environment.mjs app
candidate_compose run --rm --no-deps scheduler node scripts/validate-runtime-environment.mjs scheduler
rm -rf "$previous_directory"
mkdir -p "$previous_directory"
if [ -f "$canonical_compose_file" ] && [ -f "$canonical_environment" ]; then
had_previous_release=true
copy_release "$deployment_directory" "$previous_directory"
fi
echo "Stopping only SHD production processes before accessing the embedded database." echo "Stopping only SHD production processes before accessing the embedded database."
compose stop scheduler app || true rollback_required=true
candidate_compose stop scheduler app
echo "Creating a timestamped production backup when the database exists." echo "Creating a timestamped production backup when the database exists."
compose run --rm --no-deps app sh -c \ candidate_compose run --rm --no-deps app sh -c \
'if [ -f "$TDR_DATABASE_PATH" ]; then node scripts/db-backup.mjs; else echo "No production database exists yet; skipping backup."; fi' 'if [ -f "$TDR_DATABASE_PATH" ]; then node scripts/db-backup.mjs; else echo "No production database exists yet; skipping backup."; fi'
echo "Applying additive schema migrations." echo "Applying additive schema migrations."
compose run --rm --no-deps app node scripts/db-migrate.mjs candidate_compose run --rm --no-deps app node scripts/db-migrate.mjs
candidate_promoted=true
copy_release "$candidate_directory" "$deployment_directory"
echo "Starting production." echo "Starting production."
compose up -d --no-build --remove-orphans app scheduler canonical_compose up -d --no-build --remove-orphans app scheduler
container_id=$(compose ps -q app) if release_is_healthy "$canonical_environment" "$canonical_compose_file"; then
attempt=0 echo "SHD production deployment is healthy."
while [ "$attempt" -lt 30 ]; do rollback_required=false
status=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id") umask 077
if [ "$status" = "healthy" ]; then : > "$healthy_marker"
echo "SHD production deployment is healthy." cleanup_candidate
exit 0 trap - EXIT HUP INT TERM
fi exit 0
if [ "$status" = "unhealthy" ] || [ "$status" = "exited" ] || [ "$status" = "dead" ]; then fi
break
fi
attempt=$((attempt + 1))
sleep 2
done
echo "SHD production failed its health check." echo "SHD production failed its health check."
compose logs --tail=150 app canonical_compose logs --tail=150 app scheduler
exit 1 exit 1
+131 -25
View File
@@ -4,48 +4,154 @@ set -eu
deployment_directory=${1:-/opt/shd-build-stage} deployment_directory=${1:-/opt/shd-build-stage}
source_directory=${2:-} source_directory=${2:-}
candidate_image=${3:-} candidate_image=${3:-}
if [ -z "$source_directory" ] || [ ! -f "$source_directory/Dockerfile" ] || [ -z "$candidate_image" ]; then candidate_directory=${4:-}
echo "Usage: deploy-staging.sh <deployment-directory> <source-directory> <candidate-image>" >&2 if [ -z "$source_directory" ] || [ ! -f "$source_directory/Dockerfile" ] || [ -z "$candidate_image" ] || [ ! -f "$candidate_directory/compose.staging.yml" ] || [ ! -f "$candidate_directory/.env.deploy" ]; then
echo "Usage: deploy-staging.sh <deployment-directory> <source-directory> <candidate-image> <candidate-directory>" >&2
exit 2 exit 2
fi fi
compose_file="$deployment_directory/compose.staging.yml" case "$candidate_directory" in
deploy_environment="$deployment_directory/.env.deploy" "$deployment_directory"/candidate-*) ;;
*) echo "Candidate directory must be a candidate-* child of the deployment directory." >&2; exit 2 ;;
esac
candidate_compose_file="$candidate_directory/compose.staging.yml"
candidate_environment="$candidate_directory/.env.deploy"
canonical_compose_file="$deployment_directory/compose.staging.yml"
canonical_environment="$deployment_directory/.env.deploy"
previous_directory="$deployment_directory/.previous-release"
healthy_marker="$deployment_directory/.deployment-healthy"
release_files="compose.staging.yml .env.deploy .infisical.app.client-id .infisical.app.client-secret .infisical.scheduler.client-id .infisical.scheduler.client-secret deploy-staging.sh bootstrap-staging.sh"
rollback_required=false
candidate_promoted=false
had_previous_release=false
compose() { candidate_compose() {
docker compose --env-file "$deploy_environment" -f "$compose_file" "$@" docker compose --env-file "$candidate_environment" -f "$candidate_compose_file" "$@"
} }
canonical_compose() {
docker compose --env-file "$canonical_environment" -f "$canonical_compose_file" "$@"
}
release_is_healthy() {
health_environment=$1
health_compose_file=$2
health_attempt=0
while [ "$health_attempt" -lt 30 ]; do
health_app_container=$(docker compose --env-file "$health_environment" -f "$health_compose_file" ps -q app)
health_scheduler_container=$(docker compose --env-file "$health_environment" -f "$health_compose_file" ps -q scheduler)
health_app_status=missing
health_scheduler_status=missing
if [ -n "$health_app_container" ]; then
health_app_status=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$health_app_container" 2>/dev/null || echo missing)
fi
if [ -n "$health_scheduler_container" ]; then
health_scheduler_status=$(docker inspect --format '{{.State.Status}}' "$health_scheduler_container" 2>/dev/null || echo missing)
fi
if [ "$health_app_status" = healthy ] && [ "$health_scheduler_status" = running ]; then return 0; fi
if [ "$health_app_status" = unhealthy ] || [ "$health_app_status" = exited ] || [ "$health_app_status" = dead ] || [ "$health_scheduler_status" = exited ] || [ "$health_scheduler_status" = dead ]; then return 1; fi
health_attempt=$((health_attempt + 1))
sleep 2
done
return 1
}
copy_release() {
copy_source=$1
copy_target=$2
copy_status=0
for release_file in $release_files; do
if [ -f "$copy_source/$release_file" ]; then
copy_temporary=$(mktemp "$copy_target/.release.XXXXXX") || return 1
if cp -p "$copy_source/$release_file" "$copy_temporary"; then
mv "$copy_temporary" "$copy_target/$release_file" || copy_status=1
else
rm -f "$copy_temporary"
copy_status=1
fi
else
rm -f "$copy_target/$release_file" || copy_status=1
fi
done
return "$copy_status"
}
cleanup_candidate() {
case "$candidate_directory" in
"$deployment_directory"/candidate-*) rm -rf "$candidate_directory" ;;
esac
}
rollback() {
rollback_exit_status=$?
trap - EXIT HUP INT TERM
set +e
if [ "$rollback_required" = true ]; then
echo "Restoring the previous healthy staging release." >&2
restore_status=0
candidate_compose stop scheduler app >/dev/null 2>&1 || restore_status=1
if [ "$candidate_promoted" = true ]; then copy_release "$previous_directory" "$deployment_directory" || restore_status=1; fi
if [ "$had_previous_release" = true ]; then
canonical_compose up -d --no-build app scheduler || restore_status=1
if [ "$restore_status" -eq 0 ] && ! release_is_healthy "$canonical_environment" "$canonical_compose_file"; then restore_status=1; fi
else
echo "No previous staging release is available." >&2
restore_status=1
fi
if [ "$restore_status" -ne 0 ]; then
echo "The previous staging release could not be restored." >&2
if [ "$rollback_exit_status" -eq 0 ]; then rollback_exit_status=1; fi
fi
fi
cleanup_candidate
exit "$rollback_exit_status"
}
trap rollback EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 143' TERM
echo "Building the candidate image while the current staging service remains online." 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" docker build --label org.opencontainers.image.title=shd-build -t "$candidate_image" "$source_directory"
echo "Validating Infisical access and the candidate runtime configuration."
candidate_compose run --rm --no-deps app node scripts/validate-runtime-environment.mjs app
candidate_compose run --rm --no-deps scheduler node scripts/validate-runtime-environment.mjs scheduler
rm -rf "$previous_directory"
mkdir -p "$previous_directory"
if [ -f "$canonical_compose_file" ] && [ -f "$canonical_environment" ]; then
had_previous_release=true
copy_release "$deployment_directory" "$previous_directory"
fi
echo "Stopping SHD staging processes before accessing the embedded database." echo "Stopping SHD staging processes before accessing the embedded database."
compose stop scheduler app || true rollback_required=true
candidate_compose stop scheduler app
echo "Creating a timestamped database backup when a staging database exists." echo "Creating a timestamped database backup when a staging database exists."
compose run --rm --no-deps app sh -c \ candidate_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' '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." echo "Applying additive schema migrations."
compose run --rm --no-deps app node scripts/db-migrate.mjs candidate_compose run --rm --no-deps app node scripts/db-migrate.mjs
candidate_promoted=true
copy_release "$candidate_directory" "$deployment_directory"
echo "Starting the application and notification scheduler." echo "Starting the application and notification scheduler."
compose up -d --no-build --remove-orphans app scheduler canonical_compose up -d --no-build --remove-orphans app scheduler
container_id=$(compose ps -q app) if release_is_healthy "$canonical_environment" "$canonical_compose_file"; then
attempt=0 echo "SHD staging deployment is healthy."
while [ "$attempt" -lt 30 ]; do rollback_required=false
status=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$container_id") umask 077
if [ "$status" = "healthy" ]; then : > "$healthy_marker"
echo "SHD staging deployment is healthy." cleanup_candidate
exit 0 trap - EXIT HUP INT TERM
fi exit 0
if [ "$status" = "unhealthy" ] || [ "$status" = "exited" ] || [ "$status" = "dead" ]; then fi
break
fi
attempt=$((attempt + 1))
sleep 2
done
echo "SHD staging failed its health check." echo "SHD staging failed its health check."
compose logs --tail=150 app canonical_compose logs --tail=150 app scheduler
exit 1 exit 1
+393
View File
@@ -0,0 +1,393 @@
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import {
copyFile,
mkdir,
mkdtemp,
readFile,
rm,
writeFile,
} from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
const repositoryRoot = resolve(import.meta.dirname, '..');
const schedulerToken = 'scheduler-token-with-at-least-32-characters';
const infisicalStub = `#!/bin/sh
set -eu
case "\${1:-}" in
login)
printf '%s\n' "$@" > "$STUB_LOGIN_LOG"
if [ "\${INFISICAL_UNIVERSAL_AUTH_CLIENT_ID:-}" != "$STUB_EXPECTED_CLIENT_ID" ] || \
[ "\${INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET:-}" != "$STUB_EXPECTED_CLIENT_SECRET" ]; then
printf '%s\n' 'credentials=incorrect' >> "$STUB_LOGIN_LOG"
exit 26
fi
printf '%s\n' 'credentials=ok' >> "$STUB_LOGIN_LOG"
case "\${STUB_LOGIN_RESULT:-success}" in
fail) exit 23 ;;
empty) exit 0 ;;
*) printf '%s\n' stub-access-token ;;
esac
;;
export)
[ "\${INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN:-}" = stub-access-token ] || exit 27
secret_path=
output_file=
for argument in "$@"; do
case "$argument" in
--path=*) secret_path=\${argument#--path=} ;;
--output-file=*) output_file=\${argument#--output-file=} ;;
esac
done
[ -n "$secret_path" ] && [ -n "$output_file" ] || exit 28
printf '%s|%s\n' "$secret_path" "$output_file" >> "$STUB_EXPORT_LOG"
case "$secret_path" in
/app) cp "$STUB_APP_EXPORT" "$output_file" ;;
/shared/scheduler) cp "$STUB_SHARED_EXPORT" "$output_file" ;;
*) exit 29 ;;
esac
;;
*) exit 25 ;;
esac
`;
const environmentCapture = `#!/bin/sh
env > "$CAPTURE_ENV_PATH"
`;
const gosuStub = `#!/bin/sh
set -eu
[ "\${1:-}" = shd-runtime ] || exit 30
printf '%s\n' 'target=shd-runtime' > "$STUB_GOSU_LOG"
shift
exec "$@"
`;
const idStub = `#!/bin/sh
if [ "\${1:-}" = -u ] && [ "\${2:-}" = shd-runtime ]; then
printf '%s\n' 10001
exit 0
fi
exec /usr/bin/id "$@"
`;
const chownStub = `#!/bin/sh
exit 0
`;
describe('container runtime startup', () => {
let temporaryRoot: string;
let clientIdFile: string;
let clientSecretFile: string;
let loginLog: string;
let exportLog: string;
let gosuLog: string;
let capturedEnvironment: string;
let appExport: string;
let sharedExport: string;
beforeEach(async () => {
temporaryRoot = await mkdtemp(join(tmpdir(), 'shd-runtime-start-'));
const deployDirectory = join(temporaryRoot, 'deploy');
const scriptsDirectory = join(temporaryRoot, 'scripts');
await mkdir(deployDirectory);
await mkdir(scriptsDirectory);
await Promise.all([
copyFile(
join(repositoryRoot, 'deploy/container-entrypoint.sh'),
join(deployDirectory, 'container-entrypoint.sh'),
),
copyFile(
join(repositoryRoot, 'scripts/runtime-launcher.mjs'),
join(scriptsDirectory, 'runtime-launcher.mjs'),
),
copyFile(
join(repositoryRoot, 'scripts/runtime-environment.js'),
join(scriptsDirectory, 'runtime-environment.js'),
),
]);
clientIdFile = join(temporaryRoot, 'client-id');
clientSecretFile = join(temporaryRoot, 'client-secret');
loginLog = join(temporaryRoot, 'login.log');
exportLog = join(temporaryRoot, 'export.log');
gosuLog = join(temporaryRoot, 'gosu.log');
capturedEnvironment = join(temporaryRoot, 'child.env');
appExport = join(temporaryRoot, 'app-export.json');
sharedExport = join(temporaryRoot, 'shared-export.json');
await Promise.all([
writeFile(clientIdFile, 'universal-client-id\n', { mode: 0o600 }),
writeFile(clientSecretFile, 'universal-client-secret\n', { mode: 0o600 }),
writeFile(join(temporaryRoot, 'infisical'), infisicalStub, {
mode: 0o755,
}),
writeFile(
join(temporaryRoot, 'capture-environment'),
environmentCapture,
{
mode: 0o755,
},
),
writeFile(join(temporaryRoot, 'gosu'), gosuStub, { mode: 0o755 }),
writeFile(join(temporaryRoot, 'id'), idStub, { mode: 0o755 }),
writeFile(join(temporaryRoot, 'chown'), chownStub, { mode: 0o755 }),
writeExport(appExport, [
{ key: 'APP_ORIGIN', value: 'https://exported.invalid' },
{ key: 'DISCORD_CLIENT_ID', value: 'exported-discord-client' },
{ key: 'DISCORD_CLIENT_SECRET', value: 'exported-discord-secret' },
]),
writeExport(sharedExport, [
{ key: 'SCHEDULER_TOKEN', value: schedulerToken },
]),
]);
});
afterEach(async () => {
await rm(temporaryRoot, { recursive: true, force: true });
});
function runtimeEnvironment(
scope: 'app' | 'scheduler',
): Record<string, string> {
return {
PATH: `${temporaryRoot}:${process.env.PATH ?? ''}`,
INFISICAL_PROJECT_ID: 'project-id',
INFISICAL_ENVIRONMENT: 'staging',
INFISICAL_RUNTIME_SCOPE: scope,
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE: clientIdFile,
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE: clientSecretFile,
INFISICAL_UNIVERSAL_AUTH_CLIENT_ID: 'bootstrap-client-id',
INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET: 'bootstrap-client-secret',
INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN: 'bootstrap-access-token',
INFISICAL_TOKEN: 'bootstrap-token',
TOKEN: 'generic-bootstrap-token',
STUB_EXPECTED_CLIENT_ID: 'universal-client-id',
STUB_EXPECTED_CLIENT_SECRET: 'universal-client-secret',
STUB_LOGIN_LOG: loginLog,
STUB_EXPORT_LOG: exportLog,
STUB_GOSU_LOG: gosuLog,
STUB_APP_EXPORT: appExport,
STUB_SHARED_EXPORT: sharedExport,
CAPTURE_ENV_PATH: capturedEnvironment,
SHD_BUILD_NUMBER: '42',
SHD_COMMIT_SHA: 'abc123',
APP_ORIGIN: 'https://parent.invalid',
SCHEDULER_URL: 'https://parent.invalid/scheduler',
BASH_ENV: '',
CDPATH: temporaryRoot,
ENV: '',
IFS: 'hostile-ifs',
LD_LIBRARY_PATH: temporaryRoot,
LD_PRELOAD: '',
NODE_OPTIONS: '--no-warnings',
NODE_PATH: temporaryRoot,
PYTHONPATH: temporaryRoot,
};
}
function start(environment: Record<string, string>) {
return spawnSync(
'/bin/sh',
['./deploy/container-entrypoint.sh', 'capture-environment'],
{
cwd: temporaryRoot,
env: environment,
encoding: 'utf8',
},
);
}
it('exports app and shared secrets into a scrubbed app child environment', async () => {
const result = start(runtimeEnvironment('app'));
expect(result.status, result.stderr).toBe(0);
expect(await exportPaths()).toEqual(['/app', '/shared/scheduler']);
expect(await readFile(loginLog, 'utf8')).toBe(
'login\n--method=universal-auth\n--silent\n--plain\ncredentials=ok\n',
);
const childEnvironment = parseEnvironment(
await readFile(capturedEnvironment, 'utf8'),
);
expect(childEnvironment).toMatchObject({
APP_ORIGIN: 'https://stage.shd.build',
DISCORD_CLIENT_ID: 'exported-discord-client',
DISCORD_CLIENT_SECRET: 'exported-discord-secret',
ORIGIN: 'https://stage.shd.build',
PASSKEY_RP_ID: 'shd.build',
SCHEDULER_TOKEN: schedulerToken,
SHD_BUILD_NUMBER: '42',
SHD_COMMIT_SHA: 'abc123',
TDR_AVATAR_STORAGE_PATH: '/app/data/avatars',
TDR_DATABASE_PATH: '/app/data/shd-build-staging.db',
TDR_GAME_ICON_STORAGE_PATH: '/app/data/game-icons',
});
for (const name of removedEnvironmentNames) {
expect(childEnvironment).not.toHaveProperty(name);
}
await expectSecretFilesRemoved();
await expectRootPrivilegeDrop();
});
it('exports only shared secrets into the scheduler child environment', async () => {
const result = start(runtimeEnvironment('scheduler'));
expect(result.status, result.stderr).toBe(0);
expect(await exportPaths()).toEqual(['/shared/scheduler']);
expect(
parseEnvironment(await readFile(capturedEnvironment, 'utf8')),
).toMatchObject({
SCHEDULER_TOKEN: schedulerToken,
SCHEDULER_URL: 'http://app:3000/api/internal/scheduler',
});
await expectSecretFilesRemoved();
await expectRootPrivilegeDrop();
});
it('fails closed on an unknown secret before launching the child', async () => {
await writeExport(appExport, [
{ key: 'DISCORD_CLIENT_ID', value: 'exported-discord-client' },
{ key: 'UNKNOWN_SECRET', value: 'must-not-reach-child' },
]);
const result = start(runtimeEnvironment('app'));
expect(result.status).not.toBe(0);
expect(result.stderr).toContain(
'Infisical secret UNKNOWN_SECRET is not allowed in this runtime scope.',
);
expect(existsSync(capturedEnvironment)).toBe(false);
await expectSecretFilesRemoved();
});
it('fails closed when a credential file setting is missing', () => {
const environment = runtimeEnvironment('app');
delete environment.INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE;
const result = start(environment);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain(
'INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE is required',
);
expect(existsSync(exportLog)).toBe(false);
expect(existsSync(capturedEnvironment)).toBe(false);
});
it('fails closed when either credential is empty', async () => {
for (const credentialFile of [clientIdFile, clientSecretFile]) {
await writeFile(credentialFile, '');
const result = start(runtimeEnvironment('app'));
expect(result.status).not.toBe(0);
expect(result.stderr).toContain('credential files must not be empty');
expect(existsSync(exportLog)).toBe(false);
expect(existsSync(capturedEnvironment)).toBe(false);
await writeFile(
credentialFile,
credentialFile === clientIdFile
? 'universal-client-id\n'
: 'universal-client-secret\n',
);
}
});
it.each(['fail', 'empty'])(
'fails closed when login returns %s',
(loginResult) => {
const result = start({
...runtimeEnvironment('app'),
STUB_LOGIN_RESULT: loginResult,
});
expect(result.status).not.toBe(0);
expect(existsSync(exportLog)).toBe(false);
expect(existsSync(capturedEnvironment)).toBe(false);
},
);
it('fails closed on a malformed export and removes every secret file', async () => {
await writeFile(appExport, '{not-json');
const result = start(runtimeEnvironment('app'));
expect(result.status).not.toBe(0);
expect(result.stderr).toContain(
'Infisical returned an unreadable secret export.',
);
expect(existsSync(capturedEnvironment)).toBe(false);
await expectSecretFilesRemoved();
});
async function exportPaths() {
return (await exportRecords()).map(([path]) => path);
}
async function expectSecretFilesRemoved() {
for (const [, file] of await exportRecords()) {
expect(existsSync(file)).toBe(false);
}
}
async function exportRecords() {
return (await readFile(exportLog, 'utf8'))
.trim()
.split('\n')
.map((line) => line.split('|'));
}
async function expectRootPrivilegeDrop() {
if (typeof process.getuid === 'function' && process.getuid() === 0) {
expect(await readFile(gosuLog, 'utf8')).toBe('target=shd-runtime\n');
} else {
expect(existsSync(gosuLog)).toBe(false);
}
}
});
const removedEnvironmentNames = [
'BASH_ENV',
'CDPATH',
'ENV',
'IFS',
'INFISICAL_ENVIRONMENT',
'INFISICAL_PROJECT_ID',
'INFISICAL_RUNTIME_SCOPE',
'INFISICAL_TOKEN',
'INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN',
'INFISICAL_UNIVERSAL_AUTH_CLIENT_ID',
'INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE',
'INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET',
'INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE',
'LD_LIBRARY_PATH',
'LD_PRELOAD',
'NODE_OPTIONS',
'NODE_PATH',
'PYTHONPATH',
'TOKEN',
];
function writeExport(
file: string,
entries: Array<{ key: string; value: string }>,
) {
return writeFile(file, `${JSON.stringify(entries)}\n`);
}
function parseEnvironment(value: string) {
return Object.fromEntries(
value
.trim()
.split('\n')
.map((line) => {
const separator = line.indexOf('=');
return [line.slice(0, separator), line.slice(separator + 1)];
}),
);
}
+46 -10
View File
@@ -14,7 +14,11 @@ production_environment="$production_directory/.env.deploy"
staging_compose_file="$staging_directory/compose.staging.yml" staging_compose_file="$staging_directory/compose.staging.yml"
staging_environment="$staging_directory/.env.deploy" staging_environment="$staging_directory/.env.deploy"
snapshot_name="isac-production-$(date -u +%Y-%m-%dT%H-%M-%SZ).json" snapshot_name="isac-production-$(date -u +%Y-%m-%dT%H-%M-%SZ).json"
snapshot_path="/tmp/$snapshot_name" snapshot_path=$(mktemp "/tmp/$snapshot_name.XXXXXX")
production_snapshot_path="/app/data/exports/$snapshot_name"
staging_snapshot_path="/app/data/$snapshot_name"
remove_production_snapshot=false
remove_staging_snapshot=false
production_compose() { production_compose() {
docker compose --env-file "$production_environment" -f "$production_compose_file" "$@" docker compose --env-file "$production_environment" -f "$production_compose_file" "$@"
@@ -22,22 +26,54 @@ production_compose() {
staging_compose() { staging_compose() {
docker compose --env-file "$staging_environment" -f "$staging_compose_file" "$@" docker compose --env-file "$staging_environment" -f "$staging_compose_file" "$@"
} }
restart_services() { cleanup() {
production_compose up -d --no-build app scheduler || true exit_status=$?
staging_compose up -d --no-build app scheduler || true trap - EXIT
rm -f "$snapshot_path" if [ "$remove_production_snapshot" = true ]; then
production_compose run --rm --no-deps app rm -f "$production_snapshot_path" || true
fi
if [ "$remove_staging_snapshot" = true ]; then
staging_compose run --rm --no-deps app rm -f "$staging_snapshot_path" || true
fi
rm -f "$snapshot_path" || true
if ! production_compose up -d --no-build app scheduler; then
if [ "$exit_status" -eq 0 ]; then exit_status=1; fi
fi
if ! staging_compose up -d --no-build app scheduler; then
if [ "$exit_status" -eq 0 ]; then exit_status=1; fi
fi
exit "$exit_status"
} }
trap restart_services EXIT trap cleanup EXIT
echo "Stopping production writers for a consistent canonical export." echo "Stopping production writers for a consistent canonical export."
production_compose stop scheduler app production_compose stop scheduler app
production_compose run --rm --no-deps -v "/tmp:/export" app \ production_app_container=$(production_compose ps --all --quiet app)
node scripts/isac-data-export.mjs "/export/$snapshot_name" if [ -z "$production_app_container" ]; then
echo "The stopped production app container could not be found." >&2
exit 1
fi
remove_production_snapshot=true
production_compose run --rm --no-deps app \
node scripts/isac-data-export.mjs "$production_snapshot_path"
docker cp "$production_app_container:$production_snapshot_path" "$snapshot_path"
production_compose run --rm --no-deps app rm -f "$production_snapshot_path"
remove_production_snapshot=false
production_compose up -d --no-build app scheduler production_compose up -d --no-build app scheduler
echo "Stopping staging writers before its automatic full and ISAC safety backups." echo "Stopping staging writers before its automatic full and ISAC safety backups."
staging_compose stop scheduler app staging_compose stop scheduler app
staging_compose run --rm --no-deps -v "/tmp:/import:ro" app \ staging_app_container=$(staging_compose ps --all --quiet app)
node scripts/isac-data-import.mjs "/import/$snapshot_name" --confirm-replace-isac-data if [ -z "$staging_app_container" ]; then
echo "The stopped staging app container could not be found." >&2
exit 1
fi
remove_staging_snapshot=true
docker cp "$snapshot_path" "$staging_app_container:$staging_snapshot_path"
staging_compose run --rm --no-deps app \
node scripts/isac-data-import.mjs "$staging_snapshot_path" --confirm-replace-isac-data
staging_compose run --rm --no-deps app rm -f "$staging_snapshot_path"
remove_staging_snapshot=false
rm -f "$snapshot_path"
echo "Production ISAC data was safely synchronized into staging." echo "Production ISAC data was safely synchronized into staging."
+149 -73
View File
@@ -2,8 +2,8 @@
SHD.build runs in a Node 24 container through the official SvelteKit Node SHD.build runs in a Node 24 container through the official SvelteKit Node
adapter. The image includes application code, migration and notification adapter. The image includes application code, migration and notification
scripts, and native runtime dependencies. Environment files, databases, backups, scripts, the Infisical CLI, and native runtime dependencies. Databases, backups,
and user uploads remain outside the image. user uploads, and Universal Auth bootstrap credentials remain outside the image.
## Persistent data ## Persistent data
@@ -30,7 +30,7 @@ Staging defaults to:
- Adapter canonical origin: `https://stage.shd.build` - Adapter canonical origin: `https://stage.shd.build`
Guide ratings and reports use Cloudflare Turnstile after repeated anonymous Guide ratings and reports use Cloudflare Turnstile after repeated anonymous
activity. Configure both values in each encrypted environment: activity. Configure both values under `/app` in each Infisical environment:
```text ```text
TURNSTILE_SITE_KEY=<public site key> TURNSTILE_SITE_KEY=<public site key>
@@ -44,12 +44,13 @@ and the request address to Cloudflare for verification; it never exposes
valid secret, a visitor who reaches the challenge threshold cannot submit more valid secret, a visitor who reaches the challenge threshold cannot submit more
anonymous ratings or Guide reports. anonymous ratings or Guide reports.
Edit its encrypted configuration locally: Edit staging configuration in Infisical project
`2ee94b27-e599-4194-9797-3372d42e53f6`:
```sh ```text
npm run env:decrypt:staging Environment: staging
# Fill in Discord and SMTP values. Application path: /app
npm run env:encrypt:staging Scheduler path: /shared/scheduler
``` ```
Add `https://stage.shd.build/auth/discord/callback` to the Discord application's Add `https://stage.shd.build/auth/discord/callback` to the Discord application's
@@ -59,9 +60,43 @@ and configure the host's TLS reverse proxy to forward `stage.shd.build` to port
a containerized reverse proxy or tunnel can reach it; firewall the port to a containerized reverse proxy or tunnel can reach it; firewall the port to
trusted LAN/tunnel sources when the host is reachable from other networks. trusted LAN/tunnel sources when the host is reachable from other networks.
The trusted staging runner decrypts `.env.staging` into a mode-`0600` runtime The trusted staging runner writes separate mode-`0600` Universal Auth client-ID
file. Gitea Actions holds the decryption key; Git and the container image do and client-secret files for the app and scheduler. Compose mounts each pair only
not. into its assigned service. Each container exchanges those credentials for a
short-lived access token and writes the assigned paths to mode-`0600` temporary
JSON files. The entrypoint rejects unknown secret names, deletes the token,
transfers the exports to the dedicated `shd-runtime` UID 10001, and drops root
before parsing them. The launcher deletes the files before validation,
migration, or startup. Startup fails if the mounted credential files are
readable by UID 10001 or by their group or other users. Runtime-created data
retains group access for rollback to the previous container UID.
| Machine identity | Environment | Injected paths | Gitea variable | Gitea secret |
| -------------------------------- | ----------- | --------------------------- | ------------------------------------------ | ---------------------------------------------- |
| `shd-build-staging-app` | `staging` | `/app`, `/shared/scheduler` | `INFISICAL_STAGING_APP_CLIENT_ID` | `INFISICAL_STAGING_APP_CLIENT_SECRET` |
| `shd-build-staging-scheduler` | `staging` | `/shared/scheduler` | `INFISICAL_STAGING_SCHEDULER_CLIENT_ID` | `INFISICAL_STAGING_SCHEDULER_CLIENT_SECRET` |
| `shd-build-production-app` | `prod` | `/app`, `/shared/scheduler` | `INFISICAL_PRODUCTION_APP_CLIENT_ID` | `INFISICAL_PRODUCTION_APP_CLIENT_SECRET` |
| `shd-build-production-scheduler` | `prod` | `/shared/scheduler` | `INFISICAL_PRODUCTION_SCHEDULER_CLIENT_ID` | `INFISICAL_PRODUCTION_SCHEDULER_CLIENT_SECRET` |
The self-hosted Free plan allows five machine identities and does not include
Custom Project Roles. The four runtime identities use the built-in Viewer role,
which can read the complete project. Service-specific paths limit injected
values, but they do not restrict what a stolen root credential could request.
Use an Enterprise license with Custom Project Roles, or split runtime scopes
into separate projects, before treating identity authorization as least
privilege.
An Infisical outage does not stop a running container. A new container or direct
restart fails closed until `https://secrets.jb.codes` becomes available and the
identity can authenticate.
Keep the old `.env.*.runtime` files and `DOTENV_PRIVATE_KEY_*` Gitea secrets
only through the first successful staging and production cutovers. The rollback
path may need them to start the previous image. After both environments pass
direct restart checks, rotate every migrated provider credential in Infisical,
remove the old dotenv private keys and `SCHEDULER_TOKEN_PRODUCTION` from Gitea,
and delete local and hosted dotenv key material. Removing encrypted files from
the current Git tree does not revoke values stored in Git history.
The staging container disables default-user seeding. On a brand-new staging The staging container disables default-user seeding. On a brand-new staging
database, the existing bootstrap policy grants Admin to the first real account database, the existing bootstrap policy grants Admin to the first real account
@@ -99,31 +134,51 @@ Restart the runner session after adding it to the Docker group.
In `https://git.jb.codes/jb/shd.build/settings/actions`: In `https://git.jb.codes/jb/shd.build/settings/actions`:
1. Add repository secret `DOTENV_PRIVATE_KEY_STAGING`, using the matching value 1. Add repository secrets `INFISICAL_STAGING_APP_CLIENT_SECRET` and
from the ignored local `.env.keys`. `INFISICAL_STAGING_SCHEDULER_CLIENT_SECRET`.
2. Optionally add repository variable `STAGE_DEPLOY_PATH`. It defaults to 2. Add repository variables `INFISICAL_STAGING_APP_CLIENT_ID` and
`INFISICAL_STAGING_SCHEDULER_CLIENT_ID`.
3. Optionally add repository variable `STAGE_DEPLOY_PATH`. It defaults to
`/opt/shd-build-stage`. `/opt/shd-build-stage`.
3. Optionally add repository variable `STAGE_PORT`. It defaults to `5569`. 4. Optionally add repository variable `STAGE_PORT`. It defaults to `5569`.
4. Ensure Actions are enabled and the `stage-deploy` runner is online. 5. Ensure Actions are enabled and the `stage-deploy` runner is online.
`.gitea/workflows/deploy-staging.yml` runs for pushes to `main` and manual `.gitea/workflows/deploy-staging.yml` runs for pushes to `main` and manual
dispatches. The trusted staging runner builds each commit-tagged image locally, dispatches. The trusted staging runner builds each image locally with a tag that
so the workflow needs no registry credentials. Gitea assigns `gitea.run_number` contains the commit, workflow run, and attempt, so the workflow needs no
to `SHD_BUILD_NUMBER`; each new workflow run receives the next integer, while registry credentials and a retry cannot overwrite the active image. Gitea
retries of the same run retain their number. assigns `gitea.run_number` to `SHD_BUILD_NUMBER`; each new workflow run receives
the next integer, while retries of the same run retain their number.
Staging and production deployments, ISAC exports, and production-to-staging
synchronization share the `shd-build-host-maintenance` concurrency group. Gitea
runs only one of these database maintenance workflows on the deployment host at
a time and never cancels one in progress.
## Deployment lifecycle ## Deployment lifecycle
The workflow: The workflow stages Compose, credentials, and release metadata in an isolated
candidate directory. It leaves the canonical deployment files unchanged until
the candidate passes preflight, backup, and migration. The workflow then:
1. installs dependencies, runs `npm run check`, and produces the Node build; 1. installs dependencies, runs `npm run check` and `npm test`, and produces the
Node build;
2. builds the candidate container while the current site remains online; 2. builds the candidate container while the current site remains online;
3. stops only the staging application and notification scheduler; 3. starts one-off app and scheduler containers to verify Infisical access and
4. creates a timestamped JSON backup if the staging database exists; validate the candidate environment;
5. applies additive, repeatable migrations; 4. stops only the staging application and notification scheduler;
6. starts the application and scheduler with the new image; 5. creates a timestamped JSON backup if the staging database exists;
7. waits for `/api/health` to report healthy; 6. applies additive, repeatable migrations in a one-off container;
8. retains the logs and exits unsuccessfully if health verification fails. 7. promotes the candidate files to the canonical deployment paths;
8. starts the application and scheduler with the new image;
9. validates the app environment and reruns the repeatable migration in the app
entrypoint before serving;
10. verifies `/api/health` and scheduler liveness;
11. restores the previous canonical files and verifies the previous app and
scheduler after a failed stop, backup, migration, startup, or health check.
On a failed health check, the deploy script prints the final 150 application log
lines and exits unsuccessfully. It does not create a separate log artifact.
The outage covers backup, migration, and container startup. Migrations may also The outage covers backup, migration, and container startup. Migrations may also
remove records that use retired data formats when a release explicitly ends remove records that use retired data formats when a release explicitly ends
@@ -145,11 +200,10 @@ feed remain published; the importer performs no automatic removal. The scheduler
runs notification delivery before it starts the bounded Ubisoft request, so a runs notification delivery before it starts the bounded Ubisoft request, so a
slow provider does not postpone notifications for the current tick. slow provider does not postpone notifications for the current tick.
`deploy/compose.production.yml` declares `UBISOFT_NEWS_IMPORT_ENABLED=true`, The runtime launcher sets `UBISOFT_NEWS_IMPORT_ENABLED=true` for production and
while the staging Compose file declares `false`. Development does not set the `false` for staging. Development does not set the flag, so the importer remains
flag and remains disabled because the importer requires the exact value `true`. disabled because it requires the exact value `true`. The application process
The application process evaluates the flag when it handles evaluates the flag when it handles `/api/internal/scheduler`.
`/api/internal/scheduler`.
## Prelaunch operation ## Prelaunch operation
@@ -216,10 +270,11 @@ channel, keep it out of Git, then copy it to the staging host and run:
/secure/path/shd-stage-bootstrap-YYYY-MM-DDTHH-MM-SSZ.json /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 The bootstrap flow requires both services to stop, copies the bundle into the
target, verifies that staging is unused, imports the entire development database private data volume, assigns it to the runtime UID, creates a safety backup,
and uploads, and restarts both services. Delete the transfer copy from the verifies that staging is unused, imports the entire development database and
staging host after verifying the imported record counts and critical entities. uploads, deletes the volume copy, and restarts both services. Delete the host
transfer copy after verifying the imported record counts and critical entities.
The copy under the local ignored backup directory remains the recovery source. The copy under the local ignored backup directory remains the recovery source.
Because this is a full initial clone, development session tokens and account Because this is a full initial clone, development session tokens and account
@@ -279,16 +334,21 @@ environment, port, and deployment workflow:
- Database: `/app/data/shd-build-production.db` - Database: `/app/data/shd-build-production.db`
- Persistent volume: `shd-build-production-data` - Persistent volume: `shd-build-production-data`
Routine pushes leave production data unchanged. The production deploy stops its Routine pushes preserve production data unless an included migration removes a
application and scheduler, creates a timestamped JSON backup, applies repeatable retired prototype format. The production deploy stops its application and
migrations, restarts, and verifies database health. Keep scheduler, creates a timestamped JSON backup, applies repeatable migrations,
`docker compose down --volumes` away from production. restarts, and verifies database health. Keep `docker compose down --volumes`
away from production.
Add these repository Action secrets: Add these repository Action secrets:
- `DOTENV_PRIVATE_KEY_PRODUCTION`, matching the ignored local `.env.keys` - `INFISICAL_PRODUCTION_APP_CLIENT_SECRET`
- `SCHEDULER_TOKEN_PRODUCTION`, a long random value used only by the internal - `INFISICAL_PRODUCTION_SCHEDULER_CLIENT_SECRET`
scheduler endpoint
Add the matching repository variables:
- `INFISICAL_PRODUCTION_APP_CLIENT_ID`
- `INFISICAL_PRODUCTION_SCHEDULER_CLIENT_ID`
Optional repository variables are `PRODUCTION_DEPLOY_PATH` (default Optional repository variables are `PRODUCTION_DEPLOY_PATH` (default
`/opt/shd-build-production`) and `PRODUCTION_PORT` (default `3102`). The `/opt/shd-build-production`) and `PRODUCTION_PORT` (default `3102`). The
@@ -299,12 +359,12 @@ workflow run number supplies the production `SHD_BUILD_NUMBER`.
Prepare the host directory once from an interactive SSH session: Prepare the host directory once from an interactive SSH session:
```sh ```sh
sudo install -d -o jb -g jb -m 0750 /opt/shd-build-production sudo install -d -o act_runner -g act_runner -m 0750 /opt/shd-build-production
``` ```
Generate the scheduler secret locally without printing it into a shared log, Store a separate production `SCHEDULER_TOKEN` under Infisical path
then enter it directly in Gitea's repository-secret form. Do not commit it or `/shared/scheduler`. The application and scheduler fetch the same value from
reuse the staging scheduler token. that path. Do not reuse the staging token.
### Initial production bootstrap ### Initial production bootstrap
@@ -322,9 +382,11 @@ Copy the resulting ignored JSON bundle to the deployment host over SSH, then:
/secure/path/shd-production-bootstrap-YYYY-MM-DDTHH-MM-SSZ.json /secure/path/shd-production-bootstrap-YYYY-MM-DDTHH-MM-SSZ.json
``` ```
The importer verifies the bundle target, creates a production safety backup, and The bootstrap script requires both services to stop, stages the bundle in the
refuses to replace a production database containing users, builds, audit events, private data volume with runtime-only access, and removes that copy after the
or proposals. Remove the remote transfer copy after verification. import. The importer verifies the bundle target, creates a production safety
backup, and refuses to replace a production database containing users, builds,
audit events, or proposals. Remove the remote host copy after verification.
### Promote one News article ### Promote one News article
@@ -340,13 +402,14 @@ managed-image metadata, and image bytes. It excludes comments, reports, user
records, Builds, and ISAC data. records, Builds, and ISAC data.
After deploying the matching application schema, stop the production app and After deploying the matching application schema, stop the production app and
scheduler and mount the transferred bundle into a one-off application container. scheduler. Copy the transferred bundle into `/app/data` with `docker cp`, assign
Run `scripts/article-transfer.mjs import` with `ARTICLE_IMPORT_ENABLED=true` and it to `shd-runtime:node` with mode `0600` through a root one-off container, then
run `scripts/article-transfer.mjs import` with `ARTICLE_IMPORT_ENABLED=true` and
`--confirm-article-import`. The importer creates a production database backup `--confirm-article-import`. The importer creates a production database backup
before opening the database, resolves the article owner by stable ID or before opening the database, resolves the article owner by stable ID or
username, rejects ID and slug conflicts, verifies image checksums, and appends username, rejects ID and slug conflicts, verifies image checksums, and appends
`editorial_content / news.promoted`. Restart and health-check both services, `editorial_content / news.promoted`. Remove both bundle copies, restart the app
then remove the transferred bundle from the host. and scheduler, and health-check both services.
### Pull production ISAC data into development ### Pull production ISAC data into development
@@ -373,11 +436,11 @@ safety backups before replacement. Deployments do not run this flow.
Image Library assets use stable IDs and can be promoted additively from local Image Library assets use stable IDs and can be promoted additively from local
development. Export a bundle after finishing local uploads and metadata edits: development. Export a bundle after finishing local uploads and metadata edits:
Set the server-only `REMOVE_BG_API_KEY` in each encrypted environment that Set the server-only `REMOVE_BG_API_KEY` under `/app` in each Infisical
accepts ISAC uploads. It is sent only from the server in the `X-Api-Key` header. environment that accepts ISAC uploads. The server sends it only in the
Transparent icon uploads skip remove.bg. Opaque sources use its preview output, `X-Api-Key` header. Transparent icon uploads skip remove.bg. Opaque sources use
then pass through local trimming, white normalization, WebP conversion, and its preview output, then pass through local trimming, white normalization, WebP
storage. conversion, and storage.
The application stores a remove.bg capacity snapshot and refreshes it from the The application stores a remove.bg capacity snapshot and refreshes it from the
internal scheduler. `REMOVE_BG_CREDIT_CHECK_HOURS` controls the account lookup internal scheduler. `REMOVE_BG_CREDIT_CHECK_HOURS` controls the account lookup
@@ -399,19 +462,33 @@ embedded database is unlocked, and run the merge using the target image:
```sh ```sh
# Staging # Staging
cd /opt/shd-build-stage cd /opt/shd-build-stage
docker compose -f compose.staging.yml stop scheduler app docker compose --env-file .env.deploy -f compose.staging.yml stop scheduler app
docker compose -f compose.staging.yml run --rm \ docker compose --env-file .env.deploy -f compose.staging.yml create app
-v /secure/path:/import:ro app \ app_container=$(docker compose --env-file .env.deploy -f compose.staging.yml ps --all --quiet app)
node scripts/managed-images-import.mjs /import/managed-images.json --confirm-merge docker cp /secure/path/managed-images.json "$app_container:/app/data/.managed-images-import.json"
docker compose -f compose.staging.yml up -d app scheduler docker compose --env-file .env.deploy -f compose.staging.yml run --rm --no-deps \
--entrypoint sh --user root app -c \
'chown shd-runtime:node /app/data/.managed-images-import.json && chmod 600 /app/data/.managed-images-import.json'
docker compose --env-file .env.deploy -f compose.staging.yml run --rm --no-deps app \
node scripts/managed-images-import.mjs /app/data/.managed-images-import.json --confirm-merge
docker compose --env-file .env.deploy -f compose.staging.yml run --rm --no-deps \
--entrypoint rm --user root app -f /app/data/.managed-images-import.json
docker compose --env-file .env.deploy -f compose.staging.yml up -d app scheduler
# Production # Production
cd /opt/shd-build-production cd /opt/shd-build-production
docker compose -f compose.production.yml stop scheduler app docker compose --env-file .env.deploy -f compose.production.yml stop scheduler app
docker compose -f compose.production.yml run --rm \ docker compose --env-file .env.deploy -f compose.production.yml create app
-v /secure/path:/import:ro app \ app_container=$(docker compose --env-file .env.deploy -f compose.production.yml ps --all --quiet app)
node scripts/managed-images-import.mjs /import/managed-images.json --confirm-merge docker cp /secure/path/managed-images.json "$app_container:/app/data/.managed-images-import.json"
docker compose -f compose.production.yml up -d app scheduler docker compose --env-file .env.deploy -f compose.production.yml run --rm --no-deps \
--entrypoint sh --user root app -c \
'chown shd-runtime:node /app/data/.managed-images-import.json && chmod 600 /app/data/.managed-images-import.json'
docker compose --env-file .env.deploy -f compose.production.yml run --rm --no-deps app \
node scripts/managed-images-import.mjs /app/data/.managed-images-import.json --confirm-merge
docker compose --env-file .env.deploy -f compose.production.yml run --rm --no-deps \
--entrypoint rm --user root app -f /app/data/.managed-images-import.json
docker compose --env-file .env.deploy -f compose.production.yml up -d app scheduler
``` ```
Each import creates a full target database backup before changes. The importer Each import creates a full target database backup before changes. The importer
@@ -422,9 +499,8 @@ After reviewing an intentional metadata or file replacement, add
verifying both environments because it may contain unpublished visual assets and verifying both environments because it may contain unpublished visual assets and
creator identifiers. creator identifiers.
The `.env.staging` and `.env.production` npm import commands are intended for The staging and production npm import commands fetch `/app` from Infisical. Run
maintenance performed directly in an environment where those database and them only where the target database and storage paths are mounted:
storage paths are mounted:
```sh ```sh
npm run images:import:staging -- /path/to/bundle.json --confirm-merge npm run images:import:staging -- /path/to/bundle.json --confirm-merge
+7 -15
View File
@@ -911,12 +911,9 @@ The notification router sends Data Change review items to agents who hold
delivery for each authorized queue. Submission creates Immediate alerts; delivery for each authorized queue. Submission creates Immediate alerts;
`oversight_notification_queue` stores consolidated alerts. `oversight_notification_queue` stores consolidated alerts.
Production must run the idempotent dispatcher once per minute from one scheduler The production Compose scheduler calls the internal scheduler endpoint once per
instance: minute. That endpoint runs the idempotent dispatcher in the application process,
which owns the embedded-database connection. Do not run a second host cron job.
```text
* * * * * cd /path/to/shd.build && npm run notifications:oversight:production
```
The dispatcher groups due rows by reviewer, queue, cadence, and delivery window. The dispatcher groups due rows by reviewer, queue, cadence, and delivery window.
Daily rows use the next 10:00 AM wall-clock boundary in `America/New_York`, Daily rows use the next 10:00 AM wall-clock boundary in `America/New_York`,
@@ -941,12 +938,7 @@ default welcome introduces News, Database, Builds, Guides, Builder, ISAC
Contributor Mode, and the SHD Discord community. Contributor Mode, and the SHD Discord community.
The registration request delivers Immediate definitions. The registration request delivers Immediate definitions.
`automated_system_message_deliveries` stores definitions with a delay. `automated_system_message_deliveries` stores definitions with a delay. The same
Production must run the delayed dispatcher once per minute: Compose scheduler tick runs the delayed dispatcher. Only one scheduler instance
should call the embedded-database application. Delivered rows retain both their
```text delivery timestamp and generated notification ID.
* * * * * cd /path/to/shd.build && npm run notifications:system:production
```
Only one dispatcher instance should run for the embedded database. Delivered
rows retain both their delivery timestamp and generated notification ID.
+101 -55
View File
@@ -24,7 +24,7 @@
"sharp": "^0.35.3" "sharp": "^0.35.3"
}, },
"devDependencies": { "devDependencies": {
"@dotenvx/dotenvx": "^2.17.4", "@infisical/cli": "0.43.116",
"@sveltejs/adapter-node": "^5.5.7", "@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",
@@ -41,38 +41,6 @@
"vitest": "^4.1.10" "vitest": "^4.1.10"
} }
}, },
"node_modules/@dotenvx/dotenvx": {
"version": "2.17.4",
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-2.17.4.tgz",
"integrity": "sha512-hzW9xsf9bOUoY1V7xXnl+vZfjqdsdUs0zSOWzm6Zqfcm55nq3YaV+lXmeSzT3ju7xQ6fdSlLjwDQFqrIsMV3wA==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"@dotenvx/primitives": "^2.1.0",
"@dotenvx/tooling": "^1.0.3",
"yocto-spinner": "^1.2.1"
},
"bin": {
"dotenvx": "src/cli/dotenvx.js"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/@dotenvx/primitives": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@dotenvx/primitives/-/primitives-2.1.0.tgz",
"integrity": "sha512-GIpMSSgjZk4URRCtAw12vBmobnw3VeSKL+p7/y8ypWvTHFW+StAMZvq4wCjdX+QKuw4alXu0IMGW6QPhKo6ufg==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@dotenvx/tooling": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@dotenvx/tooling/-/tooling-1.0.3.tgz",
"integrity": "sha512-Gd6qQol/ICb4MPRWpiIH56y+pR9AVB7kPBkbhrLSGLtAiF4Qgy+k651/f2pze0Xbm3H5XewbPfIm4y9K+ic30Q==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
@@ -636,6 +604,33 @@
"url": "https://opencollective.com/libvips" "url": "https://opencollective.com/libvips"
} }
}, },
"node_modules/@infisical/cli": {
"version": "0.43.116",
"resolved": "https://registry.npmjs.org/@infisical/cli/-/cli-0.43.116.tgz",
"integrity": "sha512-RV2vOPrCfML7c2Qnw84N/nxn31f7gMEjw32Bv7ZrNfuXDOB1H3DtW3PAC6APXcjEEDRgSczr4MxH1k4HkuZt6w==",
"dev": true,
"hasInstallScript": true,
"dependencies": {
"tar": "^7.5.13",
"yauzl": "^3.2.0"
},
"bin": {
"infisical": "bin/infisical"
}
},
"node_modules/@isaacs/fs-minipass": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
"dev": true,
"license": "ISC",
"dependencies": {
"minipass": "^7.0.4"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -2561,6 +2556,16 @@
"url": "https://paulmillr.com/funding/" "url": "https://paulmillr.com/funding/"
} }
}, },
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"node_modules/cli-cursor": { "node_modules/cli-cursor": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
@@ -3314,6 +3319,29 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/minizlib": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"minipass": "^7.1.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/mri": { "node_modules/mri": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz", "resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -3481,6 +3509,13 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/pend": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz",
"integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==",
"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",
@@ -4141,6 +4176,23 @@
"typescript": ">=5.0.0" "typescript": ">=5.0.0"
} }
}, },
"node_modules/tar": {
"version": "7.5.22",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
"integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
"minipass": "^7.1.2",
"minizlib": "^3.1.0",
"yallist": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/tinybench": { "node_modules/tinybench": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -4502,6 +4554,16 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/yallist": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=18"
}
},
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.9.0", "version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
@@ -4518,33 +4580,17 @@
"url": "https://github.com/sponsors/eemeli" "url": "https://github.com/sponsors/eemeli"
} }
}, },
"node_modules/yocto-spinner": { "node_modules/yauzl": {
"version": "1.2.2", "version": "3.4.0",
"resolved": "https://registry.npmjs.org/yocto-spinner/-/yocto-spinner-1.2.2.tgz", "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-3.4.0.tgz",
"integrity": "sha512-DODGl1wJjA/s5pnJFKau9lIYHT81lnhob1i3e1TjxZRxEhWRKl74nTbWE6H5KlkViQQTo/Z29YFdxzTZAMY3ng==", "integrity": "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"yoctocolors": "^2.1.1" "pend": "~1.2.0"
}, },
"engines": { "engines": {
"node": ">=18.19" "node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/yoctocolors": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz",
"integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/zimmerframe": { "node_modules/zimmerframe": {
+32 -42
View File
@@ -4,52 +4,42 @@
"version": "0.0.1", "version": "0.0.1",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "dotenvx run -f .env.development -- vite dev --host 0.0.0.0", "dev": "infisical run --env=dev --path=/app --path=/shared/scheduler -- vite dev --host 0.0.0.0",
"build:app": "vite build", "build:app": "vite build",
"build:staging": "dotenvx run -f .env.staging -- vite build", "build:staging": "vite build",
"build": "dotenvx run -f .env.production -- vite build", "build": "vite build",
"brand:render": "node scripts/render-brand-assets.mjs", "brand:render": "node scripts/render-brand-assets.mjs",
"start": "node build", "start": "node build",
"preview": "node build", "preview": "node build",
"db:migrate": "dotenvx run -f .env.development -- node scripts/db-migrate.mjs", "db:migrate": "infisical run --env=dev --path=/app --path=/shared/scheduler -- node scripts/db-migrate.mjs",
"db:migrate:staging": "dotenvx run -f .env.staging -- node scripts/db-migrate.mjs", "db:migrate:staging": "infisical run --env=staging --path=/app --path=/shared/scheduler -- node scripts/db-migrate.mjs",
"db:migrate:production": "dotenvx run -f .env.production -- node scripts/db-migrate.mjs", "db:migrate:production": "infisical run --env=prod --path=/app --path=/shared/scheduler -- node scripts/db-migrate.mjs",
"db:seed-users": "dotenvx run -f .env.development -- node scripts/seed-initial-users.mjs", "db:seed-users": "infisical run --env=dev --path=/app -- 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": "infisical run --env=dev --path=/app -- node scripts/seed-initial-users.mjs --reset-passwords",
"db:backup": "dotenvx run -f .env.development -- node scripts/db-backup.mjs", "db:backup": "infisical run --env=dev --path=/app -- node scripts/db-backup.mjs",
"db:backup:staging": "dotenvx run -f .env.staging -- node scripts/db-backup.mjs", "db:backup:staging": "infisical run --env=staging --path=/app -- node scripts/db-backup.mjs",
"db:backup:production": "dotenvx run -f .env.production -- node scripts/db-backup.mjs", "db:backup:production": "infisical run --env=prod --path=/app -- node scripts/db-backup.mjs",
"db:restore": "dotenvx run -f .env.development -- node scripts/db-restore.mjs", "db:restore": "infisical run --env=dev --path=/app -- node scripts/db-restore.mjs",
"db:restore:production": "dotenvx run -f .env.production -- node scripts/db-restore.mjs", "db:restore:production": "infisical run --env=prod --path=/app -- node scripts/db-restore.mjs",
"article:export": "dotenvx run -f .env.development -- node scripts/article-transfer.mjs export", "article:export": "infisical run --env=dev --path=/app -- node scripts/article-transfer.mjs export",
"isac:export": "dotenvx run -f .env.development -- node scripts/isac-data-export.mjs", "isac:export": "infisical run --env=dev --path=/app -- node scripts/isac-data-export.mjs",
"isac:import": "dotenvx run -f .env.development -- node scripts/isac-data-import.mjs", "isac:import": "infisical run --env=dev --path=/app -- node scripts/isac-data-import.mjs",
"isac:attributes:import": "dotenvx run -f .env.development -- node scripts/import-resurgence-attributes.mjs", "isac:attributes:import": "infisical run --env=dev --path=/app -- node scripts/import-resurgence-attributes.mjs",
"isac:export:staging": "dotenvx run -f .env.staging -- node scripts/isac-data-export.mjs", "isac:export:staging": "infisical run --env=staging --path=/app -- node scripts/isac-data-export.mjs",
"images:export": "dotenvx run -f .env.development -- node scripts/managed-images-export.mjs", "images:export": "infisical run --env=dev --path=/app -- node scripts/managed-images-export.mjs",
"images:import": "dotenvx run -f .env.development -- node scripts/managed-images-import.mjs", "images:import": "infisical run --env=dev --path=/app -- node scripts/managed-images-import.mjs",
"images:import:staging": "dotenvx run -f .env.staging -- node scripts/managed-images-import.mjs", "images:import:staging": "infisical run --env=staging --path=/app -- node scripts/managed-images-import.mjs",
"images:import:production": "dotenvx run -f .env.production -- node scripts/managed-images-import.mjs", "images:import:production": "infisical run --env=prod --path=/app -- node scripts/managed-images-import.mjs",
"stage:bootstrap:export": "dotenvx run -f .env.development -- node scripts/stage-bootstrap-export.mjs", "stage:bootstrap:export": "infisical run --env=dev --path=/app -- node scripts/stage-bootstrap-export.mjs",
"production:bootstrap:export": "dotenvx run -f .env.development -- node scripts/environment-bootstrap-export.mjs production", "production:bootstrap:export": "infisical run --env=dev --path=/app -- node scripts/environment-bootstrap-export.mjs production",
"notifications:digest": "dotenvx run -f .env.development -- node scripts/notification-digest.mjs", "notifications:digest": "infisical run --env=dev --path=/app -- node scripts/notification-digest.mjs",
"notifications:digest:production": "dotenvx run -f .env.production -- node scripts/notification-digest.mjs", "notifications:digest:production": "infisical run --env=prod --path=/app -- node scripts/notification-digest.mjs",
"notifications:oversight": "dotenvx run -f .env.development -- node scripts/oversight-notification-dispatch.mjs", "notifications:oversight": "infisical run --env=dev --path=/app -- node scripts/oversight-notification-dispatch.mjs",
"notifications:oversight:production": "dotenvx run -f .env.production -- node scripts/oversight-notification-dispatch.mjs", "notifications:oversight:production": "infisical run --env=prod --path=/app -- node scripts/oversight-notification-dispatch.mjs",
"notifications:system": "dotenvx run -f .env.development -- node scripts/system-notification-dispatch.mjs", "notifications:system": "infisical run --env=dev --path=/app -- node scripts/system-notification-dispatch.mjs",
"notifications:system:production": "dotenvx run -f .env.production -- node scripts/system-notification-dispatch.mjs", "notifications:system:production": "infisical run --env=prod --path=/app -- node scripts/system-notification-dispatch.mjs",
"notifications:seed-test": "dotenvx run -f .env.development -- node scripts/seed-test-notifications.mjs", "notifications:seed-test": "infisical run --env=dev --path=/app -- node scripts/seed-test-notifications.mjs",
"assets:trim-icons": "node scripts/trim-static-icons.mjs", "assets:trim-icons": "node scripts/trim-static-icons.mjs",
"env:encrypt": "node scripts/env-files.mjs encrypt",
"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",
"prepare": "node scripts/prepare.mjs", "prepare": "node scripts/prepare.mjs",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"test": "vitest run", "test": "vitest run",
@@ -59,7 +49,7 @@
"**/*": "prettier --write --ignore-unknown" "**/*": "prettier --write --ignore-unknown"
}, },
"devDependencies": { "devDependencies": {
"@dotenvx/dotenvx": "^2.17.4", "@infisical/cli": "0.43.116",
"@sveltejs/adapter-node": "^5.5.7", "@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",
-35
View File
@@ -1,35 +0,0 @@
import { spawnSync } from 'node:child_process';
import { resolve } from 'node:path';
import process from 'node:process';
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');
function run(command, args) {
const result = spawnSync(command, args, {
cwd: process.cwd(),
stdio: 'inherit',
});
if (result.error) throw result.error;
if (result.status !== 0) process.exit(result.status ?? 1);
}
function dotenvx(command, file) {
run(process.execPath, [dotenvxCli, command, '-f', file]);
}
if (!['encrypt', 'decrypt', 'stage'].includes(action)) {
console.error('Usage: node scripts/env-files.mjs <encrypt|decrypt|stage>');
process.exit(2);
}
for (const file of files) {
dotenvx(action === 'decrypt' ? 'decrypt' : 'encrypt', file);
}
if (action === 'stage') {
run(gitCommand, ['add', '--', ...files]);
console.log('Encrypted and staged environment files.');
}
+232
View File
@@ -0,0 +1,232 @@
/** @typedef {'app' | 'scheduler'} RuntimeMode */
/** @typedef {Record<string, string | undefined>} RuntimeEnvironment */
const APP_REQUIRED = [
'APP_ORIGIN',
'ORIGIN',
'PASSKEY_RP_ID',
'SCHEDULER_TOKEN',
'TDR_DATABASE_PATH',
'TDR_AVATAR_STORAGE_PATH',
'TDR_GAME_ICON_STORAGE_PATH',
];
/**
* Validate environment variables after the runtime secret provider has injected them.
*
* @param {RuntimeMode} mode
* @param {RuntimeEnvironment} environment
* @returns {string[]}
*/
export function validateRuntimeEnvironment(mode, environment) {
if (mode !== 'app' && mode !== 'scheduler') {
throw new TypeError(`Unsupported runtime mode: ${mode}`);
}
const errors = [];
const get = (name) => environment[name]?.trim() ?? '';
const requireValues = (names) => {
for (const name of names) {
if (!get(name)) errors.push(`${name} is required.`);
}
};
if (mode === 'scheduler') {
requireValues(['SCHEDULER_URL', 'SCHEDULER_TOKEN']);
validateHttpUrl('SCHEDULER_URL', get('SCHEDULER_URL'), errors);
if (
get('SCHEDULER_URL') &&
get('SCHEDULER_URL') !== 'http://app:3000/api/internal/scheduler'
) {
errors.push(
'SCHEDULER_URL must use the internal app scheduler endpoint.',
);
}
validateSchedulerToken(get('SCHEDULER_TOKEN'), errors);
return errors;
}
requireValues(APP_REQUIRED);
validateSchedulerToken(get('SCHEDULER_TOKEN'), errors);
const appOrigin = validateOrigin('APP_ORIGIN', get('APP_ORIGIN'), errors);
const adapterOrigin = validateOrigin('ORIGIN', get('ORIGIN'), errors);
if (
appOrigin &&
adapterOrigin &&
appOrigin.hostname !== adapterOrigin.hostname
) {
errors.push('APP_ORIGIN and ORIGIN must use the same hostname.');
}
const rpId = get('PASSKEY_RP_ID').toLowerCase();
if (rpId && !isHostname(rpId)) {
errors.push('PASSKEY_RP_ID must be a hostname.');
} else if (
appOrigin &&
rpId &&
appOrigin.hostname !== rpId &&
!appOrigin.hostname.endsWith(`.${rpId}`)
) {
errors.push(
'PASSKEY_RP_ID must equal APP_ORIGIN hostname or be its registrable parent domain.',
);
}
requirePair(
'DISCORD_CLIENT_ID',
get('DISCORD_CLIENT_ID'),
'DISCORD_CLIENT_SECRET',
get('DISCORD_CLIENT_SECRET'),
errors,
);
requirePair(
'TURNSTILE_SITE_KEY',
get('TURNSTILE_SITE_KEY'),
'TURNSTILE_SECRET_KEY',
get('TURNSTILE_SECRET_KEY'),
errors,
);
const smtpEnabled = [
'SMTP_HOST',
'SMTP_FROM',
'SMTP_USER',
'SMTP_PASSWORD',
].some((name) => Boolean(get(name)));
if (smtpEnabled) {
requirePair(
'SMTP_HOST',
get('SMTP_HOST'),
'SMTP_FROM',
get('SMTP_FROM'),
errors,
);
requirePair(
'SMTP_USER',
get('SMTP_USER'),
'SMTP_PASSWORD',
get('SMTP_PASSWORD'),
errors,
);
}
const smtpPort = get('SMTP_PORT');
if (
smtpPort &&
(!/^\d+$/.test(smtpPort) ||
Number(smtpPort) < 1 ||
Number(smtpPort) > 65535)
) {
errors.push('SMTP_PORT must be an integer from 1 through 65535.');
}
const smtpSecure = get('SMTP_SECURE');
if (smtpSecure && !['true', 'false'].includes(smtpSecure)) {
errors.push('SMTP_SECURE must be true or false.');
}
const discordRedirect = validateHttpUrl(
'DISCORD_REDIRECT_URI',
get('DISCORD_REDIRECT_URI'),
errors,
);
if (
appOrigin &&
discordRedirect &&
(discordRedirect.hostname !== appOrigin.hostname ||
discordRedirect.pathname !== '/auth/discord/callback')
) {
errors.push(
'DISCORD_REDIRECT_URI must use APP_ORIGIN hostname and /auth/discord/callback.',
);
}
for (const name of [
'TDR_DATABASE_PATH',
'TDR_AVATAR_STORAGE_PATH',
'TDR_GAME_ICON_STORAGE_PATH',
]) {
if (get(name) && !get(name).startsWith('/')) {
errors.push(`${name} must be an absolute hosted path.`);
}
}
return errors;
}
/**
* @param {string} firstName
* @param {string} firstValue
* @param {string} secondName
* @param {string} secondValue
* @param {string[]} errors
*/
function requirePair(firstName, firstValue, secondName, secondValue, errors) {
if (Boolean(firstValue) !== Boolean(secondValue)) {
errors.push(`${firstName} and ${secondName} must be configured together.`);
}
}
/**
* @param {string} name
* @param {string} value
* @param {string[]} errors
* @returns {URL | undefined}
*/
function validateHttpUrl(name, value, errors) {
if (!value) return undefined;
try {
const url = new URL(value);
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname) {
throw new TypeError('not an HTTP URL');
}
return url;
} catch {
errors.push(`${name} must be a valid HTTP or HTTPS URL.`);
return undefined;
}
}
/**
* @param {string} name
* @param {string} value
* @param {string[]} errors
* @returns {URL | undefined}
*/
function validateOrigin(name, value, errors) {
const url = validateHttpUrl(name, value, errors);
if (!url) return undefined;
if (
url.username ||
url.password ||
url.pathname !== '/' ||
url.search ||
url.hash
) {
errors.push(`${name} must contain only a URL origin.`);
return undefined;
}
return url;
}
/** @param {string} value */
function isHostname(value) {
if (value === 'localhost') return true;
if (value.length > 253 || !value.includes('.')) return false;
return value
.split('.')
.every(
(label) =>
label.length <= 63 && /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label),
);
}
/** @param {string} value @param {string[]} errors */
function validateSchedulerToken(value, errors) {
if (value && (value.length < 32 || /\s/.test(value))) {
errors.push(
'SCHEDULER_TOKEN must contain at least 32 non-whitespace characters.',
);
}
}
+161
View File
@@ -0,0 +1,161 @@
import { describe, expect, it } from 'vitest';
import { validateRuntimeEnvironment } from './runtime-environment.js';
const validAppEnvironment = {
APP_ORIGIN: 'https://stage.shd.build',
ORIGIN: 'https://stage.shd.build',
PASSKEY_RP_ID: 'shd.build',
SCHEDULER_TOKEN: 'scheduler-secret-with-32-characters',
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',
};
describe('validateRuntimeEnvironment', () => {
it('accepts the hosted app contract without optional providers', () => {
expect(validateRuntimeEnvironment('app', validAppEnvironment)).toEqual([]);
});
it('reports every missing required app value', () => {
expect(validateRuntimeEnvironment('app', {})).toEqual([
'APP_ORIGIN is required.',
'ORIGIN is required.',
'PASSKEY_RP_ID is required.',
'SCHEDULER_TOKEN is required.',
'TDR_DATABASE_PATH is required.',
'TDR_AVATAR_STORAGE_PATH is required.',
'TDR_GAME_ICON_STORAGE_PATH is required.',
]);
});
it('validates canonical origins and passkey hostname scope', () => {
expect(
validateRuntimeEnvironment('app', {
...validAppEnvironment,
APP_ORIGIN: 'https://stage.shd.build/sign-in',
}),
).toContain('APP_ORIGIN must contain only a URL origin.');
expect(
validateRuntimeEnvironment('app', {
...validAppEnvironment,
PASSKEY_RP_ID: 'example.com',
}),
).toContain(
'PASSKEY_RP_ID must equal APP_ORIGIN hostname or be its registrable parent domain.',
);
});
it('rejects mismatched app and adapter hostnames', () => {
expect(
validateRuntimeEnvironment('app', {
...validAppEnvironment,
ORIGIN: 'https://other.shd.build',
}),
).toContain('APP_ORIGIN and ORIGIN must use the same hostname.');
});
it('requires optional integration credentials only as pairs', () => {
expect(
validateRuntimeEnvironment('app', {
...validAppEnvironment,
DISCORD_CLIENT_ID: 'client-id',
TURNSTILE_SECRET_KEY: 'secret',
}),
).toEqual([
'DISCORD_CLIENT_ID and DISCORD_CLIENT_SECRET must be configured together.',
'TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY must be configured together.',
]);
});
it('allows SMTP defaults without enabling email and validates enabled SMTP', () => {
expect(
validateRuntimeEnvironment('app', {
...validAppEnvironment,
SMTP_PORT: '587',
SMTP_SECURE: 'false',
}),
).toEqual([]);
expect(
validateRuntimeEnvironment('app', {
...validAppEnvironment,
SMTP_HOST: 'smtp.example.com',
SMTP_USER: 'agent',
SMTP_PORT: '70000',
}),
).toEqual([
'SMTP_HOST and SMTP_FROM must be configured together.',
'SMTP_USER and SMTP_PASSWORD must be configured together.',
'SMTP_PORT must be an integer from 1 through 65535.',
]);
});
it('validates optional redirect URLs when supplied', () => {
expect(
validateRuntimeEnvironment('app', {
...validAppEnvironment,
DISCORD_REDIRECT_URI: 'ftp://stage.shd.build/callback',
}),
).toContain('DISCORD_REDIRECT_URI must be a valid HTTP or HTTPS URL.');
expect(
validateRuntimeEnvironment('app', {
...validAppEnvironment,
DISCORD_REDIRECT_URI: 'https://dev.shd.build/auth/discord/callback',
}),
).toContain(
'DISCORD_REDIRECT_URI must use APP_ORIGIN hostname and /auth/discord/callback.',
);
});
it('validates hosted paths and SMTP TLS configuration', () => {
expect(
validateRuntimeEnvironment('app', {
...validAppEnvironment,
SMTP_SECURE: 'yes',
TDR_DATABASE_PATH: './data/staging.db',
}),
).toEqual([
'SMTP_SECURE must be true or false.',
'TDR_DATABASE_PATH must be an absolute hosted path.',
]);
});
it('accepts a configured scheduler and rejects missing or invalid values', () => {
expect(
validateRuntimeEnvironment('scheduler', {
SCHEDULER_URL: 'http://app:3000/api/internal/scheduler',
SCHEDULER_TOKEN: 'scheduler-secret-with-32-characters',
}),
).toEqual([]);
expect(
validateRuntimeEnvironment('scheduler', {
SCHEDULER_URL: 'not a URL',
}),
).toEqual([
'SCHEDULER_TOKEN is required.',
'SCHEDULER_URL must be a valid HTTP or HTTPS URL.',
'SCHEDULER_URL must use the internal app scheduler endpoint.',
]);
});
it('requires a strong scheduler token and fixed internal endpoint', () => {
expect(
validateRuntimeEnvironment('scheduler', {
SCHEDULER_URL: 'https://example.com/api/internal/scheduler',
SCHEDULER_TOKEN: 'too-short',
}),
).toEqual([
'SCHEDULER_URL must use the internal app scheduler endpoint.',
'SCHEDULER_TOKEN must contain at least 32 non-whitespace characters.',
]);
});
it('rejects unsupported runtime modes', () => {
expect(() =>
validateRuntimeEnvironment('worker' as 'app', validAppEnvironment),
).toThrow('Unsupported runtime mode: worker');
});
});
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process';
import { readFile, unlink } from 'node:fs/promises';
import process from 'node:process';
import { validateRuntimeEnvironment } from './runtime-environment.js';
const APP_SECRET_NAMES = new Set([
'APP_ORIGIN',
'DISCORD_CLIENT_ID',
'DISCORD_CLIENT_SECRET',
'DISCORD_REDIRECT_URI',
'OPENAI_MECHANIC_API_KEY',
'OPENAI_MECHANIC_MODEL',
'OPENAI_MECHANIC_REASONING_EFFORT',
'OPENAI_MODERATION_API_KEY',
'ORIGIN',
'PASSKEY_RP_ID',
'REMOVE_BG_API_KEY',
'REMOVE_BG_CREDIT_CHECK_HOURS',
'REMOVE_BG_LOW_CREDIT_THRESHOLD',
'REMOVE_BG_LOW_FREE_CALL_THRESHOLD',
'SMTP_FROM',
'SMTP_HOST',
'SMTP_PASSWORD',
'SMTP_PORT',
'SMTP_SECURE',
'SMTP_USER',
'TDR_AVATAR_STORAGE_PATH',
'TDR_DATABASE_PATH',
'TDR_GAME_ICON_STORAGE_PATH',
'TURNSTILE_SECRET_KEY',
'TURNSTILE_SITE_KEY',
]);
const SHARED_SECRET_NAMES = new Set(['SCHEDULER_TOKEN']);
const REMOVED_ENVIRONMENT_NAMES = [
'BASH_ENV',
'CDPATH',
'ENV',
'IFS',
'INFISICAL_ENVIRONMENT',
'INFISICAL_PROJECT_ID',
'INFISICAL_RUNTIME_SCOPE',
'INFISICAL_TOKEN',
'INFISICAL_UNIVERSAL_AUTH_ACCESS_TOKEN',
'INFISICAL_UNIVERSAL_AUTH_CLIENT_ID',
'INFISICAL_UNIVERSAL_AUTH_CLIENT_ID_FILE',
'INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET',
'INFISICAL_UNIVERSAL_AUTH_CLIENT_SECRET_FILE',
'LD_LIBRARY_PATH',
'LD_PRELOAD',
'NODE_OPTIONS',
'NODE_PATH',
'PYTHONPATH',
'TOKEN',
];
const [runtimeScope, environment, buildNumber = '0', commitSha = '', ...rest] =
process.argv.slice(2);
const separatorIndex = rest.indexOf('--');
if (
!['app', 'scheduler'].includes(runtimeScope) ||
!['staging', 'prod'].includes(environment) ||
separatorIndex < 1
) {
console.error(
'Usage: runtime-launcher.mjs <app|scheduler> <staging|prod> <build> <sha> <secret-files...> -- <command...>',
);
process.exit(2);
}
const secretFiles = rest.slice(0, separatorIndex);
const requestedCommand = rest.slice(separatorIndex + 1);
if (requestedCommand.length === 0) {
console.error('A runtime command is required.');
process.exit(2);
}
const expectedFileCount = runtimeScope === 'app' ? 2 : 1;
if (secretFiles.length !== expectedFileCount) {
console.error(
`${runtimeScope} requires ${expectedFileCount} secret export file(s).`,
);
process.exit(2);
}
const secrets = {};
try {
await loadSecretFile(
secretFiles[0],
runtimeScope === 'app' ? APP_SECRET_NAMES : SHARED_SECRET_NAMES,
secrets,
);
if (runtimeScope === 'app') {
await loadSecretFile(secretFiles[1], SHARED_SECRET_NAMES, secrets);
}
} finally {
const deletionResults = await Promise.allSettled(
secretFiles.map((file) => unlink(file)),
);
const undeletedFiles = secretFiles.filter(
(_, index) => deletionResults[index].status === 'rejected',
);
if (undeletedFiles.length > 0) {
throw new Error(
`Failed to delete Infisical secret export: ${undeletedFiles.join(', ')}`,
);
}
}
const childEnvironment = { ...process.env, ...secrets };
for (const name of REMOVED_ENVIRONMENT_NAMES) delete childEnvironment[name];
Object.assign(
childEnvironment,
trustedRuntimeEnvironment(runtimeScope, environment, buildNumber, commitSha),
);
const errors = validateRuntimeEnvironment(runtimeScope, childEnvironment);
if (errors.length > 0) {
console.error(
`Invalid ${runtimeScope} runtime environment:\n${errors.map((error) => `- ${error}`).join('\n')}`,
);
process.exit(1);
}
process.umask(0o007);
if (requestedCommand[0] === 'serve') {
await runAsNode(['node', 'scripts/db-migrate.mjs'], childEnvironment);
await runAsNode(['node', 'build'], childEnvironment);
} else if (requestedCommand[0] === 'scheduler') {
await runAsNode(['node', 'scripts/scheduler-client.mjs'], childEnvironment);
} else {
await runAsNode(requestedCommand, childEnvironment);
}
async function loadSecretFile(file, allowedNames, target) {
let entries;
try {
entries = JSON.parse(await readFile(file, 'utf8'));
} catch {
throw new Error('Infisical returned an unreadable secret export.');
}
if (!Array.isArray(entries)) {
throw new Error('Infisical secret export must be an array.');
}
for (const entry of entries) {
const name = entry?.key;
const value = entry?.value;
if (typeof name !== 'string' || typeof value !== 'string') {
throw new Error('Infisical secret export contains an invalid entry.');
}
if (!allowedNames.has(name)) {
throw new Error(
`Infisical secret ${name} is not allowed in this runtime scope.`,
);
}
if (Object.hasOwn(target, name)) {
throw new Error(`Infisical secret ${name} was exported more than once.`);
}
target[name] = value;
}
}
function trustedRuntimeEnvironment(
scope,
targetEnvironment,
release,
revision,
) {
const common = {
BODY_SIZE_LIMIT: '6M',
HOST: '0.0.0.0',
NODE_ENV: 'production',
PORT: '3000',
SHD_BUILD_NUMBER: release,
SHD_COMMIT_SHA: revision,
};
if (scope === 'scheduler') {
return {
...common,
SCHEDULER_URL: 'http://app:3000/api/internal/scheduler',
};
}
if (targetEnvironment === 'staging') {
return {
...common,
APP_ORIGIN: 'https://stage.shd.build',
DISCORD_REDIRECT_URI: 'https://stage.shd.build/auth/discord/callback',
ORIGIN: 'https://stage.shd.build',
PASSKEY_RP_ID: 'shd.build',
SKIP_DEFAULT_USERS: 'true',
TDR_AVATAR_STORAGE_PATH: '/app/data/avatars',
TDR_DATABASE_PATH: '/app/data/shd-build-staging.db',
TDR_GAME_ICON_STORAGE_PATH: '/app/data/game-icons',
UBISOFT_NEWS_IMPORT_ENABLED: 'false',
};
}
return {
...common,
APP_ORIGIN: 'https://shd.build',
DISCORD_REDIRECT_URI: 'https://shd.build/auth/discord/callback',
ORIGIN: 'https://shd.build',
PASSKEY_RP_ID: 'shd.build',
SKIP_DEFAULT_USERS: 'true',
TDR_AVATAR_STORAGE_PATH: '/app/data/avatars',
TDR_DATABASE_PATH: '/app/data/shd-build-production.db',
TDR_GAME_ICON_STORAGE_PATH: '/app/data/game-icons',
UBISOFT_NEWS_IMPORT_ENABLED: 'true',
};
}
function runAsNode(command, environmentVariables) {
const usesPrivilegeDrop =
typeof process.getuid === 'function' && process.getuid() === 0;
const executable = usesPrivilegeDrop ? 'gosu' : command[0];
const args = usesPrivilegeDrop
? ['shd-runtime', ...command]
: command.slice(1);
return new Promise((resolve, reject) => {
const child = spawn(executable, args, {
env: environmentVariables,
stdio: 'inherit',
});
const signalHandlers = new Map();
for (const signal of ['SIGHUP', 'SIGINT', 'SIGTERM']) {
const handler = () => child.kill(signal);
signalHandlers.set(signal, handler);
process.once(signal, handler);
}
const cleanup = () => {
for (const [signal, handler] of signalHandlers) {
process.off(signal, handler);
}
};
child.once('error', (error) => {
cleanup();
reject(error);
});
child.once('exit', (code, signal) => {
cleanup();
if (signal) {
process.kill(process.pid, signal);
} else if (code === 0) {
resolve();
} else {
reject(new Error(`Runtime command exited with status ${code ?? 1}.`));
}
});
});
}
-54
View File
@@ -1,54 +0,0 @@
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.',
);
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env node
import process from 'node:process';
import { validateRuntimeEnvironment } from './runtime-environment.js';
const mode = process.argv[2];
if (mode !== 'app' && mode !== 'scheduler') {
console.error(
'Usage: node scripts/validate-runtime-environment.mjs <app|scheduler>',
);
process.exit(2);
}
const errors = validateRuntimeEnvironment(mode, process.env);
if (errors.length > 0) {
console.error(
`Invalid ${mode} runtime environment:\n${errors.map((error) => `- ${error}`).join('\n')}`,
);
process.exit(1);
}