diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b48a1a95b..d31f6aecc 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -60,7 +60,7 @@ jobs: # minutes and can produce nothing actionable, because nobody edits # it and the fix for anything found there is `pnpm prisma generate`. - name: Initialise CodeQL - uses: github/codeql-action/init@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 + uses: github/codeql-action/init@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: languages: javascript-typescript build-mode: none @@ -75,6 +75,6 @@ jobs: - public - name: Analyse - uses: github/codeql-action/analyze@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 + uses: github/codeql-action/analyze@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: category: "/language:javascript-typescript" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 448ce985d..e8e5e9174 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -54,7 +54,7 @@ jobs: publish_results: true - name: Upload results - uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 + uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: sarif_file: scorecard-results.sarif category: scorecard diff --git a/.github/workflows/trivy.yml b/.github/workflows/trivy.yml index dbe96f860..5005de7d5 100644 --- a/.github/workflows/trivy.yml +++ b/.github/workflows/trivy.yml @@ -88,7 +88,7 @@ jobs: skip-dirs: src/generated - name: Upload results - uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 + uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: sarif_file: trivy-filesystem.sarif category: trivy-filesystem @@ -145,7 +145,7 @@ jobs: exit-code: "0" - name: Upload results - uses: github/codeql-action/upload-sarif@6f2ac81f5cd81fe351a197ba0b00d91d03cd2aee # v4.39.0 + uses: github/codeql-action/upload-sarif@9b3254085494b55a0f7c4c2b3d2d52cc712ce370 # v4.39.1 with: sarif_file: trivy-published-image.sarif category: trivy-published-image diff --git a/README.md b/README.md index edf748bbb..4c880ab87 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ curl -s http://localhost:3000/api/version # returns version + buildSha + built `SESSION_COOKIE_SECURE=false` is the one setting plain-HTTP self-hosts must set: without it the session cookie carries the `Secure` flag, the browser drops it over HTTP, and login silently fails. Leave it unset (or `true`) only when a TLS reverse proxy serves HTTPS — and then set `NEXT_PUBLIC_APP_URL` and `APP_URL` to the public URL first (see the [self-hosting guide](https://docs.healthlog.dev/getting-started/self-host-in-10-minutes/)). -Persistent state lives in exactly one named volume (`postgres-data`, the Postgres data); the app container is stateless, so backing up the database backs up everything. An install from v1.45 or earlier moves its Postgres 16 database into it on the first start ([details](docs/ops/postgres-major-upgrade.md)). +Persistent state lives in the database volume (`postgres-data`) and in `backups`, which holds the nightly database dumps; the app container is stateless. An install from v1.45 or earlier moves its Postgres 16 database from `pgdata` into the new volume on the first start ([details](docs/ops/postgres-major-upgrade.md)); recovering a lost server is in [disaster-recovery.md](docs/ops/disaster-recovery.md). NAS and homelab users have ready-made paths: [Unraid Community Applications](docs/self-hosting/unraid.md), [Portainer app templates](docs/self-hosting/portainer/README.md) and [TrueNAS](docs/self-hosting/truenas.md) (the Community train app is pending catalog review). diff --git a/docker-compose.yml b/docker-compose.yml index 0a6ecc172..eb6dc7dcf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -695,6 +695,10 @@ services: # # On every start it decides in a second and exits: # - no `pgdata` data (a new install): nothing to do; + # - the database service runs Postgres 16 (after going back): nothing to + # do, the live 16 volume is left alone; + # - 16 was used again after an earlier move: refuses, so the app does not + # start on the move-day copy; # - Postgres 18 already holds the database: nothing to do; # - otherwise it starts Postgres 16 inside this container on the old # volume, streams a dump into the new database in one transaction, @@ -752,14 +756,35 @@ services: log "no Postgres 16 database to move, nothing to do" exit 0 fi + # The database service must be the new major. After going back to 16 the + # service runs on this very volume: touching it then would wipe the live data. + i=0 + until NEWVER=$$(new "SHOW server_version_num" 2>/dev/null); do + i=$$((i + 1)) + [ "$$i" -lt 60 ] || { log "the database service did not answer for a minute"; exit 1; } + sleep 1 + done + if [ "$$NEWVER" -lt 180000 ]; then + log "the database service runs Postgres $$((NEWVER / 10000)), not 18: nothing to move, the old volume is left alone" + exit 0 + fi + checkpoint() { pg_controldata "$$OLD" | awk -F': *' '/^Latest checkpoint location/ { print $$2 }'; } USER_TABLES="FROM pg_tables WHERE schemaname NOT IN ('pg_catalog', 'information_schema')" TABLES=$$(new "SELECT count(*) $$USER_TABLES") if [ -e "$$MARK" ]; then + MOVED_ON=$$(sed -n 1p "$$MARK") + MOVED_CHECKPOINT=$$(sed -n 2p "$$MARK") + if [ -n "$$MOVED_CHECKPOINT" ] && [ "$$(checkpoint)" != "$$MOVED_CHECKPOINT" ]; then + log "the Postgres 16 database changed after it was moved on $$MOVED_ON: it was used again after going back to 16." + log "Postgres 18 still holds the state of that day, so it is not started on. To move the current 16 database," + log "see docs/ops/postgres-major-upgrade.md, \"Updating again after going back\"." + exit 1 + fi if [ "$$TABLES" != 0 ]; then - log "the Postgres 16 database was moved on $$(cat "$$MARK"), nothing to do" + log "the Postgres 16 database was moved on $$MOVED_ON, nothing to do" exit 0 fi - log "the Postgres 16 database was moved on $$(cat "$$MARK"), but the Postgres 18 database is empty now." + log "the Postgres 16 database was moved on $$MOVED_ON, but the Postgres 18 database is empty now." log "Not importing the old state again by itself. See docs/ops/postgres-major-upgrade.md, \"Starting over\"." exit 1 fi @@ -778,8 +803,9 @@ services: fi NEED=$$(du -sk "$$OLD" | cut -f1) FREE=$$(df -Pk /var/lib/postgresql/next | awk 'NR == 2 { print $$4 }') - if [ "$$FREE" -le "$$NEED" ]; then - log "the move needs about $$((NEED / 1024)) MiB free for the new database, the disk has $$((FREE / 1024)) MiB." + # Room for the copy, and for the dump the app takes before its first migration. + if [ "$$FREE" -le $$((NEED * 3 / 2)) ]; then + log "the move needs about $$((NEED * 3 / 2 / 1024)) MiB free for the new database and its first dump, the disk has $$((FREE / 1024)) MiB." log "Nothing was changed. Free some space and start the stack again." exit 1 fi @@ -813,6 +839,9 @@ services: vacuumdb -h db -U healthlog -d healthlog --analyze-in-stages --quiet kill -INT "$$PID" wait "$$PID" || true + # The marker keeps the old database's last checkpoint: a later start can tell + # whether 16 was used again after the move. + { date -u +%Y-%m-%dT%H:%M:%SZ; checkpoint; } > "$$MOVING" mv "$$MOVING" "$$MARK" log "done. The old volume stays as it was, as a way back; see docs/ops/postgres-major-upgrade.md" diff --git a/docs/ops/postgres-major-upgrade.md b/docs/ops/postgres-major-upgrade.md index e856dc33f..ff1c236da 100644 --- a/docs/ops/postgres-major-upgrade.md +++ b/docs/ops/postgres-major-upgrade.md @@ -17,7 +17,9 @@ Postgres 18 database is up and before the app, and it exits in a second when there is nothing to move. When the old `pgdata` volume holds a Postgres 16 database and the new one is empty, it: -1. checks there is room on the disk for a second copy; +1. checks that the database service runs Postgres 18, and that there is room + on the disk for a second copy and for the dump the app takes before its + first migration; 2. starts Postgres 16 inside its own container on the old volume, reachable only from inside that container; 3. streams a dump into Postgres 18 in a single transaction; @@ -34,8 +36,10 @@ If any step fails, the new database stays empty, the app does not start, and the log says what happened. A move cut short by a restart or a full disk is run again from scratch on the next `docker compose up -d`. -The old `pgdata` volume is read and never changed, apart from the marker -file. That is the way back. +The old `pgdata` volume keeps the database as it was. Postgres 16 runs on it +during the move, so it writes a checkpoint there (and recovers first after an +unclean stop), and the marker file is added; no data in it changes. That is +the way back. ## Going back @@ -55,6 +59,29 @@ HEALTHLOG_IMAGE_REF=:v1.45.0 docker compose up -d ``` Anything written after the move lives only in the Postgres 18 volume. +`db-upgrade` can stay in the file: it sees that the database service runs +Postgres 16 and leaves that volume alone. This also holds after a move that +stopped half-way. + +## Updating again after going back + +If you used Postgres 16 again after going back, the Postgres 18 volume holds +the state of the day of the move, not what you wrote since. `db-upgrade` +notices that the old database changed after the move and refuses to start on +the old copy. To move the current 16 database, remove the old copy and the +marker, then start again: + +```bash +docker compose down +docker volume rm _postgres-data +docker compose run --rm --entrypoint rm db-upgrade \ + /var/lib/postgresql/data/HEALTHLOG_MOVED_TO_POSTGRES_18 +docker compose up -d +``` + +`docker volume ls` shows the volume's full name. Removing it deletes what was +written on Postgres 18 before you went back; take a dump of it first if any +of that matters. ## Afterwards diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index 51ead775a..2575600c4 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -434,7 +434,9 @@ Keep the dump until the new version has run for a few days, and keep your encryption key with it: a dump without the key cannot be read. The database image is a separate matter: a new Postgres minor is a change of -the `image:` line, a new major (16 to 18) is a dump and restore, described in +the `image:` line. The move from 16 to 18 runs by itself on the first start +of the compose file from v1.46; a database you run yourself moves by dump +and restore. Both are in [`docs/ops/postgres-major-upgrade.md`](../ops/postgres-major-upgrade.md). The start log says what is about to happen before the migrations run: diff --git a/docs/self-hosting/portainer/templates.json b/docs/self-hosting/portainer/templates.json index bfa70a388..c0cebc054 100644 --- a/docs/self-hosting/portainer/templates.json +++ b/docs/self-hosting/portainer/templates.json @@ -5,7 +5,7 @@ "type": 3, "title": "HealthLog", "name": "healthlog", - "description": "Self-hosted personal health tracker (PWA + native iOS client): weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications on one encrypted timeline. AES-256-GCM at rest, no vendor cloud, no telemetry. Ships app + PostgreSQL 16 as one stack.", + "description": "Self-hosted personal health tracker (PWA + native iOS client): weight, blood pressure, pulse, blood glucose, body composition, sleep, mood, menstrual cycle, and medications on one encrypted timeline. AES-256-GCM at rest, no vendor cloud, no telemetry. Ships app + PostgreSQL 18 as one stack.", "note": "Generate each secret with: openssl rand -hex 32 (a 64-char hex string). For plain-HTTP / LAN access set SESSION_COOKIE_SECURE to false, or login fails. Set NEXT_PUBLIC_APP_URL and APP_URL to how you reach the app. Full guide: https://github.com/MBombeck/HealthLog/blob/main/docs/self-hosting/getting-started.md", "categories": ["Tools", "Other"], "platform": "linux", @@ -33,7 +33,7 @@ { "name": "HEALTHLOG_PLATFORM", "label": "Platform hint", - "description": "Opens the admin's encryption key backup step on the Portainer instructions.", + "description": "Opens the admin's recovery kit on the Portainer instructions.", "default": "portainer" }, { diff --git a/docs/self-hosting/reproductive-health-data.md b/docs/self-hosting/reproductive-health-data.md index 314b65aa2..1fcaec46a 100644 --- a/docs/self-hosting/reproductive-health-data.md +++ b/docs/self-hosting/reproductive-health-data.md @@ -95,19 +95,31 @@ different things depending on which button you press. two actions below, the row stays. A start that was folded into a later cycle stays for as long as that cycle does, because deleting the later cycle gives it back. -- **Settings → Advanced → Delete all data** removes the rows outright, tombstones +- **Settings → Advanced → Delete all data** removes the rows, tombstones included. Everything the account owns goes except what you sign in - with; the confirmation dialog lists it. -- **Deleting the account** removes the account row, and the database - cascades every record with it. + with; the confirmation dialog lists it. Before it does, it keeps a copy + of the account on the server for the deletion grace window (14 days by + default), so a mistaken wipe can be restored; the copy is erased when + the window ends. +- **Deleting the account** locks it at once and erases it, with every + record, when the grace window ends. Until then an admin can undo it. -Two consequences follow, and they matter for the seizure threat model +`ACCOUNT_DELETION_GRACE_DAYS=0` makes both immediate again, without the +copy and without an undo. + +Three consequences follow, and they matter for the seizure threat model above. A host imaged while a per-entry deletion is the only deletion you -have performed still holds that entry. And a backup taken before any of -these actions still holds whatever it captured, because a backup is a -copy of a moment. If your reason for deleting is that the data must not -be recoverable from the machine, use one of the two whole-record actions -and rotate your backups. +have performed still holds that entry; so does a host imaged inside the +grace window. A backup taken before any of these actions still holds +whatever it captured, because a backup is a copy of a moment: the nightly +database dumps keep a deleted record until they age out (`DB_BACKUP_KEEP` +nights on the server, and your bucket's lifecycle rule for `database/`). +And after the move to Postgres 18 the old `pgdata` volume is a full copy +of the database from that day until you remove it. If your reason for +deleting is that the data must not be recoverable from the machine, set +the grace window to 0 or wait it out, remove the old volume, and let the +dumps age out or delete them +([backup-restore.md](../ops/backup-restore.md)). ## Practical hardening diff --git a/docs/self-hosting/unraid/healthlog.xml b/docs/self-hosting/unraid/healthlog.xml index ba099bd70..3940fd7e5 100644 --- a/docs/self-hosting/unraid/healthlog.xml +++ b/docs/self-hosting/unraid/healthlog.xml @@ -46,14 +46,14 @@ 3000 - + false - unraid + unraid enforce diff --git a/package.json b/package.json index 2eed08de7..e372d5129 100644 --- a/package.json +++ b/package.json @@ -54,13 +54,13 @@ "mcp:stdio": "npx tsx src/cli/mcp-stdio.ts" }, "dependencies": { - "@aws-sdk/client-s3": "^3.1153.0", - "@aws-sdk/lib-storage": "3.1153.0", + "@aws-sdk/client-s3": "^3.1155.0", + "@aws-sdk/lib-storage": "3.1155.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@modelcontextprotocol/sdk": "^1.32.1", - "@napi-rs/canvas": "1.0.11", + "@napi-rs/canvas": "1.0.13", "@node-rs/argon2": "^2.2.2", "@parse/node-apn": "^8.1.0", "@prisma/adapter-pg": "^7.10.1", @@ -68,20 +68,20 @@ "@simplewebauthn/browser": "^14.0.0", "@simplewebauthn/server": "^14.0.3", "@tanstack/react-query": "5.104.5", - "@tanstack/react-virtual": "^3.14.21", + "@tanstack/react-virtual": "^3.14.22", "@types/sax": "^1.2.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.4.0", "dotenv": "^18.0.8", "fflate": "^0.8.3", - "jose": "^6.2.12", + "jose": "^6.2.13", "jspdf": "^4.2.1", "jspdf-autotable": "^5.0.9", - "lucide-react": "^1.59.0", + "lucide-react": "^1.61.0", "mmdb-lib": "^3.0.3", "next": "16.4.2", - "nodemailer": "10.1.7", + "nodemailer": "10.2.1", "otpauth": "^9.5.2", "p-limit": "^7.3.3", "pg": "^8.23.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 914dac854..81124e808 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -45,11 +45,11 @@ importers: .: dependencies: '@aws-sdk/client-s3': - specifier: ^3.1153.0 - version: 3.1153.0 + specifier: ^3.1155.0 + version: 3.1155.0 '@aws-sdk/lib-storage': - specifier: 3.1153.0 - version: 3.1153.0(@aws-sdk/client-s3@3.1153.0) + specifier: 3.1155.0 + version: 3.1155.0(@aws-sdk/client-s3@3.1155.0) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.3.1(react@19.3.1))(react@19.3.1) @@ -63,8 +63,8 @@ importers: specifier: ^1.32.1 version: 1.32.1(@cfworker/json-schema@4.1.1)(supports-color@7.2.0)(zod@4.6.7) '@napi-rs/canvas': - specifier: 1.0.11 - version: 1.0.11 + specifier: 1.0.13 + version: 1.0.13 '@node-rs/argon2': specifier: ^2.2.2 version: 2.2.2 @@ -87,8 +87,8 @@ importers: specifier: 5.104.5 version: 5.104.5(@types/react@19.3.0)(react@19.3.1) '@tanstack/react-virtual': - specifier: ^3.14.21 - version: 3.14.21(react-dom@19.3.1(react@19.3.1))(react@19.3.1) + specifier: ^3.14.22 + version: 3.14.22(react-dom@19.3.1(react@19.3.1))(react@19.3.1) '@types/sax': specifier: ^1.2.7 version: 1.2.7 @@ -108,8 +108,8 @@ importers: specifier: ^0.8.3 version: 0.8.3 jose: - specifier: ^6.2.12 - version: 6.2.12 + specifier: ^6.2.13 + version: 6.2.13 jspdf: specifier: ^4.2.1 version: 4.2.1 @@ -117,8 +117,8 @@ importers: specifier: ^5.0.9 version: 5.0.9(jspdf@4.2.1) lucide-react: - specifier: ^1.59.0 - version: 1.59.0(@types/react@19.3.0)(react@19.3.1) + specifier: ^1.61.0 + version: 1.61.0(@types/react@19.3.0)(react@19.3.1) mmdb-lib: specifier: ^3.0.3 version: 3.0.3 @@ -126,8 +126,8 @@ importers: specifier: 16.4.2 version: 16.4.2(@babel/core@7.29.7(supports-color@7.2.0))(@opentelemetry/api@1.9.1)(@playwright/test@1.64.1)(@types/node@26.6.5)(react-dom@19.3.1(react@19.3.1))(react@19.3.1) nodemailer: - specifier: 10.1.7 - version: 10.1.7 + specifier: 10.2.1 + version: 10.2.1 otpauth: specifier: ^9.5.2 version: 9.5.2 @@ -301,74 +301,74 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} - '@aws-sdk/checksums@3.1001.4': - resolution: {integrity: sha512-H8BiG/2DnfwydVxD1uDPLa1s1sfhRnVTbD2OwzfTr3WlJzgbIzICDXxBTlTMjbWNNCe4VS9X55fco+JI34L9kw==} + '@aws-sdk/checksums@3.1001.5': + resolution: {integrity: sha512-6erto9nK2OZO/woEgmFIaPh5gChgdNd7nL6jgAf8TAXz3/QHC8Pan2TFe9GYp3IxOIKCCGWPylSjtOHKfNzQlw==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-s3@3.1153.0': - resolution: {integrity: sha512-h0VScQZPfBApg1Qi4AedsOUSMZ7m758kZIg0JO0B5HtwSzQhB7Tgh6h1JoGEXELgrWADPusYMpMBDmrRAEx2yw==} + '@aws-sdk/client-s3@3.1155.0': + resolution: {integrity: sha512-l3uMeIlzWVgj/RTJTh5sKYKtQz1la3cvWLUPn1cwFH4UZpcqTH66SgJHCeB2FOE9bEAjOdbmCyPKt9aw867Fng==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.978.4': - resolution: {integrity: sha512-JvGp6y9sPX8FSs2K/EmFMZcUfdkHxqGE45+oxwGC75OIh50j2e4cbx7zcR3BwNs2DSGqJdBja5h6Vy89iIDmRQ==} + '@aws-sdk/core@3.978.5': + resolution: {integrity: sha512-ruI0AWqcrfL20XDS6SwZIJFPOBZ6N0ADa9nLzWlBhxk1aoUjbUX3JkTR9vEmk7o35XpAHi68ahZ4T6RE2U9XyA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.75': - resolution: {integrity: sha512-nc8Epg98PcYoKNx0VFlwtmZSCP+tuJ1FLWqpslET/gp4h/WYuULSJqtxppM6ywKKDdzNJPvLD7ceRfmxm3Qdxw==} + '@aws-sdk/credential-provider-env@3.972.76': + resolution: {integrity: sha512-YxmL1Ar7bV9ZkJvtv+lj+MF6le76NnZnbmgySyZuxpZaCv0/1lZ3KxKy6RIj1h9ui2hWnVjZxbmTMeO0y/NrQA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.77': - resolution: {integrity: sha512-KkYFdIQwuw9IOHfEzK78slxmVWuhcm1w6YgE3V3WVxAeRRj9q5J9jlwzpY/oszWxd4SD96FTFkDdRGBdMFF2Mw==} + '@aws-sdk/credential-provider-http@3.972.78': + resolution: {integrity: sha512-3VHknDRngoebsaqyT1iR75b5cBT2zfEtODK6yYmYltljkK1HEAQLs2FEd1IpWqAeOYdMGaG9wikngWLTufY7RA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.973.20': - resolution: {integrity: sha512-OSl2R/0zLkoeKEIy6oP2tu/bsSdY+DGgro2eOffMD2CcP8POv7rS14FY5Jzc5bjnqB1G9wv/aM1akHC2mdlhZw==} + '@aws-sdk/credential-provider-ini@3.973.21': + resolution: {integrity: sha512-aLx9hbCX6mGAyv4ZZd8UagJ4RASHVJw6BFDSIyuKmLkqNL6H47EjBPK6qaJ4B5TDV6mc+vMa0SJFYyo4mkvVQg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.82': - resolution: {integrity: sha512-8jyDPtf1WPxeQ/5xlNpyxAD6P7YKwkYeNk8B2vFEDwkPWDvb7c3tQJG5/jbNWbF2yTMIH9tDTueBkdfovYIAPw==} + '@aws-sdk/credential-provider-login@3.972.83': + resolution: {integrity: sha512-9bJU6FjRacF4FK/QHf8Dyxhur7B1grRj8C1Yh4IkpoFW2exQflPdCv0VrbemLW9ZctLNle+AE7TETs9KNvuLYg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.87': - resolution: {integrity: sha512-TKj2C0x4IMPSSOAlmeryX8fCPvvPS2pFJz2ikV9IGqyh6Ehsg4leMalCTVfV2rvMTrlGAe7X7nK0HZ3P0KZR9g==} + '@aws-sdk/credential-provider-node@3.972.88': + resolution: {integrity: sha512-AG0KID5qhQPzrFkh5b0Cws1Amytvzo0L0Xj6hMgJXEwD6ZXAqninDhSNAWJiN5zU4YVVZnuidnqwPwYJ04IamA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.75': - resolution: {integrity: sha512-ZNm8T/HJNhpS8MikDVAsym8QyUSUuOUGiCkmI3TTya7G6xhOgfRLd2iK91HhQQyvRM36eo6Xm845hHVyfToA2w==} + '@aws-sdk/credential-provider-process@3.972.76': + resolution: {integrity: sha512-pdhKFTXv1zPYhh92MTlp0jVbLWvUEV9v+rziuEkoskGGQ2iJskxHTO38nA8UAbT/N4IM3n8OZJ9rIY5IBO15/A==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.973.19': - resolution: {integrity: sha512-qHGDAGesyWtRw3BapyFkYSUzVNrC1CWPS6+S2lf/MLMEkWB2gR3Bl6imVHy1nhImXKS+gcuUDzbu39BnsHJc1Q==} + '@aws-sdk/credential-provider-sso@3.973.20': + resolution: {integrity: sha512-DlxrAB3MnjNLx/da7b89W+3DyJQYso/q7Ns15MsST2IV2nkAYQOYTUCaffNSjgrpMP99Dm2mV2jxzcbyF94ZfA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.81': - resolution: {integrity: sha512-nHkgkMIC6NHgqfI6UAQq5cCS4bUkNxkT+r9fXCq/X4IKfX/DHGZ8UM3T4b78OXpWK5OlzEzN4kxwyax0W/MUCA==} + '@aws-sdk/credential-provider-web-identity@3.972.82': + resolution: {integrity: sha512-LUo46IqPTJ4xKBsrLuoaxiv/D2GTBCcFDMD1y3jKIqPR/p7Rw14RJFZ0iRWftqZtyLPQX9JMGnSi3mBu4y/pNA==} engines: {node: '>=20.0.0'} - '@aws-sdk/lib-storage@3.1153.0': - resolution: {integrity: sha512-zshL4tYVwTXfEOQ0iqNsXUPq/0aoZwOHrAsqlBmGcAij1+8qc79rm9LpzBuEV7KWiSMetZ+89n7mSYgcJKFgyA==} + '@aws-sdk/lib-storage@3.1155.0': + resolution: {integrity: sha512-ThvepKZILBOGCO1rsRsuAWps6hwfMfFVVhRpNrxup+o0q/Hyi50IaDSGZYxrZMPbWH/8pG2LFld//OYDmyr48A==} engines: {node: '>=20.0.0'} peerDependencies: - '@aws-sdk/client-s3': ^3.1153.0 + '@aws-sdk/client-s3': ^3.1155.0 - '@aws-sdk/middleware-sdk-s3@3.972.80': - resolution: {integrity: sha512-zX3lXN/9XOUAuGpxgt3wXwZrXhTjQYEgRsqa856PbZWpOtleUWmCMbhlkLfusPuhW24cOC/8JYR8hnRalrrIkQ==} + '@aws-sdk/middleware-sdk-s3@3.972.81': + resolution: {integrity: sha512-jfanv1pV0sJFZw6/DPiNjvK7B478god0zOum5yIWo+/+1Gy+CSXWpGBWx+3nBJyDvjcu/2kUMr4ADUp9BHH34Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/nested-clients@3.997.49': - resolution: {integrity: sha512-zjqIqzTYLRKhT7TPJDUreK9vG9PjhmnfLCaTaqArkRT7j+Ogl0tuXB7MpSd8rlRfRT+xTOeyNFWaOfyNcf51hg==} + '@aws-sdk/nested-clients@3.997.50': + resolution: {integrity: sha512-nKb8qu4QsnpZTq/6JIXc72mzuzfh7BK4sUQkpQznPec2z4W7Ob2xYkcM4i5e1yGTBnbJVeFv/0sf3jcz85hclA==} engines: {node: '>=20.0.0'} - '@aws-sdk/signature-v4-multi-region@3.996.48': - resolution: {integrity: sha512-cFDXfEbXaGwbnpvR/DyqfxfjxpjXVRVthQLgkwvcfDS5u3+8G9WSj1X1Ag/pTRg/QOeOno1Tq3ycEdrKsCUcOA==} + '@aws-sdk/signature-v4-multi-region@3.996.49': + resolution: {integrity: sha512-nvuAZNRrRRVCiARRKsYlQ0VnXiEQiq7vXPH+UA+LSo+kN3Djl9eo/V2r0udkw85PnsNshmv2Em1clld5M3j67w==} engines: {node: '>=20.0.0'} - '@aws-sdk/token-providers@3.1153.0': - resolution: {integrity: sha512-6bKJ2OfLboxhzeJ3hBDeB8L6emCYf9DfdrjBAharLrY42J1RV/6jA6G8jvfNghzID8Yb40BdSY7jQMtWx9Ot0Q==} + '@aws-sdk/token-providers@3.1155.0': + resolution: {integrity: sha512-iIcQnYLhWuq9qOLQbIWFrxF3PF0svVeZvHnUS4DUnO4zUFmFHIyGyjqrntXG+e/5PUgfavV0pYfDIKHxTzN2LA==} engines: {node: '>=20.0.0'} - '@aws-sdk/types@3.974.6': - resolution: {integrity: sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==} + '@aws-sdk/types@3.975.0': + resolution: {integrity: sha512-WQlhs6juByzWOzj5fwxOx0mPJaEqMcG/sDpl3Id61B3XhKE63xY+0QWcnG+sWR/sCfprmreyUo1lM7IKZmHMzw==} engines: {node: '>=20.0.0'} '@aws-sdk/xml-builder@3.972.41': @@ -1237,79 +1237,79 @@ packages: resolution: {integrity: sha512-VVPPgHyQ6ShqnrmDWuxjmUIsO9gWyOZFmuOfLd9LfBGQJwZfy0gvv9pbHSJuoFNIYC7ZDX9aoFwowjcdSC4E8w==} engines: {node: '>=18'} - '@napi-rs/canvas-android-arm64@1.0.11': - resolution: {integrity: sha512-dtJS4FQEfH9OwSYLtMrEnj7T8DzBbJc/97LI/OkiWOYpW/iW5t6R/gcxVrUXS8l45gCVpwMufgucRe/pe0ISqA==} + '@napi-rs/canvas-android-arm64@1.0.13': + resolution: {integrity: sha512-trn7AaDzQ+yeeA0ezhkC7cB1ZDPtp4UYk0EvB5F8qtYL9jEVWtVrU30WOpI52DszoTxaTsCgf5PP4ZVaOlOIHQ==} engines: {node: '>= 10'} cpu: [arm64] os: [android] - '@napi-rs/canvas-darwin-arm64@1.0.11': - resolution: {integrity: sha512-J/JylWoJTG8OQqZaiqly4IODyAxNofUlZo0wtSBJtHk6AdymbdIkPynAM1JvQaZpPaG5h+msk9QuK8y4i1ooqw==} + '@napi-rs/canvas-darwin-arm64@1.0.13': + resolution: {integrity: sha512-6Y2djEetL8copxfNK6hg7oig/xqTwOchv19hBOm+zBNdKc/02Z3kjZN5wrLzv/l5O04/bAQS6I/AlQpwBlXgPg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@napi-rs/canvas-darwin-x64@1.0.11': - resolution: {integrity: sha512-nlecCk3xy06eAGC7krZtzNDm9UNTHKuHQYK8ZYegdjzgSL8anGt/eLJZm86OWaNgZkwQwvDqfSmG8Nqe6MPZNQ==} + '@napi-rs/canvas-darwin-x64@1.0.13': + resolution: {integrity: sha512-OZoPwwu4TMMg8GTCFBrg8UFExwQTU/55i3uTC/izDat6PHE+ro9dimyQoOboFf91+NY7Pnn70ScR8f8I8+eZXw==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@napi-rs/canvas-linux-arm-gnueabihf@1.0.11': - resolution: {integrity: sha512-DKImmG+sTPolE0kGPsYPBxTtOjb3INALp32O2lW2CfSr0bAzSEKKuu+GrQbtilClyoPOahUErS3OrznFDraoWA==} + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.13': + resolution: {integrity: sha512-dZSYaKROuswU2eUWG5aBKndNVe68elo1YX93xtRax7q658tj8rYE+HwduovWJgs6qwv1r1/CLR60nDeBbyjvyg==} engines: {node: '>= 10'} cpu: [arm] os: [linux] - '@napi-rs/canvas-linux-arm64-gnu@1.0.11': - resolution: {integrity: sha512-55sykACGENj5j1rhj+81AO8JbR9HYgGjRZ7/H+pNr4VE1DvINKYNMTcutk8ZmWxk276jAQvUKJ3+Kdj9GXU3lQ==} + '@napi-rs/canvas-linux-arm64-gnu@1.0.13': + resolution: {integrity: sha512-W6FzYE8ADHI93In1vi80KBL6btDtAMuj50Ch5ptkWS8xuntyzbR4h5JDUmXoakxZn2yMInksudTEqVSWoFqFGg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@napi-rs/canvas-linux-arm64-musl@1.0.11': - resolution: {integrity: sha512-6Jz6jys17yXydO+WdJgu7JhoB4K7/w9sWHByzzTmLjQghUx3TDMWvLv+UcCdnWhg4oprET67EMymnrETdeGNAg==} + '@napi-rs/canvas-linux-arm64-musl@1.0.13': + resolution: {integrity: sha512-+6fQ+rR49hkAa4voh2stLIwQ+4t+gmoseM8Bc53/DlMXb73IKymDPWK9A/NtWeqTW4ijWppYuyuxS2hPI8tzNA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@napi-rs/canvas-linux-riscv64-gnu@1.0.11': - resolution: {integrity: sha512-lf2XeEebWiUYVOVEjLXxF7dfXrBBSEOBn9AqBh39VYXrPt5cDqI+1t58IFSjn8npqkg5jIOs8TQbjKABbFW1gQ==} + '@napi-rs/canvas-linux-riscv64-gnu@1.0.13': + resolution: {integrity: sha512-17XEMO3t9RW/TtHtSFf2AkaSWuCWdzbPeidz7zrOFawHxoLSJCnpTI1hN5aUvrZNnkNNmt/vZVJCh0ryUaik3A==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] libc: [glibc] - '@napi-rs/canvas-linux-x64-gnu@1.0.11': - resolution: {integrity: sha512-iDapw7nFSE5tsmQxcH2+KotJhyfVPmHd6vUOQN/Em+8WNbkZJwJ/rDowlJMFnMl0SYSDSfgKCNUGi6Sn5++Smg==} + '@napi-rs/canvas-linux-x64-gnu@1.0.13': + resolution: {integrity: sha512-lc48IwknVHBsNzXn9Xs5OkUsRX38Ml3Z1aOybQ51mMEzZckm8+4A0qROlyYHEoBPzr1kL3IU8ZUAqDb7wtdX8w==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@napi-rs/canvas-linux-x64-musl@1.0.11': - resolution: {integrity: sha512-26q26AyITo3CGZX4HWFIMgBMA1lu3YN07gxzBm4tW6Hglq/OVyTP50kDrmWuKybCX6dh3m8Ly+c7YhI+sqCgCQ==} + '@napi-rs/canvas-linux-x64-musl@1.0.13': + resolution: {integrity: sha512-bGY1r26S2bs8UNFJcPWcCSNdCFbKep5PATsvtgtC9A5u6SRgr2/jJE/RlhHL5m9ZpixNDhVkLY/5h16Kx5V9YA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@napi-rs/canvas-win32-arm64-msvc@1.0.11': - resolution: {integrity: sha512-cUO96IdsfvIj2S+2PNcDSkTcLhjPj8pIOEWRV1Kj70MOP4wnE9zSk7N4C1EKHlFGRcgdR5AkbnBZj0dfHK9nbQ==} + '@napi-rs/canvas-win32-arm64-msvc@1.0.13': + resolution: {integrity: sha512-2vRPBZoaIvJrUkDhZTE2Hn0YrnpJRCWVM3EJACoBKjuWI6WgPxGtUoRMsxRviCsyks8bKTSFdyw14CellnG2qA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@napi-rs/canvas-win32-x64-msvc@1.0.11': - resolution: {integrity: sha512-DFYp/ZQRdKunI/LPc0UkRXG1vlNjJKOJf8O8Ctb8+wm2Nt2ELg9Eamc31D2VoakkfGo8L+z0Iejz6WtFbQwyGw==} + '@napi-rs/canvas-win32-x64-msvc@1.0.13': + resolution: {integrity: sha512-DUlW3lZPz63kYsf8mxgyyZuevhlH0T3/BGP9l+NazhB0F1+gL6D4hqZdnhZpw24DlWmTpATDcyYSZlJOi+DdfQ==} engines: {node: '>= 10'} cpu: [x64] os: [win32] - '@napi-rs/canvas@1.0.11': - resolution: {integrity: sha512-vt5tog1cr+QKadIddGe+3LrcX0EmjEd0izF717Uwf92mBmgYfq2XRmL2r4bZU40spB69exxhrBn9I91HYEr/7w==} + '@napi-rs/canvas@1.0.13': + resolution: {integrity: sha512-b87ugZpQ5ViwqVBkjervxpwa0GmJPsKHLBNoZN6GpjWeHzTbDUGryriq+2DYW2XbB+xCqSIINQysC0/I87nPbw==} engines: {node: '>= 10'} '@napi-rs/wasm-runtime@1.2.5': @@ -2792,8 +2792,8 @@ packages: resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} engines: {node: '>=18'} - '@smithy/core@3.36.0': - resolution: {integrity: sha512-3hJiwG1F5t1SoXqp/yOCmYXziyx7ZVqF63S5W6YU201e/qwVaHPex+E/3vvyY9a3ORq/b+BydeNZpP2saQ9X2g==} + '@smithy/core@3.36.2': + resolution: {integrity: sha512-oRw0rB9QSUiCZfEs23G5F4Af+b2rQt1lItIICWu5IejFNd+uPM8SWqiKLMpQ6bX1Q7slsLbjuoE2niErFtkVPQ==} engines: {node: '>=18.0.0'} '@smithy/credential-provider-imds@4.5.2': @@ -2952,14 +2952,14 @@ packages: '@types/react': optional: true - '@tanstack/react-virtual@3.14.21': - resolution: {integrity: sha512-wWE/ZJDNNDp18JHWy7n5PQooqerHePVkEEu6Op0twKQ2InF9DPhYMQptQY0syDEviMd3/r8D0OUvKJibDGm9HQ==} + '@tanstack/react-virtual@3.14.22': + resolution: {integrity: sha512-zNiB9IF3prySTfAUrj9tPHjD/lonBYkfxrPP4v4ITUx+qwHajJR5cY/xirjvwc+KWkur10GX3TpsbKh7JkD8Lw==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/virtual-core@3.19.6': - resolution: {integrity: sha512-CsTW892z23gy7IyxYTTorVu1uZWB+0N349rSMgKz4rFLPpLza7F7TpcCKeI5iUAwYQCt8QUlOU67J+KXCxtpGw==} + '@tanstack/virtual-core@3.19.7': + resolution: {integrity: sha512-gxy5SvH0mFRJ0YujtrflB/1HAu9Wn1KGvL0bjZfQkYVVMsIpQiwcf1mR06/ViN4NjllUb4HNCjqE9FGwgTvMCQ==} '@tesseract.js-data/deu@1.0.0': resolution: {integrity: sha512-3ter1p2gFAiCd5CMC+PodZ9YY2UDomkKj+ytlKeeZqOUmhLx1SDnYPf5zog0g5MBbxYveW+8omI3PUflqHfZ4Q==} @@ -5040,8 +5040,8 @@ packages: resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} hasBin: true - jose@6.2.12: - resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==} + jose@6.2.13: + resolution: {integrity: sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==} js-md4@0.3.2: resolution: {integrity: sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==} @@ -5420,8 +5420,8 @@ packages: resolution: {integrity: sha512-5J9ysMYUpYIg9RF2vJpy9SinEmSviFSe0GyPpCQ4L5QSkLAgeLXlTAOu2ZwWUU5m+0SBl6gUU1R1ZQB3aKypfA==} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} - lucide-react@1.59.0: - resolution: {integrity: sha512-5qgMyyCPU6OvnKpLS2t693+ZtiqA8oaSweWoUows0BLFKFf6V75m4+CptpmohZHpD5IuOVIBXHBBiv2vpLJmtw==} + lucide-react@1.61.0: + resolution: {integrity: sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==} peerDependencies: '@types/react': '*' react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -5633,8 +5633,8 @@ packages: resolution: {integrity: sha512-BdiKAgbfRlvfMq/XzO/AF9NPmaA8a+YpLAyAFTVFBj3gARrAIfWvNq7Q7NE4se3KgFu0KkrvQMxTS0XVnsGzHg==} engines: {node: '>=18'} - nodemailer@10.1.7: - resolution: {integrity: sha512-OQa6FDln5fLttmqSbjDiRYvFDOFNrBqfiOwHPlEbURil3b+vPB1v8xpi+Sp46drzNDlsTnNsS502vthmz56r4w==} + nodemailer@10.2.1: + resolution: {integrity: sha512-ZrDNsPFoDMwefNs7sJwrYiEJ/CG4ClJWDZv05iNJsEBWa8GRDry1QJHTJxsuTAY9eDDKjX4k+q3SxslXJOao7w==} engines: {node: '>=20.0.0'} non-error@0.1.0: @@ -7268,170 +7268,170 @@ snapshots: '@alloc/quick-lru@5.2.0': {} - '@aws-sdk/checksums@3.1001.4': + '@aws-sdk/checksums@3.1001.5': dependencies: - '@aws-sdk/core': 3.978.4 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.36.0 + '@aws-sdk/core': 3.978.5 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.36.2 '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/client-s3@3.1153.0': + '@aws-sdk/client-s3@3.1155.0': dependencies: - '@aws-sdk/checksums': 3.1001.4 - '@aws-sdk/core': 3.978.4 - '@aws-sdk/credential-provider-node': 3.972.87 - '@aws-sdk/middleware-sdk-s3': 3.972.80 - '@aws-sdk/signature-v4-multi-region': 3.996.48 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.36.0 + '@aws-sdk/checksums': 3.1001.5 + '@aws-sdk/core': 3.978.5 + '@aws-sdk/credential-provider-node': 3.972.88 + '@aws-sdk/middleware-sdk-s3': 3.972.81 + '@aws-sdk/signature-v4-multi-region': 3.996.49 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.36.2 '@smithy/fetch-http-handler': 5.8.0 '@smithy/node-http-handler': 4.12.1 '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/core@3.978.4': + '@aws-sdk/core@3.978.5': dependencies: - '@aws-sdk/types': 3.974.6 + '@aws-sdk/types': 3.975.0 '@aws-sdk/xml-builder': 3.972.41 '@aws/lambda-invoke-store': 0.3.0 - '@smithy/core': 3.36.0 + '@smithy/core': 3.36.2 '@smithy/signature-v4': 5.7.4 '@smithy/types': 4.19.0 bowser: 2.14.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.75': + '@aws-sdk/credential-provider-env@3.972.76': dependencies: - '@aws-sdk/core': 3.978.4 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.36.0 + '@aws-sdk/core': 3.978.5 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.36.2 '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-http@3.972.77': + '@aws-sdk/credential-provider-http@3.972.78': dependencies: - '@aws-sdk/core': 3.978.4 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.36.0 + '@aws-sdk/core': 3.978.5 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.36.2 '@smithy/fetch-http-handler': 5.8.0 '@smithy/node-http-handler': 4.12.1 '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.973.20': + '@aws-sdk/credential-provider-ini@3.973.21': dependencies: - '@aws-sdk/core': 3.978.4 - '@aws-sdk/credential-provider-env': 3.972.75 - '@aws-sdk/credential-provider-http': 3.972.77 - '@aws-sdk/credential-provider-login': 3.972.82 - '@aws-sdk/credential-provider-process': 3.972.75 - '@aws-sdk/credential-provider-sso': 3.973.19 - '@aws-sdk/credential-provider-web-identity': 3.972.81 - '@aws-sdk/nested-clients': 3.997.49 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.36.0 + '@aws-sdk/core': 3.978.5 + '@aws-sdk/credential-provider-env': 3.972.76 + '@aws-sdk/credential-provider-http': 3.972.78 + '@aws-sdk/credential-provider-login': 3.972.83 + '@aws-sdk/credential-provider-process': 3.972.76 + '@aws-sdk/credential-provider-sso': 3.973.20 + '@aws-sdk/credential-provider-web-identity': 3.972.82 + '@aws-sdk/nested-clients': 3.997.50 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.36.2 '@smithy/credential-provider-imds': 4.5.2 '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-login@3.972.82': + '@aws-sdk/credential-provider-login@3.972.83': dependencies: - '@aws-sdk/core': 3.978.4 - '@aws-sdk/nested-clients': 3.997.49 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.36.0 + '@aws-sdk/core': 3.978.5 + '@aws-sdk/nested-clients': 3.997.50 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.36.2 '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-node@3.972.87': + '@aws-sdk/credential-provider-node@3.972.88': dependencies: - '@aws-sdk/credential-provider-env': 3.972.75 - '@aws-sdk/credential-provider-http': 3.972.77 - '@aws-sdk/credential-provider-ini': 3.973.20 - '@aws-sdk/credential-provider-process': 3.972.75 - '@aws-sdk/credential-provider-sso': 3.973.19 - '@aws-sdk/credential-provider-web-identity': 3.972.81 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.36.0 + '@aws-sdk/credential-provider-env': 3.972.76 + '@aws-sdk/credential-provider-http': 3.972.78 + '@aws-sdk/credential-provider-ini': 3.973.21 + '@aws-sdk/credential-provider-process': 3.972.76 + '@aws-sdk/credential-provider-sso': 3.973.20 + '@aws-sdk/credential-provider-web-identity': 3.972.82 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.36.2 '@smithy/credential-provider-imds': 4.5.2 '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-process@3.972.75': + '@aws-sdk/credential-provider-process@3.972.76': dependencies: - '@aws-sdk/core': 3.978.4 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.36.0 + '@aws-sdk/core': 3.978.5 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.36.2 '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.973.19': + '@aws-sdk/credential-provider-sso@3.973.20': dependencies: - '@aws-sdk/core': 3.978.4 - '@aws-sdk/nested-clients': 3.997.49 - '@aws-sdk/token-providers': 3.1153.0 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.36.0 + '@aws-sdk/core': 3.978.5 + '@aws-sdk/nested-clients': 3.997.50 + '@aws-sdk/token-providers': 3.1155.0 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.36.2 '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/credential-provider-web-identity@3.972.81': + '@aws-sdk/credential-provider-web-identity@3.972.82': dependencies: - '@aws-sdk/core': 3.978.4 - '@aws-sdk/nested-clients': 3.997.49 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.36.0 + '@aws-sdk/core': 3.978.5 + '@aws-sdk/nested-clients': 3.997.50 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.36.2 '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/lib-storage@3.1153.0(@aws-sdk/client-s3@3.1153.0)': + '@aws-sdk/lib-storage@3.1155.0(@aws-sdk/client-s3@3.1155.0)': dependencies: - '@aws-sdk/client-s3': 3.1153.0 - '@smithy/core': 3.36.0 + '@aws-sdk/client-s3': 3.1155.0 + '@smithy/core': 3.36.2 '@smithy/types': 4.19.0 buffer: 5.6.0 events: 3.3.0 stream-browserify: 3.0.0 tslib: 2.8.1 - '@aws-sdk/middleware-sdk-s3@3.972.80': + '@aws-sdk/middleware-sdk-s3@3.972.81': dependencies: - '@aws-sdk/core': 3.978.4 - '@aws-sdk/signature-v4-multi-region': 3.996.48 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.36.0 + '@aws-sdk/core': 3.978.5 + '@aws-sdk/signature-v4-multi-region': 3.996.49 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.36.2 '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/nested-clients@3.997.49': + '@aws-sdk/nested-clients@3.997.50': dependencies: - '@aws-sdk/core': 3.978.4 - '@aws-sdk/signature-v4-multi-region': 3.996.48 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.36.0 + '@aws-sdk/core': 3.978.5 + '@aws-sdk/signature-v4-multi-region': 3.996.49 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.36.2 '@smithy/fetch-http-handler': 5.8.0 '@smithy/node-http-handler': 4.12.1 '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/signature-v4-multi-region@3.996.48': + '@aws-sdk/signature-v4-multi-region@3.996.49': dependencies: - '@aws-sdk/types': 3.974.6 + '@aws-sdk/types': 3.975.0 '@smithy/signature-v4': 5.7.4 '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/token-providers@3.1153.0': + '@aws-sdk/token-providers@3.1155.0': dependencies: - '@aws-sdk/core': 3.978.4 - '@aws-sdk/nested-clients': 3.997.49 - '@aws-sdk/types': 3.974.6 - '@smithy/core': 3.36.0 + '@aws-sdk/core': 3.978.5 + '@aws-sdk/nested-clients': 3.997.50 + '@aws-sdk/types': 3.975.0 + '@smithy/core': 3.36.2 '@smithy/types': 4.19.0 tslib: 2.8.1 - '@aws-sdk/types@3.974.6': + '@aws-sdk/types@3.975.0': dependencies: '@smithy/types': 4.19.0 tslib: 2.8.1 @@ -8368,7 +8368,7 @@ snapshots: express: 5.2.1(supports-color@7.2.0) express-rate-limit: 8.6.2(express@5.2.1(supports-color@7.2.0))(supports-color@7.2.0) hono: 4.13.13 - jose: 6.2.12 + jose: 6.2.13 json-schema-typed: 8.0.2 pkce-challenge: 5.0.1 raw-body: 3.0.2 @@ -8389,52 +8389,52 @@ snapshots: strict-event-emitter: 0.5.1 optional: true - '@napi-rs/canvas-android-arm64@1.0.11': + '@napi-rs/canvas-android-arm64@1.0.13': optional: true - '@napi-rs/canvas-darwin-arm64@1.0.11': + '@napi-rs/canvas-darwin-arm64@1.0.13': optional: true - '@napi-rs/canvas-darwin-x64@1.0.11': + '@napi-rs/canvas-darwin-x64@1.0.13': optional: true - '@napi-rs/canvas-linux-arm-gnueabihf@1.0.11': + '@napi-rs/canvas-linux-arm-gnueabihf@1.0.13': optional: true - '@napi-rs/canvas-linux-arm64-gnu@1.0.11': + '@napi-rs/canvas-linux-arm64-gnu@1.0.13': optional: true - '@napi-rs/canvas-linux-arm64-musl@1.0.11': + '@napi-rs/canvas-linux-arm64-musl@1.0.13': optional: true - '@napi-rs/canvas-linux-riscv64-gnu@1.0.11': + '@napi-rs/canvas-linux-riscv64-gnu@1.0.13': optional: true - '@napi-rs/canvas-linux-x64-gnu@1.0.11': + '@napi-rs/canvas-linux-x64-gnu@1.0.13': optional: true - '@napi-rs/canvas-linux-x64-musl@1.0.11': + '@napi-rs/canvas-linux-x64-musl@1.0.13': optional: true - '@napi-rs/canvas-win32-arm64-msvc@1.0.11': + '@napi-rs/canvas-win32-arm64-msvc@1.0.13': optional: true - '@napi-rs/canvas-win32-x64-msvc@1.0.11': + '@napi-rs/canvas-win32-x64-msvc@1.0.13': optional: true - '@napi-rs/canvas@1.0.11': + '@napi-rs/canvas@1.0.13': optionalDependencies: - '@napi-rs/canvas-android-arm64': 1.0.11 - '@napi-rs/canvas-darwin-arm64': 1.0.11 - '@napi-rs/canvas-darwin-x64': 1.0.11 - '@napi-rs/canvas-linux-arm-gnueabihf': 1.0.11 - '@napi-rs/canvas-linux-arm64-gnu': 1.0.11 - '@napi-rs/canvas-linux-arm64-musl': 1.0.11 - '@napi-rs/canvas-linux-riscv64-gnu': 1.0.11 - '@napi-rs/canvas-linux-x64-gnu': 1.0.11 - '@napi-rs/canvas-linux-x64-musl': 1.0.11 - '@napi-rs/canvas-win32-arm64-msvc': 1.0.11 - '@napi-rs/canvas-win32-x64-msvc': 1.0.11 + '@napi-rs/canvas-android-arm64': 1.0.13 + '@napi-rs/canvas-darwin-arm64': 1.0.13 + '@napi-rs/canvas-darwin-x64': 1.0.13 + '@napi-rs/canvas-linux-arm-gnueabihf': 1.0.13 + '@napi-rs/canvas-linux-arm64-gnu': 1.0.13 + '@napi-rs/canvas-linux-arm64-musl': 1.0.13 + '@napi-rs/canvas-linux-riscv64-gnu': 1.0.13 + '@napi-rs/canvas-linux-x64-gnu': 1.0.13 + '@napi-rs/canvas-linux-x64-musl': 1.0.13 + '@napi-rs/canvas-win32-arm64-msvc': 1.0.13 + '@napi-rs/canvas-win32-x64-msvc': 1.0.13 '@napi-rs/wasm-runtime@1.2.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: @@ -9887,32 +9887,32 @@ snapshots: '@sindresorhus/merge-streams@4.0.0': {} - '@smithy/core@3.36.0': + '@smithy/core@3.36.2': dependencies: '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/credential-provider-imds@4.5.2': dependencies: - '@smithy/core': 3.36.0 + '@smithy/core': 3.36.2 '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/fetch-http-handler@5.8.0': dependencies: - '@smithy/core': 3.36.0 + '@smithy/core': 3.36.2 '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/node-http-handler@4.12.1': dependencies: - '@smithy/core': 3.36.0 + '@smithy/core': 3.36.2 '@smithy/types': 4.19.0 tslib: 2.8.1 '@smithy/signature-v4@5.7.4': dependencies: - '@smithy/core': 3.36.0 + '@smithy/core': 3.36.2 '@smithy/types': 4.19.0 tslib: 2.8.1 @@ -10072,13 +10072,13 @@ snapshots: optionalDependencies: '@types/react': 19.3.0 - '@tanstack/react-virtual@3.14.21(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': + '@tanstack/react-virtual@3.14.22(react-dom@19.3.1(react@19.3.1))(react@19.3.1)': dependencies: - '@tanstack/virtual-core': 3.19.6 + '@tanstack/virtual-core': 3.19.7 react: 19.3.1 react-dom: 19.3.1(react@19.3.1) - '@tanstack/virtual-core@3.19.6': {} + '@tanstack/virtual-core@3.19.7': {} '@tesseract.js-data/deu@1.0.0': {} @@ -12354,7 +12354,7 @@ snapshots: jiti@2.7.0: {} - jose@6.2.12: {} + jose@6.2.13: {} js-md4@0.3.2: {} @@ -12677,7 +12677,7 @@ snapshots: lru.min@1.1.5: {} - lucide-react@1.59.0(@types/react@19.3.0)(react@19.3.1): + lucide-react@1.61.0(@types/react@19.3.0)(react@19.3.1): dependencies: react: 19.3.1 optionalDependencies: @@ -12868,7 +12868,7 @@ snapshots: node-releases@2.0.58: {} - nodemailer@10.1.7: {} + nodemailer@10.2.1: {} non-error@0.1.0: {} @@ -13113,7 +13113,7 @@ snapshots: pdfjs-dist@6.4.299: optionalDependencies: - '@napi-rs/canvas': 1.0.11 + '@napi-rs/canvas': 1.0.13 perfect-debounce@2.1.0: {} diff --git a/scripts/compose-db-upgrade-check.sh b/scripts/compose-db-upgrade-check.sh index ad49e8b39..b22f3dd02 100755 --- a/scripts/compose-db-upgrade-check.sh +++ b/scripts/compose-db-upgrade-check.sh @@ -11,7 +11,11 @@ # empty the new database and run the move again. # 5. Empty the new volume: it must refuse to import the old state again, # and move it once more after the marker is removed as the guide says. -# 6. A new install with no old volume: nothing to do. +# 6. Going back to 16 after a move that did not finish: db-upgrade leaves +# the live 16 database alone. +# 7. Using 16 again after a move, then updating: refused, not the old copy. +# 8. Updating again as the guide says: the current 16 database moves. +# 9. A new install with no old volume: nothing to do. # # Needs Docker and the repo's node_modules (for the migrations). Run it from # anywhere in the repo: `bash scripts/compose-db-upgrade-check.sh`. @@ -85,7 +89,7 @@ wait_for_port() { migrate_old() { local started=$SECONDS until DATABASE_URL="postgresql://healthlog:$POSTGRES_PASSWORD@127.0.0.1:$PORT/healthlog?schema=public" \ - node "$ROOT/node_modules/prisma/build/index.js" migrate deploy --schema "$ROOT/prisma/schema.prisma" >"$WORK/migrate.log" 2>&1; do + node "$ROOT/node_modules/prisma/build/index.js" migrate deploy --schema "$WORK/old-prisma/prisma/schema.prisma" >"$WORK/migrate.log" 2>&1; do if [ $((SECONDS - started)) -ge 30 ] || ! grep -q "P1001" "$WORK/migrate.log"; then cat "$WORK/migrate.log" >&2 return 1 @@ -94,6 +98,18 @@ migrate_old() { done } +# The v1.45 schema, migrated with v1.45's own migrations. +mkdir -p "$WORK/old-prisma" +git -C "$ROOT" archive "$OLD_REF" prisma | tar -x -C "$WORK/old-prisma" +# The current compose file with its database pointed back at Postgres 16, the +# way docs/ops/postgres-major-upgrade.md "Going back" describes it. +sed -e 's|image: postgres:18\.[0-9]*-alpine|image: postgres:16.15-alpine|' \ + -e 's|- postgres-data:/var/lib/postgresql$|- pgdata:/var/lib/postgresql/data|' \ + "$ROOT/docker-compose.yml" >"$WORK/back.yml" +grep -q 'image: postgres:16.15-alpine' "$WORK/back.yml" && grep -q -- '- pgdata:/var/lib/postgresql/data' "$WORK/back.yml" || + { echo "FAIL: could not derive the going-back compose file" >&2; exit 1; } +back() { docker compose -p "$PROJECT" -f "$WORK/back.yml" "$@"; } + down_all step "a v1.45 database on Postgres 16" old up -d --wait db >"$WORK/up.log" 2>&1 || { cat "$WORK/up.log" >&2; fail "the v1.45 database did not start"; } @@ -157,10 +173,42 @@ CODE=$(run_upgrade) [ "$CODE" = 0 ] || fail "db-upgrade exited $CODE after the marker was removed" [ "$(probe docker exec healthlog-db)" = "$PROBE" ] || fail "the probe rows differ after starting over" +step "going back to 16 after a move that did not finish leaves 16 alone" +new down >/dev/null 2>&1 +docker volume rm "${PROJECT}_postgres-data" >/dev/null +docker run --rm -v "${PROJECT}_pgdata:/old" postgres:16.15-alpine \ + mv /old/HEALTHLOG_MOVED_TO_POSTGRES_18 /old/HEALTHLOG_MOVING_TO_POSTGRES_18 +back up -d db-upgrade >/dev/null 2>&1 || true +CODE=$(docker wait healthlog-db-upgrade) +upgrade_log +[ "$CODE" = 0 ] || fail "db-upgrade exited $CODE beside a Postgres 16 database service" +upgrade_log | grep -q 'not 18: nothing to move' || fail "db-upgrade did not recognise the Postgres 16 service" +[ "$(probe docker exec healthlog-db)" = "$PROBE" ] || fail "the live Postgres 16 data changed beside db-upgrade" + +step "using 16 again after a move, then updating, is refused instead of serving the old copy" +back down >/dev/null 2>&1 +CODE=$(run_upgrade) +[ "$CODE" = 0 ] || fail "the move after going back exited $CODE" +back up -d db >/dev/null 2>&1 +back exec -T db psql -U healthlog -d healthlog -v ON_ERROR_STOP=1 -qc \ + "INSERT INTO check_probe.rows VALUES (50001, 'written on 16 after the move')" +back down >/dev/null 2>&1 +CODE=$(run_upgrade) +[ "$CODE" = 1 ] || fail "db-upgrade exited $CODE after 16 was used again, expected 1" +upgrade_log | grep -q 'changed after it was moved' || fail "the refusal did not say that 16 changed" + +step "updating again as the guide says moves the current 16 database" +new down >/dev/null 2>&1 +docker volume rm "${PROJECT}_postgres-data" >/dev/null +new run --rm --entrypoint rm db-upgrade /var/lib/postgresql/data/HEALTHLOG_MOVED_TO_POSTGRES_18 >/dev/null 2>&1 +CODE=$(run_upgrade) +[ "$CODE" = 0 ] || fail "db-upgrade exited $CODE when moving the current 16 database" +[ "$(sql "SELECT count(*) FROM check_probe.rows")" = 50001 ] || fail "the row written on 16 did not arrive" + step "a new install: nothing to do" new down -v >/dev/null 2>&1 CODE=$(run_upgrade) [ "$CODE" = 0 ] || fail "db-upgrade exited $CODE on a new install" upgrade_log | tail -1 | grep -q 'no Postgres 16 database to move' || fail "the new install did not say there was nothing to move" -echo "OK: the Postgres 16 -> 18 move behaves in all six cases" +echo "OK: the Postgres 16 -> 18 move behaves in all nine cases" diff --git a/src/__tests__/compose-db-upgrade-guard.test.ts b/src/__tests__/compose-db-upgrade-guard.test.ts index 1ab117a85..2109088d9 100644 --- a/src/__tests__/compose-db-upgrade-guard.test.ts +++ b/src/__tests__/compose-db-upgrade-guard.test.ts @@ -77,6 +77,8 @@ describe("the Postgres 16 -> 18 move in docker-compose.yml", () => { // The checks run before the restore, in this order. const order = [ "PG_VERSION", + // Never touch the old volume while the database service runs on it. + 'if [ "$$NEWVER" -lt 180000 ]', "$$MARK", "$$MOVING", 'elif [ "$$TABLES" != 0 ]', diff --git a/tests/integration/demo-seed-shape.test.ts b/tests/integration/demo-seed-shape.test.ts index d35aa6742..d7557b8bd 100644 --- a/tests/integration/demo-seed-shape.test.ts +++ b/tests/integration/demo-seed-shape.test.ts @@ -105,7 +105,9 @@ describe("demo seed, short-window shape", () => { it("leaves the median of the window in the settled band", () => { // The usual range is the median ± 3 MAD of the last 30 days. A stretch // that fills half the window moves that median onto the stretch and - // widens the band to something like 87-181 mmHg. + // widens the band to something like 87-181 mmHg. The seed's own spread + // reaches about ±22 in one run of 150, so the bound sits at 24, still far + // below what a stretch that long produces. const sorted = [...systolic].sort((a, b) => a - b); const median = sorted[Math.floor(sorted.length / 2)]; expect(median).toBeLessThanOrEqual(124); @@ -113,7 +115,7 @@ describe("demo seed, short-window shape", () => { .map((v) => Math.abs(v - median)) .sort((a, b) => a - b); const mad = deviations[Math.floor(deviations.length / 2)]; - expect(3 * 1.4826 * mad).toBeLessThanOrEqual(20); + expect(3 * 1.4826 * mad).toBeLessThanOrEqual(24); }); it("raises ramipril on the day the stretch turns", () => {