From 563386af17acaa67545a1c8a498c6b66628461fc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 07:49:11 +0000 Subject: [PATCH 1/2] feat: release images to GHCR and update notices (systemUpdates) - release.yml: a v* tag (or a manual run on main) builds ghcr.io/mgpl-diagnostics/api for amd64 and arm64 and creates the GitHub release with generated notes - systemUpdates (settings:manage): running vs newest api and dashboard releases, changelog, update steps - docker-compose.yml pulls the released api and dashboard images; nginx serves both on one origin - docs/18-updates.md; version read from the right package.json in dist (health showed 0.0.0) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_018jBohmSXdQwAG8EmEBKYkQ --- .env.example | 17 + .github/workflows/release.yml | 79 ++++ deploy/nginx.conf | 13 +- docker-compose.build.yml | 19 + docker-compose.yml | 43 +- docs/04-api-reference.md | 6 + docs/09-configuration.md | 9 +- docs/12-deployment.md | 4 +- docs/17-scaling.md | 12 +- docs/18-updates.md | 100 +++++ docs/README.md | 2 + kits.json | 2 +- llms-full.txt | 597 ++++++++++++++++++++++++++- package-lock.json | 6 +- package.json | 2 +- schema.graphql | 71 ++++ src/config/env.ts | 32 ++ src/container.ts | 4 +- src/controllers/base.controller.ts | 10 +- src/controllers/system.controller.ts | 12 + src/graphql/schema.ts | 3 + src/schema/system.graphql.ts | 43 ++ src/services/index.ts | 12 + src/services/system.service.ts | 154 +++++++ src/version.ts | 10 + test/run.ts | 62 +++ 26 files changed, 1262 insertions(+), 62 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 docker-compose.build.yml create mode 100644 docs/18-updates.md create mode 100644 src/controllers/system.controller.ts create mode 100644 src/schema/system.graphql.ts create mode 100644 src/services/system.service.ts create mode 100644 src/version.ts diff --git a/.env.example b/.env.example index 8c07296..9d3a5b6 100644 --- a/.env.example +++ b/.env.example @@ -173,6 +173,10 @@ PASSWORD_RESET_URL=http://localhost:3000/reset-password # SHUTDOWN_TIMEOUT_MS=30000 # # Read only by docker-compose.yml (Docker Compose reads this file for them), not by the API: +# MGPL_API_VERSION=latest api image tag: pin a release in production, e.g. 1.3.0 (docs/18-updates.md) +# MGPL_DASHBOARD_VERSION=latest dashboard image tag +# MGPL_API_IMAGE=ghcr.io/mgpl-diagnostics/api +# MGPL_DASHBOARD_IMAGE=ghcr.io/mgpl-diagnostics/dashboard # API_REPLICAS=2 api containers behind nginx (or `docker compose up -d --scale api=N`) # HTTP_PORT=8080 nginx's port on the host # POSTGRES_PASSWORD=postgres [secret] URL-safe characters only: it goes into connection URLs @@ -192,3 +196,16 @@ PASSWORD_RESET_URL=http://localhost:3000/reset-password # MONGOMS_SYSTEM_BINARY= `npm test`: path to a local mongod # PGBOUNCER_BIN=pgbouncer `npm test`: the pgbouncer binary for the [pgbouncer] and [replicas] checks (skipped without it) # DEBUG=1 `npm test`: print resolver errors + + +# ── Updates: notices for owners and admins (docs/18-updates.md) ────────────────────────────────────── +# [optional] The api reads the newest GitHub releases of the api and the dashboard (one GET each, every +# UPDATE_CHECK_INTERVAL_MS) and the dashboard shows "update available". false: no outbound call. +# UPDATE_CHECK=true +# UPDATE_CHECK_INTERVAL_MS=21600000 +# [optional] [secret] A GitHub token with no scopes, only for a higher rate limit. +# UPDATE_GITHUB_TOKEN= +# UPDATE_REPO_API=mgpl-diagnostics/api +# UPDATE_REPO_DASHBOARD=mgpl-diagnostics/dashboard +# UPDATE_FEED_URL=https://api.github.com +# IMAGE_REGISTRY=ghcr.io diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7e57fc6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,79 @@ +name: Release + +# A `v*` tag (admin-only) or a manual run on main publishes one release: +# 1. checks that the tag equals package.json's version, +# 2. builds the Docker image for linux/amd64 and linux/arm64 and pushes it to GHCR: +# ghcr.io/mgpl-diagnostics/api:, :., : and :latest, +# 3. creates the GitHub release with generated notes (the changelog the dashboard shows owners and admins). +# Bump the version in a PR first; then `git tag v1.2.3 && git push origin v1.2.3`, or run this workflow on main. +on: + push: + tags: ["v*"] + workflow_dispatch: + +permissions: + contents: write + packages: write + +concurrency: + group: release-${{ github.ref }} + cancel-in-progress: false + +jobs: + release: + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v7 + - name: Version from package.json (must match the tag) + id: v + run: | + version=$(node -p "require('./package.json').version") + if [ "$GITHUB_REF_TYPE" = "tag" ] && [ "$GITHUB_REF_NAME" != "v$version" ]; then + echo "::error::tag $GITHUB_REF_NAME does not match package.json version $version"; exit 1 + fi + if [ "$GITHUB_REF_TYPE" != "tag" ] && [ "$GITHUB_REF_NAME" != "main" ]; then + echo "::error::run this workflow on main or push a v* tag"; exit 1 + fi + echo "version=$version" >> "$GITHUB_OUTPUT" + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}},value=v${{ steps.v.outputs.version }} + type=semver,pattern={{major}}.{{minor}},value=v${{ steps.v.outputs.version }} + type=semver,pattern={{major}},value=v${{ steps.v.outputs.version }},enable=${{ !startsWith(steps.v.outputs.version, '0.') }} + type=raw,value=latest + labels: | + org.opencontainers.image.title=MGPL Diagnostics API + org.opencontainers.image.version=${{ steps.v.outputs.version }} + # the kits come from GitHub Packages: the workflow token reads them (build secret, never a build arg) + - uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + secrets: npm_token=${{ secrets.GITHUB_TOKEN }} + cache-from: type=gha + cache-to: type=gha,mode=max + - name: GitHub release with generated notes + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAG: v${{ steps.v.outputs.version }} + run: | + if gh release view "$TAG" >/dev/null 2>&1; then + echo "release $TAG exists" + else + gh release create "$TAG" --target "$GITHUB_SHA" --title "$TAG" --generate-notes \ + --notes "Image: \`ghcr.io/${{ github.repository }}:${{ steps.v.outputs.version }}\` (linux/amd64, linux/arm64). Update guide: docs/18-updates.md" + fi diff --git a/deploy/nginx.conf b/deploy/nginx.conf index 7a9eb76..fa64e72 100644 --- a/deploy/nginx.conf +++ b/deploy/nginx.conf @@ -1,4 +1,5 @@ -# The load balancer in docker-compose.yml: spreads requests over every `api` replica. +# The front door in docker-compose.yml: /graphql and /health go to the `api` replicas (spread over all of them), +# everything else to the dashboard. One origin for the browser: no CORS. # Docker's DNS (127.0.0.11) answers "api" with one address per replica; resolving through a variable makes # nginx re-resolve every 10 s, so `docker compose up --scale api=N` needs no restart here. # TLS: terminate it here (listen 443 ssl + certificates) or in front of it (a CDN or a cloud load balancer); @@ -15,6 +16,16 @@ http { listen 80; location / { + set $dashboard http://dashboard:3000; + proxy_pass $dashboard; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + location ~ ^/(graphql|health)$ { set $api http://api:4000; proxy_pass $api; proxy_http_version 1.1; diff --git a/docker-compose.build.yml b/docker-compose.build.yml new file mode 100644 index 0000000..8776abf --- /dev/null +++ b/docker-compose.build.yml @@ -0,0 +1,19 @@ +# Build the api image from this checkout instead of pulling the released one: +# NODE_AUTH_TOKEN=… docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build +# The kits come from GitHub Packages (a token with read:packages, passed as a build secret). +services: + db-sync: + image: mgpl-api:local + build: + context: . + secrets: [npm_token] + api: + image: mgpl-api:local + pull_policy: never + worker: + image: mgpl-api:local + pull_policy: never + +secrets: + npm_token: + environment: NODE_AUTH_TOKEN diff --git a/docker-compose.yml b/docker-compose.yml index eac92a0..2ff3175 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,19 +1,26 @@ -# The whole backend on one host: PostgreSQL ← PgBouncer ← N api replicas ← nginx, plus Redis and the worker. -# Guide: docs/17-scaling.md. +# The whole system on one host: PostgreSQL ← PgBouncer ← N api replicas, the worker, Redis, the dashboard, +# and nginx in front (/graphql and /health → api, everything else → dashboard). Guides: docs/17-scaling.md, +# docs/18-updates.md. # # cp .env.example .env # set JWT_SECRET, CORS_ORIGINS, POSTGRES_PASSWORD, … -# NODE_AUTH_TOKEN=… docker compose build # the kits come from GitHub Packages (read:packages) -# docker compose up -d # API_REPLICAS replicas (default 2) on http://localhost:8080 -# docker compose up -d --scale api=4 # more or fewer, at any time +# docker compose up -d # pulls the released images; http://localhost:8080 +# docker compose up -d --scale api=4 # more or fewer api replicas, at any time +# +# Updating: set MGPL_API_VERSION / MGPL_DASHBOARD_VERSION in .env (default "latest"), then +# docker compose pull && docker compose up -d +# db-sync adds new tables first; the replicas restart after it. Owners and admins see new releases in the +# dashboard (Updates). +# +# Building the api from this checkout instead of pulling it: +# NODE_AUTH_TOKEN=… docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build # # The api and the worker read .env too; the values below (database, Redis, proxy) win over it, because the # services find each other by name inside this network. name: mgpl -# api and worker run the image that db-sync builds (one build, three commands) +# api, worker and db-sync run the same image (three commands) x-api: &api - image: mgpl-api - pull_policy: never + image: ${MGPL_API_IMAGE:-ghcr.io/mgpl-diagnostics/api}:${MGPL_API_VERSION:-latest} restart: unless-stopped env_file: - path: .env @@ -74,10 +81,7 @@ services: # Creates missing tables once per `up`, straight on PostgreSQL, before any replica starts (they run with DB_SYNC=false). db-sync: - image: mgpl-api - build: - context: . - secrets: [npm_token] + image: ${MGPL_API_IMAGE:-ghcr.io/mgpl-diagnostics/api}:${MGPL_API_VERSION:-latest} env_file: - path: .env required: false @@ -119,16 +123,21 @@ services: disable: true stop_grace_period: 40s + # The staff dashboard (mgpl-diagnostics/dashboard). The browser reaches it and the api through nginx on one + # origin, so no CORS and one trusted proxy (TRUST_PROXY=1). MGPL_API_URL is only for its own /graphql fallback. + dashboard: + image: ${MGPL_DASHBOARD_IMAGE:-ghcr.io/mgpl-diagnostics/dashboard}:${MGPL_DASHBOARD_VERSION:-latest} + restart: unless-stopped + environment: + MGPL_API_URL: http://api:4000 + depends_on: [api] + nginx: image: nginx:1.27-alpine restart: unless-stopped ports: ["${HTTP_PORT:-8080}:80"] volumes: [./deploy/nginx.conf:/etc/nginx/nginx.conf:ro] - depends_on: [api] - -secrets: - npm_token: - environment: NODE_AUTH_TOKEN + depends_on: [api, dashboard] volumes: pgdata: diff --git a/docs/04-api-reference.md b/docs/04-api-reference.md index 0044f40..59b7f26 100644 --- a/docs/04-api-reference.md +++ b/docs/04-api-reference.md @@ -192,3 +192,9 @@ and runnable examples are in [examples/](examples/). | query | `renderReport(request: JSON!, format: DocumentFormat, variant: ReportVariant, design: ReportDesign, fileName: String): Document!` | `report:read` | Any report: request = report-kit's ReportRequest JSON (patient + tests \| profile \| report \| sample). | | query | `reportTemplates: TemplateCatalog!` | (signed in) | | | mutation | `startDocumentJob(input: DocumentJobInput!): Job!` | `invoice:read`, `report:read` | Renders a stored invoice or a booking's reports in the background; poll job(id) until completed or failed. | + +## system + +| Kind | Operation | Permission | Notes | +|---|---|---|---| +| query | `systemUpdates(dashboardVersion: String, refresh: Boolean): SystemUpdates!` | `settings:manage` | Owner and admin (settings:manage): is a newer api or dashboard release out, what changed, and how to update. The dashboard passes its own version; refresh skips the cache (at most once a minute). | diff --git a/docs/09-configuration.md b/docs/09-configuration.md index 8fa29a4..ddd0c1e 100644 --- a/docs/09-configuration.md +++ b/docs/09-configuration.md @@ -48,8 +48,15 @@ variables already set in the real environment win over the file. | `MGPL_SIGNATURE_FILE` | — | One file for both | | `SHUTDOWN_DRAIN_MS` | `5000` in production, `0` otherwise | On SIGTERM, `GET /health` answers 503 this long while requests are still served ([17-scaling.md](17-scaling.md#graceful-shutdown)) | | `SHUTDOWN_TIMEOUT_MS` | `30000` | Exit anyway this long after the signal (API and worker) | +| `UPDATE_CHECK` | `true` (`false` in tests) | Update notices for owners and admins: read the newest GitHub releases ([18-updates.md](18-updates.md)); `false` makes no outbound call | +| `UPDATE_CHECK_INTERVAL_MS` | `21600000` (6 h) | How long one check is reused (at least 60000) | +| `UPDATE_GITHUB_TOKEN` | empty | Optional token (no scopes) for GitHub's higher rate limit | +| `UPDATE_REPO_API` / `UPDATE_REPO_DASHBOARD` | `mgpl-diagnostics/api` / `mgpl-diagnostics/dashboard` | Where the releases are read | +| `UPDATE_FEED_URL` | `https://api.github.com` | The releases API (GitHub Enterprise or a mirror) | +| `IMAGE_REGISTRY` | `ghcr.io` | Registry shown in the image names | -`docker-compose.yml` reads a few more from `.env` (not the API): `API_REPLICAS`, `HTTP_PORT`, +`docker-compose.yml` reads a few more from `.env` (not the API): `MGPL_API_VERSION`, `MGPL_DASHBOARD_VERSION` +(image tags, default `latest`), `MGPL_API_IMAGE`, `MGPL_DASHBOARD_IMAGE`, `API_REPLICAS`, `HTTP_PORT`, `POSTGRES_PASSWORD`, `POSTGRES_MAX_CONNECTIONS` and the `PGBOUNCER_*` pool settings ([17-scaling.md](17-scaling.md#docker-compose)). diff --git a/docs/12-deployment.md b/docs/12-deployment.md index 21f3962..53a3350 100644 --- a/docs/12-deployment.md +++ b/docs/12-deployment.md @@ -33,7 +33,9 @@ NODE_ENV=production node dist/main.js # API NODE_ENV=production node dist/worker.js # worker (with REDIS_URL) ``` -A container image: the [`Dockerfile`](../Dockerfile) (Node 22 slim, `dist/`, production `node_modules/`; +Released images: `ghcr.io/mgpl-diagnostics/api` and `ghcr.io/mgpl-diagnostics/dashboard`, one tag per +release; owners and admins see new releases in the dashboard ([18-updates.md](18-updates.md)). +The image is built from the [`Dockerfile`](../Dockerfile) (Node 22 slim, `dist/`, production `node_modules/`; `node dist/main.js`, `node dist/worker.js` or `node dist/sync.js`; port 4000). It needs `NODE_AUTH_TOKEN` as a build secret for the kits. The PDF engine needs no system fonts (fonts are bundled), but report-kit's resvg is a native module: build the image on the same platform it runs on. diff --git a/docs/17-scaling.md b/docs/17-scaling.md index 6842e50..225e4d4 100644 --- a/docs/17-scaling.md +++ b/docs/17-scaling.md @@ -121,14 +121,16 @@ Stopping one replica of three under constant traffic through nginx gave 40 of 40 ## Docker Compose -[`docker-compose.yml`](../docker-compose.yml) runs the whole backend on one host: PostgreSQL, PgBouncer -(transaction mode), Redis (AOF on), a one-shot `db-sync`, `API_REPLICAS` API containers, a worker and -nginx ([`deploy/nginx.conf`](../deploy/nginx.conf)) in front of the replicas on `HTTP_PORT`. +[`docker-compose.yml`](../docker-compose.yml) runs the whole system on one host from the released images +([18-updates.md](18-updates.md)): PostgreSQL, PgBouncer (transaction mode), Redis (AOF on), a one-shot +`db-sync`, `API_REPLICAS` API containers, a worker, the dashboard and nginx +([`deploy/nginx.conf`](../deploy/nginx.conf)) in front on `HTTP_PORT` (`/graphql` and `/health` → the +replicas, everything else → the dashboard). ```bash cp .env.example .env # NODE_ENV=production, JWT_SECRET, CORS_ORIGINS, POSTGRES_PASSWORD, SMTP_URL, … -NODE_AUTH_TOKEN=… docker compose build # the kits come from GitHub Packages (read:packages) -docker compose up -d # http://localhost:8080/graphql +docker compose up -d # pulls the images; http://localhost:8080 (/graphql: the api) +NODE_AUTH_TOKEN=… docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build # or build the api here docker compose up -d --scale api=4 # change the number of replicas at any time docker compose exec -e PGPASSWORD=… postgres psql -h pgbouncer -p 6432 -U postgres pgbouncer -c 'SHOW POOLS' ``` diff --git a/docs/18-updates.md b/docs/18-updates.md new file mode 100644 index 0000000..ddea752 --- /dev/null +++ b/docs/18-updates.md @@ -0,0 +1,100 @@ +# 18 · Releases, Docker images and updates + +Every release of the api and of the dashboard publishes a Docker image, and owners and admins see in the +dashboard when a newer one is out: which version runs, which is the newest, what changed in between, and +the commands to update. + +```mermaid +flowchart LR + T["v* tag on main
(admin)"] --> W["release.yml"] + W --> I["ghcr.io/mgpl-diagnostics/api:1.2.3
:1.2 · :1 · :latest"] + W --> R["GitHub release
generated notes"] + R -. "every 6 h" .-> A["api: systemUpdates"] + A --> D["dashboard: Updates page
and notice (owner, admin)"] + D -. "docker compose pull
docker compose up -d" .-> S["your server"] +``` + +## Images + +| Image | Runs | Port | +|---|---|---| +| `ghcr.io/mgpl-diagnostics/api` | `node dist/main.js` (api), `node dist/worker.js` (worker), `node dist/sync.js` (tables) | 4000 | +| `ghcr.io/mgpl-diagnostics/dashboard` | `node server.js` (Next.js standalone); `MGPL_API_URL` is read at start | 3000 | + +Tags: the exact version (`1.2.3`), the minor line (`1.2`), the major line (`1`, from 1.0.0 on) and `latest`. +Both are built for `linux/amd64` and `linux/arm64`. + +## Hosting with Docker Compose + +[`docker-compose.yml`](../docker-compose.yml) runs everything on one host: PostgreSQL, PgBouncer, Redis, +`db-sync`, the api replicas, the worker, the dashboard and nginx in front of them on `HTTP_PORT` (8080). +nginx sends `/graphql` and `/health` to the api and everything else to the dashboard, so the browser sees one +origin. + +```bash +mkdir mgpl && cd mgpl +curl -fsSLO https://raw.githubusercontent.com/mgpl-diagnostics/api/main/docker-compose.yml +mkdir deploy && curl -fsSL -o deploy/nginx.conf https://raw.githubusercontent.com/mgpl-diagnostics/api/main/deploy/nginx.conf +curl -fsSL -o .env https://raw.githubusercontent.com/mgpl-diagnostics/api/main/.env.example +# edit .env: NODE_ENV=production, JWT_SECRET, POSTGRES_PASSWORD, CORS_ORIGINS=https://your-lab-domain, SMTP_URL, … +docker compose up -d +``` + +Seed the first organisation once ([03-getting-started.md](03-getting-started.md)). Then open `http://your-server:8080` (put TLS in front: a CDN or cloud load balancer, then `TRUST_PROXY=2`, or a +`listen 443 ssl` server in `deploy/nginx.conf`). `CORS_ORIGINS` lists the address people open. + +To build the api from a checkout instead of pulling it, add the build file: +`NODE_AUTH_TOKEN=… docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build`. + +## Updating + +The dashboard's **Updates** page (Organisation → Updates, for roles with `settings:manage`: owner and +admin) shows both parts with their versions, the release notes in between and the steps. By hand: + +```bash +pg_dump … > backup.sql # or your provider's snapshot: always before an update +# in .env: MGPL_API_VERSION=1.3.0 and MGPL_DASHBOARD_VERSION=1.3.0 (or leave both "latest") +docker compose pull +docker compose up -d # db-sync adds new tables first, then api, worker and dashboard restart +docker compose ps # all up, api healthy +``` + +Sessions live in Redis, so people stay signed in. To go back, set the previous versions in `.env` and run the +same two commands (a release that changes stored data says so in its notes). + +Pin versions in production (`MGPL_API_VERSION=1.3.0` rather than `latest`), so a restart never upgrades by surprise. + +## How the check works + +`systemUpdates(dashboardVersion, refresh)` (permission `settings:manage`) compares the api's own version +(its `package.json`) and the version the dashboard sends (its build) with the newest published, non-pre-release +GitHub releases of `mgpl-diagnostics/api` and `mgpl-diagnostics/dashboard`. Each api process asks GitHub at +most once per `UPDATE_CHECK_INTERVAL_MS` (6 hours); "Check now" skips that, at most once a minute. GitHub +allows 60 unauthenticated calls an hour per IP, which is plenty; `UPDATE_GITHUB_TOKEN` (a token with no +scopes) raises it. A failed check keeps the last answer and says why. Nothing about the lab is sent: one GET per repository. + +| Variable | Default | Meaning | +|---|---|---| +| `UPDATE_CHECK` | `true` (`false` in tests) | `false`: no outbound call; the page shows the running versions only | +| `UPDATE_CHECK_INTERVAL_MS` | `21600000` (6 h, at least 60000) | How long one check is reused | +| `UPDATE_GITHUB_TOKEN` | empty | Optional; raises GitHub's rate limit | +| `UPDATE_REPO_API`, `UPDATE_REPO_DASHBOARD` | `mgpl-diagnostics/api`, `…/dashboard` | Where releases are read (a fork) | +| `UPDATE_FEED_URL` | `https://api.github.com` | GitHub Enterprise, or a mirror with the same API | +| `IMAGE_REGISTRY` | `ghcr.io` | Shown in the image names | + +## Releasing + +1. Bump `version` in `package.json` in a PR (api and dashboard version separately) and merge it. +2. An admin pushes the tag: `git tag v1.3.0 && git push origin v1.3.0`, or runs **Release** by hand on `main`. +3. `release.yml` checks the tag equals `package.json`, builds and pushes the image, and creates the GitHub + release with generated notes (edit them on GitHub afterwards: the dashboard shows the release body). + +The first time, make each package public once: GitHub → the organisation → Packages → `api` / `dashboard` → +Package settings → Change visibility → Public (or keep them private and `docker login ghcr.io` on the server +with a token that has `read:packages`). + +## One-click updates + +Not built in: the api never gets access to the Docker socket. A watchtower-style updater that pulls new images +on a schedule or on a token-protected HTTP call could be added next to the compose file; see the open +question in the PR that introduced this page. diff --git a/docs/README.md b/docs/README.md index ab11de9..e10dc27 100644 --- a/docs/README.md +++ b/docs/README.md @@ -19,6 +19,7 @@ flowchart TD D --> N["14 Client"] B --> O["16 Worker"] L --> P["17 Scaling"] + L --> Q["18 Updates"] ``` | # | Page | What it answers | @@ -40,5 +41,6 @@ flowchart TD | 15 | [Glossary](15-glossary.md) | Lab, billing and accounting terms | | 16 | [Worker](16-worker.md) | Background jobs on Redis + BullMQ: emails, documents, running workers | | 17 | [Scaling](17-scaling.md) | N replicas behind a proxy, PgBouncer, readiness, graceful shutdown, docker-compose | +| 18 | [Releases and updates](18-updates.md) | Docker images on GHCR, hosting with Compose, the update notice, releasing | | — | [examples/](examples/) | Runnable `.graphql` operations | | — | [LOCAL-AGENT-BRIEF.md](LOCAL-AGENT-BRIEF.md) | What a local agent (or a person) must do next, outside this repo | diff --git a/kits.json b/kits.json index 96fe5aa..71cd2ee 100644 --- a/kits.json +++ b/kits.json @@ -31,7 +31,7 @@ }, { "name": "client", - "ref": "v1.2.0" + "ref": "v1.6.0" }, { "name": "invoice-kit", diff --git a/llms-full.txt b/llms-full.txt index 804f83d..176a12f 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1,6 +1,6 @@ # @mgpl-diagnostics/api — full documentation and source -> Generated by scripts/llms-full.mjs. Do not edit by hand. 89 files. +> Generated by scripts/llms-full.mjs. Do not edit by hand. 94 files. ## Contents @@ -23,6 +23,7 @@ - docs/15-glossary.md - docs/16-worker.md - docs/17-scaling.md +- docs/18-updates.md - docs/LOCAL-AGENT-BRIEF.md - docs/README.md - kits.json @@ -45,6 +46,7 @@ - src/controllers/finance.controller.ts - src/controllers/lab.controller.ts - src/controllers/organization.controller.ts +- src/controllers/system.controller.ts - src/graphql/context.ts - src/graphql/device.ts - src/graphql/errors.ts @@ -71,6 +73,7 @@ - src/schema/finance.graphql.ts - src/schema/lab.graphql.ts - src/schema/organization.graphql.ts +- src/schema/system.graphql.ts - src/server.ts - src/services/auth.service.ts - src/services/billing.service.ts @@ -89,8 +92,10 @@ - src/services/receivables.service.ts - src/services/report.service.ts - src/services/settlement.service.ts +- src/services/system.service.ts - src/services/util.ts - src/sync.ts +- src/version.ts - src/worker.ts - test/run.ts @@ -852,6 +857,12 @@ and runnable examples are in [examples/](examples/). | query | `reportTemplates: TemplateCatalog!` | (signed in) | | | mutation | `startDocumentJob(input: DocumentJobInput!): Job!` | `invoice:read`, `report:read` | Renders a stored invoice or a booking's reports in the background; poll job(id) until completed or failed. | +## system + +| Kind | Operation | Permission | Notes | +|---|---|---|---| +| query | `systemUpdates(dashboardVersion: String, refresh: Boolean): SystemUpdates!` | `settings:manage` | Owner and admin (settings:manage): is a newer api or dashboard release out, what changed, and how to update. The dashboard passes its own version; refresh skips the cache (at most once a minute). | + --- @@ -1427,8 +1438,15 @@ variables already set in the real environment win over the file. | `MGPL_SIGNATURE_FILE` | — | One file for both | | `SHUTDOWN_DRAIN_MS` | `5000` in production, `0` otherwise | On SIGTERM, `GET /health` answers 503 this long while requests are still served ([17-scaling.md](17-scaling.md#graceful-shutdown)) | | `SHUTDOWN_TIMEOUT_MS` | `30000` | Exit anyway this long after the signal (API and worker) | - -`docker-compose.yml` reads a few more from `.env` (not the API): `API_REPLICAS`, `HTTP_PORT`, +| `UPDATE_CHECK` | `true` (`false` in tests) | Update notices for owners and admins: read the newest GitHub releases ([18-updates.md](18-updates.md)); `false` makes no outbound call | +| `UPDATE_CHECK_INTERVAL_MS` | `21600000` (6 h) | How long one check is reused (at least 60000) | +| `UPDATE_GITHUB_TOKEN` | empty | Optional token (no scopes) for GitHub's higher rate limit | +| `UPDATE_REPO_API` / `UPDATE_REPO_DASHBOARD` | `mgpl-diagnostics/api` / `mgpl-diagnostics/dashboard` | Where the releases are read | +| `UPDATE_FEED_URL` | `https://api.github.com` | The releases API (GitHub Enterprise or a mirror) | +| `IMAGE_REGISTRY` | `ghcr.io` | Registry shown in the image names | + +`docker-compose.yml` reads a few more from `.env` (not the API): `MGPL_API_VERSION`, `MGPL_DASHBOARD_VERSION` +(image tags, default `latest`), `MGPL_API_IMAGE`, `MGPL_DASHBOARD_IMAGE`, `API_REPLICAS`, `HTTP_PORT`, `POSTGRES_PASSWORD`, `POSTGRES_MAX_CONNECTIONS` and the `PGBOUNCER_*` pool settings ([17-scaling.md](17-scaling.md#docker-compose)). @@ -1642,7 +1660,9 @@ NODE_ENV=production node dist/main.js # API NODE_ENV=production node dist/worker.js # worker (with REDIS_URL) ``` -A container image: the [`Dockerfile`](../Dockerfile) (Node 22 slim, `dist/`, production `node_modules/`; +Released images: `ghcr.io/mgpl-diagnostics/api` and `ghcr.io/mgpl-diagnostics/dashboard`, one tag per +release; owners and admins see new releases in the dashboard ([18-updates.md](18-updates.md)). +The image is built from the [`Dockerfile`](../Dockerfile) (Node 22 slim, `dist/`, production `node_modules/`; `node dist/main.js`, `node dist/worker.js` or `node dist/sync.js`; port 4000). It needs `NODE_AUTH_TOKEN` as a build secret for the kits. The PDF engine needs no system fonts (fonts are bundled), but report-kit's resvg is a native module: build the image on the same platform it runs on. @@ -2156,14 +2176,16 @@ Stopping one replica of three under constant traffic through nginx gave 40 of 40 ## Docker Compose -[`docker-compose.yml`](../docker-compose.yml) runs the whole backend on one host: PostgreSQL, PgBouncer -(transaction mode), Redis (AOF on), a one-shot `db-sync`, `API_REPLICAS` API containers, a worker and -nginx ([`deploy/nginx.conf`](../deploy/nginx.conf)) in front of the replicas on `HTTP_PORT`. +[`docker-compose.yml`](../docker-compose.yml) runs the whole system on one host from the released images +([18-updates.md](18-updates.md)): PostgreSQL, PgBouncer (transaction mode), Redis (AOF on), a one-shot +`db-sync`, `API_REPLICAS` API containers, a worker, the dashboard and nginx +([`deploy/nginx.conf`](../deploy/nginx.conf)) in front on `HTTP_PORT` (`/graphql` and `/health` → the +replicas, everything else → the dashboard). ```bash cp .env.example .env # NODE_ENV=production, JWT_SECRET, CORS_ORIGINS, POSTGRES_PASSWORD, SMTP_URL, … -NODE_AUTH_TOKEN=… docker compose build # the kits come from GitHub Packages (read:packages) -docker compose up -d # http://localhost:8080/graphql +docker compose up -d # pulls the images; http://localhost:8080 (/graphql: the api) +NODE_AUTH_TOKEN=… docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build # or build the api here docker compose up -d --scale api=4 # change the number of replicas at any time docker compose exec -e PGPASSWORD=… postgres psql -h pgbouncer -p 6432 -U postgres pgbouncer -c 'SHOW POOLS' ``` @@ -2187,6 +2209,112 @@ pooler such as the provider's PgBouncer); Redis managed or as a StatefulSet; the Deployment with `command: ["node", "dist/worker.js"]`. +--- + +## File: docs/18-updates.md + +# 18 · Releases, Docker images and updates + +Every release of the api and of the dashboard publishes a Docker image, and owners and admins see in the +dashboard when a newer one is out: which version runs, which is the newest, what changed in between, and +the commands to update. + +```mermaid +flowchart LR + T["v* tag on main
(admin)"] --> W["release.yml"] + W --> I["ghcr.io/mgpl-diagnostics/api:1.2.3
:1.2 · :1 · :latest"] + W --> R["GitHub release
generated notes"] + R -. "every 6 h" .-> A["api: systemUpdates"] + A --> D["dashboard: Updates page
and notice (owner, admin)"] + D -. "docker compose pull
docker compose up -d" .-> S["your server"] +``` + +## Images + +| Image | Runs | Port | +|---|---|---| +| `ghcr.io/mgpl-diagnostics/api` | `node dist/main.js` (api), `node dist/worker.js` (worker), `node dist/sync.js` (tables) | 4000 | +| `ghcr.io/mgpl-diagnostics/dashboard` | `node server.js` (Next.js standalone); `MGPL_API_URL` is read at start | 3000 | + +Tags: the exact version (`1.2.3`), the minor line (`1.2`), the major line (`1`, from 1.0.0 on) and `latest`. +Both are built for `linux/amd64` and `linux/arm64`. + +## Hosting with Docker Compose + +[`docker-compose.yml`](../docker-compose.yml) runs everything on one host: PostgreSQL, PgBouncer, Redis, +`db-sync`, the api replicas, the worker, the dashboard and nginx in front of them on `HTTP_PORT` (8080). +nginx sends `/graphql` and `/health` to the api and everything else to the dashboard, so the browser sees one +origin. + +```bash +mkdir mgpl && cd mgpl +curl -fsSLO https://raw.githubusercontent.com/mgpl-diagnostics/api/main/docker-compose.yml +mkdir deploy && curl -fsSL -o deploy/nginx.conf https://raw.githubusercontent.com/mgpl-diagnostics/api/main/deploy/nginx.conf +curl -fsSL -o .env https://raw.githubusercontent.com/mgpl-diagnostics/api/main/.env.example +# edit .env: NODE_ENV=production, JWT_SECRET, POSTGRES_PASSWORD, CORS_ORIGINS=https://your-lab-domain, SMTP_URL, … +docker compose up -d +``` + +Seed the first organisation once ([03-getting-started.md](03-getting-started.md)). Then open `http://your-server:8080` (put TLS in front: a CDN or cloud load balancer, then `TRUST_PROXY=2`, or a +`listen 443 ssl` server in `deploy/nginx.conf`). `CORS_ORIGINS` lists the address people open. + +To build the api from a checkout instead of pulling it, add the build file: +`NODE_AUTH_TOKEN=… docker compose -f docker-compose.yml -f docker-compose.build.yml up -d --build`. + +## Updating + +The dashboard's **Updates** page (Organisation → Updates, for roles with `settings:manage`: owner and +admin) shows both parts with their versions, the release notes in between and the steps. By hand: + +```bash +pg_dump … > backup.sql # or your provider's snapshot: always before an update +# in .env: MGPL_API_VERSION=1.3.0 and MGPL_DASHBOARD_VERSION=1.3.0 (or leave both "latest") +docker compose pull +docker compose up -d # db-sync adds new tables first, then api, worker and dashboard restart +docker compose ps # all up, api healthy +``` + +Sessions live in Redis, so people stay signed in. To go back, set the previous versions in `.env` and run the +same two commands (a release that changes stored data says so in its notes). + +Pin versions in production (`MGPL_API_VERSION=1.3.0` rather than `latest`), so a restart never upgrades by surprise. + +## How the check works + +`systemUpdates(dashboardVersion, refresh)` (permission `settings:manage`) compares the api's own version +(its `package.json`) and the version the dashboard sends (its build) with the newest published, non-pre-release +GitHub releases of `mgpl-diagnostics/api` and `mgpl-diagnostics/dashboard`. Each api process asks GitHub at +most once per `UPDATE_CHECK_INTERVAL_MS` (6 hours); "Check now" skips that, at most once a minute. GitHub +allows 60 unauthenticated calls an hour per IP, which is plenty; `UPDATE_GITHUB_TOKEN` (a token with no +scopes) raises it. A failed check keeps the last answer and says why. Nothing about the lab is sent: one GET per repository. + +| Variable | Default | Meaning | +|---|---|---| +| `UPDATE_CHECK` | `true` (`false` in tests) | `false`: no outbound call; the page shows the running versions only | +| `UPDATE_CHECK_INTERVAL_MS` | `21600000` (6 h, at least 60000) | How long one check is reused | +| `UPDATE_GITHUB_TOKEN` | empty | Optional; raises GitHub's rate limit | +| `UPDATE_REPO_API`, `UPDATE_REPO_DASHBOARD` | `mgpl-diagnostics/api`, `…/dashboard` | Where releases are read (a fork) | +| `UPDATE_FEED_URL` | `https://api.github.com` | GitHub Enterprise, or a mirror with the same API | +| `IMAGE_REGISTRY` | `ghcr.io` | Shown in the image names | + +## Releasing + +1. Bump `version` in `package.json` in a PR (api and dashboard version separately) and merge it. +2. An admin pushes the tag: `git tag v1.3.0 && git push origin v1.3.0`, or runs **Release** by hand on `main`. +3. `release.yml` checks the tag equals `package.json`, builds and pushes the image, and creates the GitHub + release with generated notes (edit them on GitHub afterwards: the dashboard shows the release body). + +The first time, make each package public once: GitHub → the organisation → Packages → `api` / `dashboard` → +Package settings → Change visibility → Public (or keep them private and `docker login ghcr.io` on the server +with a token that has `read:packages`). + +## One-click updates + +Not built in: the api never gets access to the Docker socket. A watchtower-style updater that pulls new images +on a schedule or on a token-protected HTTP call could be added next to the compose file; see the open +question in the PR that introduced this page. + + --- ## File: docs/LOCAL-AGENT-BRIEF.md @@ -2424,6 +2552,7 @@ flowchart TD D --> N["14 Client"] B --> O["16 Worker"] L --> P["17 Scaling"] + L --> Q["18 Updates"] ``` | # | Page | What it answers | @@ -2445,6 +2574,7 @@ flowchart TD | 15 | [Glossary](15-glossary.md) | Lab, billing and accounting terms | | 16 | [Worker](16-worker.md) | Background jobs on Redis + BullMQ: emails, documents, running workers | | 17 | [Scaling](17-scaling.md) | N replicas behind a proxy, PgBouncer, readiness, graceful shutdown, docker-compose | +| 18 | [Releases and updates](18-updates.md) | Docker images on GHCR, hosting with Compose, the update notice, releasing | | — | [examples/](examples/) | Runnable `.graphql` operations | | — | [LOCAL-AGENT-BRIEF.md](LOCAL-AGENT-BRIEF.md) | What a local agent (or a person) must do next, outside this repo | @@ -2487,7 +2617,7 @@ flowchart TD }, { "name": "client", - "ref": "v1.2.0" + "ref": "v1.6.0" }, { "name": "invoice-kit", @@ -2597,7 +2727,7 @@ Key facts for agents: "sequelize": "^6.37.8" }, "devDependencies": { - "@mgpl-diagnostics/client": "^1.2.0", + "@mgpl-diagnostics/client": "^1.6.0", "@types/node": "^22.20.5", "embedded-postgres": "^18.4.0-beta.17", "ioredis-mock": "^8.13.1", @@ -2966,6 +3096,35 @@ enum CollectionType { walk_in } +""" +One part of the installation: the version that runs, the newest release, and the releases in between. +""" +type ComponentUpdate { + """ + Releases newer than current, newest first (at most 20); the newest release alone when current is unknown. + """ + changes: [Release!]! + + """api or dashboard""" + component: String! + + """ + The version that runs; null for the dashboard when it did not say (dashboardVersion). + """ + current: String + + """ + Docker image, e.g. ghcr.io/mgpl-diagnostics/api (tags: 1.2.3, 1.2, 1, latest). + """ + image: String! + + """ + The newest published release; null before the first check or when the check is off. + """ + latest: String + updateAvailable: Boolean! +} + input CreateBookingInput { branchId: ID! collectionType: CollectionType = walk_in @@ -4030,6 +4189,12 @@ type Query { """Computes a month's settlement without saving it. period: YYYY-MM.""" settlementPreview(deductions: [SettlementDeductionInput!], partnerId: ID!, period: String!): SettlementPreview! settlements(branchId: ID, limit: Int = 50, offset: Int = 0, partnerId: ID, status: SettlementStatus): [Settlement!]! + + """ + Owner and admin (settings:manage): is a newer api or dashboard release out, what changed, and how to + update. The dashboard passes its own version; refresh skips the cache (at most once a minute). + """ + systemUpdates(dashboardVersion: String, refresh: Boolean): SystemUpdates! taxRules: [TaxRule!]! trialBalance(asOf: DateTime, branchId: ID): TrialBalance! user(id: ID!): User @@ -4087,6 +4252,17 @@ input RegisterPatientInput { title: String } +""" +A published release of the api or the dashboard (its GitHub release). notes is the changelog, in Markdown. +""" +type Release { + name: String + notes: String + publishedAt: DateTime + url: String + version: String! +} + type Report { booking: Booking! id: ID! @@ -4328,6 +4504,31 @@ type StatementRow { voucherType: VoucherType! } +type SystemUpdates { + """ + When the releases were last read (each server checks every UPDATE_CHECK_INTERVAL_MS, 6 hours by default). + """ + checkedAt: DateTime + components: [ComponentUpdate!]! + + """The update guide.""" + docsUrl: String! + + """ + false when the server has UPDATE_CHECK=false (no outbound calls); versions then show current only. + """ + enabled: Boolean! + + """ + Why the last check failed (the releases shown are then the last known). + """ + error: String + + """How to update a Docker Compose installation, one step per line.""" + steps: [String!]! + updateAvailable: Boolean! +} + type TaxRule { category: ItemCategory code: String @@ -4852,6 +5053,19 @@ export interface Config { * requests in flight; after `timeoutMs` in all it exits anyway. */ shutdown: { drainMs: number; timeoutMs: number }; + /** + * Update notices for owners and admins (`systemUpdates`): the api compares its own version and the + * dashboard's with the newest GitHub releases of `repos`. `UPDATE_CHECK=false` turns the outbound check off + * (air-gapped servers); `githubToken` only raises GitHub's rate limit (no scopes needed for public repos). + */ + updates: { + enabled: boolean; + feedUrl: string; + repos: { api: string; dashboard: string }; + images: { api: string; dashboard: string }; + githubToken: string | null; + intervalMs: number; + }; } /** @@ -4893,6 +5107,24 @@ function dbPool(env: Record): Config["db"]["pool"] { return { max, min, idleMs: int(env.DB_POOL_IDLE_MS, 10_000), acquireMs: int(env.DB_POOL_ACQUIRE_MS, 30_000) }; } +const REPO = /^[A-Za-z0-9][A-Za-z0-9_.-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/; +function updatesConfig(env: Record, mode: Config["env"]): Config["updates"] { + const repos = { api: env.UPDATE_REPO_API || "mgpl-diagnostics/api", dashboard: env.UPDATE_REPO_DASHBOARD || "mgpl-diagnostics/dashboard" }; + for (const [k, v] of Object.entries(repos)) if (!REPO.test(v)) throw new Error(`UPDATE_REPO_${k.toUpperCase()} must be owner/name, got "${v}"`); + const feedUrl = (env.UPDATE_FEED_URL || "https://api.github.com").replace(/\/+$/, ""); + if (!/^https?:\/\//.test(feedUrl)) throw new Error("UPDATE_FEED_URL must be an http(s) URL"); + const registry = (env.IMAGE_REGISTRY || "ghcr.io").replace(/\/+$/, ""); + return { + // tests make no outbound calls unless they ask for them + enabled: bool(env.UPDATE_CHECK, mode !== "test"), + feedUrl, + repos, + images: { api: `${registry}/${repos.api.toLowerCase()}`, dashboard: `${registry}/${repos.dashboard.toLowerCase()}` }, + githubToken: env.UPDATE_GITHUB_TOKEN || null, + intervalMs: Math.max(60_000, int(env.UPDATE_CHECK_INTERVAL_MS, 6 * 3600_000)), + }; +} + export function loadConfig(env: Record = process.env): Config { const mode = env.NODE_ENV === "production" ? "production" : env.NODE_ENV === "test" ? "test" : "development"; const dev = mode !== "production"; @@ -4947,6 +5179,7 @@ export function loadConfig(env: Record = process.env smtp: smtpUrl ? { url: smtpUrl, from: env.MAIL_FROM || "MGPL Diagnostics " } : null, mailWebhookUrl: env.MAIL_WEBHOOK_URL || null, shutdown: { drainMs: int(env.SHUTDOWN_DRAIN_MS, mode === "production" ? 5_000 : 0), timeoutMs: int(env.SHUTDOWN_TIMEOUT_MS, 30_000) }, + updates: updatesConfig(env, mode), }; } ``` @@ -4977,6 +5210,8 @@ export interface ContainerOptions { jobs?: JobQueue; /** Lab branding and signatures for documents. Default: report-kit's defaultLab() (MGPL_SIGNATURE_* files). */ lab?: LabProfile; + /** The HTTP client the update check uses (tests). Default: global fetch. */ + fetch?: typeof fetch; } /** The composition root: one database, one auth, one set of services, shared by every request. */ @@ -5042,7 +5277,7 @@ export async function createContainer(o: ContainerOptions): Promise { (config.redisUrl ? createBullJobQueue({ redisUrl: config.redisUrl, prefix: config.jobs.prefix, keepResultsMs: config.jobs.keepResultsMs }) : createInlineJobQueue(() => jobDeps!, { keepResultsMs: config.jobs.keepResultsMs })); - const services = createServices({ repos: db.repos, auth, mailer, resetUrl: config.resetUrl, lab, samples: config.samples, jobs: () => jobs }); + const services = createServices({ repos: db.repos, auth, mailer, resetUrl: config.resetUrl, lab, samples: config.samples, jobs: () => jobs, updates: config.updates, fetch: o.fetch }); jobDeps = { repos: db.repos, auth, services }; return { config, @@ -5154,17 +5389,9 @@ export const authController = withErrorCodes({ ## File: src/controllers/base.controller.ts ```ts -import { readFileSync } from "node:fs"; import type { RequestContext } from "../graphql/context"; import { Day, DateTime, JSONScalar } from "../graphql/scalars"; - -const VERSION = (() => { - try { - return (JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")) as { version: string }).version; - } catch { - return "0.0.0"; - } -})(); +import { VERSION } from "../version"; export const baseController = { DateTime, @@ -5774,6 +6001,26 @@ export const organizationController = withErrorCodes({ ``` +--- + +## File: src/controllers/system.controller.ts + +```ts +import type { RequestContext as Ctx } from "../graphql/context"; +import { withErrorCodes } from "../graphql/errors"; +import { authorize } from "../graphql/guards"; + +export const systemController = withErrorCodes({ + Query: { + systemUpdates: (_: unknown, a: { dashboardVersion?: string | null; refresh?: boolean | null }, ctx: Ctx) => { + authorize(ctx, "settings:manage"); + return ctx.services.system.updates({ dashboardVersion: a.dashboardVersion?.slice(0, 40), refresh: a.refresh }); + }, + }, +}); +``` + + --- ## File: src/graphql/context.ts @@ -6210,6 +6457,7 @@ import { documentsController } from "../controllers/documents.controller"; import { financeController } from "../controllers/finance.controller"; import { labController } from "../controllers/lab.controller"; import { organizationController } from "../controllers/organization.controller"; +import { systemController } from "../controllers/system.controller"; import { authTypeDefs } from "../schema/auth.graphql"; import { baseTypeDefs } from "../schema/base.graphql"; import { billingTypeDefs } from "../schema/billing.graphql"; @@ -6219,6 +6467,7 @@ import { documentsTypeDefs } from "../schema/documents.graphql"; import { financeTypeDefs } from "../schema/finance.graphql"; import { labTypeDefs } from "../schema/lab.graphql"; import { organizationTypeDefs } from "../schema/organization.graphql"; +import { systemTypeDefs } from "../schema/system.graphql"; /** One domain = one SDL file in src/schema + one controller in src/controllers. */ export interface GraphQLModule { @@ -6237,6 +6486,7 @@ export const MODULES: GraphQLModule[] = [ { name: "books", typeDefs: booksTypeDefs, resolvers: booksController }, { name: "finance", typeDefs: financeTypeDefs, resolvers: financeController }, { name: "documents", typeDefs: documentsTypeDefs, resolvers: documentsController }, + { name: "system", typeDefs: systemTypeDefs, resolvers: systemController }, ]; /** The whole API as one executable schema. */ @@ -7702,6 +7952,57 @@ export const organizationTypeDefs = /* GraphQL */ ` ``` +--- + +## File: src/schema/system.graphql.ts + +```ts +/** The installation itself: versions and update notices for owners and admins. */ +export const systemTypeDefs = /* GraphQL */ ` + "A published release of the api or the dashboard (its GitHub release). notes is the changelog, in Markdown." + type Release { version: String!, name: String, notes: String, url: String, publishedAt: DateTime } + + "One part of the installation: the version that runs, the newest release, and the releases in between." + type ComponentUpdate { + "api or dashboard" + component: String! + "The version that runs; null for the dashboard when it did not say (dashboardVersion)." + current: String + "The newest published release; null before the first check or when the check is off." + latest: String + updateAvailable: Boolean! + "Docker image, e.g. ghcr.io/mgpl-diagnostics/api (tags: 1.2.3, 1.2, 1, latest)." + image: String! + "Releases newer than current, newest first (at most 20); the newest release alone when current is unknown." + changes: [Release!]! + } + + type SystemUpdates { + "false when the server has UPDATE_CHECK=false (no outbound calls); versions then show current only." + enabled: Boolean! + "When the releases were last read (each server checks every UPDATE_CHECK_INTERVAL_MS, 6 hours by default)." + checkedAt: DateTime + "Why the last check failed (the releases shown are then the last known)." + error: String + updateAvailable: Boolean! + components: [ComponentUpdate!]! + "How to update a Docker Compose installation, one step per line." + steps: [String!]! + "The update guide." + docsUrl: String! + } + + extend type Query { + """ + Owner and admin (settings:manage): is a newer api or dashboard release out, what changed, and how to + update. The dashboard passes its own version; refresh skips the cache (at most once a minute). + """ + systemUpdates(dashboardVersion: String, refresh: Boolean): SystemUpdates! + } +`; +``` + + --- ## File: src/server.ts @@ -8872,6 +9173,8 @@ import { PeriodService } from "./period.service"; import { ReceivablesService } from "./receivables.service"; import { ReportService } from "./report.service"; import { SettlementService } from "./settlement.service"; +import { SystemService } from "./system.service"; +import type { Config } from "../config/env"; export interface ServiceDeps { repos: Repositories; @@ -8882,8 +9185,17 @@ export interface ServiceDeps { samples: boolean; /** The job queue (BullMQ or inline); a getter because the inline queue needs the services first. */ jobs: () => JobQueue; + /** Update notices (UPDATE_* settings); off when omitted. */ + updates?: Config["updates"]; + /** The HTTP client for the release feed (tests). */ + fetch?: typeof fetch; } +const NO_UPDATES: Config["updates"] = { + enabled: false, feedUrl: "https://api.github.com", repos: { api: "mgpl-diagnostics/api", dashboard: "mgpl-diagnostics/dashboard" }, + images: { api: "ghcr.io/mgpl-diagnostics/api", dashboard: "ghcr.io/mgpl-diagnostics/dashboard" }, githubToken: null, intervalMs: 6 * 3600_000, +}; + /** Every service, one instance each. Services know nothing about GraphQL; controllers call them. */ export function createServices(d: ServiceDeps) { return { @@ -8903,6 +9215,7 @@ export function createServices(d: ServiceDeps) { exports: new ExportService(d.repos), documents: new DocumentService(d.repos, { lab: d.lab, samples: d.samples }), jobs: new JobService(d.repos, d.jobs), + system: new SystemService(d.updates ?? NO_UPDATES, d.fetch), }; } @@ -9418,6 +9731,168 @@ export class SettlementService { ``` +--- + +## File: src/services/system.service.ts + +```ts +import type { Config } from "../config/env"; +import { VERSION } from "../version"; + +// Update notices: which api and dashboard versions run, the newest releases on GitHub, the changelog in +// between and the steps to update. Releases are public data, so one cache per process is fine (nothing +// per user lives here); every replica asks GitHub at most once per UPDATE_CHECK_INTERVAL_MS. + +export type Component = "api" | "dashboard"; +export interface Release { version: string; name: string | null; notes: string | null; url: string | null; publishedAt: Date | null } +export interface ComponentUpdate { component: Component; current: string | null; latest: string | null; updateAvailable: boolean; image: string; changes: Release[] } +export interface SystemUpdates { + enabled: boolean; + checkedAt: Date | null; + error: string | null; + updateAvailable: boolean; + docsUrl: string; + steps: string[]; + components: ComponentUpdate[]; +} + +/** "1.2.3" or "v1.2.3" (no pre-release: those never count as an update). */ +const SEMVER = /^v?(\d{1,6})\.(\d{1,6})\.(\d{1,6})$/; +export const parseVersion = (v: string | null | undefined): [number, number, number] | null => { + const m = v ? SEMVER.exec(v.trim()) : null; + return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null; +}; +export function compareVersions(a: string, b: string): number { + const x = parseVersion(a), y = parseVersion(b); + if (!x || !y) return 0; + for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i]! - y[i]!; + return 0; +} +const clean = (v: string) => v.trim().replace(/^v/, ""); + +interface GithubRelease { tag_name?: unknown; name?: unknown; body?: unknown; html_url?: unknown; published_at?: unknown; draft?: unknown; prerelease?: unknown } +const str = (v: unknown, max: number) => (typeof v === "string" && v ? v.slice(0, max) : null); + +/** Published, non-pre-release releases with a semver tag, newest first. Everything from the feed is untrusted: typed and capped. */ +export function toReleases(json: unknown): Release[] { + if (!Array.isArray(json)) throw new Error("unexpected answer from the release feed"); + return (json as GithubRelease[]) + .filter((r) => r && !r.draft && !r.prerelease && typeof r.tag_name === "string" && parseVersion(r.tag_name)) + .map((r) => { + const url = str(r.html_url, 500); + const at = str(r.published_at, 40); + return { + version: clean(r.tag_name as string), + name: str(r.name, 200), + notes: str(r.body, 20_000), + url: url && /^https:\/\//.test(url) ? url : null, + publishedAt: at && !Number.isNaN(Date.parse(at)) ? new Date(at) : null, + }; + }) + .sort((a, b) => compareVersions(b.version, a.version)); +} + +const DOCS_URL = "https://github.com/mgpl-diagnostics/api/blob/main/docs/18-updates.md"; +const MIN_REFRESH_MS = 60_000; + +export class SystemService { + private cache: { at: Date; releases: Record } | null = null; + private error: string | null = null; + private inflight: Promise | null = null; + private lastAttempt = 0; + + constructor( + private readonly config: Config["updates"], + private readonly fetchFn: typeof fetch = (...a) => fetch(...a), + private readonly now: () => number = Date.now, + ) {} + + /** The api's own version (package.json of this build). */ + get version(): string { + return VERSION; + } + + private async fetchReleases(repo: string): Promise { + const res = await this.fetchFn(`${this.config.feedUrl}/repos/${repo}/releases?per_page=30`, { + headers: { + accept: "application/vnd.github+json", + "user-agent": "mgpl-api-update-check", + "x-github-api-version": "2022-11-28", + ...(this.config.githubToken ? { authorization: `Bearer ${this.config.githubToken}` } : {}), + }, + signal: AbortSignal.timeout(8_000), + }); + if (!res.ok) throw new Error(res.status === 403 || res.status === 429 ? "GitHub rate limit reached; set UPDATE_GITHUB_TOKEN or try later" : `the release feed answered ${res.status}`); + return toReleases(await res.json()); + } + + private async load(force: boolean): Promise { + const t = this.now(); + const fresh = this.cache && t - this.cache.at.getTime() < this.config.intervalMs; + if (fresh && !force) return; + // a failed check or a forced one waits at least a minute before the next + if (t - this.lastAttempt < MIN_REFRESH_MS && (this.cache || this.error)) return; + if (!this.inflight) { + this.lastAttempt = t; + this.inflight = (async () => { + try { + const [api, dashboard] = await Promise.all([this.fetchReleases(this.config.repos.api), this.fetchReleases(this.config.repos.dashboard)]); + this.cache = { at: new Date(this.now()), releases: { api, dashboard } }; + this.error = null; + } catch (e) { + // keep the last good answer; the message is ours or a network error, never a token + this.error = `Could not check for updates: ${e instanceof Error && e.name === "TimeoutError" ? "the release feed did not answer" : e instanceof Error ? e.message.slice(0, 200) : "unknown error"}`; + } finally { + this.inflight = null; + } + })(); + } + await this.inflight; + } + + private component(component: Component, current: string | null): ComponentUpdate { + const releases = this.cache?.releases[component] ?? []; + const latest = releases[0]?.version ?? null; + const known = current !== null && parseVersion(current) !== null; + const changes = known ? releases.filter((r) => compareVersions(r.version, current) > 0).slice(0, 20) : releases.slice(0, 1); + return { + component, + current, + latest, + updateAvailable: known && latest !== null && compareVersions(latest, current) > 0, + image: this.config.images[component], + changes, + }; + } + + /** Owners and admins: what runs, what's out, what changed, how to update. `dashboardVersion` comes from the dashboard build. */ + async updates(input: { dashboardVersion?: string | null; refresh?: boolean | null } = {}): Promise { + const dashboardVersion = input.dashboardVersion && parseVersion(input.dashboardVersion) ? clean(input.dashboardVersion) : null; + if (this.config.enabled) await this.load(!!input.refresh); + const components = [this.component("api", VERSION), this.component("dashboard", dashboardVersion)]; + const target = (c: ComponentUpdate) => c.latest ?? "latest"; + const [api, dash] = components as [ComponentUpdate, ComponentUpdate]; + return { + enabled: this.config.enabled, + checkedAt: this.cache?.at ?? null, + error: this.config.enabled ? this.error : null, + updateAvailable: components.some((c) => c.updateAvailable), + docsUrl: DOCS_URL, + steps: [ + "Back up the database (pg_dump, or your provider's snapshot) before every update.", + "On the server, open the folder that holds docker-compose.yml and .env.", + `In .env, set MGPL_API_VERSION=${target(api)} and MGPL_DASHBOARD_VERSION=${target(dash)} (or leave both on "latest").`, + "docker compose pull", + "docker compose up -d (db-sync adds new tables first, then the api, worker and dashboard restart)", + "docker compose ps (every service up; the api shows healthy)", + ], + components, + }; + } +} +``` + + --- ## File: src/services/util.ts @@ -9491,6 +9966,24 @@ console.log(`[sync] ${config.db.backend}: tables and indexes are in place`); ``` +--- + +## File: src/version.ts + +```ts +import { readFileSync } from "node:fs"; + +/** This build's version: package.json's, which the release workflow checks against the `v*` tag and the image tag. */ +export const VERSION: string = (() => { + try { + return (JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string }).version; + } catch { + return "0.0.0"; + } +})(); +``` + + --- ## File: src/worker.ts @@ -9553,6 +10046,7 @@ import type { Mailer } from "../src/services/auth.service"; import { createMailer, memoryTransport } from "@mgpl-diagnostics/mail-kit"; import { MGPL_LAB } from "@mgpl-diagnostics/report-kit"; import { brandFromLab, mailerFromConfig, mailKitMailer } from "../src/jobs"; +import { SystemService } from "../src/services/system.service"; let passed = 0; let failed = 0; @@ -9857,11 +10351,72 @@ async function fromConfig(url: string) { ok((await c.auth.sessionsOf(l.data?.login.user.id)).find((x) => x.id === sid)?.tlsFingerprintHash === undefined, "[config] without TLS_FINGERPRINT_HEADER a client's x-ja4 header is ignored"); await s.close(); } + await updatesSuite(url); let refused = ""; try { loadConfig({ NODE_ENV: "test", TLS_FINGERPRINT_HEADER: "x-ja4" }); } catch (e) { refused = (e as Error).message; } ok(/TRUST_PROXY/.test(refused), "[config] TLS_FINGERPRINT_HEADER without TRUST_PROXY is refused (the header would be spoofable)"); } +/** systemUpdates: versions from GitHub releases (stubbed), cache, changelog between versions, permissions, failures. */ +async function updatesSuite(url: string) { + const pw = "Lab-Owner-2026!"; + const release = (tag: string, extra: Record = {}) => ({ tag_name: tag, name: `Release ${tag}`, body: `Changes in ${tag}`, html_url: `https://github.com/x/releases/${tag}`, published_at: "2026-11-01T10:00:00Z", draft: false, prerelease: false, ...extra }); + const feeds: Record = { + "mgpl-diagnostics/api": [release("v0.0.1"), release("v99.0.0"), release("v100.0.0-rc.1", { prerelease: true }), release("v101.0.0", { draft: true }), release("not-a-version"), release("v98.1.0", { html_url: "javascript:alert(1)" })], + "mgpl-diagnostics/dashboard": [release("v0.2.0"), release("v0.3.0"), release("v0.1.0")], + }; + let calls = 0; + let status = 200; + const stub: typeof fetch = async (input, init) => { + calls++; + const u = new URL(String(input)); + const repo = u.pathname.replace(/^\/repos\//, "").replace(/\/releases$/, ""); + ok(!(init?.headers as Record)?.authorization, "[updates] no Authorization header to the feed without UPDATE_GITHUB_TOKEN"); + return new Response(status === 200 ? JSON.stringify(feeds[repo] ?? []) : "{}", { status, headers: { "content-type": "application/json" } }); + }; + const c = await createContainer({ config: loadConfig({ NODE_ENV: "test", DB_BACKEND: "pg", DATABASE_URL: url, UPDATE_CHECK: "1" }), fetch: stub }); + const s = await buildServer(c); + const post = async (query: string, token?: string, variables?: Record) => + (await s.inject({ method: "POST", url: "/graphql", headers: { "content-type": "application/json", ...(token ? { authorization: `Bearer ${token}` } : {}) }, payload: JSON.stringify({ query, variables }) })).json() as Gql; + const login = async (email: string, password: string) => (await post("mutation($i: LoginInput!) { login(input: $i) { accessToken } }", undefined, { i: { email, password } })).data?.login.accessToken as string; + const owner = await login("owner@apl.test", pw); + const Q = "query($v: String, $r: Boolean) { systemUpdates(dashboardVersion: $v, refresh: $r) { enabled error updateAvailable steps docsUrl checkedAt components { component current latest updateAvailable image changes { version notes url } } } }"; + const r = (await post(Q, owner, { v: "0.1.0" })).data?.systemUpdates; + const api = r?.components.find((x: any) => x.component === "api"); + const dash = r?.components.find((x: any) => x.component === "dashboard"); + const { VERSION } = await import("../src/version"); + ok(r?.enabled && r.updateAvailable && !r.error && api?.current === VERSION && api.latest === "99.0.0" && api.image === "ghcr.io/mgpl-diagnostics/api", `[updates] api ${VERSION} → 99.0.0 (pre-releases, drafts and non-semver tags ignored)`); + ok(api?.changes.map((x: any) => x.version).join() === "99.0.0,98.1.0" && api.changes[1].url === null, "[updates] changelog lists only the releases in between, newest first; a non-https link is dropped"); + ok(dash?.current === "0.1.0" && dash.latest === "0.3.0" && dash.updateAvailable && dash.changes.map((x: any) => x.version).join() === "0.3.0,0.2.0" && dash.changes[0].notes === "Changes in v0.3.0", "[updates] dashboard 0.1.0 → 0.3.0 with both changelogs"); + ok(r?.steps.some((x: string) => x.includes("MGPL_API_VERSION=99.0.0") && x.includes("MGPL_DASHBOARD_VERSION=0.3.0")) && r.steps.includes("docker compose pull"), "[updates] update steps name the target versions"); + const before = calls; + await post(Q, owner, { v: "0.3.0" }); + await post(Q, owner, { v: "0.3.0", r: true }); + ok(calls === before, "[updates] cached: no second call to the feed within the interval, and refresh waits a minute"); + const odd = (await post(Q, owner, { v: "