mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Merge the release with the security review fixes into the data-safety review fixes
This commit is contained in:
commit
bcb31c80db
98 files changed
+3166
-608
No files matched your search
+11
-1
@@ -234,6 +234,12 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
|
||||
# refuse the headers, log one warning, and share one rate-limit bucket across
|
||||
# all anonymous callers.
|
||||
# TRUST_PROXY_HOPS="1"
|
||||
# The addresses the reverse proxy connects from (comma-separated CIDRs). Set,
|
||||
# only those connections may report the client's address; unset, any private
|
||||
# address may, which behind Docker Desktop, OrbStack or docker-proxy includes
|
||||
# every caller of the published port. Same host: 127.0.0.1/32,::1/128; Docker
|
||||
# network: the proxy's subnet. See docs/self-hosting/reverse-proxy.md.
|
||||
# TRUSTED_PROXY_CIDRS=""
|
||||
# TRUST_CF_CONNECTING_IP=""
|
||||
|
||||
|
||||
@@ -370,8 +376,12 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
|
||||
# Configure the SAME value in Coolify's Notifications → Webhook
|
||||
# custom-headers panel. See docs/ops/deploy.md.
|
||||
# DEPLOY_WEBHOOK_SECRET=""
|
||||
# "true" accepts signed requests only; leave unset for Coolify's stock sender.
|
||||
# "true" also requires a fresh X-Deploy-Webhook-Timestamp on a secret-header
|
||||
# request; leave unset for Coolify's stock sender, which sends none.
|
||||
# DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=""
|
||||
# "true" accepts signed requests only (the setting that stops a replay);
|
||||
# leave unset for Coolify's stock sender.
|
||||
# DEPLOY_WEBHOOK_REQUIRE_SIGNATURE=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
+26
-3
@@ -295,11 +295,17 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# Optional: deployment-dashboard URL (e.g. the Coolify UI) linked in
|
||||
# deploy-failure notifications. When unset the notification omits the link.
|
||||
# DEPLOY_LOGS_URL=""
|
||||
# Optional: require an X-Deploy-Webhook-Timestamp within +/- 5 minutes on a
|
||||
# request that authenticates with the secret header. Keeps a stale capture
|
||||
# out, not a fresh forgery by someone who saw the secret. Leave unset for
|
||||
# Coolify's stock sender, which sends no timestamp.
|
||||
# DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP="false"
|
||||
# Optional replay protection: accept only signed webhook calls
|
||||
# (X-Deploy-Webhook-Signature, an HMAC of the timestamp and body under the
|
||||
# secret, with a timestamp within +/- 5 minutes). Leave unset for Coolify's
|
||||
# stock sender, which can only send the secret header; see docs/ops/deploy.md.
|
||||
# DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP="false"
|
||||
# secret, with a timestamp within +/- 5 minutes); the secret header alone is
|
||||
# refused. Leave unset for Coolify's stock sender, which can only send the
|
||||
# secret header; see docs/ops/deploy.md.
|
||||
# DEPLOY_WEBHOOK_REQUIRE_SIGNATURE="false"
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -565,6 +571,23 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# anonymous callers into one rate-limit bucket.
|
||||
# TRUST_PROXY_HOPS="1"
|
||||
#
|
||||
# The addresses your reverse proxy connects from, as comma-separated CIDRs
|
||||
# (IPv4 or IPv6; a bare address is one host). When set, only those
|
||||
# connections may report the client's address; every other connection is
|
||||
# its own client. When unset, any private address may, which is the old
|
||||
# behaviour and is NOT safe behind Docker Desktop, OrbStack or docker-proxy:
|
||||
# there every caller of the published port arrives from the gateway, a
|
||||
# private address, and could name its own client address. Set it whenever a
|
||||
# reverse proxy is in front:
|
||||
# proxy on the same host (proxying to 127.0.0.1:3000): 127.0.0.1/32,::1/128
|
||||
# proxy container on a Docker network: that network's subnet (docker
|
||||
# network inspect <network>), e.g. 172.18.0.0/16
|
||||
# proxy on another machine with a public address: that address, /32
|
||||
# A proxy on a public address that is not listed is not believed: every
|
||||
# request through it shares the proxy's address and one rate-limit bucket.
|
||||
# The boot log names the rule in force in one [proxy-trust] line.
|
||||
# TRUSTED_PROXY_CIDRS="127.0.0.1/32,::1/128"
|
||||
#
|
||||
# Behind Cloudflare the real visitor IP arrives in the `cf-connecting-ip`
|
||||
# header; the X-Forwarded-For chain otherwise yields the Cloudflare edge IP,
|
||||
# so the audit-log overview stores the CF datacentre address and the geo
|
||||
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
+49
-9
@@ -108,7 +108,14 @@ x-healthlog-env: &healthlog_env
|
||||
# collapses every anonymous caller into one rate-limit bucket. Listed here
|
||||
# so an operator value in .env reaches the container (the `environment:`
|
||||
# block is a whitelist).
|
||||
# TRUSTED_PROXY_CIDRS names the addresses the reverse proxy connects from
|
||||
# (comma-separated CIDRs). Set, only those connections may report a client
|
||||
# address; unset, any private address may, which behind Docker Desktop,
|
||||
# OrbStack or docker-proxy includes every caller of the published port.
|
||||
# Same host: 127.0.0.1/32,::1/128. Docker network: the proxy's subnet.
|
||||
# See docs/self-hosting/reverse-proxy.md.
|
||||
TRUST_PROXY_HOPS: "${TRUST_PROXY_HOPS:-}"
|
||||
TRUSTED_PROXY_CIDRS: "${TRUSTED_PROXY_CIDRS:-}"
|
||||
TRUST_CF_CONNECTING_IP: "${TRUST_CF_CONNECTING_IP:-}"
|
||||
IP_GEO_LOOKUP_URL: "${IP_GEO_LOOKUP_URL:-}"
|
||||
IP_GEO_LOOKUP_DISABLED: "${IP_GEO_LOOKUP_DISABLED:-}"
|
||||
@@ -304,14 +311,18 @@ x-healthlog-env: &healthlog_env
|
||||
# X-Deploy-Webhook-Secret header (what Coolify's stock sender can do; no
|
||||
# replay protection) or with X-Deploy-Webhook-Signature: sha256=<HMAC of
|
||||
# "<X-Deploy-Webhook-Timestamp>.<body>" under the secret>, the timestamp
|
||||
# within 5 minutes. DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true accepts signed
|
||||
# requests only. DEPLOY_LOGS_URL is the deployment-dashboard URL (e.g. the
|
||||
# Coolify UI) linked in deploy-failure notifications. Listed here so
|
||||
# operator values in .env reach the container (the `environment:` block is
|
||||
# a whitelist). See docs/ops/deploy.md.
|
||||
# within 5 minutes. DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true also requires a
|
||||
# fresh X-Deploy-Webhook-Timestamp on a secret-header request;
|
||||
# DEPLOY_WEBHOOK_REQUIRE_SIGNATURE=true accepts signed requests only, the
|
||||
# one setting that stops a replay. DEPLOY_LOGS_URL is the
|
||||
# deployment-dashboard URL (e.g. the Coolify UI) linked in deploy-failure
|
||||
# notifications. Listed here so operator values in .env reach the
|
||||
# container (the `environment:` block is a whitelist). See
|
||||
# docs/ops/deploy.md.
|
||||
DEPLOY_WEBHOOK_SECRET: "${DEPLOY_WEBHOOK_SECRET:-}"
|
||||
DEPLOY_LOGS_URL: "${DEPLOY_LOGS_URL:-}"
|
||||
DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP: "${DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP:-}"
|
||||
DEPLOY_WEBHOOK_REQUIRE_SIGNATURE: "${DEPLOY_WEBHOOK_REQUIRE_SIGNATURE:-}"
|
||||
# TLS leaf SPKI-change alarm baseline (optional). The native client
|
||||
# pins the served TLS leaf; this is the known-good pin set the monitor
|
||||
# alarms against on a rotation. Comma-separated for the dual-pin
|
||||
@@ -684,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,
|
||||
@@ -741,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
|
||||
@@ -767,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
|
||||
@@ -802,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"
|
||||
|
||||
|
||||
+6
-3
@@ -82,9 +82,12 @@ before treating a warning as a regression.
|
||||
v1.30.17. The path still lists (`GET`) and revokes (`DELETE /api/tokens/{id}`)
|
||||
every token above, whichever surface issued it.
|
||||
|
||||
- `sessionCookie` — HttpOnly `healthlog_session` cookie set by the
|
||||
password and passkey login flows. Used by the web app and any iOS
|
||||
client running inside a web view.
|
||||
- `sessionCookie` — HttpOnly session cookie set by the password and
|
||||
passkey login flows. Used by the web app and any iOS client running
|
||||
inside a web view. It is named `__Host-healthlog_session` wherever it is
|
||||
`Secure` (every HTTPS install), so no other host under the same domain
|
||||
can set it; a plain-HTTP install keeps `healthlog_session`. A cookie
|
||||
under the old name is still read and is re-issued under the new one.
|
||||
- **`components.schemas`** — DTOs for the documented routes plus the
|
||||
underlying Prisma models (e.g. `Measurement`, `Medication`,
|
||||
`MedicationIntakeEvent`, `MoodEntry`, `User`, `DashboardLayout`,
|
||||
|
||||
@@ -38530,8 +38530,8 @@ components:
|
||||
activity.calendar.too_old; ai — ai.budget.exceeded, ai.provider.none, ai.record.notPermitted,
|
||||
ai.unavailable, ai.usage.aboveCeiling, ai.usage.defaultAboveCeiling, ai.usage.invalid,
|
||||
ai.usage.levelTooLow, ai.usage.rateLimited; ai_provider — ai_provider.invalid, ai_provider.no_fields;
|
||||
aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.timedOut, aiRuns.workerUnavailable; allergy —
|
||||
allergy.invalid; analytics — analytics.invalid_query; anamnesis — anamnesis.auditC.invalid,
|
||||
aiRuns — aiRuns.failed, aiRuns.notFound, aiRuns.readerBusy, aiRuns.timedOut, aiRuns.workerUnavailable;
|
||||
allergy — allergy.invalid; analytics — analytics.invalid_query; anamnesis — anamnesis.auditC.invalid,
|
||||
anamnesis.auditC.notFound, anamnesis.auditC.rateLimited, anamnesis.fact.conflict,
|
||||
anamnesis.fact.currentExists, anamnesis.fact.invalidValue, anamnesis.lifeConfirm.invalid,
|
||||
anamnesis.lifeSections.invalid; auth — auth.account.deletion_pending, auth.admin.required,
|
||||
@@ -75841,7 +75841,8 @@ components:
|
||||
description: "Present once `status` is `FAILED`: the `meta.errorCode`, message and HTTP status the queuing route would
|
||||
have answered with, or one of the run's own codes: `aiRuns.workerUnavailable` (no worker took the run within
|
||||
fifteen minutes), `aiRuns.timedOut` (the read outlived the calls the AI response time allows),
|
||||
`aiRuns.failed` (anything else)."
|
||||
`aiRuns.readerBusy` (the server's document reader was busy past the wait a read gets, or stopped before it
|
||||
answered; nothing was read or charged, try again in a minute), `aiRuns.failed` (anything else)."
|
||||
createdAt:
|
||||
type: string
|
||||
startedAt:
|
||||
@@ -94543,4 +94544,6 @@ components:
|
||||
type: apiKey
|
||||
in: cookie
|
||||
name: healthlog_session
|
||||
description: Browser session cookie — set by /api/auth/login.
|
||||
description: Browser session cookie — set by /api/auth/login. Named `__Host-healthlog_session` wherever the cookie is
|
||||
`Secure` (every HTTPS install); a plain-HTTP install, which cannot hold a `__Host-` cookie, keeps
|
||||
`healthlog_session`.
|
||||
+13
-4
@@ -564,8 +564,17 @@ low-friction default.
|
||||
**5 minutes** of server time. A signed request sends no secret, and a
|
||||
captured one is useless once the window has passed.
|
||||
|
||||
Set `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true` to accept signed requests only.
|
||||
Use it when the sender, or a small relay in front of the app, can sign:
|
||||
Two settings tighten this:
|
||||
|
||||
- `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true` requires a fresh
|
||||
`X-Deploy-Webhook-Timestamp` on a request that uses the secret header; one
|
||||
without it is refused with `401 {"status":"stale"}`. This is what the
|
||||
setting has always meant. It keeps an old capture out, but not someone who
|
||||
saw the secret and sends a new request with a new timestamp.
|
||||
- `DEPLOY_WEBHOOK_REQUIRE_SIGNATURE=true` accepts signed requests only; the
|
||||
secret header alone is refused with 401. This is the one that stops a
|
||||
replay. Use it when the sender, or a small relay in front of the app, can
|
||||
sign:
|
||||
|
||||
```sh
|
||||
TS=$(date +%s)
|
||||
@@ -579,5 +588,5 @@ curl -X POST https://<host>/api/internal/deploy-webhook \
|
||||
-d "$BODY"
|
||||
```
|
||||
|
||||
Before 1.46 the strict setting accepted the secret header plus a fresh
|
||||
timestamp. Such a sender now gets 401 in strict mode and has to sign.
|
||||
Both may be set; a signed request satisfies both. An operator who set the
|
||||
timestamp flag before 1.46 keeps exactly the behaviour they had.
|
||||
@@ -107,10 +107,12 @@ In the admin area, or with a signed-in admin session:
|
||||
```sh
|
||||
curl -X POST https://<your-instance>/api/admin/maintenance/measurements \
|
||||
-H 'content-type: application/json' \
|
||||
--cookie 'healthlog_session=<session>' \
|
||||
--cookie '__Host-healthlog_session=<session>' \
|
||||
-d '{"vacuum": true, "reindex": true}'
|
||||
```
|
||||
|
||||
On a plain-HTTP install the cookie is named `healthlog_session`.
|
||||
|
||||
The answer is `202` with `{ "enqueued": true }`, or `enqueued: false` when a
|
||||
run is already queued or running. A Bearer token is refused: the route is
|
||||
cookie-only, like every admin route. Without a background worker the route
|
||||
|
||||
@@ -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 <project>_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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -21,13 +21,14 @@ tailnet with no extra container. See
|
||||
| `NEXT_PUBLIC_APP_URL` | Every public-facing absolute URL the UI emits | Must match the hostname the proxy serves |
|
||||
| `APP_URL` | Server-side absolute URLs from background jobs | Mirror of the above; both must match |
|
||||
| `TRUST_PROXY_HOPS` | `X-Forwarded-For` chain trust | Defaults to `1` — one trusted hop |
|
||||
| `TRUSTED_PROXY_CIDRS` | Which connections may report a client address | Unset: any private address; see below |
|
||||
| `TRUST_CF_CONNECTING_IP` | Reading the visitor address Cloudflare sends | Off unless `1`; see the warning below |
|
||||
|
||||
Set the URL pair to the public origin (`https://your-instance.example.com`)
|
||||
and restart the `app` container. The image reads both at startup; no
|
||||
rebuild needed.
|
||||
|
||||
All four variables are on the shipped `docker-compose.yml`
|
||||
All five variables are on the shipped `docker-compose.yml`
|
||||
`environment:` whitelist, so a value in `.env` reaches the container
|
||||
without editing the compose file. (Before v1.37.33 the whitelist was
|
||||
missing `TRUST_PROXY_HOPS`; on those versions add
|
||||
@@ -68,13 +69,56 @@ The reserved and multicast blocks are in the list because no host on the
|
||||
internet can open a connection from them, so a peer there sits on a network
|
||||
you run. A request that reaches HealthLog straight from a public
|
||||
address carries headers its sender wrote, so HealthLog ignores them and
|
||||
uses the connection's own address, and says so once in the log. A proxy on
|
||||
the same host or Docker network connects from a private address, so the
|
||||
usual setups above need nothing. If your proxy reaches HealthLog over a
|
||||
public address, put it on a private network with HealthLog instead. The log
|
||||
uses the connection's own address, and says so once in the log. The log
|
||||
line also shows up when the published port is open to the internet beside
|
||||
the proxy; bind it to the proxy's network or to `127.0.0.1` then.
|
||||
|
||||
That list is the default when `TRUSTED_PROXY_CIDRS` is unset, and it is
|
||||
only as good as the network in front of the port. Read the next section
|
||||
before relying on it.
|
||||
|
||||
### `TRUSTED_PROXY_CIDRS` — name the proxy
|
||||
|
||||
Set `TRUSTED_PROXY_CIDRS` to the addresses your reverse proxy connects
|
||||
from, as comma-separated CIDRs (IPv4 or IPv6; a bare address counts as a
|
||||
single host). Once it is set, only a connection from inside those ranges
|
||||
may report the client's address. Every other connection is its own client,
|
||||
whatever its address, private ones included, and the log says so once.
|
||||
|
||||
Why it matters: some setups make every caller look like a private address.
|
||||
Docker Desktop, OrbStack, and Docker's `docker-proxy` (the userland proxy
|
||||
behind a published port such as `3000:3000`) forward each connection from
|
||||
the gateway address of the Docker network, which is private. On those
|
||||
hosts, someone who can reach the published port directly, from the LAN or
|
||||
from the internet if the port is open, passes the private-address check
|
||||
and can again choose the address that every per-IP rate limit, the audit
|
||||
log and the sign-in notices see, a new one per request if they like.
|
||||
Naming your proxy closes that. The default port binding in
|
||||
`docker-compose.yml` stays as it is (LAN installs rely on it); this
|
||||
variable is the fix.
|
||||
|
||||
What to set:
|
||||
|
||||
| Where the proxy runs | `TRUSTED_PROXY_CIDRS` |
|
||||
| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
|
||||
| On the same host, proxying to `127.0.0.1:3000` (bare Nginx, Caddy) | `127.0.0.1/32,::1/128` |
|
||||
| In a container on a Docker network with HealthLog (Traefik, NPM, Coolify) | that network's subnet, e.g. `172.18.0.0/16` (`docker network inspect <network>` shows it under `IPAM`) |
|
||||
| On another machine that connects from a public address | that machine's address, e.g. `198.51.100.7/32` |
|
||||
| No proxy, or `tailscale serve` only | leave it unset |
|
||||
|
||||
At boot the app logs one `[proxy-trust]` line naming the rule in force:
|
||||
which connections it reads forwarding headers from and how many hops deep.
|
||||
A value that is not a CIDR is logged as an error, and until it is fixed no
|
||||
connection may report a client address.
|
||||
|
||||
**A proxy that connects from a public address.** Without
|
||||
`TRUSTED_PROXY_CIDRS`, such a proxy is not believed: every request through
|
||||
it resolves to the proxy's own address, so all your users share one
|
||||
rate-limit bucket and one person mistyping a password can hold sign-in back
|
||||
for everyone. Releases before v1.46 believed any peer, so this shows up after
|
||||
the upgrade. Set `TRUSTED_PROXY_CIDRS` to the proxy's address (or put the proxy
|
||||
on a private network with HealthLog) and restart.
|
||||
|
||||
### `TRUST_CF_CONNECTING_IP` — only when the origin takes Cloudflare only
|
||||
|
||||
With `TRUST_CF_CONNECTING_IP=1` HealthLog takes the visitor's address from
|
||||
@@ -283,6 +327,15 @@ The one-shot stderr warning `[getClientIp] TRUST_PROXY_HOPS=N but
|
||||
X-Forwarded-For carried M entries` confirms a mismatch. Adjust the
|
||||
env var down to the actual chain length and restart.
|
||||
|
||||
If every request logs the same client address, the proxy's own, the proxy
|
||||
is not one HealthLog believes: the `[getClientIp]` warning about a
|
||||
connection "from a public address" or "from outside TRUSTED_PROXY_CIDRS"
|
||||
names the case. Add the proxy's address to `TRUSTED_PROXY_CIDRS`.
|
||||
|
||||
A `TRUST_PROXY_HOPS` value that is not a whole number resolves no client
|
||||
address at all (requests keep working) and is named in the `[proxy-trust]`
|
||||
boot line.
|
||||
|
||||
## Running over plain HTTP on a LAN (no reverse proxy)
|
||||
|
||||
The session cookie carries the `Secure` flag by default under
|
||||
|
||||
@@ -46,14 +46,14 @@
|
||||
|
||||
<Config Name="WebUI Port" Target="3000" Default="3000" Mode="tcp" Description="HTTP port HealthLog listens on." Type="Port" Display="always" Required="true" Mask="false">3000</Config>
|
||||
|
||||
<Config Name="DATABASE_URL" Target="DATABASE_URL" Default="postgresql://healthlog:CHANGE-ME@POSTGRES-HOST:5432/healthlog?schema=public" Mode="" Description="Connection string for your separately-installed PostgreSQL 16. Replace CHANGE-ME with the DB password and POSTGRES-HOST with the Postgres container IP or name." Type="Variable" Display="always" Required="true" Mask="true"/>
|
||||
<Config Name="DATABASE_URL" Target="DATABASE_URL" Default="postgresql://healthlog:CHANGE-ME@POSTGRES-HOST:5432/healthlog?schema=public" Mode="" Description="Connection string for your separately-installed PostgreSQL (18, or 16). Replace CHANGE-ME with the DB password and POSTGRES-HOST with the Postgres container IP or name." Type="Variable" Display="always" Required="true" Mask="true"/>
|
||||
|
||||
<Config Name="ENCRYPTION_KEY" Target="ENCRYPTION_KEY" Default="" Mode="" Description="AES-256-GCM key for data at rest. Generate with: openssl rand -hex 32 (64-char hex). Back this up — losing it makes encrypted data unrecoverable." Type="Variable" Display="always" Required="true" Mask="true"/>
|
||||
|
||||
<Config Name="API_TOKEN_HMAC_KEY" Target="API_TOKEN_HMAC_KEY" Default="" Mode="" Description="HMAC key for API token hashing. Generate with: openssl rand -hex 32 (64-char hex)." Type="Variable" Display="always" Required="true" Mask="true"/>
|
||||
|
||||
<Config Name="SESSION_COOKIE_SECURE" Target="SESSION_COOKIE_SECURE" Default="false" Mode="" Description="Set to false for LAN / plain-HTTP installs (http://tower:3000) or login fails. Set to true only behind an HTTPS reverse proxy." Type="Variable" Display="always" Required="true" Mask="false">false</Config>
|
||||
<Config Name="HEALTHLOG_PLATFORM" Target="HEALTHLOG_PLATFORM" Default="unraid" Mode="" Description="Where this server is configured. Opens the admin's encryption key backup step on the Unraid instructions." Type="Variable" Display="advanced" Required="false" Mask="false">unraid</Config>
|
||||
<Config Name="HEALTHLOG_PLATFORM" Target="HEALTHLOG_PLATFORM" Default="unraid" Mode="" Description="Where this server is configured. Opens the admin's recovery kit on the Unraid instructions." Type="Variable" Display="advanced" Required="false" Mask="false">unraid</Config>
|
||||
<Config Name="ENCRYPTION_KEY_CHECK" Target="ENCRYPTION_KEY_CHECK" Default="enforce" Mode="" Description="Start-up encryption key check. enforce refuses to serve when the key does not open the stored data. warn only logs it: a last resort for a refusal you are certain is false, and it disables a safety check." Type="Variable" Display="advanced" Required="false" Mask="false">enforce</Config>
|
||||
|
||||
<Config Name="NEXT_PUBLIC_APP_URL" Target="NEXT_PUBLIC_APP_URL" Default="http://tower:3000" Mode="" Description="Public URL users reach HealthLog at. Use the LAN URL (e.g. http://tower:3000) or your HTTPS domain behind a reverse proxy." Type="Variable" Display="always" Required="true" Mask="false"/>
|
||||
|
||||
+5
-1
@@ -175,6 +175,9 @@
|
||||
"failed": "Das Etikett konnte nicht gelesen werden. Versuch ein schärferes Foto."
|
||||
}
|
||||
}
|
||||
},
|
||||
"aiRuns": {
|
||||
"readerBusy": "Der Dokumentleser ist gerade mit anderen Aufgaben beschäftigt. Versuch es in einer Minute noch einmal."
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -13274,7 +13277,8 @@
|
||||
"backgroundScan": "Wird im Hintergrund gelesen. Mit einem langsamen Modell kann das ein paar Minuten dauern.",
|
||||
"waitingForWorker": "Warte auf den Hintergrunddienst. Ändert sich das nicht, läuft der Worker auf dem Server vielleicht nicht.",
|
||||
"errorWorkerUnavailable": "Der Hintergrunddienst ist gerade nicht erreichbar. Versuch es gleich noch einmal oder bitte die Person, die den Server betreibt, ihn zu prüfen.",
|
||||
"errorTimedOut": "Das Lesen hat länger gedauert, als deine KI-Antwortzeit erlaubt. Versuch es noch einmal oder erhöhe die Antwortzeit in den KI-Einstellungen."
|
||||
"errorTimedOut": "Das Lesen hat länger gedauert, als deine KI-Antwortzeit erlaubt. Versuch es noch einmal oder erhöhe die Antwortzeit in den KI-Einstellungen.",
|
||||
"errorReaderBusy": "Der Dokumentleser ist gerade mit anderen Aufgaben beschäftigt. Versuch es in einer Minute noch einmal."
|
||||
},
|
||||
"symptoms": {
|
||||
"add": "Neues Symptom",
|
||||
|
||||
+5
-1
@@ -175,6 +175,9 @@
|
||||
"failed": "The label could not be read. Try a sharper photo."
|
||||
}
|
||||
}
|
||||
},
|
||||
"aiRuns": {
|
||||
"readerBusy": "The document reader is busy with other work. Try again in a minute."
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -13274,7 +13277,8 @@
|
||||
"backgroundScan": "Reading in the background. With a slow model this can take a few minutes.",
|
||||
"waitingForWorker": "Waiting for the background worker. If this does not change, the server's worker may not be running.",
|
||||
"errorWorkerUnavailable": "The background worker is not available right now. Try again in a moment, or ask the server operator to check it.",
|
||||
"errorTimedOut": "Reading took longer than your AI response time allows. Try again, or raise the response time in the AI settings."
|
||||
"errorTimedOut": "Reading took longer than your AI response time allows. Try again, or raise the response time in the AI settings.",
|
||||
"errorReaderBusy": "The document reader is busy with other work. Try again in a minute."
|
||||
},
|
||||
"symptoms": {
|
||||
"add": "New symptom",
|
||||
|
||||
+5
-1
@@ -175,6 +175,9 @@
|
||||
"failed": "No se pudo leer la etiqueta. Prueba con una foto más nítida."
|
||||
}
|
||||
}
|
||||
},
|
||||
"aiRuns": {
|
||||
"readerBusy": "El lector de documentos está ocupado con otras tareas. Inténtalo de nuevo en un minuto."
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -13274,7 +13277,8 @@
|
||||
"backgroundScan": "Leyendo en segundo plano. Con un modelo lento puede tardar unos minutos.",
|
||||
"waitingForWorker": "Esperando al proceso en segundo plano. Si no cambia, puede que el worker del servidor no esté en marcha.",
|
||||
"errorWorkerUnavailable": "El proceso en segundo plano no está disponible ahora mismo. Inténtalo de nuevo en un momento o pide a quien administra el servidor que lo revise.",
|
||||
"errorTimedOut": "La lectura tardó más de lo que permite tu tiempo de respuesta de IA. Inténtalo de nuevo o aumenta el tiempo de respuesta en los ajustes de IA."
|
||||
"errorTimedOut": "La lectura tardó más de lo que permite tu tiempo de respuesta de IA. Inténtalo de nuevo o aumenta el tiempo de respuesta en los ajustes de IA.",
|
||||
"errorReaderBusy": "El lector de documentos está ocupado con otras tareas. Inténtalo de nuevo en un minuto."
|
||||
},
|
||||
"symptoms": {
|
||||
"add": "Nuevo síntoma",
|
||||
|
||||
+5
-1
@@ -175,6 +175,9 @@
|
||||
"failed": "L’étiquette n’a pas pu être lue. Essayez une photo plus nette."
|
||||
}
|
||||
}
|
||||
},
|
||||
"aiRuns": {
|
||||
"readerBusy": "Le lecteur de documents est occupé par d'autres tâches. Réessayez dans une minute."
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -13274,7 +13277,8 @@
|
||||
"backgroundScan": "Lecture en arrière-plan. Avec un modèle lent, cela peut prendre quelques minutes.",
|
||||
"waitingForWorker": "En attente du traitement en arrière-plan. Si rien ne change, le worker du serveur n'est peut-être pas lancé.",
|
||||
"errorWorkerUnavailable": "Le traitement en arrière-plan n'est pas disponible pour le moment. Réessayez dans un instant ou demandez à la personne qui gère le serveur de le vérifier.",
|
||||
"errorTimedOut": "La lecture a pris plus de temps que votre délai de réponse IA ne le permet. Réessayez ou augmentez ce délai dans les réglages IA."
|
||||
"errorTimedOut": "La lecture a pris plus de temps que votre délai de réponse IA ne le permet. Réessayez ou augmentez ce délai dans les réglages IA.",
|
||||
"errorReaderBusy": "Le lecteur de documents est occupé par d'autres tâches. Réessayez dans une minute."
|
||||
},
|
||||
"symptoms": {
|
||||
"add": "Nouveau symptôme",
|
||||
|
||||
+5
-1
@@ -175,6 +175,9 @@
|
||||
"failed": "Non è stato possibile leggere l'etichetta. Prova con una foto più nitida."
|
||||
}
|
||||
}
|
||||
},
|
||||
"aiRuns": {
|
||||
"readerBusy": "Il lettore di documenti è occupato con altre attività. Riprova tra un minuto."
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -13274,7 +13277,8 @@
|
||||
"backgroundScan": "Lettura in background. Con un modello lento può richiedere qualche minuto.",
|
||||
"waitingForWorker": "In attesa del processo in background. Se non cambia, il worker del server potrebbe non essere attivo.",
|
||||
"errorWorkerUnavailable": "Il processo in background non è disponibile al momento. Riprova tra poco o chiedi a chi gestisce il server di controllarlo.",
|
||||
"errorTimedOut": "La lettura ha richiesto più tempo di quanto consenta il tuo tempo di risposta dell'IA. Riprova o aumenta il tempo di risposta nelle impostazioni IA."
|
||||
"errorTimedOut": "La lettura ha richiesto più tempo di quanto consenta il tuo tempo di risposta dell'IA. Riprova o aumenta il tempo di risposta nelle impostazioni IA.",
|
||||
"errorReaderBusy": "Il lettore di documenti è occupato con altre attività. Riprova tra un minuto."
|
||||
},
|
||||
"symptoms": {
|
||||
"add": "Nuovo sintomo",
|
||||
|
||||
+5
-1
@@ -175,6 +175,9 @@
|
||||
"failed": "라벨을 읽지 못했어요. 더 선명한 사진으로 다시 시도하세요."
|
||||
}
|
||||
}
|
||||
},
|
||||
"aiRuns": {
|
||||
"readerBusy": "문서 판독기가 다른 작업으로 바빠요. 1분 후에 다시 시도하세요."
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -13274,7 +13277,8 @@
|
||||
"backgroundScan": "백그라운드에서 읽고 있어요. 느린 모델이면 몇 분 걸릴 수 있어요.",
|
||||
"waitingForWorker": "백그라운드 작업자를 기다리고 있어요. 계속 그대로라면 서버의 작업자가 실행 중이 아닐 수 있어요.",
|
||||
"errorWorkerUnavailable": "지금은 백그라운드 작업자를 사용할 수 없어요. 잠시 후 다시 시도하거나 서버 운영자에게 확인을 요청하세요.",
|
||||
"errorTimedOut": "AI 응답 시간 설정보다 읽기가 오래 걸렸어요. 다시 시도하거나 AI 설정에서 응답 시간을 늘려 주세요."
|
||||
"errorTimedOut": "AI 응답 시간 설정보다 읽기가 오래 걸렸어요. 다시 시도하거나 AI 설정에서 응답 시간을 늘려 주세요.",
|
||||
"errorReaderBusy": "문서 판독기가 다른 작업으로 바빠요. 1분 후에 다시 시도하세요."
|
||||
},
|
||||
"symptoms": {
|
||||
"add": "새 증상",
|
||||
|
||||
+5
-1
@@ -175,6 +175,9 @@
|
||||
"failed": "Het etiket kon niet worden gelezen. Probeer een scherpere foto."
|
||||
}
|
||||
}
|
||||
},
|
||||
"aiRuns": {
|
||||
"readerBusy": "De documentlezer is met ander werk bezig. Probeer het over een minuut opnieuw."
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -13274,7 +13277,8 @@
|
||||
"backgroundScan": "Wordt op de achtergrond gelezen. Met een traag model kan dit een paar minuten duren.",
|
||||
"waitingForWorker": "Wachten op het achtergrondproces. Als dit niet verandert, draait het achtergrondproces van de server mogelijk niet.",
|
||||
"errorWorkerUnavailable": "Het achtergrondproces is op dit moment niet beschikbaar. Probeer het zo opnieuw, of vraag de beheerder van de server om het te controleren.",
|
||||
"errorTimedOut": "Het lezen duurde langer dan je ingestelde AI-responstijd toestaat. Probeer het opnieuw, of verhoog de responstijd in de AI-instellingen."
|
||||
"errorTimedOut": "Het lezen duurde langer dan je ingestelde AI-responstijd toestaat. Probeer het opnieuw, of verhoog de responstijd in de AI-instellingen.",
|
||||
"errorReaderBusy": "De documentlezer is met ander werk bezig. Probeer het over een minuut opnieuw."
|
||||
},
|
||||
"symptoms": {
|
||||
"add": "Nieuw symptoom",
|
||||
|
||||
+5
-1
@@ -175,6 +175,9 @@
|
||||
"failed": "Nie udało się odczytać etykiety. Spróbuj ostrzejszego zdjęcia."
|
||||
}
|
||||
}
|
||||
},
|
||||
"aiRuns": {
|
||||
"readerBusy": "Czytnik dokumentów jest zajęty innymi zadaniami. Spróbuj ponownie za minutę."
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -13274,7 +13277,8 @@
|
||||
"backgroundScan": "Odczyt w tle. Przy wolnym modelu może to potrwać kilka minut.",
|
||||
"waitingForWorker": "Oczekiwanie na proces w tle. Jeśli nic się nie zmienia, worker na serwerze może nie działać.",
|
||||
"errorWorkerUnavailable": "Proces w tle jest teraz niedostępny. Spróbuj ponownie za chwilę albo poproś administratora serwera o sprawdzenie go.",
|
||||
"errorTimedOut": "Odczyt trwał dłużej, niż pozwala ustawiony czas odpowiedzi AI. Spróbuj ponownie albo wydłuż czas odpowiedzi w ustawieniach AI."
|
||||
"errorTimedOut": "Odczyt trwał dłużej, niż pozwala ustawiony czas odpowiedzi AI. Spróbuj ponownie albo wydłuż czas odpowiedzi w ustawieniach AI.",
|
||||
"errorReaderBusy": "Czytnik dokumentów jest zajęty innymi zadaniami. Spróbuj ponownie za minutę."
|
||||
},
|
||||
"symptoms": {
|
||||
"add": "Nowy objaw",
|
||||
|
||||
+5
-1
@@ -175,6 +175,9 @@
|
||||
"failed": "Não foi possível ler o rótulo. Tente uma foto mais nítida."
|
||||
}
|
||||
}
|
||||
},
|
||||
"aiRuns": {
|
||||
"readerBusy": "O leitor de documentos está ocupado com outras tarefas. Tente novamente em um minuto."
|
||||
}
|
||||
},
|
||||
"doctorReport": {
|
||||
@@ -13274,7 +13277,8 @@
|
||||
"backgroundScan": "Lendo em segundo plano. Com um modelo lento, isso pode levar alguns minutos.",
|
||||
"waitingForWorker": "Aguardando o worker em segundo plano. Se isso não mudar, o worker do servidor pode não estar em execução.",
|
||||
"errorWorkerUnavailable": "O worker em segundo plano não está disponível agora. Tente novamente em instantes, ou peça ao operador do servidor para verificá-lo.",
|
||||
"errorTimedOut": "A leitura demorou mais do que o seu tempo de resposta de IA permite. Tente novamente, ou aumente o tempo de resposta nas configurações de IA."
|
||||
"errorTimedOut": "A leitura demorou mais do que o seu tempo de resposta de IA permite. Tente novamente, ou aumente o tempo de resposta nas configurações de IA.",
|
||||
"errorReaderBusy": "O leitor de documentos está ocupado com outras tarefas. Tente novamente em um minuto."
|
||||
},
|
||||
"symptoms": {
|
||||
"add": "Novo sintoma",
|
||||
|
||||
+5
-3
@@ -302,11 +302,13 @@ const nextConfig: NextConfig = {
|
||||
outputFileTracingIncludes: {
|
||||
"*": [
|
||||
"./src/lib/ai/prompts/safety-contracts.*.yaml",
|
||||
// PDFs are read and rendered in a child process with a memory ceiling
|
||||
// and a time limit (`src/lib/documents/pdf-isolation.ts`). The child is
|
||||
// started from these two files by path, which the tracer cannot follow.
|
||||
// PDFs are read and rendered, and uploaded images decoded into their
|
||||
// thumbnails, in a child process with a memory ceiling and a time limit
|
||||
// (`src/lib/documents/pdf-isolation.ts`). The child is started from
|
||||
// these files by path, which the tracer cannot follow.
|
||||
"./src/lib/documents/pdf-child.mjs",
|
||||
"./src/lib/documents/pdf-engine.mjs",
|
||||
"./src/lib/documents/image-engine.mjs",
|
||||
// Document AI local text extraction and rasterization both run the
|
||||
// app's one pdfjs-dist (`src/lib/documents/pdfjs.ts`) server-side; the
|
||||
// text read is the provider-free fallback that keeps content search
|
||||
|
||||
+7
-7
@@ -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",
|
||||
|
||||
Generated
+205
-205
@@ -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: {}
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -268,7 +268,12 @@
|
||||
},
|
||||
{
|
||||
"name": "DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP",
|
||||
"purpose": "\"true\" (or \"1\") accepts only signed deploy-webhook requests (X-Deploy-Webhook-Signature with a fresh X-Deploy-Webhook-Timestamp); the secret header alone is refused with 401. Leave unset for Coolify's stock sender, which can only send the secret header.",
|
||||
"purpose": "\"true\" (or \"1\") requires a fresh X-Deploy-Webhook-Timestamp (within 5 minutes) on a deploy-webhook request that authenticates with the secret header; one without it is refused with 401. Keeps a stale capture out, not a fresh forgery by someone who saw the secret. Leave unset for Coolify's stock sender, which sends no timestamp.",
|
||||
"example": "true"
|
||||
},
|
||||
{
|
||||
"name": "DEPLOY_WEBHOOK_REQUIRE_SIGNATURE",
|
||||
"purpose": "\"true\" (or \"1\") accepts only signed deploy-webhook requests (X-Deploy-Webhook-Signature with a fresh X-Deploy-Webhook-Timestamp); the secret header alone is refused with 401. The setting that stops a replay. Leave unset for Coolify's stock sender, which can only send the secret header.",
|
||||
"example": "true"
|
||||
}
|
||||
]
|
||||
@@ -432,6 +437,11 @@
|
||||
"purpose": "Trusted reverse-proxy hop count; the client IP is read that many entries from the right of X-Forwarded-For. Forwarding headers are read only from a peer whose own address is not public (loopback, private, CGNAT, unique-local, link-local, reserved, multicast).",
|
||||
"example": "1"
|
||||
},
|
||||
{
|
||||
"name": "TRUSTED_PROXY_CIDRS",
|
||||
"purpose": "Comma-separated CIDRs (IPv4 or IPv6; a bare address is one host) the reverse proxy connects from. Set, only a connection from inside them may report the client address in X-Forwarded-For or X-Real-IP; every other connection is its own client. Unset, any non-public address may, which behind Docker Desktop, OrbStack or docker-proxy includes every caller of a published port. Same host: 127.0.0.1/32,::1/128; Docker network: the proxy's subnet. An entry that is not a CIDR lets no connection report an address and is named at boot.",
|
||||
"example": "127.0.0.1/32,::1/128"
|
||||
},
|
||||
{
|
||||
"name": "TRUST_CF_CONNECTING_IP",
|
||||
"purpose": "\"1\" takes the client IP from Cloudflare's CF-Connecting-IP header, from every connection and before the peer check. Safe only when the origin accepts connections from Cloudflare alone (a Tunnel, or a firewall admitting only Cloudflare's ranges); otherwise any caller forges the address rate limits and the audit log record. Off by default.",
|
||||
|
||||
@@ -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 ]',
|
||||
|
||||
@@ -14,49 +14,197 @@
|
||||
*
|
||||
* ## What it proves and what it does not
|
||||
*
|
||||
* Every module under the paths that build model input (`src/lib/ai`,
|
||||
* `src/lib/insights`, `src/lib/jobs`, `src/lib/mcp`) that reads the lab
|
||||
* table either asks the labs module itself (`isModuleEnabled(…, "labs")`,
|
||||
* the `correlation:LAB_DRAWS` surface, or the MCP `whenModuleOn` wrapper) or
|
||||
* is listed below with the reason its read cannot reach a prompt. A new
|
||||
* reader fails here until it does one or the other. The data routes are not
|
||||
* in scope: a module decides what a model is given, not what the owner can
|
||||
* read (`CLAUDE.md`, "An AI capability gates model work").
|
||||
* Every lab read under the paths that build model input (`src/lib/ai`,
|
||||
* `src/lib/insights`, `src/lib/jobs`, `src/lib/mcp`) either sits in a
|
||||
* function that asks the labs module itself (`isModuleEnabled(…, "labs")`,
|
||||
* the `correlation:LAB_DRAWS` surface, or the MCP `whenModuleOn(…, "labs")`
|
||||
* wrapper), or sits in a private helper of its file whose every caller in
|
||||
* that file does, or is listed below by file and function with the reason
|
||||
* its rows cannot reach a prompt. A new reader fails here until it does one
|
||||
* of those. The data routes are not in scope: a module decides what a model
|
||||
* is given, not what the owner can read (`CLAUDE.md`, "An AI capability
|
||||
* gates model work").
|
||||
*
|
||||
* It is grep-shaped: it sees a read, not where the rows go, and a reader in
|
||||
* another directory is outside it. The behaviour is pinned per path by the
|
||||
* module-off cases in the suites of the readers themselves.
|
||||
* The check is per read, not per file. It used to ask whether a file that
|
||||
* reads the table mentions the gate anywhere, so one ungated read in a file
|
||||
* with a gated one passed, and `whenModuleOn` for any module counted as the
|
||||
* labs gate. It still sees a read and the function around it, not where the
|
||||
* rows go: a gate in the function is taken to cover the read, and a reader
|
||||
* in another directory is outside it. The behaviour is pinned per path by
|
||||
* the module-off cases in the suites of the readers themselves.
|
||||
*/
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { readSourceFolder, stripComments } from "./helpers/source-files";
|
||||
|
||||
const ROOTS = ["src/lib/ai", "src/lib/insights", "src/lib/jobs", "src/lib/mcp"];
|
||||
|
||||
/** A read of the lab table: the Prisma delegate or raw SQL naming it. */
|
||||
const LAB_READ =
|
||||
/\.labResult\s*\.\s*(?:findMany|findFirst|findUnique|count|aggregate|groupBy)\s*\(|\blab_results\b/;
|
||||
const READ_METHODS = new Set([
|
||||
"findMany",
|
||||
"findFirst",
|
||||
"findUnique",
|
||||
"count",
|
||||
"aggregate",
|
||||
"groupBy",
|
||||
]);
|
||||
|
||||
/** Raw SQL naming the lab table. */
|
||||
const LAB_TABLE = /\blab_results\b/;
|
||||
|
||||
/** What counts as asking the labs module. */
|
||||
const LABS_GATE =
|
||||
/isModuleEnabled\(\s*[\w.]+\s*,\s*"labs"\s*\)|"correlation:LAB_DRAWS"|whenModuleOn\(/;
|
||||
/isModuleEnabled\(\s*[\w.]+\s*,\s*"labs"\s*\)|"correlation:LAB_DRAWS"|whenModuleOn\(\s*[\w.]+\s*,\s*"labs"/;
|
||||
|
||||
/** Readers whose rows never reach a model, each with the reason. */
|
||||
/**
|
||||
* Reads whose rows never reach a model, by `file#top-level name`, each with
|
||||
* the reason.
|
||||
*/
|
||||
const NOT_MODEL_INPUT: Record<string, string> = {
|
||||
"src/lib/jobs/lab-biomarker-backfill.ts":
|
||||
"src/lib/jobs/lab-biomarker-backfill.ts#runLabBiomarkerBackfillForUser":
|
||||
"links stored rows to their biomarker; no model is involved",
|
||||
"src/lib/jobs/soft-delete-retention.ts":
|
||||
"src/lib/jobs/lab-biomarker-backfill.ts#enqueueBootTimeLabBiomarkerBackfill":
|
||||
"finds the accounts whose rows need linking; no model is involved",
|
||||
"src/lib/jobs/soft-delete-retention.ts#SOFT_DELETE_RETENTION":
|
||||
"purges rows past the trash window; no model is involved",
|
||||
"src/lib/insights/correlation-channel-series.ts":
|
||||
"`fetchLabDraws`: both callers gate it, the Coach on correlation:LAB_DRAWS and the correlations data route on the same surface",
|
||||
"src/lib/mcp/rich-reads.ts":
|
||||
"`getLabHistory`: reached only through the MCP get_labs tool, which runs behind whenModuleOn",
|
||||
"src/lib/ai/coach/tools/availability.ts":
|
||||
"src/lib/insights/correlation-channel-series.ts#fetchLabDraws":
|
||||
"both callers gate it, the Coach on correlation:LAB_DRAWS and the correlations data route on the same surface",
|
||||
"src/lib/mcp/rich-reads.ts#getLabHistory":
|
||||
'reached only through the MCP get_labs tool, which runs behind whenModuleOn(ctx, "labs")',
|
||||
"src/lib/ai/coach/tools/availability.ts#readTableBounds":
|
||||
"counts lab rows for the Coach's empty-read answer; the only lab subject comes from get_labs, which answers module_disabled first",
|
||||
"src/lib/ai/coach/tools/availability.ts#holdsRowsBefore":
|
||||
"whether lab rows predate the lookback, for the same empty-read answer and the same get_labs subject",
|
||||
};
|
||||
|
||||
function labReaders(): Array<{ path: string; gated: boolean }> {
|
||||
const out: Array<{ path: string; gated: boolean }> = [];
|
||||
type FunctionNode = ts.SignatureDeclaration & { body?: ts.Node };
|
||||
|
||||
function isReadCall(node: ts.Node): boolean {
|
||||
if (!ts.isCallExpression(node)) return false;
|
||||
const callee = node.expression;
|
||||
if (!ts.isPropertyAccessExpression(callee)) return false;
|
||||
if (!READ_METHODS.has(callee.name.text)) return false;
|
||||
const target = callee.expression;
|
||||
return (
|
||||
ts.isPropertyAccessExpression(target) && target.name.text === "labResult"
|
||||
);
|
||||
}
|
||||
|
||||
function isTableLiteral(node: ts.Node): boolean {
|
||||
return (
|
||||
(ts.isStringLiteral(node) ||
|
||||
ts.isNoSubstitutionTemplateLiteral(node) ||
|
||||
ts.isTemplateHead(node) ||
|
||||
ts.isTemplateMiddle(node) ||
|
||||
ts.isTemplateTail(node)) &&
|
||||
LAB_TABLE.test(node.text)
|
||||
);
|
||||
}
|
||||
|
||||
function enclosingFunctions(node: ts.Node): FunctionNode[] {
|
||||
const out: FunctionNode[] = [];
|
||||
for (let at = node.parent; at; at = at.parent) {
|
||||
if (ts.isFunctionLike(at)) out.push(at as FunctionNode);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The statement directly under the file that holds `node`. */
|
||||
function topLevelStatement(node: ts.Node): ts.Statement | null {
|
||||
let at: ts.Node = node;
|
||||
while (at.parent && !ts.isSourceFile(at.parent)) at = at.parent;
|
||||
return at.parent ? (at as ts.Statement) : null;
|
||||
}
|
||||
|
||||
function declaredName(statement: ts.Statement): string | null {
|
||||
if (ts.isFunctionDeclaration(statement)) return statement.name?.text ?? null;
|
||||
if (ts.isVariableStatement(statement)) {
|
||||
const [first] = statement.declarationList.declarations;
|
||||
return first && ts.isIdentifier(first.name) ? first.name.text : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isExported(statement: ts.Statement): boolean {
|
||||
return (
|
||||
ts.canHaveModifiers(statement) &&
|
||||
(ts.getModifiers(statement) ?? []).some(
|
||||
(m) => m.kind === ts.SyntaxKind.ExportKeyword,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the read at `node` is behind the labs gate: a function around it
|
||||
* asks, or it sits in a private top-level helper every one of whose
|
||||
* references in the file is itself behind the gate.
|
||||
*/
|
||||
function gated(
|
||||
file: ts.SourceFile,
|
||||
node: ts.Node,
|
||||
seen: Set<string> = new Set(),
|
||||
): boolean {
|
||||
for (const fn of enclosingFunctions(node)) {
|
||||
if (LABS_GATE.test(stripComments(fn.getText(file)))) return true;
|
||||
}
|
||||
const statement = topLevelStatement(node);
|
||||
const name = statement ? declaredName(statement) : null;
|
||||
if (!statement || !name || isExported(statement) || seen.has(name)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(name);
|
||||
const references: ts.Node[] = [];
|
||||
const visit = (at: ts.Node) => {
|
||||
if (
|
||||
ts.isIdentifier(at) &&
|
||||
at.text === name &&
|
||||
topLevelStatement(at) !== statement
|
||||
) {
|
||||
references.push(at);
|
||||
}
|
||||
ts.forEachChild(at, visit);
|
||||
};
|
||||
visit(file);
|
||||
return (
|
||||
references.length > 0 &&
|
||||
references.every((ref) => gated(file, ref, new Set(seen)))
|
||||
);
|
||||
}
|
||||
|
||||
interface LabRead {
|
||||
/** `file#top-level name`. */
|
||||
site: string;
|
||||
line: number;
|
||||
gated: boolean;
|
||||
}
|
||||
|
||||
function readsIn(path: string, source: string): LabRead[] {
|
||||
const file = ts.createSourceFile(
|
||||
path,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
path.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
||||
);
|
||||
const out: LabRead[] = [];
|
||||
const visit = (node: ts.Node) => {
|
||||
if (isReadCall(node) || isTableLiteral(node)) {
|
||||
const statement = topLevelStatement(node);
|
||||
const name = (statement && declaredName(statement)) ?? "(top level)";
|
||||
out.push({
|
||||
site: `${path}#${name}`,
|
||||
line: file.getLineAndCharacterOfPosition(node.getStart(file)).line + 1,
|
||||
gated: gated(file, node),
|
||||
});
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(file);
|
||||
return out;
|
||||
}
|
||||
|
||||
function labReads(): LabRead[] {
|
||||
const out: LabRead[] = [];
|
||||
for (const root of ROOTS) {
|
||||
for (const [rel, source] of readSourceFolder(root, {
|
||||
floor: 20,
|
||||
@@ -64,49 +212,84 @@ function labReaders(): Array<{ path: string; gated: boolean }> {
|
||||
!r.split("/").includes("__tests__") &&
|
||||
!/\.(?:test|spec)\.tsx?$/.test(r),
|
||||
})) {
|
||||
const code = stripComments(source);
|
||||
if (!LAB_READ.test(code)) continue;
|
||||
out.push({ path: `${root}/${rel}`, gated: LABS_GATE.test(code) });
|
||||
out.push(...readsIn(`${root}/${rel}`, source));
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => a.path.localeCompare(b.path));
|
||||
return out.sort((a, b) => a.site.localeCompare(b.site) || a.line - b.line);
|
||||
}
|
||||
|
||||
describe("labs reach a model only with the labs module on", () => {
|
||||
const readers = labReaders();
|
||||
const reads = labReads();
|
||||
|
||||
it("finds the readers it exists to check", () => {
|
||||
it("finds the reads it exists to check", () => {
|
||||
// An empty match set would pass every assertion below vacuously.
|
||||
expect(readers.length).toBeGreaterThanOrEqual(8);
|
||||
expect(readers.filter((r) => r.gated).length).toBeGreaterThanOrEqual(6);
|
||||
expect(reads.length).toBeGreaterThanOrEqual(14);
|
||||
expect(reads.filter((r) => r.gated).length).toBeGreaterThanOrEqual(8);
|
||||
});
|
||||
|
||||
it("every reader asks the labs module or is listed as not model input", () => {
|
||||
const open = readers
|
||||
.filter((r) => !r.gated && !(r.path in NOT_MODEL_INPUT))
|
||||
.map((r) => r.path);
|
||||
it("every read asks the labs module or is listed as not model input", () => {
|
||||
const open = reads
|
||||
.filter((r) => !r.gated && !(r.site in NOT_MODEL_INPUT))
|
||||
.map((r) => `${r.site}:${r.line}`);
|
||||
expect(open).toEqual([]);
|
||||
});
|
||||
|
||||
it("lists no reader that has since gained the gate or gone", () => {
|
||||
const paths = new Map(readers.map((r) => [r.path, r.gated]));
|
||||
it("lists no site that has since gained the gate or gone", () => {
|
||||
const stale = Object.keys(NOT_MODEL_INPUT).filter(
|
||||
(path) => paths.get(path) !== false,
|
||||
(site) => !reads.some((r) => r.site === site && !r.gated),
|
||||
);
|
||||
expect(stale).toEqual([]);
|
||||
});
|
||||
|
||||
it("the matchers catch what they exist to catch", () => {
|
||||
expect(LAB_READ.test("await prisma.labResult.findMany({")).toBe(true);
|
||||
expect(LAB_READ.test("tx.labResult\n .count({")).toBe(true);
|
||||
expect(LAB_READ.test("SELECT 1 FROM lab_results")).toBe(true);
|
||||
expect(LAB_READ.test("prisma.labResultDocumentLink.findMany(")).toBe(false);
|
||||
it("judges each read on its own, not the file it is in", () => {
|
||||
const source = `
|
||||
export async function gatedRead(userId: string) {
|
||||
if (!(await isModuleEnabled(userId, "labs"))) return null;
|
||||
return prisma.labResult.findMany({ where: { userId } });
|
||||
}
|
||||
export async function openRead(userId: string) {
|
||||
return prisma.labResult.count({ where: { userId } });
|
||||
}
|
||||
async function helper(userId: string) {
|
||||
return prisma.labResult.findFirst({ where: { userId } });
|
||||
}
|
||||
export async function callsHelperGated(userId: string) {
|
||||
if (!(await isModuleEnabled(userId, "labs"))) return null;
|
||||
return helper(userId);
|
||||
}
|
||||
async function helperTwo(userId: string) {
|
||||
return prisma.$queryRaw\`SELECT 1 FROM lab_results\`;
|
||||
}
|
||||
export async function callsHelperTwo(userId: string) {
|
||||
return helperTwo(userId);
|
||||
}
|
||||
export function medsOnly(ctx: Ctx) {
|
||||
return whenModuleOn(ctx, "medications", "t", () =>
|
||||
prisma.labResult.findMany({}),
|
||||
);
|
||||
}
|
||||
`;
|
||||
const verdicts = readsIn("fixture.ts", source).map(
|
||||
(r) => `${r.site}:${r.gated}`,
|
||||
);
|
||||
expect(verdicts).toEqual([
|
||||
"fixture.ts#gatedRead:true",
|
||||
"fixture.ts#openRead:false",
|
||||
"fixture.ts#helper:true",
|
||||
"fixture.ts#helperTwo:false",
|
||||
"fixture.ts#medsOnly:false",
|
||||
]);
|
||||
});
|
||||
|
||||
it("the gate matcher takes the labs module only", () => {
|
||||
expect(LABS_GATE.test('await isModuleEnabled(userId, "labs")')).toBe(true);
|
||||
expect(LABS_GATE.test('await isModuleEnabled(ctx.userId, "labs")')).toBe(
|
||||
true,
|
||||
);
|
||||
expect(LABS_GATE.test('whenModuleOn(ctx, "labs", "get_labs"')).toBe(true);
|
||||
expect(LABS_GATE.test('isModuleEnabled(userId, "medications")')).toBe(
|
||||
false,
|
||||
);
|
||||
expect(LABS_GATE.test('whenModuleOn(ctx, "environment", "x"')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -75,6 +75,16 @@ describe("proxy.ts onboarding redirect (v1.4.22 C4)", () => {
|
||||
expect(res.headers.get("location")).toBeNull();
|
||||
});
|
||||
|
||||
it("counts the __Host- session cookie as a session at the page gate", () => {
|
||||
const res = proxy(
|
||||
makeRequest("/insights", { "__Host-healthlog_session": "sess-1" }),
|
||||
);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("location")).toBeNull();
|
||||
const anonymous = proxy(makeRequest("/insights"));
|
||||
expect(anonymous.headers.get("location")).toMatch(/\/auth\/login$/);
|
||||
});
|
||||
|
||||
it("passes through when hl_onboarding cookie is absent (completed users)", () => {
|
||||
const res = proxy(makeRequest("/", { healthlog_session: "sess-1" }));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
@@ -190,10 +190,13 @@ describe("S1 — the direct session-resolution set is frozen", () => {
|
||||
/\b(getSession|getUnswitchedSession|getSessionUserLocale|validateSessionFromCookieValue|validateSessionWithCreatedAt|loadOnboardingFlowState)\s*\(/;
|
||||
|
||||
/**
|
||||
* Reaching for the cookie by name is the way around the helpers. Both the
|
||||
* exported constant and the raw literal count.
|
||||
* Reaching for the cookie by name is the way around the helpers. The
|
||||
* reader (`readSessionCookie`, which tries both names), the exported names
|
||||
* and either raw literal all count; the `__Host-` name joined the old one
|
||||
* when a `Secure` session cookie started being issued under it.
|
||||
*/
|
||||
const RAW_SESSION_COOKIE = /\bSESSION_COOKIE_NAME\b|"healthlog_session"/;
|
||||
const RAW_SESSION_COOKIE =
|
||||
/\b(readSessionCookie|SESSION_COOKIE_NAMES?|HOST_SESSION_COOKIE|LEGACY_SESSION_COOKIE)\b|"(__Host-)?healthlog_session"/;
|
||||
|
||||
it("no file under src/app derives a session outside requireAuth unlisted", () => {
|
||||
const resolvers = appFilesMatching(SESSION_DERIVING_HELPERS);
|
||||
|
||||
@@ -20,7 +20,6 @@ vi.mock("@/lib/rate-limit", () => ({
|
||||
const validateSessionWithCreatedAt = vi.fn();
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
validateSessionWithCreatedAt: (v: unknown) => validateSessionWithCreatedAt(v),
|
||||
SESSION_COOKIE_NAME: "healthlog_session",
|
||||
}));
|
||||
|
||||
// Keep the real shared core (buildHandoffCallbackUrl is used by
|
||||
|
||||
@@ -29,11 +29,11 @@ import { annotate } from "@/lib/logging/context";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
|
||||
import { validateSessionWithCreatedAt } from "@/lib/auth/session";
|
||||
import {
|
||||
validateSessionWithCreatedAt,
|
||||
SESSION_COOKIE_NAME,
|
||||
} from "@/lib/auth/session";
|
||||
expireSessionCookies,
|
||||
readSessionCookie,
|
||||
} from "@/lib/auth/session-cookie";
|
||||
import { mintNativeHandoff } from "@/lib/auth/native-handoff";
|
||||
import {
|
||||
NATIVE_HANDOFF_STATE_COOKIE,
|
||||
@@ -95,7 +95,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
// (row + expiry), never "a cookie is present". Projects `createdAt` so the
|
||||
// freshness comparison below is structurally always available.
|
||||
const authed = await validateSessionWithCreatedAt(
|
||||
req.cookies.get(SESSION_COOKIE_NAME)?.value,
|
||||
readSessionCookie(req.cookies)?.value,
|
||||
);
|
||||
if (!authed) {
|
||||
annotate({ meta: { reason: "no_session" } });
|
||||
@@ -140,13 +140,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
await prisma.session
|
||||
.delete({ where: { id: authed.session.id } })
|
||||
.catch(() => {});
|
||||
response.cookies.set(SESSION_COOKIE_NAME, "", {
|
||||
httpOnly: true,
|
||||
secure: shouldEmitSecureCookie(),
|
||||
sameSite: "lax",
|
||||
maxAge: 0,
|
||||
path: "/",
|
||||
});
|
||||
expireSessionCookies(response.cookies);
|
||||
|
||||
return response;
|
||||
});
|
||||
@@ -42,7 +42,6 @@ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() }));
|
||||
vi.mock("@/lib/auth/session", () => ({
|
||||
createSession: vi.fn(),
|
||||
validateSessionFromCookieValue: vi.fn(),
|
||||
SESSION_COOKIE_NAME: "healthlog_session",
|
||||
}));
|
||||
vi.mock("@/lib/auth/login-alert", () => ({ recordSignInDevice: vi.fn() }));
|
||||
vi.mock("@/lib/auth/mfa/challenge", () => ({
|
||||
|
||||
@@ -3,10 +3,8 @@ import { NextRequest, NextResponse } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
validateSessionFromCookieValue,
|
||||
SESSION_COOKIE_NAME,
|
||||
} from "@/lib/auth/session";
|
||||
import { validateSessionFromCookieValue } from "@/lib/auth/session";
|
||||
import { readSessionCookie } from "@/lib/auth/session-cookie";
|
||||
import {
|
||||
discoverOidcMetadata,
|
||||
exchangeCodeForTokens,
|
||||
@@ -225,7 +223,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
// session gains nothing here but a redirect to a page they could already
|
||||
// reach — no code redemption, no session mint, no identity change.
|
||||
const existingSession = await validateSessionFromCookieValue(
|
||||
req.cookies.get(SESSION_COOKIE_NAME)?.value,
|
||||
readSessionCookie(req.cookies)?.value,
|
||||
);
|
||||
if (existingSession) {
|
||||
annotate({ meta: { reason: "duplicate_callback_authenticated" } });
|
||||
@@ -258,7 +256,7 @@ export const GET = apiHandler(async (req: NextRequest) => {
|
||||
: null;
|
||||
if (isClaim) {
|
||||
const liveSession = await validateSessionFromCookieValue(
|
||||
req.cookies.get(SESSION_COOKIE_NAME)?.value,
|
||||
readSessionCookie(req.cookies)?.value,
|
||||
);
|
||||
if (liveSession) {
|
||||
annotate({ meta: { reason: "claim_signed_in" } });
|
||||
|
||||
@@ -444,12 +444,18 @@ describe("POST /api/internal/deploy-webhook (timestamp replay protection)", () =
|
||||
*/
|
||||
describe("POST /api/internal/deploy-webhook (signed requests)", () => {
|
||||
const ORIGINAL_REQUIRE = process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP;
|
||||
const ORIGINAL_SIGNATURE = process.env.DEPLOY_WEBHOOK_REQUIRE_SIGNATURE;
|
||||
afterEach(() => {
|
||||
if (ORIGINAL_REQUIRE === undefined) {
|
||||
delete process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP;
|
||||
} else {
|
||||
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP = ORIGINAL_REQUIRE;
|
||||
}
|
||||
if (ORIGINAL_SIGNATURE === undefined) {
|
||||
delete process.env.DEPLOY_WEBHOOK_REQUIRE_SIGNATURE;
|
||||
} else {
|
||||
process.env.DEPLOY_WEBHOOK_REQUIRE_SIGNATURE = ORIGINAL_SIGNATURE;
|
||||
}
|
||||
});
|
||||
|
||||
async function signed(
|
||||
@@ -491,8 +497,8 @@ describe("POST /api/internal/deploy-webhook (signed requests)", () => {
|
||||
expect(auditLog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("in strict mode accepts only a signed request, not the bare secret with a fresh timestamp", async () => {
|
||||
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP = "true";
|
||||
it("with DEPLOY_WEBHOOK_REQUIRE_SIGNATURE accepts only a signed request, not the bare secret with a fresh timestamp", async () => {
|
||||
process.env.DEPLOY_WEBHOOK_REQUIRE_SIGNATURE = "true";
|
||||
const unsigned = jsonRequest(
|
||||
{ status: "success" },
|
||||
{
|
||||
@@ -503,4 +509,24 @@ describe("POST /api/internal/deploy-webhook (signed requests)", () => {
|
||||
expect((await POST(unsigned)).status).toBe(401);
|
||||
expect((await POST(await signed('{"status":"success"}'))).status).toBe(200);
|
||||
});
|
||||
|
||||
it("keeps DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP meaning the secret plus a fresh timestamp", async () => {
|
||||
// What the flag meant before 1.46, and what an operator who set it with
|
||||
// a sender that timestamps but cannot sign still gets.
|
||||
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP = "true";
|
||||
const fresh = jsonRequest(
|
||||
{ status: "success" },
|
||||
{
|
||||
"x-deploy-webhook-secret": "test-secret",
|
||||
"x-deploy-webhook-timestamp": String(Math.floor(Date.now() / 1000)),
|
||||
},
|
||||
);
|
||||
expect((await POST(fresh)).status).toBe(200);
|
||||
const bare = jsonRequest(
|
||||
{ status: "success" },
|
||||
{ "x-deploy-webhook-secret": "test-secret" },
|
||||
);
|
||||
expect((await POST(bare)).status).toBe(401);
|
||||
expect((await POST(await signed('{"status":"success"}'))).status).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -110,9 +110,15 @@ function normalizePayload(payload: CoolifyDeployPayload): NormalizedEvent {
|
||||
* - Default: a signed request is verified as above; an unsigned one
|
||||
* authenticates with the secret header, as Coolify's stock sender does,
|
||||
* and a timestamp it carries must still be fresh.
|
||||
* - `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true`: only a signed request is
|
||||
* accepted. The name predates the signature; requiring a timestamp
|
||||
* without one would not make a replay any harder.
|
||||
* - `DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP=true`: what it has always meant, the
|
||||
* secret header plus a fresh timestamp; an unsigned request without one
|
||||
* is refused. It keeps a stale capture out, not a fresh forgery by
|
||||
* someone who saw the secret. A signed request is accepted as above.
|
||||
* - `DEPLOY_WEBHOOK_REQUIRE_SIGNATURE=true`: only a signed request is
|
||||
* accepted; the secret header alone is refused. This is the setting
|
||||
* that stops a replay. It has its own name so that an operator who set
|
||||
* the timestamp flag, with a sender that adds a timestamp but cannot
|
||||
* sign, keeps the behaviour they asked for.
|
||||
*
|
||||
* See docs/ops/deploy.md ("Deploy-status webhook") for the operator
|
||||
* guidance.
|
||||
@@ -132,7 +138,11 @@ function checkTimestampFreshness(
|
||||
now: number = Date.now(),
|
||||
): { ok: boolean; reason?: string } {
|
||||
const raw = request.headers.get("x-deploy-webhook-timestamp");
|
||||
if (raw === null || raw === "") return { ok: true };
|
||||
if (raw === null || raw === "") {
|
||||
return flagSet("DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP")
|
||||
? { ok: false, reason: "timestamp_missing" }
|
||||
: { ok: true };
|
||||
}
|
||||
const ts = parseTimestamp(raw);
|
||||
if (ts === null) return { ok: false, reason: "timestamp_invalid" };
|
||||
if (Math.abs(now - ts) > TIMESTAMP_WINDOW_MS) {
|
||||
@@ -141,11 +151,11 @@ function checkTimestampFreshness(
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function strictMode(): boolean {
|
||||
return (
|
||||
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP === "true" ||
|
||||
process.env.DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP === "1"
|
||||
);
|
||||
function flagSet(
|
||||
name: "DEPLOY_WEBHOOK_REQUIRE_TIMESTAMP" | "DEPLOY_WEBHOOK_REQUIRE_SIGNATURE",
|
||||
): boolean {
|
||||
const value = process.env[name]?.trim().toLowerCase();
|
||||
return value === "true" || value === "1";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -296,7 +306,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
getEvent()?.setAuth({ auth_method: "webhook_secret" });
|
||||
annotate({ meta: { deploy_webhook_auth: "signature" } });
|
||||
} else {
|
||||
if (strictMode() || !hasValidSecret(request)) {
|
||||
if (
|
||||
flagSet("DEPLOY_WEBHOOK_REQUIRE_SIGNATURE") ||
|
||||
!hasValidSecret(request)
|
||||
) {
|
||||
return NextResponse.json({ status: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
// Freshness AFTER the secret check: an unauthenticated caller learns
|
||||
|
||||
@@ -228,6 +228,8 @@ export function documentAiErrorKey(err: unknown): string {
|
||||
return "aiRuns.errorWorkerUnavailable";
|
||||
case "aiRuns.timedOut":
|
||||
return "aiRuns.errorTimedOut";
|
||||
case "aiRuns.readerBusy":
|
||||
return "aiRuns.errorReaderBusy";
|
||||
case "consent.ai.required":
|
||||
return "documents.assist.errorConsent";
|
||||
case "ai.provider.none":
|
||||
|
||||
@@ -117,6 +117,8 @@ export function extractErrorMessage(
|
||||
return t("aiRuns.errorWorkerUnavailable");
|
||||
case "aiRuns.timedOut":
|
||||
return t("aiRuns.errorTimedOut");
|
||||
case "aiRuns.readerBusy":
|
||||
return t("aiRuns.errorReaderBusy");
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,18 @@ async function registerOfflineGeoNoticeAtBoot(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Which connections may report a client address (TRUSTED_PROXY_CIDRS,
|
||||
* TRUST_PROXY_HOPS), said once, with a bad value named.
|
||||
*/
|
||||
async function logProxyTrustAtBootSafely(): Promise<void> {
|
||||
try {
|
||||
(await import("@/lib/http/proxy-trust")).logProxyTrustAtBoot();
|
||||
} catch {
|
||||
// A log line must never break boot.
|
||||
}
|
||||
}
|
||||
|
||||
export async function register() {
|
||||
// Only start the worker on the Node.js server runtime (not Edge, not build)
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
@@ -54,6 +66,7 @@ export async function register() {
|
||||
}
|
||||
|
||||
await registerOfflineGeoNoticeAtBoot();
|
||||
await logProxyTrustAtBootSafely();
|
||||
|
||||
// Does the configured encryption key open this database? A process whose
|
||||
// key does not refuses every API request with one clear 503 and starts
|
||||
|
||||
@@ -114,6 +114,65 @@ describe("apiHandler and a state change from another site", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a browser-attached Basic or Negotiate header riding a session cookie", async () => {
|
||||
// The browser adds these on its own once a proxy or the host asked for
|
||||
// them, and the caller is resolved from the cookie first: the header is
|
||||
// not what the request is authenticated by.
|
||||
for (const authorization of ["Basic dXNlcjpwYXNz", "Negotiate YIIGhg"]) {
|
||||
const response = await post({
|
||||
"content-type": "application/json",
|
||||
authorization,
|
||||
"sec-fetch-site": "same-site",
|
||||
origin: "https://evil.example.com",
|
||||
cookie: "healthlog_session=s",
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
}
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("checks a Bearer request that also carries a session cookie as the cookie request it resolves to", async () => {
|
||||
for (const cookie of [
|
||||
"healthlog_session=s",
|
||||
"theme=dark; __Host-healthlog_session=s",
|
||||
]) {
|
||||
const response = await post({
|
||||
"content-type": "application/json",
|
||||
authorization: "Bearer hlk_x",
|
||||
"sec-fetch-site": "cross-site",
|
||||
cookie,
|
||||
});
|
||||
expect(response.status).toBe(403);
|
||||
}
|
||||
expect(handler).not.toHaveBeenCalled();
|
||||
// From the app's own page the same pair passes.
|
||||
const own = await post({
|
||||
"content-type": "application/json",
|
||||
authorization: "Bearer hlk_x",
|
||||
"sec-fetch-site": "same-origin",
|
||||
cookie: "healthlog_session=s",
|
||||
});
|
||||
expect(own.status).toBe(200);
|
||||
});
|
||||
|
||||
it("lets the share-link passphrase POST through from a no-referrer page", async () => {
|
||||
// The shared view is served with `Referrer-Policy: no-referrer`; a
|
||||
// browser without Sec-Fetch-Site then sends `Origin: null`. The token in
|
||||
// the path and the passphrase in the body are the credential.
|
||||
const response = await route(
|
||||
new NextRequest(`${ORIGIN}/api/c/hls_token/unlock`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
host: "health.example.com",
|
||||
"content-type": "application/json",
|
||||
origin: "null",
|
||||
},
|
||||
body: '{"value":1}',
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it("leaves reads alone", async () => {
|
||||
const response = await route(
|
||||
new NextRequest(`${ORIGIN}/api/measurements`, {
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/db", () => ({ prisma: {} }));
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
vi.mock("@/lib/auth/hmac", () => ({ hashToken: vi.fn() }));
|
||||
vi.mock("@/lib/auth/audit", () => ({
|
||||
auditLog: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler } from "@/lib/api-handler";
|
||||
import { getClientIp } from "@/lib/api-response";
|
||||
|
||||
/**
|
||||
* A typo in `TRUST_PROXY_HOPS` used to make every request that asked for the
|
||||
* client address throw, and `apiHandler` asks for it on every request to
|
||||
* record it: every route answered 500, the health probe included, so the
|
||||
* container looked dead for a configuration slip. It now resolves no address
|
||||
* and the boot log names the value.
|
||||
*/
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("apiHandler with an invalid TRUST_PROXY_HOPS", () => {
|
||||
it("answers the route instead of 500, with no client address", async () => {
|
||||
vi.stubEnv("TRUST_PROXY_HOPS", "two");
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const seen: Array<string | null> = [];
|
||||
const route = apiHandler(async (request: NextRequest) => {
|
||||
seen.push(getClientIp(request));
|
||||
return Response.json({ data: { status: "ok" }, error: null });
|
||||
});
|
||||
const response = await route(
|
||||
new NextRequest("https://health.example.com/api/health", {
|
||||
headers: { "x-forwarded-for": "203.0.113.4" },
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(200);
|
||||
expect(seen).toEqual([null]);
|
||||
});
|
||||
});
|
||||
@@ -10,16 +10,22 @@ import {
|
||||
import {
|
||||
getClientIp,
|
||||
getClientIpOrTrustWarning,
|
||||
_resetInvalidHopsWarningForTests,
|
||||
_resetPublicPeerForwardingWarningForTests,
|
||||
_resetTrustViolationWarningForTests,
|
||||
_resetUnreadProxyHeadersWarningForTests,
|
||||
} from "../api-response";
|
||||
import { describeProxyTrust } from "../http/proxy-trust";
|
||||
|
||||
const ORIGINAL_HOPS = process.env.TRUST_PROXY_HOPS;
|
||||
const ORIGINAL_CF_FLAG = process.env.TRUST_CF_CONNECTING_IP;
|
||||
const ORIGINAL_CIDRS = process.env.TRUSTED_PROXY_CIDRS;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.TRUST_PROXY_HOPS;
|
||||
delete process.env.TRUST_CF_CONNECTING_IP;
|
||||
delete process.env.TRUSTED_PROXY_CIDRS;
|
||||
_resetPublicPeerForwardingWarningForTests();
|
||||
_resetTrustViolationWarningForTests();
|
||||
_resetUnreadProxyHeadersWarningForTests();
|
||||
});
|
||||
@@ -29,6 +35,8 @@ afterEach(() => {
|
||||
else process.env.TRUST_PROXY_HOPS = ORIGINAL_HOPS;
|
||||
if (ORIGINAL_CF_FLAG === undefined) delete process.env.TRUST_CF_CONNECTING_IP;
|
||||
else process.env.TRUST_CF_CONNECTING_IP = ORIGINAL_CF_FLAG;
|
||||
if (ORIGINAL_CIDRS === undefined) delete process.env.TRUSTED_PROXY_CIDRS;
|
||||
else process.env.TRUSTED_PROXY_CIDRS = ORIGINAL_CIDRS;
|
||||
});
|
||||
|
||||
function makeRequest(headers: Record<string, string>): Request {
|
||||
@@ -64,11 +72,23 @@ describe("getClientIp trusted-proxy semantics (V3 audit)", () => {
|
||||
expect(ip).toBeNull();
|
||||
});
|
||||
|
||||
it("throws at boot when TRUST_PROXY_HOPS is unparseable (fixes review L-1)", () => {
|
||||
it("resolves no address for an unparseable TRUST_PROXY_HOPS instead of failing the request", () => {
|
||||
// It used to throw on every request that asked for the client address,
|
||||
// so every route answered 500, the health probe included. Reading the
|
||||
// headers anyway would silently trust a chain the operator never
|
||||
// described (review L-1), so: no address, a trust violation, one warning.
|
||||
process.env.TRUST_PROXY_HOPS = "garbage";
|
||||
expect(() =>
|
||||
getClientIp(makeRequest({ "x-forwarded-for": "5.6.7.8" })),
|
||||
).toThrow(/TRUST_PROXY_HOPS/);
|
||||
_resetInvalidHopsWarningForTests();
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
onTestFinished(() => warn.mockRestore());
|
||||
const request = makeRequest({ "x-forwarded-for": "5.6.7.8" });
|
||||
expect(getClientIpOrTrustWarning(request)).toEqual({
|
||||
ip: null,
|
||||
trustViolation: true,
|
||||
});
|
||||
expect(getClientIp(request)).toBeNull();
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn.mock.calls[0]![0]).toMatch(/TRUST_PROXY_HOPS="garbage"/);
|
||||
});
|
||||
|
||||
it.each(["", " "])(
|
||||
@@ -480,3 +500,93 @@ describe("getClientIp against the connection's own peer", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Behind Docker Desktop, OrbStack or `docker-proxy`, every connection to a
|
||||
* published port arrives from the gateway, a private address, so the
|
||||
* private-range default believed a direct caller's forwarding headers again.
|
||||
* `TRUSTED_PROXY_CIDRS` names the proxies; no other peer may forward.
|
||||
*/
|
||||
describe("getClientIp with TRUSTED_PROXY_CIDRS", () => {
|
||||
const stamped = (peer: string, headers: Record<string, string> = {}) =>
|
||||
makeRequest({ ...headers, "x-healthlog-peer-address": peer });
|
||||
|
||||
it("reads forwarding headers only from a peer inside the list", () => {
|
||||
process.env.TRUSTED_PROXY_CIDRS = "172.18.0.0/16, 127.0.0.1";
|
||||
expect(
|
||||
getClientIp(stamped("172.18.0.5", { "x-forwarded-for": "203.0.113.5" })),
|
||||
).toBe("203.0.113.5");
|
||||
expect(
|
||||
getClientIp(stamped("127.0.0.1", { "x-real-ip": "203.0.113.6" })),
|
||||
).toBe("203.0.113.6");
|
||||
});
|
||||
|
||||
it("uses the gateway's own address for a direct caller from a private network outside the list", () => {
|
||||
process.env.TRUSTED_PROXY_CIDRS = "172.18.0.0/16";
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const forged = ["203.0.113.1", "203.0.113.2"].map((ip) =>
|
||||
getClientIp(
|
||||
stamped("192.168.65.1", { "x-forwarded-for": ip, "x-real-ip": ip }),
|
||||
),
|
||||
);
|
||||
expect(forged).toEqual(["192.168.65.1", "192.168.65.1"]);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("believes a proxy on a public address once it is listed", () => {
|
||||
process.env.TRUSTED_PROXY_CIDRS = "198.51.100.7/32";
|
||||
expect(
|
||||
getClientIp(
|
||||
stamped("198.51.100.7", { "x-forwarded-for": "203.0.113.9" }),
|
||||
),
|
||||
).toBe("203.0.113.9");
|
||||
});
|
||||
|
||||
it("takes IPv6 ranges and bare addresses", () => {
|
||||
process.env.TRUSTED_PROXY_CIDRS = "fd00::/8,2001:db8::1";
|
||||
expect(
|
||||
getClientIp(stamped("fd12::3", { "x-forwarded-for": "203.0.113.1" })),
|
||||
).toBe("203.0.113.1");
|
||||
expect(
|
||||
getClientIp(stamped("2001:db8::1", { "x-forwarded-for": "203.0.113.2" })),
|
||||
).toBe("203.0.113.2");
|
||||
expect(
|
||||
getClientIp(stamped("2001:db8::2", { "x-forwarded-for": "203.0.113.3" })),
|
||||
).toBe("2001:db8::2");
|
||||
});
|
||||
|
||||
it("lets no peer forward when an entry is not a CIDR", () => {
|
||||
process.env.TRUSTED_PROXY_CIDRS = "172.18.0.0/16, not-a-range";
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
expect(
|
||||
getClientIp(stamped("172.18.0.5", { "x-forwarded-for": "203.0.113.5" })),
|
||||
).toBe("172.18.0.5");
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the boot line naming the proxy rule", () => {
|
||||
it("names the private-range default, and what it lets in behind a port forwarder", () => {
|
||||
expect(describeProxyTrust()).toMatchObject({
|
||||
level: "info",
|
||||
message: expect.stringMatching(/TRUSTED_PROXY_CIDRS unset.*docker-proxy/),
|
||||
});
|
||||
});
|
||||
|
||||
it("names the list when one is set", () => {
|
||||
process.env.TRUSTED_PROXY_CIDRS = "127.0.0.1/32";
|
||||
expect(describeProxyTrust()).toMatchObject({
|
||||
level: "info",
|
||||
message: expect.stringContaining("only connections from 127.0.0.1/32"),
|
||||
});
|
||||
});
|
||||
|
||||
it("names a bad TRUST_PROXY_HOPS and a bad CIDR as errors", () => {
|
||||
process.env.TRUST_PROXY_HOPS = "one";
|
||||
process.env.TRUSTED_PROXY_CIDRS = "10.0.0.0/33";
|
||||
const line = describeProxyTrust();
|
||||
expect(line.level).toBe("error");
|
||||
expect(line.message).toContain('TRUST_PROXY_HOPS="one"');
|
||||
expect(line.message).toContain('"10.0.0.0/33"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
const u16 = (n: number) => [n & 0xff, (n >> 8) & 0xff];
|
||||
|
||||
/**
|
||||
* A GIF with the given logical screen and frames. Each frame carries one
|
||||
* byte of image data, which is enough for a header walk and for a decoder.
|
||||
*/
|
||||
export function gif(
|
||||
screen: { width: number; height: number; colourTable?: boolean },
|
||||
frames: Array<{
|
||||
width: number;
|
||||
height: number;
|
||||
left?: number;
|
||||
top?: number;
|
||||
extension?: boolean;
|
||||
}>,
|
||||
): Buffer {
|
||||
const out: number[] = [...Buffer.from("GIF89a", "latin1")];
|
||||
out.push(...u16(screen.width), ...u16(screen.height));
|
||||
out.push(screen.colourTable ? 0x80 : 0x00, 0, 0);
|
||||
if (screen.colourTable) out.push(0, 0, 0, 255, 255, 255);
|
||||
for (const frame of frames) {
|
||||
if (frame.extension) {
|
||||
// A graphic control extension.
|
||||
out.push(0x21, 0xf9, 4, 0, 0, 0, 0, 0);
|
||||
}
|
||||
out.push(0x2c, ...u16(frame.left ?? 0), ...u16(frame.top ?? 0));
|
||||
out.push(...u16(frame.width), ...u16(frame.height), 0x80);
|
||||
out.push(0, 0, 0, 255, 255, 255); // local colour table, two entries
|
||||
out.push(2, 2, 0x44, 0x01, 0); // LZW size, one sub-block, terminator
|
||||
}
|
||||
out.push(0x3b);
|
||||
return Buffer.from(out);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { createCanvas } from "@napi-rs/canvas";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { readImageDimensions } from "../image-dimensions";
|
||||
import { gif } from "./gif-fixture";
|
||||
|
||||
/**
|
||||
* The header walk both the avatar upload and the document thumbnail trust to
|
||||
@@ -36,9 +37,58 @@ describe("readImageDimensions", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reads a GIF's logical screen", () => {
|
||||
const gif = Buffer.from("GIF89a\x25\x00\x17\x00\x00\x00\x00", "latin1");
|
||||
expect(readImageDimensions(gif, "image/gif")).toEqual(SIZE);
|
||||
it("reads a GIF's logical screen when its frames fit inside it", () => {
|
||||
expect(
|
||||
readImageDimensions(
|
||||
gif({ width: 37, height: 23 }, [{ width: 37, height: 23 }]),
|
||||
"image/gif",
|
||||
),
|
||||
).toEqual(SIZE);
|
||||
});
|
||||
|
||||
it("sizes a GIF by its largest frame, not only its logical screen", () => {
|
||||
// The decoder allocates for the frame: 10 x 10 on the screen, 20000 x
|
||||
// 20000 in the descriptor was a 1.6 GB decode behind a 100-pixel check.
|
||||
expect(
|
||||
readImageDimensions(
|
||||
gif({ width: 10, height: 10 }, [{ width: 20_000, height: 20_000 }]),
|
||||
"image/gif",
|
||||
),
|
||||
).toEqual({ width: 20_000, height: 20_000 });
|
||||
// A later frame, an offset, a global colour table and an extension in
|
||||
// between are all walked.
|
||||
expect(
|
||||
readImageDimensions(
|
||||
gif({ width: 10, height: 10, colourTable: true }, [
|
||||
{ width: 4, height: 4 },
|
||||
{ left: 60_000, top: 5, width: 5_000, height: 3, extension: true },
|
||||
]),
|
||||
"image/gif",
|
||||
),
|
||||
).toEqual({ width: 65_000, height: 10 });
|
||||
});
|
||||
|
||||
it("refuses a GIF it cannot walk to the end of a frame descriptor", () => {
|
||||
const whole = gif({ width: 10, height: 10 }, [{ width: 10, height: 10 }]);
|
||||
// Logical screen only: no frame, no size.
|
||||
expect(readImageDimensions(whole.subarray(0, 13), "image/gif")).toBeNull();
|
||||
// Cut inside the image descriptor.
|
||||
expect(readImageDimensions(whole.subarray(0, 18), "image/gif")).toBeNull();
|
||||
// A block no decoder knows.
|
||||
const unknown = Buffer.from(whole);
|
||||
unknown[13] = 0x99;
|
||||
expect(readImageDimensions(unknown, "image/gif")).toBeNull();
|
||||
// A zero-sized frame.
|
||||
expect(
|
||||
readImageDimensions(
|
||||
gif({ width: 10, height: 10 }, [{ width: 0, height: 10 }]),
|
||||
"image/gif",
|
||||
),
|
||||
).toBeNull();
|
||||
// Not a GIF signature a decoder accepts.
|
||||
const signature = Buffer.from(whole);
|
||||
signature.write("GIF80a", 0, "latin1");
|
||||
expect(readImageDimensions(signature, "image/gif")).toBeNull();
|
||||
});
|
||||
|
||||
it("skips fill bytes, RSTn and TEM before the frame header", () => {
|
||||
|
||||
+54
-29
@@ -7,7 +7,11 @@ import {
|
||||
peerAddressStampInstalled,
|
||||
readPeerAddress,
|
||||
} from "@/lib/http/peer-address";
|
||||
import { isPublicIp } from "@/lib/validations/notifications";
|
||||
import {
|
||||
parseTrustProxyHops,
|
||||
parseTrustedProxyCidrs,
|
||||
peerMayForward,
|
||||
} from "@/lib/http/proxy-trust";
|
||||
|
||||
export function apiSuccess<T>(data: T, status = 200) {
|
||||
return NextResponse.json({ data, error: null }, { status });
|
||||
@@ -508,13 +512,16 @@ export async function safeJson<T = unknown>(
|
||||
* observed when the request arrived.
|
||||
* - "N" (>1) → trust N hops; read the Nth-from-rightmost XFF entry.
|
||||
*
|
||||
* Forwarding headers are read only from a connection whose peer could be a
|
||||
* proxy: loopback, a private or unique-local network, CGNAT (Tailscale). A
|
||||
* connection from a public address reached the server directly, so whatever
|
||||
* forwarding headers it sends are its own, and its own address is the client
|
||||
* IP. The peer comes from `@/lib/http/peer-address`. Without that check, the
|
||||
* default of one trusted hop let any direct caller name a fresh address per
|
||||
* request and step around every per-IP limit.
|
||||
* Forwarding headers are read only from a connection whose peer may be a
|
||||
* proxy (`peerMayForward` in `@/lib/http/proxy-trust`): one inside
|
||||
* `TRUSTED_PROXY_CIDRS` when that is set, otherwise any loopback, private,
|
||||
* unique-local or CGNAT (Tailscale) address. Any other connection reached the
|
||||
* server directly, so whatever forwarding headers it sends are its own, and
|
||||
* its own address is the client IP. The peer comes from
|
||||
* `@/lib/http/peer-address`. Without that check, the default of one trusted
|
||||
* hop let any direct caller name a fresh address per request and step around
|
||||
* every per-IP limit. An invalid `TRUST_PROXY_HOPS` resolves no address
|
||||
* (and is named at boot) instead of failing the request.
|
||||
*
|
||||
* Cloudflare opt-in (`TRUST_CF_CONNECTING_IP=1`): when the env flag is
|
||||
* set, the helper prefers the `cf-connecting-ip` header before walking
|
||||
@@ -561,22 +568,24 @@ function readCfConnectingIp(request: Request): string | null {
|
||||
return looksLikeIp(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
function parseTrustProxyHops(raw: string | undefined): number {
|
||||
// Empty is unset. The shipped docker-compose.yml passes
|
||||
// `TRUST_PROXY_HOPS: "${TRUST_PROXY_HOPS:-}"`, which is an empty string for
|
||||
// every operator who never set it; reading that as invalid threw on every
|
||||
// request that asked for the client IP.
|
||||
const trimmed = raw?.trim() ?? "";
|
||||
if (trimmed === "") return 1;
|
||||
if (!/^\d+$/.test(trimmed)) {
|
||||
// Reject explicitly-invalid values so an operator typo doesn't silently
|
||||
// switch a real-proxy deployment to "no XFF trust" mode (review
|
||||
// finding L-1: TRUST_PROXY_HOPS=garbage degraded to hops=0 silently).
|
||||
throw new Error(
|
||||
`TRUST_PROXY_HOPS must be a non-negative integer, got: ${JSON.stringify(raw)}`,
|
||||
);
|
||||
}
|
||||
return parseInt(trimmed, 10);
|
||||
/**
|
||||
* Once per process: `TRUST_PROXY_HOPS` is not a number. Every request that
|
||||
* asks for the client address used to throw on it, so every route answered
|
||||
* 500, `/api/health` included. It resolves no address instead, and says so.
|
||||
*/
|
||||
let invalidHopsWarned = false;
|
||||
|
||||
function warnInvalidHopsOnce(raw: string | undefined): void {
|
||||
if (invalidHopsWarned) return;
|
||||
invalidHopsWarned = true;
|
||||
console.warn(
|
||||
`[getClientIp] TRUST_PROXY_HOPS=${JSON.stringify(raw)} is not a non-negative integer; no client address is read from forwarding headers until it is fixed, and every anonymous caller shares one rate-limit bucket.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Test-only reset for {@link warnInvalidHopsOnce}. */
|
||||
export function _resetInvalidHopsWarningForTests(): void {
|
||||
invalidHopsWarned = false;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -646,12 +655,23 @@ let publicPeerForwardingWarned = false;
|
||||
function warnPublicPeerForwardingOnce(): void {
|
||||
if (publicPeerForwardingWarned) return;
|
||||
publicPeerForwardingWarned = true;
|
||||
const listed =
|
||||
parseTrustedProxyCidrs(process.env.TRUSTED_PROXY_CIDRS).kind !== "unset";
|
||||
console.warn(
|
||||
"[getClientIp] a connection from a public address sent X-Forwarded-For or X-Real-IP; those headers are ignored and the connection's own address is used. " +
|
||||
"If HealthLog is meant to be reached only through a reverse proxy, the published port is open beyond it.",
|
||||
listed
|
||||
? "[getClientIp] a connection from outside TRUSTED_PROXY_CIDRS sent X-Forwarded-For or X-Real-IP; those headers are ignored and the connection's own address is used. " +
|
||||
"If it was your proxy, add its address to TRUSTED_PROXY_CIDRS; otherwise the published port is reachable beyond the proxy."
|
||||
: "[getClientIp] a connection from a public address sent X-Forwarded-For or X-Real-IP; those headers are ignored and the connection's own address is used. " +
|
||||
"If HealthLog is meant to be reached only through a reverse proxy, the published port is open beyond it. " +
|
||||
"If the proxy connects from a public address, set TRUSTED_PROXY_CIDRS to it.",
|
||||
);
|
||||
}
|
||||
|
||||
/** Test-only reset for {@link warnPublicPeerForwardingOnce}. */
|
||||
export function _resetPublicPeerForwardingWarningForTests(): void {
|
||||
publicPeerForwardingWarned = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tagged return shape so a caller can apply a tighter universal
|
||||
* rate-limit when the trust chain is misconfigured. F-6 (mobile security
|
||||
@@ -681,6 +701,10 @@ export function getClientIpOrTrustWarning(request: Request): {
|
||||
if (cfIp) return { ip: cfIp, trustViolation: false };
|
||||
|
||||
const hops = parseTrustProxyHops(process.env.TRUST_PROXY_HOPS);
|
||||
if (hops === null) {
|
||||
warnInvalidHopsOnce(process.env.TRUST_PROXY_HOPS);
|
||||
return { ip: null, trustViolation: true };
|
||||
}
|
||||
|
||||
// The connection's own peer, stamped by the server for every request it
|
||||
// receives. `undefined` only for a request the server did not receive (a
|
||||
@@ -693,9 +717,10 @@ export function getClientIpOrTrustWarning(request: Request): {
|
||||
if (peer !== undefined) {
|
||||
// No proxy declared: the peer is the client.
|
||||
if (hops === 0) return { ip: peer, trustViolation: false };
|
||||
// A peer on a public address reached the server directly; its
|
||||
// forwarding headers are its own choice, not a proxy's report.
|
||||
if (isPublicIp(peer)) {
|
||||
// A peer that is not a proxy (outside TRUSTED_PROXY_CIDRS when it is
|
||||
// set, on a public address when it is not) reached the server directly;
|
||||
// its forwarding headers are its own choice, not a proxy's report.
|
||||
if (!peerMayForward(peer)) {
|
||||
if (
|
||||
request.headers.has("x-real-ip") ||
|
||||
request.headers.get("x-forwarded-for")?.trim() !== peer
|
||||
|
||||
@@ -124,6 +124,17 @@ describe("isCookielessNativeCaller (M-3 refresh-token issuance gate)", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("false when the session cookie arrives under its __Host- name", () => {
|
||||
expect(
|
||||
isCookielessNativeCaller(
|
||||
h({
|
||||
"x-client-type": "native",
|
||||
cookie: "foo=bar; __Host-healthlog_session=abc123",
|
||||
}),
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("true for a native caller whose cookie header lacks the session cookie", () => {
|
||||
expect(
|
||||
isCookielessNativeCaller(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const cookieState = vi.hoisted(() => ({
|
||||
sessionId: undefined as string | undefined,
|
||||
/** The session cookie under the `__Host-` name, when the browser holds one. */
|
||||
hostSessionId: undefined as string | undefined,
|
||||
delete: vi.fn(),
|
||||
set: vi.fn(),
|
||||
}));
|
||||
@@ -71,10 +73,15 @@ vi.mock("@/lib/auth/hmac", () => ({
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) =>
|
||||
name === "healthlog_session" && cookieState.sessionId
|
||||
? { value: cookieState.sessionId }
|
||||
: undefined,
|
||||
get: (name: string) => {
|
||||
if (name === "healthlog_session" && cookieState.sessionId) {
|
||||
return { value: cookieState.sessionId };
|
||||
}
|
||||
if (name === "__Host-healthlog_session" && cookieState.hostSessionId) {
|
||||
return { value: cookieState.hostSessionId };
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
set: cookieState.set,
|
||||
delete: cookieState.delete,
|
||||
})),
|
||||
@@ -98,6 +105,16 @@ const UNKNOWN_SECRET = `hls_${"c".repeat(64)}`;
|
||||
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/** Both session cookie names were expired, the `__Host-` one as `Secure`. */
|
||||
function expectSessionCookiesExpired() {
|
||||
expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session");
|
||||
expect(cookieState.set).toHaveBeenCalledWith(
|
||||
"__Host-healthlog_session",
|
||||
"",
|
||||
expect.objectContaining({ maxAge: 0, path: "/", secure: true }),
|
||||
);
|
||||
}
|
||||
|
||||
function seed(row: Partial<SessionRow> & { id: string }): SessionRow {
|
||||
const full: SessionRow = {
|
||||
userId: "user-1",
|
||||
@@ -115,6 +132,7 @@ beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
store.sessions = [];
|
||||
cookieState.sessionId = "sess-expired";
|
||||
cookieState.hostSessionId = undefined;
|
||||
// `clearAllMocks` drops recorded calls but keeps implementations, so a
|
||||
// rejection staged by one test would otherwise leak into the next. Re-arm
|
||||
// the write mocks with a benign default each time.
|
||||
@@ -203,6 +221,97 @@ describe("getSession — cookie resolution", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the session cookie's name on a Secure install", () => {
|
||||
// A cookie named `healthlog_session` can be planted by any host under the
|
||||
// same registrable domain; one named `__Host-…` only by this host, and only
|
||||
// when Secure, on Path=/, without Domain.
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "true");
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("issues a new session as __Host-healthlog_session, Secure, on /, without Domain", async () => {
|
||||
cookieState.sessionId = undefined;
|
||||
await createSession("user-1", false);
|
||||
const issued = cookieState.set.mock.calls.find(
|
||||
(c) => c[0] === "__Host-healthlog_session",
|
||||
);
|
||||
expect(issued?.[1]).toMatch(/^hls_[0-9a-f]{64}$/);
|
||||
expect(issued?.[2]).toMatchObject({ secure: true, path: "/" });
|
||||
expect(issued?.[2]).not.toHaveProperty("domain");
|
||||
expect(
|
||||
cookieState.set.mock.calls.some((c) => c[0] === "healthlog_session"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps a session found under the old name, and re-issues it under the new one", async () => {
|
||||
seed({ id: "sess_cuid_1", tokenHash: `hash:${SECRET}` });
|
||||
cookieState.sessionId = SECRET;
|
||||
|
||||
const result = await getSession();
|
||||
|
||||
expect(result?.session.id).toBe("sess_cuid_1");
|
||||
expect(cookieState.set).toHaveBeenCalledWith(
|
||||
"__Host-healthlog_session",
|
||||
SECRET,
|
||||
expect.objectContaining({ secure: true, path: "/" }),
|
||||
);
|
||||
expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session");
|
||||
});
|
||||
|
||||
it("answers from the __Host- cookie when a planted old-name cookie rides along", async () => {
|
||||
seed({ id: "sess_mine", tokenHash: `hash:${SECRET}` });
|
||||
seed({
|
||||
id: "sess_planted",
|
||||
userId: "attacker",
|
||||
tokenHash: `hash:${FORGED_SECRET}`,
|
||||
});
|
||||
cookieState.hostSessionId = SECRET;
|
||||
cookieState.sessionId = FORGED_SECRET;
|
||||
|
||||
const result = await getSession();
|
||||
|
||||
expect(result?.session.id).toBe("sess_mine");
|
||||
});
|
||||
|
||||
it("logs out both names", async () => {
|
||||
seed({ id: "sess_cuid_1", tokenHash: `hash:${SECRET}` });
|
||||
cookieState.hostSessionId = SECRET;
|
||||
cookieState.sessionId = undefined;
|
||||
await destroySession();
|
||||
expect(prisma.session.delete).toHaveBeenCalledWith({
|
||||
where: { id: "sess_cuid_1" },
|
||||
});
|
||||
expectSessionCookiesExpired();
|
||||
});
|
||||
});
|
||||
|
||||
describe("the session cookie's name on a plain-HTTP install", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv("SESSION_COOKIE_SECURE", "false");
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("keeps the old name, which a browser accepts without Secure", async () => {
|
||||
cookieState.sessionId = undefined;
|
||||
await createSession("user-1", false);
|
||||
expect(
|
||||
cookieState.set.mock.calls.some(
|
||||
(c) => c[0] === "healthlog_session" && c[2]?.secure === false,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
cookieState.set.mock.calls.some(
|
||||
(c) => c[0] === "__Host-healthlog_session",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getSession — sliding expiry", () => {
|
||||
it("extends a modern session and re-emits the same secret", async () => {
|
||||
seed({
|
||||
@@ -257,7 +366,7 @@ describe("getSession", () => {
|
||||
expect(prisma.session.deleteMany).toHaveBeenCalledWith({
|
||||
where: { id: "sess-expired" },
|
||||
});
|
||||
expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session");
|
||||
expectSessionCookiesExpired();
|
||||
expect(cookieState.delete).toHaveBeenCalledWith("hl_onboarding");
|
||||
});
|
||||
});
|
||||
@@ -385,7 +494,7 @@ describe("a session lookup that fails is not a missing session", () => {
|
||||
cookieState.sessionId = SECRET;
|
||||
failLookup();
|
||||
await expect(destroySession()).resolves.toBeUndefined();
|
||||
expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session");
|
||||
expectSessionCookiesExpired();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -401,7 +510,7 @@ describe("destroySession", () => {
|
||||
expect(prisma.session.delete).toHaveBeenCalledWith({
|
||||
where: { id: "sess_cuid_1" },
|
||||
});
|
||||
expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session");
|
||||
expectSessionCookiesExpired();
|
||||
});
|
||||
|
||||
it("logs a legacy cookie out too", async () => {
|
||||
@@ -427,7 +536,7 @@ describe("destroySession", () => {
|
||||
expect(prisma.session.delete).toHaveBeenCalledWith({
|
||||
where: { id: "sess-gone" },
|
||||
});
|
||||
expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session");
|
||||
expectSessionCookiesExpired();
|
||||
expect(cookieState.delete).toHaveBeenCalledWith("hl_onboarding");
|
||||
});
|
||||
|
||||
@@ -441,7 +550,7 @@ describe("destroySession", () => {
|
||||
// A non-P2025 delete failure is recorded on the wide event, not thrown,
|
||||
// so logout never leaves the client authenticated with the cookie intact.
|
||||
await expect(destroySession()).resolves.toBeUndefined();
|
||||
expect(cookieState.delete).toHaveBeenCalledWith("healthlog_session");
|
||||
expectSessionCookiesExpired();
|
||||
expect(cookieState.delete).toHaveBeenCalledWith("hl_onboarding");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* - `X-Client-Type: native` always forces native policy.
|
||||
* - `X-Client-Type: web` always forces web policy.
|
||||
*/
|
||||
import { cookieHeaderCarriesSession } from "./session-cookie";
|
||||
|
||||
export type ClientPolicy = "web" | "native";
|
||||
|
||||
@@ -95,13 +96,6 @@ export function resolveTokenPolicy(headers: Headers): TokenPolicyDecision {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Name of the browser session cookie (mirrors `SESSION_COOKIE` in
|
||||
* `src/lib/auth/session.ts`). A request that round-trips this cookie is a
|
||||
* browser, not a genuine cookie-less native caller.
|
||||
*/
|
||||
const SESSION_COOKIE_NAME = "healthlog_session";
|
||||
|
||||
/**
|
||||
* True only for a genuinely cookie-less native caller — the gate for
|
||||
* *issuing* a 60-day refresh token (M-3 hardening).
|
||||
@@ -110,7 +104,7 @@ const SESSION_COOKIE_NAME = "healthlog_session";
|
||||
* a browser context (DOM/XSS-reachable), even when that browser spoofs
|
||||
* `X-Client-Type: native`. Two browser tells suppress issuance:
|
||||
* - a `Mozilla/`-bearing User-Agent (every real browser sends one), and
|
||||
* - an inbound `healthlog_session` cookie (a browser round-trips its
|
||||
* - an inbound session cookie, under either of its names (a browser round-trips its
|
||||
* session cookie; the native client authenticates by Bearer and never
|
||||
* holds one).
|
||||
*
|
||||
@@ -127,9 +121,7 @@ export function isCookielessNativeCaller(headers: Headers): boolean {
|
||||
|
||||
// Belt-and-braces: a request that presents the session cookie is a
|
||||
// browser round-trip regardless of UA. Native callers never hold one.
|
||||
const cookie = headers.get("cookie") ?? "";
|
||||
if (cookie.split(/;\s*/).some((c) => c.startsWith(`${SESSION_COOKIE_NAME}=`)))
|
||||
return false;
|
||||
if (cookieHeaderCarriesSession(headers.get("cookie"))) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* The browser session cookie's name, and the one way to read and clear it.
|
||||
*
|
||||
* A cookie named `healthlog_session` can be set by any host under the same
|
||||
* registrable domain: a page on `other.example.com` may write one with
|
||||
* `Domain=example.com`, and the browser sends it to `health.example.com`
|
||||
* alongside, or instead of, the server's own. That lets a sibling subdomain
|
||||
* plant a session of its choosing in a visitor's browser. A cookie whose name
|
||||
* starts with `__Host-` is accepted by the browser only when it is `Secure`,
|
||||
* has `Path=/` and carries no `Domain`, so only this exact host can have set
|
||||
* it.
|
||||
*
|
||||
* So wherever the session cookie is `Secure` (`shouldEmitSecureCookie()`),
|
||||
* it is issued as `__Host-healthlog_session`. A plain-HTTP install cannot
|
||||
* hold a `__Host-` cookie and keeps the old name.
|
||||
*
|
||||
* The transition signs nobody out: the old name is still read, after the new
|
||||
* one, and `getSession()` re-issues a session found under the old name under
|
||||
* the new one and expires the old cookie. A browser that holds the new
|
||||
* cookie is never answered from the old one, so once a person has been seen
|
||||
* once after the upgrade, a planted old-name cookie no longer reaches them.
|
||||
*
|
||||
* Nothing here imports the database, so the proxy can use it.
|
||||
*/
|
||||
import { shouldEmitSecureCookie } from "./secure-cookie";
|
||||
|
||||
/** The name every release before this one used, and plain-HTTP installs keep. */
|
||||
export const LEGACY_SESSION_COOKIE = "healthlog_session";
|
||||
|
||||
/** The name a `Secure` session cookie is issued under. */
|
||||
export const HOST_SESSION_COOKIE = "__Host-healthlog_session";
|
||||
|
||||
/** The names a session cookie is read under, in the order they are tried. */
|
||||
export const SESSION_COOKIE_NAMES = [
|
||||
HOST_SESSION_COOKIE,
|
||||
LEGACY_SESSION_COOKIE,
|
||||
] as const;
|
||||
|
||||
export type SessionCookieName = (typeof SESSION_COOKIE_NAMES)[number];
|
||||
|
||||
/** The name a session cookie issued now is set under. */
|
||||
export function sessionCookieName(): SessionCookieName {
|
||||
return shouldEmitSecureCookie() ? HOST_SESSION_COOKIE : LEGACY_SESSION_COOKIE;
|
||||
}
|
||||
|
||||
interface CookieReader {
|
||||
get(name: string): { value: string } | undefined;
|
||||
}
|
||||
|
||||
interface CookieStore extends CookieReader, CookieWriter {
|
||||
delete(name: string): unknown;
|
||||
}
|
||||
|
||||
interface CookieWriter {
|
||||
set(
|
||||
name: string,
|
||||
value: string,
|
||||
options: {
|
||||
httpOnly: boolean;
|
||||
secure: boolean;
|
||||
sameSite: "lax";
|
||||
maxAge: number;
|
||||
path: string;
|
||||
},
|
||||
): unknown;
|
||||
}
|
||||
|
||||
/** The session cookie a request carries, under whichever name it came. */
|
||||
export function readSessionCookie(
|
||||
jar: CookieReader,
|
||||
): { name: SessionCookieName; value: string } | null {
|
||||
for (const name of SESSION_COOKIE_NAMES) {
|
||||
const value = jar.get(name)?.value;
|
||||
if (value) return { name, value };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether a raw `Cookie` header carries a session cookie under either name. */
|
||||
export function cookieHeaderCarriesSession(header: string | null): boolean {
|
||||
if (!header) return false;
|
||||
return header
|
||||
.split(/;\s*/)
|
||||
.some((pair) =>
|
||||
SESSION_COOKIE_NAMES.some((name) => pair.startsWith(`${name}=`)),
|
||||
);
|
||||
}
|
||||
|
||||
/** The attributes the session cookie is issued with. */
|
||||
export function sessionCookieOptions(maxAgeSeconds: number) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
// A `__Host-` cookie is refused by the browser without `Secure`; the
|
||||
// name and the flag come from the same switch.
|
||||
secure: shouldEmitSecureCookie(),
|
||||
// OAuth callbacks (e.g. Withings) arrive via top-level cross-site
|
||||
// redirect. Lax keeps CSRF protection for unsafe methods while allowing
|
||||
// this flow.
|
||||
sameSite: "lax" as const,
|
||||
maxAge: maxAgeSeconds,
|
||||
path: "/",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the session cookie under the name this install issues, and expire a
|
||||
* cookie under the old name the request still carried, so a browser holds
|
||||
* one session cookie, not two.
|
||||
*/
|
||||
export function issueSessionCookie(
|
||||
store: CookieStore,
|
||||
value: string,
|
||||
maxAgeSeconds: number,
|
||||
): void {
|
||||
const name = sessionCookieName();
|
||||
store.set(name, value, sessionCookieOptions(maxAgeSeconds));
|
||||
if (name !== LEGACY_SESSION_COOKIE && store.get(LEGACY_SESSION_COOKIE)) {
|
||||
store.delete(LEGACY_SESSION_COOKIE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Expire the session cookie under both names. The `__Host-` one is
|
||||
* overwritten with `Secure` and `Path=/`, without which the browser ignores
|
||||
* the overwrite and the cookie stays; the old name is deleted as it always
|
||||
* was.
|
||||
*/
|
||||
export function expireSessionCookies(
|
||||
jar: CookieWriter & { delete(name: string): unknown },
|
||||
): void {
|
||||
jar.set(HOST_SESSION_COOKIE, "", {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
maxAge: 0,
|
||||
path: "/",
|
||||
});
|
||||
jar.delete(LEGACY_SESSION_COOKIE);
|
||||
}
|
||||
+25
-28
@@ -11,14 +11,15 @@ import { SessionStoreUnavailableError } from "@/lib/api-errors";
|
||||
import { credentialRevocations } from "@/lib/auth/credential-revocations";
|
||||
import { locales, type Locale } from "@/lib/i18n/config";
|
||||
import { LOCALE_COOKIE, setLocaleCookie } from "@/lib/i18n/locale-cookie";
|
||||
import {
|
||||
expireSessionCookies,
|
||||
issueSessionCookie,
|
||||
readSessionCookie,
|
||||
sessionCookieName,
|
||||
} from "@/lib/auth/session-cookie";
|
||||
|
||||
const SESSION_COOKIE = "healthlog_session";
|
||||
const SESSION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
|
||||
/** Public name of the browser session cookie, for routes that read it off a
|
||||
* `NextRequest` directly (rather than through `getSession()`'s `cookies()`). */
|
||||
export const SESSION_COOKIE_NAME = SESSION_COOKIE;
|
||||
|
||||
/**
|
||||
* Marks a cookie value as a v1.30.32 session secret. Doubles as the branch
|
||||
* between the two resolution paths below, so a cookie is only ever looked up
|
||||
@@ -244,7 +245,7 @@ export async function setOnboardingPendingCookie(
|
||||
secure: shouldEmitSecureCookie(),
|
||||
// v1.4.22 — Strict (not Lax) because
|
||||
// no cross-site redirect flow ever depends on this cookie. The
|
||||
// sibling `healthlog_session` cookie stays Lax because the
|
||||
// sibling session cookie stays Lax because the
|
||||
// Withings OAuth callback arrives via top-level cross-site
|
||||
// redirect; the onboarding hint has no equivalent flow.
|
||||
sameSite: "strict",
|
||||
@@ -291,15 +292,7 @@ export async function createSession(
|
||||
});
|
||||
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(SESSION_COOKIE, secret, {
|
||||
httpOnly: true,
|
||||
secure: shouldEmitSecureCookie(),
|
||||
// OAuth callbacks (e.g. Withings) arrive via top-level cross-site redirect.
|
||||
// Lax keeps CSRF protection for unsafe methods while allowing this flow.
|
||||
sameSite: "lax",
|
||||
maxAge: SESSION_MAX_AGE_MS / 1000,
|
||||
path: "/",
|
||||
});
|
||||
issueSessionCookie(cookieStore, secret, SESSION_MAX_AGE_MS / 1000);
|
||||
|
||||
// Anchor the onboarding cookie alongside the session cookie so a
|
||||
// future auth surface added without remembering the helper can never
|
||||
@@ -329,8 +322,9 @@ export async function getSession(): Promise<{
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const cookieValue = cookieStore.get(SESSION_COOKIE)?.value;
|
||||
if (!cookieValue) return null;
|
||||
const carried = readSessionCookie(cookieStore);
|
||||
if (!carried) return null;
|
||||
const cookieValue = carried.value;
|
||||
|
||||
const session = await findSessionByCookie(cookieValue);
|
||||
|
||||
@@ -340,7 +334,7 @@ export async function getSession(): Promise<{
|
||||
.deleteMany({ where: { id: session.id } })
|
||||
.catch(() => {});
|
||||
}
|
||||
cookieStore.delete(SESSION_COOKIE);
|
||||
expireSessionCookies(cookieStore);
|
||||
cookieStore.delete(ONBOARDING_COOKIE);
|
||||
return null;
|
||||
}
|
||||
@@ -364,13 +358,16 @@ export async function getSession(): Promise<{
|
||||
where: { id: session.id },
|
||||
data: { expiresAt: newExpiry },
|
||||
});
|
||||
cookieStore.set(SESSION_COOKIE, cookieValue, {
|
||||
httpOnly: true,
|
||||
secure: shouldEmitSecureCookie(),
|
||||
sameSite: "lax",
|
||||
maxAge: SESSION_MAX_AGE_MS / 1000,
|
||||
path: "/",
|
||||
});
|
||||
issueSessionCookie(cookieStore, cookieValue, SESSION_MAX_AGE_MS / 1000);
|
||||
} else if (carried.name !== sessionCookieName()) {
|
||||
// Issued before the `__Host-` name: re-issued under it, so the upgrade
|
||||
// signs nobody out. A server-component render cannot write cookies; the
|
||||
// next route request does.
|
||||
try {
|
||||
issueSessionCookie(cookieStore, cookieValue, SESSION_MAX_AGE_MS / 1000);
|
||||
} catch {
|
||||
// Read-only cookie context.
|
||||
}
|
||||
}
|
||||
|
||||
// v1.23 — sliding "last seen" stamp for the active-session list. Throttled
|
||||
@@ -501,7 +498,7 @@ export async function getSession(): Promise<{
|
||||
*/
|
||||
export async function getSessionUserLocale(): Promise<string | null> {
|
||||
const cookieStore = await cookies();
|
||||
const cookieValue = cookieStore.get(SESSION_COOKIE)?.value;
|
||||
const cookieValue = readSessionCookie(cookieStore)?.value;
|
||||
if (!cookieValue) return null;
|
||||
|
||||
// Same two-path resolution as `getSession`, read-only: this runs in the root
|
||||
@@ -514,7 +511,7 @@ export async function getSessionUserLocale(): Promise<string | null> {
|
||||
|
||||
export async function destroySession(): Promise<void> {
|
||||
const cookieStore = await cookies();
|
||||
const cookieValue = cookieStore.get(SESSION_COOKIE)?.value;
|
||||
const cookieValue = readSessionCookie(cookieStore)?.value;
|
||||
if (cookieValue) {
|
||||
// Resolve the cookie to its row before deleting — the cookie is a secret,
|
||||
// not the primary key, so `delete({ where: { id: cookieValue } })` would
|
||||
@@ -547,7 +544,7 @@ export async function destroySession(): Promise<void> {
|
||||
});
|
||||
}
|
||||
}
|
||||
cookieStore.delete(SESSION_COOKIE);
|
||||
expireSessionCookies(cookieStore);
|
||||
cookieStore.delete(ONBOARDING_COOKIE);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { createCanvas } from "@napi-rs/canvas";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderImageThumbnail } from "../image-engine.mjs";
|
||||
|
||||
/**
|
||||
* What runs in the reader's child for an image upload: decode, check the
|
||||
* decoded size once more, draw onto a fresh JPEG. The header walk in the
|
||||
* server is a reading of the file; the decoder has the final word, so the
|
||||
* decoded size is checked before a canvas is drawn from it.
|
||||
*/
|
||||
|
||||
const OPTIONS = { longEdge: 320, quality: 70, maxPixels: 60_000_000 };
|
||||
|
||||
function png(width: number, height: number): Buffer {
|
||||
const canvas = createCanvas(width, height);
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.fillStyle = "#3366cc";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
return canvas.toBuffer("image/png");
|
||||
}
|
||||
|
||||
describe("renderImageThumbnail", () => {
|
||||
it("draws a decoded image onto a JPEG no longer than the long edge", async () => {
|
||||
const outcome = await renderImageThumbnail(png(1200, 800), OPTIONS);
|
||||
expect(outcome).toMatchObject({ ok: true, width: 320, height: 213 });
|
||||
if (!outcome.ok) return;
|
||||
expect(Buffer.from(outcome.jpeg).subarray(0, 2).toString("hex")).toBe(
|
||||
"ffd8",
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a decoded image larger than the header said, before drawing it", async () => {
|
||||
const createCanvasSpy = vi.fn();
|
||||
const outcome = await renderImageThumbnail(
|
||||
Buffer.from("anything"),
|
||||
OPTIONS,
|
||||
{
|
||||
loadImage: async () => ({ width: 30_000, height: 30_000 }),
|
||||
createCanvas: createCanvasSpy,
|
||||
},
|
||||
);
|
||||
expect(outcome).toEqual({ ok: false, reason: "pixel-cap" });
|
||||
expect(createCanvasSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws what the decoder threw", async () => {
|
||||
await expect(
|
||||
renderImageThumbnail(Buffer.from("not an image"), OPTIONS),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -19,11 +19,11 @@ vi.mock("../pdfjs", async (importOriginal) => ({
|
||||
readPdfText,
|
||||
}));
|
||||
|
||||
import { PdfResourceLimitError } from "../pdfjs";
|
||||
import { PdfReaderUnavailableError, PdfResourceLimitError } from "../pdfjs";
|
||||
import { extractPdfText, localExtractText } from "../local-extract";
|
||||
|
||||
describe("a PDF past the reader's limits", () => {
|
||||
it.each(["memory", "time", "exit"] as const)(
|
||||
it.each(["memory", "time"] as const)(
|
||||
"answers resource-limit for the %s bound, without the error log a broken file gets",
|
||||
async (limit) => {
|
||||
readPdfText.mockRejectedValueOnce(new PdfResourceLimitError(limit));
|
||||
@@ -42,6 +42,18 @@ describe("a PDF past the reader's limits", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["busy", "interrupted"] as const)(
|
||||
"passes a reader that was %s up to the job instead of recording an outcome",
|
||||
async (kind) => {
|
||||
readPdfText.mockRejectedValueOnce(new PdfReaderUnavailableError(kind));
|
||||
logCaught.mockClear();
|
||||
await expect(
|
||||
extractPdfText(Buffer.from("%PDF-1.7 large")),
|
||||
).rejects.toBeInstanceOf(PdfReaderUnavailableError);
|
||||
expect(logCaught).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("still answers error for anything else the reader throws", async () => {
|
||||
const broken = new Error("Invalid PDF structure");
|
||||
broken.name = "InvalidPDFException";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createDeflate } from "node:zlib";
|
||||
|
||||
@@ -8,7 +9,16 @@ import { describe, expect, it, vi } from "vitest";
|
||||
const { annotate } = vi.hoisted(() => ({ annotate: vi.fn() }));
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate }));
|
||||
|
||||
import { runPdfChild } from "../pdf-isolation";
|
||||
import { gif } from "../../__tests__/gif-fixture";
|
||||
import {
|
||||
MAX_QUEUED_PER_OWNER,
|
||||
READER_WAIT_MS,
|
||||
runOnce,
|
||||
runPdfChild,
|
||||
withPdfWork,
|
||||
type PdfChildRequest,
|
||||
type PdfChildResult,
|
||||
} from "../pdf-isolation";
|
||||
import { readPdfText } from "../pdfjs";
|
||||
import { rasterizePdf } from "../rasterize-pdf";
|
||||
import { generateThumbnail } from "../thumbnail";
|
||||
@@ -187,3 +197,181 @@ describe("PDF reading runs in a bounded child process", () => {
|
||||
expect(result).toMatchObject({ ok: false, reason: "error" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("an image upload is decoded in the bounded child", () => {
|
||||
it("ends the child, not the server, when a decoder allocates past the ceiling", async () => {
|
||||
// The header walk refuses this file; here it goes to the decoder
|
||||
// directly, as a decoder surprise the walk did not foresee would.
|
||||
const bomb = gif({ width: 10, height: 10 }, [
|
||||
{ width: 20_000, height: 20_000 },
|
||||
]);
|
||||
const before = process.memoryUsage().rss;
|
||||
const result = await runPdfChild(
|
||||
{
|
||||
op: "image-thumbnail",
|
||||
data: bomb,
|
||||
options: { longEdge: 320, quality: 70, maxPixels: 1e12 },
|
||||
},
|
||||
{ timeoutMs: 60_000, memoryMb: 300 },
|
||||
);
|
||||
expect(result).toEqual({ ok: false, reason: "limit", limit: "memory" });
|
||||
expect(process.memoryUsage().rss - before).toBeLessThan(200 * 1024 ** 2);
|
||||
}, 90_000);
|
||||
});
|
||||
|
||||
describe("a child that ends without an answer", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "reader-child-"));
|
||||
const script = (name: string, body: string) => {
|
||||
const path = join(dir, name);
|
||||
writeFileSync(path, body);
|
||||
return path;
|
||||
};
|
||||
const request = { op: "text", data: Buffer.from("x"), maxPages: 1 } as const;
|
||||
|
||||
it("is not a final verdict when nothing this module measured ended it", async () => {
|
||||
// A kill from outside: the kernel's out-of-memory killer acting for the
|
||||
// whole container looks like this to the parent.
|
||||
const killed = script(
|
||||
"killed-child",
|
||||
'process.kill(process.pid, "SIGKILL");\n',
|
||||
);
|
||||
await expect(
|
||||
runOnce(killed, request, { timeoutMs: 10_000 }),
|
||||
).resolves.toEqual({ ok: false, reason: "interrupted" });
|
||||
});
|
||||
|
||||
it("is the document's doing when V8 reached the heap cap the child was given", async () => {
|
||||
const heap = script(
|
||||
"heap-child",
|
||||
'process.stderr.write("FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory\\n", () => process.exit(134));\n',
|
||||
);
|
||||
await expect(
|
||||
runOnce(heap, request, { timeoutMs: 10_000 }),
|
||||
).resolves.toEqual({ ok: false, reason: "limit", limit: "memory" });
|
||||
});
|
||||
|
||||
it("is run once more, and answered interrupted when it ends that way again", async () => {
|
||||
const interrupted = vi.fn(
|
||||
async (): Promise<PdfChildResult<PdfChildRequest>> => ({
|
||||
ok: false,
|
||||
reason: "interrupted",
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
runPdfChild(request, { timeoutMs: 1_000, run: interrupted }),
|
||||
).resolves.toEqual({ ok: false, reason: "interrupted" });
|
||||
expect(interrupted).toHaveBeenCalledTimes(2);
|
||||
|
||||
const recovers = vi
|
||||
.fn<() => Promise<PdfChildResult<PdfChildRequest>>>()
|
||||
.mockResolvedValueOnce({ ok: false, reason: "interrupted" })
|
||||
.mockResolvedValueOnce({ ok: true, value: "text" });
|
||||
await expect(
|
||||
runPdfChild(request, { timeoutMs: 1_000, run: recovers }),
|
||||
).resolves.toEqual({ ok: true, value: "text" });
|
||||
expect(recovers).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the reader's queue", () => {
|
||||
it("answers one account's work past its waiting cap busy, at once", async () => {
|
||||
const request = {
|
||||
op: "text",
|
||||
data: Buffer.from("x"),
|
||||
maxPages: 1,
|
||||
} as const;
|
||||
let open: () => void = () => {};
|
||||
const gate = new Promise<void>((resolve) => (open = resolve));
|
||||
const run = vi.fn(async (): Promise<PdfChildResult<PdfChildRequest>> => {
|
||||
await gate;
|
||||
return { ok: true, value: "text" };
|
||||
});
|
||||
const asA = () =>
|
||||
withPdfWork({ owner: "account-a", lane: "interactive" }, () =>
|
||||
runPdfChild(request, { timeoutMs: 1_000, run }),
|
||||
);
|
||||
|
||||
const running = asA();
|
||||
const waiting = Array.from({ length: MAX_QUEUED_PER_OWNER }, asA);
|
||||
await expect(asA()).resolves.toMatchObject({ ok: false, reason: "busy" });
|
||||
|
||||
open();
|
||||
await expect(running).resolves.toMatchObject({ ok: true });
|
||||
for (const item of waiting) {
|
||||
await expect(item).resolves.toMatchObject({ ok: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the caller's clock while it waits for the reader", () => {
|
||||
const request = { op: "text", data: Buffer.from("x"), maxPages: 1 } as const;
|
||||
|
||||
it("moves by the wait plus the read's allowance when the slot is granted, then gives back what the read did not use", async () => {
|
||||
let open: () => void = () => {};
|
||||
const gate = new Promise<void>((resolve) => (open = resolve));
|
||||
const holder = vi.fn(async (): Promise<PdfChildResult<PdfChildRequest>> => {
|
||||
await gate;
|
||||
return { ok: true, value: "first" };
|
||||
});
|
||||
const quick = vi.fn(async (): Promise<PdfChildResult<PdfChildRequest>> => ({
|
||||
ok: true,
|
||||
value: "second",
|
||||
}));
|
||||
const moves: number[] = [];
|
||||
const first = withPdfWork({ owner: "a", lane: "interactive" }, () =>
|
||||
runPdfChild(request, { timeoutMs: 1_000, run: holder }),
|
||||
);
|
||||
const second = withPdfWork(
|
||||
{
|
||||
owner: "b",
|
||||
lane: "interactive",
|
||||
extendDeadline: async (ms) => {
|
||||
moves.push(ms);
|
||||
},
|
||||
},
|
||||
() => runPdfChild(request, { timeoutMs: 1_000, run: quick }),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
open();
|
||||
await expect(first).resolves.toMatchObject({ ok: true });
|
||||
await expect(second).resolves.toMatchObject({ ok: true, value: "second" });
|
||||
|
||||
// Granted after about 150 ms of waiting, with two attempts' allowance.
|
||||
expect(moves).toHaveLength(2);
|
||||
expect(moves[0]!).toBeGreaterThanOrEqual(2_000 + 140);
|
||||
expect(moves[0]!).toBeLessThan(2_000 + 1_000);
|
||||
// The quick read gives back nearly all of its allowance.
|
||||
expect(moves[1]!).toBeLessThan(0);
|
||||
expect(moves[1]!).toBeGreaterThan(-2_000 - 1);
|
||||
// Net: the wait and the read's own time, nothing more.
|
||||
expect(moves[0]! + moves[1]!).toBeGreaterThanOrEqual(140);
|
||||
expect(moves[0]! + moves[1]!).toBeLessThan(1_000);
|
||||
});
|
||||
|
||||
it("reads on when moving the clock fails", async () => {
|
||||
const run = vi.fn(async (): Promise<PdfChildResult<PdfChildRequest>> => ({
|
||||
ok: true,
|
||||
value: "text",
|
||||
}));
|
||||
await expect(
|
||||
withPdfWork(
|
||||
{
|
||||
owner: "c",
|
||||
lane: "interactive",
|
||||
extendDeadline: async () => {
|
||||
throw new Error("database gone");
|
||||
},
|
||||
},
|
||||
() => runPdfChild(request, { timeoutMs: 1_000, run }),
|
||||
),
|
||||
).resolves.toEqual({ ok: true, value: "text" });
|
||||
});
|
||||
|
||||
it("waits no longer for the reader than a run's margin can absorb before the slot moves its deadline", async () => {
|
||||
// The deadline moves when the slot is granted; until then the run's
|
||||
// armed margin is all that stands between a waiting read and the
|
||||
// reaper.
|
||||
const { AI_RUN_MARGIN_MS } = await import("../ai-runs/types");
|
||||
expect(READER_WAIT_MS.interactive).toBeLessThanOrEqual(AI_RUN_MARGIN_MS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createReaderQueue, type ReaderTicket } from "../reader-queue";
|
||||
|
||||
/**
|
||||
* The document reader has one slot. It used to go to whoever asked first,
|
||||
* and a waiting item waited without end: a few heavy PDFs from one account
|
||||
* held every other account's lab scan behind them. These pin the order the
|
||||
* slot is handed out in, the cap on what one account may queue, and the
|
||||
* deadline a waiting item gets.
|
||||
*/
|
||||
|
||||
const WAIT = { interactive: 1_000, background: 5_000 };
|
||||
|
||||
function queue(maxQueuedPerOwner = 10) {
|
||||
return createReaderQueue({ slots: 1, maxQueuedPerOwner, waitMs: WAIT });
|
||||
}
|
||||
|
||||
/** Acquire, and note the order in which tickets are granted. */
|
||||
function recorder() {
|
||||
const order: string[] = [];
|
||||
const tickets = new Map<string, ReaderTicket>();
|
||||
const track = (label: string, pending: Promise<ReaderTicket>) =>
|
||||
pending.then((ticket) => {
|
||||
if (ticket.ok) order.push(label);
|
||||
tickets.set(label, ticket);
|
||||
return ticket;
|
||||
});
|
||||
const release = (label: string) => {
|
||||
const ticket = tickets.get(label);
|
||||
if (ticket?.ok) ticket.release();
|
||||
};
|
||||
return { order, track, release };
|
||||
}
|
||||
|
||||
const flush = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
describe("the reader queue", () => {
|
||||
it("takes accounts in turn instead of first come, first served", async () => {
|
||||
const q = queue();
|
||||
const r = recorder();
|
||||
void r.track("a1", q.acquire("a", "background"));
|
||||
void r.track("a2", q.acquire("a", "background"));
|
||||
void r.track("a3", q.acquire("a", "background"));
|
||||
void r.track("b1", q.acquire("b", "background"));
|
||||
void r.track("c1", q.acquire("c", "background"));
|
||||
await flush();
|
||||
for (const label of ["a1", "a2", "b1", "c1", "a3"]) {
|
||||
r.release(label);
|
||||
await flush();
|
||||
}
|
||||
// a1 held the slot. Of the waiting items a2 came first, then b and c
|
||||
// each get their turn before a's third item comes round.
|
||||
expect(r.order).toEqual(["a1", "a2", "b1", "c1", "a3"]);
|
||||
});
|
||||
|
||||
it("serves a read a person is waiting on before background work", async () => {
|
||||
const q = queue();
|
||||
const r = recorder();
|
||||
void r.track("bg-running", q.acquire("a", "background"));
|
||||
void r.track("bg-a", q.acquire("a", "background"));
|
||||
void r.track("bg-b", q.acquire("b", "background"));
|
||||
void r.track("lab-scan", q.acquire("c", "interactive"));
|
||||
await flush();
|
||||
r.release("bg-running");
|
||||
await flush();
|
||||
expect(r.order).toEqual(["bg-running", "lab-scan"]);
|
||||
});
|
||||
|
||||
it("refuses an account's item past its waiting cap at once, without touching others", async () => {
|
||||
const q = queue(2);
|
||||
const r = recorder();
|
||||
await r.track("a-running", q.acquire("a", "interactive"));
|
||||
void r.track("a-1", q.acquire("a", "interactive"));
|
||||
void r.track("a-2", q.acquire("a", "background"));
|
||||
await expect(q.acquire("a", "interactive")).resolves.toMatchObject({
|
||||
ok: false,
|
||||
reason: "queue-full",
|
||||
});
|
||||
// Another account still gets in line, and its turn comes before the
|
||||
// capped account's second waiting item.
|
||||
void r.track("b-1", q.acquire("b", "interactive"));
|
||||
expect(q.snapshot().waiting).toEqual({ interactive: 2, background: 1 });
|
||||
for (const label of ["a-running", "a-1"]) {
|
||||
r.release(label);
|
||||
await flush();
|
||||
}
|
||||
expect(r.order).toEqual(["a-running", "a-1", "b-1"]);
|
||||
r.release("b-1");
|
||||
await flush();
|
||||
expect(r.order.at(-1)).toBe("a-2");
|
||||
r.release("a-2");
|
||||
});
|
||||
|
||||
describe("with a deadline", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("answers a waiting item busy when its lane's deadline passes, and keeps the slot for the next", async () => {
|
||||
const q = queue();
|
||||
const held = await q.acquire("a", "background");
|
||||
const waiting = q.acquire("b", "interactive");
|
||||
const later = q.acquire("c", "background");
|
||||
|
||||
await vi.advanceTimersByTimeAsync(WAIT.interactive + 1);
|
||||
await expect(waiting).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "wait-expired",
|
||||
retryAfterMs: WAIT.interactive,
|
||||
});
|
||||
expect(q.snapshot()).toEqual({
|
||||
running: 1,
|
||||
waiting: { interactive: 0, background: 1 },
|
||||
});
|
||||
|
||||
// The expired item left the queue: releasing hands the slot to the
|
||||
// next one in line, not to nobody.
|
||||
if (held.ok) held.release();
|
||||
await expect(later).resolves.toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("releases a slot once, however often release is called", async () => {
|
||||
const q = queue();
|
||||
const first = await q.acquire("a", "background");
|
||||
if (!first.ok) throw new Error("expected the slot");
|
||||
first.release();
|
||||
first.release();
|
||||
expect(q.snapshot().running).toBe(0);
|
||||
const a = await q.acquire("a", "background");
|
||||
expect(a.ok).toBe(true);
|
||||
expect(q.snapshot().running).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,10 +5,9 @@ import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
// Partial mock of the native canvas module: `loadImage` delegates to the real
|
||||
// decoder by default (so every real-fixture test below is unaffected) and can
|
||||
// be overridden per-test via `loadImageMock` to simulate a decoded image whose
|
||||
// dimensions the header sniff could not have known.
|
||||
// Partial mock of the native canvas module in this process: `loadImage`
|
||||
// delegates to the real decoder and records every call, so a test can tell
|
||||
// whether an upload was decoded here rather than in the reader's child.
|
||||
const { loadImageMock } = vi.hoisted(() => ({ loadImageMock: vi.fn() }));
|
||||
vi.mock("@napi-rs/canvas", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@napi-rs/canvas")>();
|
||||
@@ -19,6 +18,7 @@ vi.mock("@napi-rs/canvas", async (importActual) => {
|
||||
};
|
||||
});
|
||||
|
||||
import { gif } from "../../__tests__/gif-fixture";
|
||||
import { generateThumbnail, THUMB_LONG_EDGE } from "../thumbnail";
|
||||
|
||||
/**
|
||||
@@ -165,14 +165,16 @@ describe("generateThumbnail", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses an oversize image the header sniff missed (post-decode cap)", async () => {
|
||||
// A readable header declaring a small image whose decoder is forced to
|
||||
// return 30000×30000 = 900 MP: only the post-decode backstop can catch
|
||||
// it.
|
||||
loadImageMock.mockResolvedValueOnce({ width: 30_000, height: 30_000 });
|
||||
await expect(
|
||||
generateThumbnail(sourceImage(16, 16, "image/jpeg"), "image/jpeg"),
|
||||
).resolves.toEqual({ ok: false, reason: "pixel-cap" });
|
||||
it("decodes an uploaded image in the reader's child process, not in this one", async () => {
|
||||
// The decoder allocates what the file makes it allocate; in the child
|
||||
// that ends the child. Nothing of an image upload is decoded here.
|
||||
loadImageMock.mockClear();
|
||||
const result = await generateThumbnail(
|
||||
sourceImage(640, 480, "image/png"),
|
||||
"image/png",
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(loadImageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -214,6 +216,32 @@ describe("generateThumbnail", () => {
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses a GIF whose frame is far larger than its logical screen, before decoding", async () => {
|
||||
loadImageMock.mockClear();
|
||||
// A 10 x 10 screen around a 20000 x 20000 frame: the decoder sizes its
|
||||
// buffer from the frame.
|
||||
const bomb = gif({ width: 10, height: 10 }, [
|
||||
{ width: 20_000, height: 20_000 },
|
||||
]);
|
||||
expect(bomb.length).toBeLessThan(64);
|
||||
await expect(generateThumbnail(bomb, "image/gif")).resolves.toEqual({
|
||||
ok: false,
|
||||
reason: "pixel-cap",
|
||||
});
|
||||
expect(loadImageMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still previews an ordinary GIF", async () => {
|
||||
const result = await generateThumbnail(
|
||||
gif({ width: 1, height: 1 }, [{ width: 1, height: 1 }]),
|
||||
"image/gif",
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
thumbnail: { width: 1, height: 1 },
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses an image whose size cannot be read instead of decoding it", async () => {
|
||||
loadImageMock.mockClear();
|
||||
// Start of image, then straight into a scan: no frame header to read.
|
||||
|
||||
@@ -154,9 +154,14 @@ export type VisionInput =
|
||||
// Refs #776 — `rasterFailed` (this PDF would not render on a raster-
|
||||
// capable build) is distinct from `pdfNeedsAnthropic` (this build /
|
||||
// provider cannot take a PDF at all): they need different fixes, so
|
||||
// they must not share a label.
|
||||
// they must not share a label. `readerBusy` is neither: the reader
|
||||
// could not take the document now, and the work is tried again.
|
||||
reason:
|
||||
"decryptFailed" | "fileType" | "pdfNeedsAnthropic" | "rasterFailed";
|
||||
| "decryptFailed"
|
||||
| "fileType"
|
||||
| "pdfNeedsAnthropic"
|
||||
| "rasterFailed"
|
||||
| "readerBusy";
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -211,6 +216,9 @@ export async function prepareVisionInput(
|
||||
if (raster.ok) {
|
||||
return { ok: true, images: raster.images, documents: [] };
|
||||
}
|
||||
if (raster.reason === "busy" || raster.reason === "interrupted") {
|
||||
return { ok: false, reason: "readerBusy" };
|
||||
}
|
||||
// Refs #776 — a build that cannot rasterize at all is a capability gap a
|
||||
// PDF-native (Anthropic) provider would cover; a render failure on a
|
||||
// capable build is a property of THIS document. Report them apart.
|
||||
|
||||
@@ -52,6 +52,7 @@ import { dateOnlyAtNoonUtc } from "@/lib/tz/date-only";
|
||||
import { refusalAsFailure } from "./http";
|
||||
import { meteredReadBudget } from "./metered-budget";
|
||||
import {
|
||||
readerBusyFailure,
|
||||
runFailure,
|
||||
unreadableDocument,
|
||||
type DocumentTextPick,
|
||||
@@ -306,7 +307,9 @@ export async function executeDocumentExtract(args: {
|
||||
source = { mode: "text", text: input.text };
|
||||
}
|
||||
const plan = await planInboundReads(source, schedule);
|
||||
if (!plan.ok) return refused(pdfUnreadable());
|
||||
if (!plan.ok) {
|
||||
return refused(plan.busy ? readerBusyFailure() : pdfUnreadable());
|
||||
}
|
||||
await schedule.arm(plan.args.chunks.length * OCR_EXTRACT_MODEL_CALLS);
|
||||
|
||||
let result: InboundExtractionResult;
|
||||
|
||||
@@ -32,6 +32,7 @@ import { recordIndexAttempt } from "@/lib/documents/index-document";
|
||||
import type { requireDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
import { readerBusyFailure } from "./run-support";
|
||||
import { settleAiRunBudget } from "./store";
|
||||
import type {
|
||||
AiRunBudget,
|
||||
@@ -157,6 +158,8 @@ export async function executeDocumentIndex(args: {
|
||||
// gone.
|
||||
await settleAiRunBudget(userId, budget, 0, null);
|
||||
await refundDocumentAiSlot(userId);
|
||||
// A busy reader says nothing about the document: no attempt is recorded.
|
||||
if (vision.reason === "readerBusy") return readerBusyFailure();
|
||||
if (vision.reason === "pdfNeedsAnthropic") {
|
||||
await recordIndexAttempt(userId, document.id, {
|
||||
indexed: false,
|
||||
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
} from "@/lib/documents/provider-order";
|
||||
import type { VisionInput } from "@/lib/documents/ai-route-support";
|
||||
|
||||
import type { AiRunOutcome } from "./types";
|
||||
import { AI_RUN_ERROR_CODES, type AiRunOutcome } from "./types";
|
||||
|
||||
export type DocumentVisionPick = Awaited<
|
||||
ReturnType<typeof requireDocumentVisionProvider>
|
||||
@@ -44,6 +44,19 @@ export function unreadableDocument(parseError: boolean): AiRunOutcome<never> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The document reader was busy with other work for longer than a person's
|
||||
* read waits, or its process ended for no reason it measured. Nothing was
|
||||
* read and nothing is charged; asking again in a minute is the answer.
|
||||
*/
|
||||
export function readerBusyFailure(): AiRunOutcome<never> {
|
||||
return runFailure({
|
||||
status: 503,
|
||||
message: "The document reader is busy. Try again in a minute.",
|
||||
errorCode: AI_RUN_ERROR_CODES.readerBusy,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A stored original that could not become a provider input, as the summary
|
||||
* and suggest routes have always answered it.
|
||||
@@ -59,6 +72,7 @@ export function visionPreparationFailure(
|
||||
errorCode: "documents.inbound.pdfNeedsAnthropic",
|
||||
});
|
||||
}
|
||||
if (reason === "readerBusy") return readerBusyFailure();
|
||||
if (reason === "fileType") {
|
||||
return runFailure({
|
||||
status: 422,
|
||||
|
||||
@@ -74,6 +74,7 @@ export const AI_RUN_ERROR_CODES = {
|
||||
workerUnavailable: "aiRuns.workerUnavailable",
|
||||
timedOut: "aiRuns.timedOut",
|
||||
failed: "aiRuns.failed",
|
||||
readerBusy: "aiRuns.readerBusy",
|
||||
} as const;
|
||||
|
||||
/** How long a queued run waits for a worker before it is failed. */
|
||||
|
||||
@@ -484,7 +484,7 @@ export function planInboundTextReads(
|
||||
export async function planInboundReads(
|
||||
source: InboundReadSource,
|
||||
schedule: OcrSchedule,
|
||||
): Promise<({ ok: true } & InboundReads) | { ok: false }> {
|
||||
): Promise<({ ok: true } & InboundReads) | { ok: false; busy?: true }> {
|
||||
if (source.mode === "text") {
|
||||
return { ok: true, ...planInboundTextReads(source.text, schedule) };
|
||||
}
|
||||
@@ -494,7 +494,7 @@ export async function planInboundReads(
|
||||
source.pdfSupported,
|
||||
schedule,
|
||||
);
|
||||
if (!plan.ok) return { ok: false };
|
||||
if (!plan.ok) return plan;
|
||||
return {
|
||||
ok: true,
|
||||
args: { mode: "vision", chunks: plan.chunks, totalPages: plan.totalPages },
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* Decoding an uploaded image into the vault's preview thumbnail.
|
||||
*
|
||||
* The server never runs this in its own process. An image decoder allocates
|
||||
* the whole bitmap before anything can look at it, and the size it allocates
|
||||
* is whatever the file declares in a place the decoder chooses: a GIF frame
|
||||
* descriptor once reached a 1.6 GB decode behind a header check that only
|
||||
* read the logical screen. The header check (`../image-dimensions.ts`) still
|
||||
* refuses what it can see before a child is started; this module is what
|
||||
* runs in that child (`./pdf-child.mjs`, under the memory ceiling and time
|
||||
* limit of `./pdf-isolation.ts`), so a decoder surprise ends the child and
|
||||
* not the server.
|
||||
*
|
||||
* Plain JavaScript for the same reason as `./pdf-engine.mjs`: the child runs
|
||||
* it with plain Node from the checkout and the standalone image alike.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
/**
|
||||
* @typedef {object} ImageThumbnailOptions
|
||||
* @property {number} longEdge The preview's longest side; never upscaled.
|
||||
* @property {number} quality JPEG quality, 0 to 100.
|
||||
* @property {number} maxPixels The most pixels a decoded source may have.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {{ ok: true; jpeg: Uint8Array; width: number; height: number }
|
||||
* | { ok: false; reason: "pixel-cap" | "empty-render" }} ImageThumbnailOutcome
|
||||
*/
|
||||
|
||||
/**
|
||||
* The slice of `@napi-rs/canvas` used here; the suites pass a stand-in.
|
||||
*
|
||||
* @typedef {object} CanvasModule
|
||||
* @property {(source: Buffer) => Promise<{ width: number; height: number }>} loadImage
|
||||
* @property {(width: number, height: number) => {
|
||||
* getContext(type: "2d"): { drawImage(image: unknown, dx: number, dy: number, dw: number, dh: number): void };
|
||||
* toBuffer(mime: "image/jpeg", quality?: number): Buffer;
|
||||
* }} createCanvas
|
||||
*/
|
||||
|
||||
/**
|
||||
* Decode an image and draw it onto a fresh JPEG no longer than
|
||||
* `options.longEdge` on its long side. The re-encode carries none of the
|
||||
* source's metadata. Throws what the decoder threw.
|
||||
*
|
||||
* @param {Uint8Array} data
|
||||
* @param {ImageThumbnailOptions} options
|
||||
* @param {CanvasModule} [canvasModule]
|
||||
* @returns {Promise<ImageThumbnailOutcome>}
|
||||
*/
|
||||
export async function renderImageThumbnail(data, options, canvasModule) {
|
||||
const napi =
|
||||
canvasModule ??
|
||||
/** @type {CanvasModule} */ (
|
||||
/** @type {unknown} */ (await import("@napi-rs/canvas"))
|
||||
);
|
||||
const image = await napi.loadImage(
|
||||
Buffer.from(data.buffer, data.byteOffset, data.byteLength),
|
||||
);
|
||||
// The decoded size again, before a canvas of that size is drawn from: the
|
||||
// header check is a reading of the file, the decoder had the final word.
|
||||
if (image.width * image.height > options.maxPixels) {
|
||||
return { ok: false, reason: "pixel-cap" };
|
||||
}
|
||||
if (image.width <= 0 || image.height <= 0) {
|
||||
return { ok: false, reason: "empty-render" };
|
||||
}
|
||||
const scale = Math.min(
|
||||
1,
|
||||
options.longEdge / Math.max(image.width, image.height),
|
||||
);
|
||||
const width = Math.max(1, Math.round(image.width * scale));
|
||||
const height = Math.max(1, Math.round(image.height * scale));
|
||||
const canvas = napi.createCanvas(width, height);
|
||||
canvas.getContext("2d").drawImage(image, 0, 0, width, height);
|
||||
const jpeg = canvas.toBuffer("image/jpeg", options.quality);
|
||||
if (jpeg.byteLength === 0) return { ok: false, reason: "empty-render" };
|
||||
return { ok: true, jpeg: new Uint8Array(jpeg), width, height };
|
||||
}
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
import { transcribeDocument } from "@/lib/documents/describe";
|
||||
import { documentAutoReadEnabled } from "@/lib/documents/document-settings";
|
||||
import { localExtractText } from "@/lib/documents/local-extract";
|
||||
import { PdfReaderUnavailableError } from "@/lib/documents/pdfjs";
|
||||
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import { decryptDocumentContent } from "@/lib/documents/store";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -238,6 +239,12 @@ async function tryProviderIndex(
|
||||
if (vision.reason === "rasterFailed") {
|
||||
return { outcome: null, note: "raster-failed" };
|
||||
}
|
||||
if (vision.reason === "readerBusy") {
|
||||
// The reader could not take the document now. Falling back to the
|
||||
// local text layer would settle for the lesser read for good; the job
|
||||
// runs again instead.
|
||||
throw new PdfReaderUnavailableError("busy");
|
||||
}
|
||||
if (vision.reason === "pdfNeedsAnthropic") {
|
||||
return { outcome: null, note: "pdf-needs-anthropic" };
|
||||
}
|
||||
|
||||
@@ -32,7 +32,11 @@ import { Buffer } from "node:buffer";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { logCaught } from "@/lib/logging/signal";
|
||||
|
||||
import { PdfResourceLimitError, readPdfText } from "./pdfjs";
|
||||
import {
|
||||
PdfReaderUnavailableError,
|
||||
PdfResourceLimitError,
|
||||
readPdfText,
|
||||
} from "./pdfjs";
|
||||
|
||||
/**
|
||||
* Minimum joined text length for a PDF to count as a real text-layer document.
|
||||
@@ -70,9 +74,11 @@ export type LocalExtractResult =
|
||||
/**
|
||||
* Extract a text-layer PDF's embedded text server-side. Returns `empty` for a
|
||||
* scanned/no-text-layer PDF, `resource-limit` for one past the reader's memory
|
||||
* or time bound, and `error` for a malformed/encrypted one — never
|
||||
* throws, so a bad document can never abort the caller (the upload/backfill job
|
||||
* just leaves it un-indexed). An `error` leaves a `warn` line naming what
|
||||
* or time bound, and `error` for a malformed/encrypted one, so a bad document
|
||||
* can never abort the caller (the upload/backfill job just leaves it
|
||||
* un-indexed). The one throw is `PdfReaderUnavailableError`: the reader was
|
||||
* busy or its process ended for no measured reason, which says nothing about
|
||||
* the document, so the job runs again instead of recording an outcome. An `error` leaves a `warn` line naming what
|
||||
* pdf.js threw, because the outcome alone does not say why a document stayed
|
||||
* out of the index.
|
||||
*/
|
||||
@@ -84,6 +90,7 @@ export async function extractPdfText(
|
||||
// Bound the page walk so a pathological many-page PDF costs no more.
|
||||
text = (await readPdfText(buffer, LOCAL_MAX_PAGES)).trim();
|
||||
} catch (err) {
|
||||
if (err instanceof PdfReaderUnavailableError) throw err;
|
||||
if (err instanceof PdfResourceLimitError) {
|
||||
// Not a fault of the file's bytes being unreadable: they are readable,
|
||||
// only not within the bounds this server gives one document.
|
||||
|
||||
@@ -170,7 +170,12 @@ export type OcrVisionPlan =
|
||||
/** Set when the PDF is longer than the page cap: only the first pages are read. */
|
||||
pageCoverage?: { read: number; total: number };
|
||||
}
|
||||
| { ok: false };
|
||||
/**
|
||||
* `busy`: the reader could not take the PDF now (busy past the wait a read
|
||||
* gets, or its process ended for no measured reason). Not a verdict on the
|
||||
* file; the person asks again.
|
||||
*/
|
||||
| { ok: false; busy?: true };
|
||||
|
||||
/**
|
||||
* Plan the reads of an image or a PDF. Every PDF is rendered first: a report
|
||||
@@ -226,7 +231,12 @@ export async function planVisionReads(
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
if (!pdfSupported) return { ok: false };
|
||||
if (!pdfSupported) {
|
||||
return !raster.ok &&
|
||||
(raster.reason === "busy" || raster.reason === "interrupted")
|
||||
? { ok: false, busy: true }
|
||||
: { ok: false };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
chunks: [
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// @ts-check
|
||||
/**
|
||||
* The process a PDF is read and rendered in. `./pdf-isolation.ts` starts it
|
||||
* for one request, hands it the bytes over the IPC channel, and takes back
|
||||
* either the result or the fact that it died. It holds no secrets: it is
|
||||
* started with an almost empty environment and never opens the database.
|
||||
* The process a PDF is read and rendered in, and an uploaded image is decoded
|
||||
* into its thumbnail in. `./pdf-isolation.ts` starts it for one request,
|
||||
* hands it the bytes over the IPC channel, and takes back either the result
|
||||
* or the fact that it died. It holds no secrets: it is started with an almost
|
||||
* empty environment and never opens the database.
|
||||
*
|
||||
* On Linux it volunteers itself to the kernel's out-of-memory killer first,
|
||||
* so when a document outruns the memory check between two samples and the
|
||||
@@ -12,6 +13,7 @@
|
||||
*/
|
||||
import { writeFileSync } from "node:fs";
|
||||
|
||||
import { renderImageThumbnail } from "./image-engine.mjs";
|
||||
import { readTextLayer, renderPdfPages } from "./pdf-engine.mjs";
|
||||
|
||||
try {
|
||||
@@ -22,7 +24,8 @@ try {
|
||||
|
||||
/**
|
||||
* @typedef {{ op: "text"; data: Uint8Array; maxPages: number }
|
||||
* | { op: "render"; data: Uint8Array; options: import("./pdf-engine.mjs").RenderOptions }} PdfChildRequest
|
||||
* | { op: "render"; data: Uint8Array; options: import("./pdf-engine.mjs").RenderOptions }
|
||||
* | { op: "image-thumbnail"; data: Uint8Array; options: import("./image-engine.mjs").ImageThumbnailOptions }} PdfChildRequest
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -52,6 +55,12 @@ async function handle(request) {
|
||||
document: await renderPdfPages(data, request.options),
|
||||
};
|
||||
}
|
||||
if (request.op === "image-thumbnail") {
|
||||
return {
|
||||
ok: true,
|
||||
thumbnail: await renderImageThumbnail(data, request.options),
|
||||
};
|
||||
}
|
||||
return failure(new Error("unknown request"));
|
||||
} catch (err) {
|
||||
return failure(err);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Runs pdf.js in a child process with a memory ceiling and a time limit.
|
||||
* Runs pdf.js, and the image decoder behind the vault's thumbnails, in a child
|
||||
* process with a memory ceiling and a time limit.
|
||||
*
|
||||
* A PDF's cost is set by whoever wrote it. A 400 KB file can declare an image
|
||||
* of twenty thousand pixels square, and pdf.js allocated about 7 GB decoding
|
||||
@@ -20,20 +21,34 @@
|
||||
* - it starts with an almost empty environment, so it holds no key, token
|
||||
* or database address;
|
||||
* - one runs at a time (`MAX_PARALLEL_CHILDREN`), so a burst of uploads
|
||||
* cannot multiply the ceiling.
|
||||
* cannot multiply the ceiling. Who runs next is decided by
|
||||
* `./reader-queue.ts`: a read a person is waiting on before background
|
||||
* work, accounts in turn, a few waiting items per account, and a
|
||||
* deadline after which a waiting item is answered `busy`.
|
||||
*
|
||||
* A document that crosses a bound fails with `limit` and is not something a
|
||||
* retry will fix; callers record it as a final failure.
|
||||
* A document that crosses a bound this module measured (the sampled memory,
|
||||
* the time limit, or the child's own heap cap) fails with `limit` and is not
|
||||
* something a retry will fix; callers record it as a final failure. A child
|
||||
* that is gone without an answer and without a measured cause is not that:
|
||||
* the kernel may have taken it because the container as a whole ran short,
|
||||
* the server's own memory included. It is run once more, and a second such
|
||||
* exit answers `interrupted`, which callers treat as retryable.
|
||||
*
|
||||
* This module logs nothing and imports nothing from the app, so the
|
||||
* end-to-end suite can read report text through `./pdfjs.ts` as well.
|
||||
*/
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { execFile, fork, type ChildProcess } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type {
|
||||
ImageThumbnailOptions,
|
||||
ImageThumbnailOutcome,
|
||||
} from "./image-engine.mjs";
|
||||
import type { RenderOptions, RenderedDocument } from "./pdf-engine.mjs";
|
||||
import { createReaderQueue, type ReaderLane } from "./reader-queue";
|
||||
|
||||
/** Resident memory a child may reach before it is killed. */
|
||||
export const PDF_CHILD_MEMORY_MB = 640;
|
||||
@@ -61,18 +76,31 @@ const CHILD_PATH = ["src", "lib", "documents", "pdf-child.mjs"];
|
||||
|
||||
export type PdfChildRequest =
|
||||
| { op: "text"; data: Uint8Array; maxPages: number }
|
||||
| { op: "render"; data: Uint8Array; options: RenderOptions };
|
||||
| { op: "render"; data: Uint8Array; options: RenderOptions }
|
||||
| { op: "image-thumbnail"; data: Uint8Array; options: ImageThumbnailOptions };
|
||||
|
||||
type PdfChildValue<R extends PdfChildRequest> = R extends { op: "text" }
|
||||
? string
|
||||
: RenderedDocument;
|
||||
: R extends { op: "render" }
|
||||
? RenderedDocument
|
||||
: ImageThumbnailOutcome;
|
||||
|
||||
/** Why a child gave no result. */
|
||||
export type PdfChildFailure =
|
||||
/** The document crossed the memory ceiling or the time limit. */
|
||||
| { ok: false; reason: "limit"; limit: "memory" | "time" | "exit" }
|
||||
/** pdf.js refused the document: malformed, encrypted, unreadable. */
|
||||
| { ok: false; reason: "error"; name: string; message: string };
|
||||
| { ok: false; reason: "limit"; limit: "memory" | "time" }
|
||||
/** pdf.js or the decoder refused the input: malformed, encrypted, unreadable. */
|
||||
| { ok: false; reason: "error"; name: string; message: string }
|
||||
/**
|
||||
* The child ended without an answer and without a cause this module
|
||||
* measured, twice. Not the document's verdict; try again later.
|
||||
*/
|
||||
| { ok: false; reason: "interrupted" }
|
||||
/**
|
||||
* The reader was taken for longer than the wait this work may spend, or
|
||||
* this account already has as much waiting as it may. Try again later.
|
||||
*/
|
||||
| { ok: false; reason: "busy"; retryAfterMs: number };
|
||||
|
||||
export type PdfChildResult<R extends PdfChildRequest> =
|
||||
{ ok: true; value: PdfChildValue<R> } | PdfChildFailure;
|
||||
@@ -81,27 +109,77 @@ export interface PdfChildLimits {
|
||||
timeoutMs: number;
|
||||
/** Overrides `PDF_CHILD_MEMORY_MB`; the suites use a small one. */
|
||||
memoryMb?: number;
|
||||
/** Replaces the child start; the suites use it to stand in for a child. */
|
||||
run?: (
|
||||
script: string,
|
||||
request: PdfChildRequest,
|
||||
limits: PdfChildLimits,
|
||||
) => Promise<PdfChildResult<PdfChildRequest>>;
|
||||
}
|
||||
|
||||
// ── Concurrency ────────────────────────────────────────────────────────────
|
||||
// ── Who runs next ──────────────────────────────────────────────────────────
|
||||
|
||||
let running = 0;
|
||||
const waiting: Array<() => void> = [];
|
||||
/** Items one account may have waiting for the reader, across both lanes. */
|
||||
export const MAX_QUEUED_PER_OWNER = 4;
|
||||
|
||||
async function acquireSlot(): Promise<void> {
|
||||
if (running < MAX_PARALLEL_CHILDREN) {
|
||||
running++;
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => waiting.push(resolve));
|
||||
/**
|
||||
* How long an item may wait for the reader. A person waiting on a read gets
|
||||
* an answer within a minute; background work can wait longer, and its job is
|
||||
* retried when it does not get a turn.
|
||||
*/
|
||||
export const READER_WAIT_MS: Record<ReaderLane, number> = {
|
||||
interactive: 60_000,
|
||||
background: 5 * 60_000,
|
||||
};
|
||||
|
||||
const queue = createReaderQueue({
|
||||
slots: MAX_PARALLEL_CHILDREN,
|
||||
maxQueuedPerOwner: MAX_QUEUED_PER_OWNER,
|
||||
waitMs: READER_WAIT_MS,
|
||||
});
|
||||
|
||||
/** Whose work this is and whether a person is waiting on it. */
|
||||
export interface PdfWork {
|
||||
/** The account the work is done for; the queue takes accounts in turn. */
|
||||
owner: string;
|
||||
lane: ReaderLane;
|
||||
/**
|
||||
* Moves the caller's own clock by `ms`. A background AI run has a deadline
|
||||
* measured in model calls, and time spent waiting for the reader or
|
||||
* reading is not model time: when the slot is granted the clock moves by
|
||||
* the wait plus the most the read may take, and when the read ends it moves
|
||||
* back by the part of that allowance the read did not use. So the run's
|
||||
* model-time budget starts once the reader is done with it, however long
|
||||
* the queue was. Failures are the caller's to swallow; the read goes on.
|
||||
*/
|
||||
extendDeadline?: (ms: number) => Promise<void>;
|
||||
}
|
||||
|
||||
function releaseSlot(): void {
|
||||
const next = waiting.shift();
|
||||
if (next) next();
|
||||
else running--;
|
||||
const workContext = new AsyncLocalStorage<PdfWork>();
|
||||
|
||||
/**
|
||||
* Run `fn` with every reader call inside it counted as `work`. The entry
|
||||
* points set it: the background AI run worker (interactive) and the document
|
||||
* jobs (background). A call outside any of them is background work of an
|
||||
* unattributed owner.
|
||||
*/
|
||||
export function withPdfWork<T>(
|
||||
work: PdfWork,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
return workContext.run(work, fn);
|
||||
}
|
||||
|
||||
const UNATTRIBUTED: PdfWork = { owner: "unattributed", lane: "background" };
|
||||
|
||||
/** The work the reader would count a call made here as. */
|
||||
export function currentPdfWork(): PdfWork {
|
||||
return workContext.getStore() ?? UNATTRIBUTED;
|
||||
}
|
||||
|
||||
/** How many times a child that vanished without a measured cause is run. */
|
||||
const MAX_ATTEMPTS = 2;
|
||||
|
||||
// ── Resident memory of another process ─────────────────────────────────────
|
||||
|
||||
/** The child's resident set in MB, or null where it cannot be read. */
|
||||
@@ -132,6 +210,7 @@ interface ChildReply {
|
||||
ok: boolean;
|
||||
text?: string;
|
||||
document?: RenderedDocument;
|
||||
thumbnail?: ImageThumbnailOutcome;
|
||||
error?: { name: string; message: string };
|
||||
}
|
||||
|
||||
@@ -150,9 +229,36 @@ function decodeDocument(document: RenderedDocument): RenderedDocument {
|
||||
};
|
||||
}
|
||||
|
||||
function decodeValue<R extends PdfChildRequest>(
|
||||
request: R,
|
||||
reply: ChildReply,
|
||||
): PdfChildValue<R> {
|
||||
if (request.op === "text") return reply.text as PdfChildValue<R>;
|
||||
if (request.op === "render") {
|
||||
return decodeDocument(
|
||||
reply.document as RenderedDocument,
|
||||
) as PdfChildValue<R>;
|
||||
}
|
||||
const thumbnail = reply.thumbnail as ImageThumbnailOutcome;
|
||||
return (
|
||||
thumbnail.ok
|
||||
? {
|
||||
...thumbnail,
|
||||
jpeg: Buffer.from(
|
||||
thumbnail.jpeg.buffer,
|
||||
thumbnail.jpeg.byteOffset,
|
||||
thumbnail.jpeg.byteLength,
|
||||
),
|
||||
}
|
||||
: thumbnail
|
||||
) as PdfChildValue<R>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read or render one PDF in a child process. Never throws: a document that
|
||||
* crosses a bound answers `limit`, one pdf.js refuses answers `error`.
|
||||
* Read or render one PDF, or decode one image, in a child process. Never
|
||||
* throws: a document that crosses a bound answers `limit`, one the reader
|
||||
* refuses answers `error`, a reader that is taken answers `busy`, and a
|
||||
* child that twice ended for no reason it could see answers `interrupted`.
|
||||
*/
|
||||
export async function runPdfChild<R extends PdfChildRequest>(
|
||||
request: R,
|
||||
@@ -168,15 +274,44 @@ export async function runPdfChild<R extends PdfChildRequest>(
|
||||
};
|
||||
}
|
||||
|
||||
await acquireSlot();
|
||||
const work = currentPdfWork();
|
||||
const queuedAt = Date.now();
|
||||
const ticket = await queue.acquire(work.owner, work.lane);
|
||||
if (!ticket.ok) {
|
||||
return { ok: false, reason: "busy", retryAfterMs: ticket.retryAfterMs };
|
||||
}
|
||||
const grantedAt = Date.now();
|
||||
// The most this read may take: every attempt to its time limit.
|
||||
const allowance = limits.timeoutMs * MAX_ATTEMPTS;
|
||||
await moveCallerClock(work, grantedAt - queuedAt + allowance);
|
||||
try {
|
||||
return await runOnce(script, request, limits);
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
const result = limits.run
|
||||
? ((await limits.run(script, request, limits)) as PdfChildResult<R>)
|
||||
: await runOnce(script, request, limits);
|
||||
if (result.ok || result.reason !== "interrupted") return result;
|
||||
if (attempt >= MAX_ATTEMPTS) return result;
|
||||
}
|
||||
} finally {
|
||||
releaseSlot();
|
||||
ticket.release();
|
||||
await moveCallerClock(
|
||||
work,
|
||||
-Math.max(0, allowance - (Date.now() - grantedAt)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function runOnce<R extends PdfChildRequest>(
|
||||
async function moveCallerClock(work: PdfWork, ms: number): Promise<void> {
|
||||
if (!work.extendDeadline || ms === 0) return;
|
||||
try {
|
||||
await work.extendDeadline(ms);
|
||||
} catch {
|
||||
// The caller's clock is the caller's; the read is not failed for it.
|
||||
}
|
||||
}
|
||||
|
||||
/** Start one child for `request` and wait for its answer or its end. */
|
||||
export function runOnce<R extends PdfChildRequest>(
|
||||
script: string,
|
||||
request: R,
|
||||
limits: PdfChildLimits,
|
||||
@@ -244,11 +379,7 @@ function runOnce<R extends PdfChildRequest>(
|
||||
child.on("message", (raw) => {
|
||||
const reply = raw as ChildReply;
|
||||
if (reply.ok) {
|
||||
const value =
|
||||
request.op === "text"
|
||||
? reply.text
|
||||
: decodeDocument(reply.document as RenderedDocument);
|
||||
finish({ ok: true, value: value as PdfChildValue<R> });
|
||||
finish({ ok: true, value: decodeValue(request, reply) });
|
||||
} else {
|
||||
finish({
|
||||
ok: false,
|
||||
@@ -259,13 +390,10 @@ function runOnce<R extends PdfChildRequest>(
|
||||
}
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
finish({
|
||||
ok: false,
|
||||
reason: "error",
|
||||
name: err.name,
|
||||
message: err.message.slice(0, 300),
|
||||
});
|
||||
// The process could not be started or signalled. What pdf.js or the
|
||||
// decoder thought of the document arrives as a reply, never here.
|
||||
child.on("error", () => {
|
||||
finish({ ok: false, reason: "interrupted" });
|
||||
});
|
||||
|
||||
child.on("close", () => {
|
||||
@@ -273,21 +401,24 @@ function runOnce<R extends PdfChildRequest>(
|
||||
finish({ ok: false, reason: "limit", limit: killedFor });
|
||||
return;
|
||||
}
|
||||
// Gone without an answer: V8 hit its heap ceiling, the kernel took it
|
||||
// for memory, or it crashed in native code. Any of them is the
|
||||
// document's doing.
|
||||
const heap = /heap limit|heap out of memory/i.test(stderrTail);
|
||||
finish({ ok: false, reason: "limit", limit: heap ? "memory" : "exit" });
|
||||
// Gone without an answer. V8 reaching the heap cap this module gave
|
||||
// the child is the document's doing, whatever else the host runs. Any
|
||||
// other end (the kernel's out-of-memory killer, which also acts when
|
||||
// the server itself grew, a signal from outside, a native crash) says
|
||||
// nothing certain about the document, so it is not a final verdict.
|
||||
if (/heap limit/i.test(stderrTail)) {
|
||||
finish({ ok: false, reason: "limit", limit: "memory" });
|
||||
return;
|
||||
}
|
||||
finish({ ok: false, reason: "interrupted" });
|
||||
});
|
||||
|
||||
child.send(request, (err) => {
|
||||
if (err) {
|
||||
finish({
|
||||
ok: false,
|
||||
reason: "error",
|
||||
name: err.name,
|
||||
message: err.message.slice(0, 300),
|
||||
});
|
||||
// The bytes never reached the child, mostly because it was already
|
||||
// gone. That says nothing about the document either: end the child and
|
||||
// let its close answer `interrupted`.
|
||||
if (err && child.exitCode === null && child.signalCode === null) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,16 +21,33 @@ export const PDF_TEXT_TIMEOUT_MS = 60_000;
|
||||
/** A document that crossed the memory ceiling or the time limit. */
|
||||
export class PdfResourceLimitError extends Error {
|
||||
override name = "PdfResourceLimitError";
|
||||
constructor(readonly limit: "memory" | "time" | "exit") {
|
||||
constructor(readonly limit: "memory" | "time") {
|
||||
super(`the PDF crossed the reader's ${limit} limit`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The reader could not take the document now: it was busy past the wait this
|
||||
* work may spend, or its process ended for a reason that says nothing about
|
||||
* the document. Not a verdict on the file; the work is tried again later.
|
||||
*/
|
||||
export class PdfReaderUnavailableError extends Error {
|
||||
override name = "PdfReaderUnavailableError";
|
||||
constructor(readonly kind: "busy" | "interrupted") {
|
||||
super(
|
||||
kind === "busy"
|
||||
? "the document reader is busy"
|
||||
: "the document reader stopped before it answered",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The text layer of the first `maxPages` pages, one page after another.
|
||||
* Throws what pdf.js threw (a malformed file, a password, a page that will
|
||||
* not parse) under its own name, or `PdfResourceLimitError` when the
|
||||
* document crossed a bound.
|
||||
* not parse) under its own name, `PdfResourceLimitError` when the document
|
||||
* crossed a bound, or `PdfReaderUnavailableError` when the reader could not
|
||||
* take it now.
|
||||
*/
|
||||
export async function readPdfText(
|
||||
data: Uint8Array,
|
||||
@@ -43,6 +60,9 @@ export async function readPdfText(
|
||||
);
|
||||
if (result.ok) return result.value;
|
||||
if (result.reason === "limit") throw new PdfResourceLimitError(result.limit);
|
||||
if (result.reason === "busy" || result.reason === "interrupted") {
|
||||
throw new PdfReaderUnavailableError(result.reason);
|
||||
}
|
||||
const err = new Error(result.message);
|
||||
err.name = result.name;
|
||||
throw err;
|
||||
|
||||
@@ -107,7 +107,20 @@ export type RasterResult =
|
||||
*/
|
||||
texts?: string[];
|
||||
}
|
||||
| { ok: false; reason: "unsupported" | "render-failed" | "resource-limit" };
|
||||
| {
|
||||
ok: false;
|
||||
reason:
|
||||
| "unsupported"
|
||||
| "render-failed"
|
||||
| "resource-limit"
|
||||
/**
|
||||
* Not an answer about this document: the reader was busy past the
|
||||
* wait this work may spend, or its process ended for no reason it
|
||||
* measured. The caller tries again later.
|
||||
*/
|
||||
| "busy"
|
||||
| "interrupted";
|
||||
};
|
||||
|
||||
export {
|
||||
pageText,
|
||||
@@ -166,6 +179,13 @@ export async function rasterizePdf(
|
||||
);
|
||||
|
||||
if (!result.ok) {
|
||||
if (result.reason === "busy" || result.reason === "interrupted") {
|
||||
annotate({
|
||||
action: { name: "documents.rasterize.deferred" },
|
||||
meta: { reason: result.reason },
|
||||
});
|
||||
return { ok: false, reason: result.reason };
|
||||
}
|
||||
if (result.reason === "limit") {
|
||||
annotate({
|
||||
action: { name: "documents.rasterize.failed" },
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Who gets the document reader next.
|
||||
*
|
||||
* The reader (`./pdf-isolation.ts`) runs one child process at a time, because
|
||||
* the server and one child at its memory ceiling are what fit the default
|
||||
* container. It used to hand that one slot out first come, first served,
|
||||
* with no limit on how long anyone waited: a few heavy PDFs from one account,
|
||||
* each allowed up to a minute, put every other person's lab scan behind them
|
||||
* for as long as the uploads kept coming.
|
||||
*
|
||||
* Now the slot goes:
|
||||
*
|
||||
* - to interactive work first: a read a person started and is waiting on
|
||||
* (a lab scan, a medication label, a document read) is served before
|
||||
* background work (thumbnails, indexing, summaries) that nobody watches;
|
||||
* - round robin across accounts within each lane, so one account's queue
|
||||
* takes one turn and then waits behind everyone else's next item;
|
||||
* - to at most `maxQueuedPerOwner` waiting items per account; one more is
|
||||
* refused at once rather than queued;
|
||||
* - within a deadline per lane: an item still waiting when it passes leaves
|
||||
* the queue with a retryable "busy" answer instead of waiting on.
|
||||
*
|
||||
* Nothing here knows what a PDF is, so the order can be tested on its own.
|
||||
*/
|
||||
|
||||
export type ReaderLane = "interactive" | "background";
|
||||
|
||||
/** Interactive before background. */
|
||||
const LANE_ORDER: readonly ReaderLane[] = ["interactive", "background"];
|
||||
|
||||
export interface ReaderQueueOptions {
|
||||
/** Work running at once. */
|
||||
slots: number;
|
||||
/** Items one owner may have waiting, across both lanes. */
|
||||
maxQueuedPerOwner: number;
|
||||
/** How long an item may wait in each lane before it is answered busy. */
|
||||
waitMs: Record<ReaderLane, number>;
|
||||
}
|
||||
|
||||
/** The slot, or why there is none. `release` must be called exactly once. */
|
||||
export type ReaderTicket =
|
||||
| { ok: true; release: () => void }
|
||||
| { ok: false; reason: "queue-full" | "wait-expired"; retryAfterMs: number };
|
||||
|
||||
interface Waiter {
|
||||
owner: string;
|
||||
grant: () => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
export interface ReaderQueue {
|
||||
acquire(owner: string, lane: ReaderLane): Promise<ReaderTicket>;
|
||||
/** Items running and waiting, for the suites and diagnostics. */
|
||||
snapshot(): { running: number; waiting: Record<ReaderLane, number> };
|
||||
}
|
||||
|
||||
export function createReaderQueue(options: ReaderQueueOptions): ReaderQueue {
|
||||
let running = 0;
|
||||
// Per lane, one FIFO per owner. A Map iterates in insertion order, so the
|
||||
// first key is the owner whose turn it is; after a turn the owner moves to
|
||||
// the back.
|
||||
const lanes: Record<ReaderLane, Map<string, Waiter[]>> = {
|
||||
interactive: new Map(),
|
||||
background: new Map(),
|
||||
};
|
||||
|
||||
const waitingFor = (owner: string): number =>
|
||||
LANE_ORDER.reduce(
|
||||
(sum, lane) => sum + (lanes[lane].get(owner)?.length ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
const remove = (lane: ReaderLane, waiter: Waiter): void => {
|
||||
const queue = lanes[lane].get(waiter.owner);
|
||||
if (!queue) return;
|
||||
const at = queue.indexOf(waiter);
|
||||
if (at !== -1) queue.splice(at, 1);
|
||||
if (queue.length === 0) lanes[lane].delete(waiter.owner);
|
||||
};
|
||||
|
||||
const next = (): Waiter | null => {
|
||||
for (const lane of LANE_ORDER) {
|
||||
const owners = lanes[lane];
|
||||
const first = owners.keys().next();
|
||||
if (first.done) continue;
|
||||
const owner = first.value;
|
||||
const queue = owners.get(owner)!;
|
||||
const waiter = queue.shift()!;
|
||||
owners.delete(owner);
|
||||
if (queue.length > 0) owners.set(owner, queue);
|
||||
return waiter;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const release = (): void => {
|
||||
const waiter = next();
|
||||
if (waiter) {
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.grant();
|
||||
} else {
|
||||
running--;
|
||||
}
|
||||
};
|
||||
|
||||
const ticket = (): ReaderTicket => {
|
||||
let released = false;
|
||||
return {
|
||||
ok: true,
|
||||
release: () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
release();
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
acquire(owner, lane) {
|
||||
if (running < options.slots) {
|
||||
running++;
|
||||
return Promise.resolve(ticket());
|
||||
}
|
||||
if (waitingFor(owner) >= options.maxQueuedPerOwner) {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
reason: "queue-full",
|
||||
retryAfterMs: options.waitMs[lane],
|
||||
});
|
||||
}
|
||||
return new Promise<ReaderTicket>((resolve) => {
|
||||
const waiter: Waiter = {
|
||||
owner,
|
||||
// The slot passes straight from the releasing item to this one;
|
||||
// `running` stays as it is.
|
||||
grant: () => resolve(ticket()),
|
||||
timer: setTimeout(() => {
|
||||
remove(lane, waiter);
|
||||
resolve({
|
||||
ok: false,
|
||||
reason: "wait-expired",
|
||||
retryAfterMs: options.waitMs[lane],
|
||||
});
|
||||
}, options.waitMs[lane]),
|
||||
};
|
||||
const queue = lanes[lane].get(owner);
|
||||
if (queue) queue.push(waiter);
|
||||
else lanes[lane].set(owner, [waiter]);
|
||||
});
|
||||
},
|
||||
snapshot() {
|
||||
const count = (lane: ReaderLane) =>
|
||||
[...lanes[lane].values()].reduce((sum, q) => sum + q.length, 0);
|
||||
return {
|
||||
running,
|
||||
waiting: {
|
||||
interactive: count("interactive"),
|
||||
background: count("background"),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -6,6 +6,12 @@
|
||||
* rasterised (via `rasterizePdf`, bounded to one page) then downscaled; every
|
||||
* other MIME type has no preview (the card keeps its kind icon).
|
||||
*
|
||||
* Both untrusted decodes happen in the reader's child process
|
||||
* (`./pdf-isolation.ts`) under its memory ceiling and time limit: the image
|
||||
* through `./image-engine.mjs`, the PDF page through `./pdf-engine.mjs`. An
|
||||
* image's declared size is still checked here first, so a bomb the header
|
||||
* shows is refused without starting a child.
|
||||
*
|
||||
* `@napi-rs/canvas` + pdfjs are pulled LAZILY via runtime `import()` — pdfjs's
|
||||
* module top-level references the browser-only `DOMMatrix` global, so a STATIC
|
||||
* import would make the Turbopack server chunk evaluate that reference at
|
||||
@@ -24,12 +30,14 @@
|
||||
* binary, or any render error resolves to `{ ok: false }`, and the caller
|
||||
* leaves the document without a thumbnail (the card falls back to its kind
|
||||
* icon). This preserves the "a bad document never aborts an upload/backfill"
|
||||
* contract.
|
||||
* contract. `reader-busy` and `interrupted` are the two answers that say
|
||||
* nothing about the document; the job tries those again.
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { nativeCanvasSupported } from "./native-canvas-support";
|
||||
|
||||
import { runPdfChild } from "@/lib/documents/pdf-isolation";
|
||||
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
|
||||
import {
|
||||
readImageDimensions,
|
||||
@@ -96,6 +104,10 @@ export type ThumbnailSkipReason =
|
||||
| "raster-failed"
|
||||
| "resource-limit"
|
||||
| "empty-render"
|
||||
/** The reader was busy; the job is retried. */
|
||||
| "reader-busy"
|
||||
/** The reader's process ended for no measured reason; tried again later. */
|
||||
| "interrupted"
|
||||
| "error";
|
||||
|
||||
/** Best-effort generation outcome — never an exception. */
|
||||
@@ -152,6 +164,76 @@ function drawScaledJpeg(
|
||||
return { jpeg, width, height };
|
||||
}
|
||||
|
||||
/** How long decoding one image into its preview may take. */
|
||||
export const IMAGE_THUMBNAIL_TIMEOUT_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Decode and downscale an image in the reader's child process, under its
|
||||
* memory ceiling and time limit. The decoder allocates whatever the file
|
||||
* makes it allocate; in the child that ends the child, not the server.
|
||||
*/
|
||||
async function imageThumbnail(bytes: Buffer): Promise<ThumbnailResult> {
|
||||
const result = await runPdfChild(
|
||||
{
|
||||
op: "image-thumbnail",
|
||||
data: bytes,
|
||||
options: {
|
||||
longEdge: THUMB_LONG_EDGE,
|
||||
quality: THUMB_QUALITY,
|
||||
maxPixels: MAX_SOURCE_PIXELS,
|
||||
},
|
||||
},
|
||||
{ timeoutMs: IMAGE_THUMBNAIL_TIMEOUT_MS },
|
||||
);
|
||||
if (!result.ok) {
|
||||
if (result.reason === "busy") return { ok: false, reason: "reader-busy" };
|
||||
if (result.reason === "interrupted") {
|
||||
return { ok: false, reason: "interrupted" };
|
||||
}
|
||||
if (result.reason === "limit") {
|
||||
annotate({
|
||||
action: { name: "documents.thumbnail.rejected" },
|
||||
meta: {
|
||||
source: "image",
|
||||
reason: "resource_limit",
|
||||
limit: result.limit,
|
||||
},
|
||||
});
|
||||
return { ok: false, reason: "resource-limit" };
|
||||
}
|
||||
annotate({
|
||||
action: { name: "documents.thumbnail.failed" },
|
||||
meta: {
|
||||
mimeType: "image",
|
||||
reason: result.name,
|
||||
message: result.message.slice(0, 300),
|
||||
},
|
||||
});
|
||||
return { ok: false, reason: "error" };
|
||||
}
|
||||
const outcome = result.value;
|
||||
if (!outcome.ok) {
|
||||
if (outcome.reason === "pixel-cap") {
|
||||
// The decoder found a larger image than the header walk did.
|
||||
annotate({
|
||||
action: { name: "documents.thumbnail.rejected" },
|
||||
meta: { source: "image", reason: "pixel_cap_post_decode" },
|
||||
});
|
||||
}
|
||||
return { ok: false, reason: outcome.reason };
|
||||
}
|
||||
const thumb: Thumbnail = {
|
||||
jpeg: Buffer.from(outcome.jpeg),
|
||||
width: outcome.width,
|
||||
height: outcome.height,
|
||||
};
|
||||
annotate({
|
||||
action: { name: "documents.thumbnail.ok" },
|
||||
meta: { source: "image", width: thumb.width, height: thumb.height },
|
||||
});
|
||||
return { ok: true, thumbnail: thumb };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a small JPEG preview for a stored document, or `{ ok: false }` when
|
||||
* the type is unsupported or rendering fails. Never throws.
|
||||
@@ -173,15 +255,10 @@ export async function generateThumbnail(
|
||||
});
|
||||
return { ok: false, reason: "native-canvas-unsupported" };
|
||||
}
|
||||
// Lazy import: keeps @napi-rs/canvas (+ transitively pdfjs on the PDF path)
|
||||
// out of the eager server chunk. Resolved on first generation, then cached.
|
||||
const napi =
|
||||
(await import("@napi-rs/canvas")) as unknown as NapiCanvasModule;
|
||||
|
||||
if (THUMBNAILABLE_IMAGE_MIMES.has(mimeType)) {
|
||||
// Refuse a decompression bomb BEFORE `loadImage` allocates the bitmap,
|
||||
// and refuse an image whose size cannot be read: that one would reach
|
||||
// the decoder with no ceiling at all.
|
||||
// Refuse a decompression bomb before a decoder is started at all, and
|
||||
// refuse an image whose size cannot be read: that one would reach the
|
||||
// decoder with no ceiling but the child's.
|
||||
const pixels = sniffSourcePixels(bytes, mimeType);
|
||||
if (pixels === null) {
|
||||
annotate({
|
||||
@@ -197,28 +274,7 @@ export async function generateThumbnail(
|
||||
});
|
||||
return { ok: false, reason: "pixel-cap" };
|
||||
}
|
||||
const image = await napi.loadImage(bytes);
|
||||
// Post-decode backstop: should a decoder ever read a larger frame than
|
||||
// the header walk did, the decoded size is checked again before the
|
||||
// bitmap is drawn.
|
||||
if (image.width * image.height > MAX_SOURCE_PIXELS) {
|
||||
annotate({
|
||||
action: { name: "documents.thumbnail.rejected" },
|
||||
meta: {
|
||||
source: "image",
|
||||
reason: "pixel_cap_post_decode",
|
||||
pixels: image.width * image.height,
|
||||
},
|
||||
});
|
||||
return { ok: false, reason: "pixel-cap" };
|
||||
}
|
||||
const thumb = drawScaledJpeg(napi, image);
|
||||
if (!thumb) return { ok: false, reason: "empty-render" };
|
||||
annotate({
|
||||
action: { name: "documents.thumbnail.ok" },
|
||||
meta: { source: "image", width: thumb.width, height: thumb.height },
|
||||
});
|
||||
return { ok: true, thumbnail: thumb };
|
||||
return await imageThumbnail(bytes);
|
||||
}
|
||||
|
||||
if (mimeType === "application/pdf") {
|
||||
@@ -229,9 +285,21 @@ export async function generateThumbnail(
|
||||
// bytes will again, so the job records it as final.
|
||||
return { ok: false, reason: "resource-limit" };
|
||||
}
|
||||
if (!raster.ok && raster.reason === "busy") {
|
||||
return { ok: false, reason: "reader-busy" };
|
||||
}
|
||||
if (!raster.ok && raster.reason === "interrupted") {
|
||||
return { ok: false, reason: "interrupted" };
|
||||
}
|
||||
if (!raster.ok || raster.images.length === 0) {
|
||||
return { ok: false, reason: "raster-failed" };
|
||||
}
|
||||
// The page is a JPEG the child rendered, no longer than
|
||||
// `RASTER_TARGET_LONG_EDGE` on its long side: the one decode left in
|
||||
// this process is of the server's own output at a bounded size.
|
||||
// Lazy import: keeps @napi-rs/canvas out of the eager server chunk.
|
||||
const napi =
|
||||
(await import("@napi-rs/canvas")) as unknown as NapiCanvasModule;
|
||||
const pageJpeg = Buffer.from(raster.images[0]!.dataBase64, "base64");
|
||||
const image = await napi.loadImage(pageJpeg);
|
||||
const thumb = drawScaledJpeg(napi, image);
|
||||
|
||||
@@ -25,17 +25,28 @@
|
||||
* - A request with neither header is not from a browser page (the phone
|
||||
* app, a script, a webhook sender) and passes; it carries no cookie a
|
||||
* page could have lent it.
|
||||
* - A request carrying `Authorization` passes: a page cannot attach that
|
||||
* header to another origin without a CORS preflight, which this server
|
||||
* never answers.
|
||||
* - A request carrying `Authorization: Bearer` and no session cookie
|
||||
* passes: a page cannot attach that header to another origin without a
|
||||
* CORS preflight, which this server never answers, and without a session
|
||||
* cookie the token is what the request is authenticated by. Any other
|
||||
* scheme does not count: a browser attaches `Basic` or `Negotiate`
|
||||
* credentials on its own when a proxy or the host asked for them, and
|
||||
* the caller is resolved from the session cookie before any header
|
||||
* (`src/lib/auth/guards/caller.ts`), so a request with both is a cookie
|
||||
* request and is checked as one.
|
||||
*
|
||||
* Exempt by path: the surfaces that authenticate with a secret in a header
|
||||
* or the URL rather than the session cookie, and that a third party's client
|
||||
* may legitimately call from a browser (a push client's action button). The
|
||||
* MCP OAuth endpoints, which must accept cross-origin requests by their
|
||||
* specification, do not run through `apiHandler` at all.
|
||||
* may legitimately call from a browser (a push client's action button), and
|
||||
* the share-link passphrase check under `/api/c/`, whose credential is the
|
||||
* token in the path plus the passphrase in the body. A shared-record page is
|
||||
* served with `Referrer-Policy: no-referrer`, and a browser without
|
||||
* `Sec-Fetch-Site` then sends `Origin: null` on its POST, which no host
|
||||
* check can admit. The MCP OAuth endpoints, which must accept cross-origin
|
||||
* requests by their specification, do not run through `apiHandler` at all.
|
||||
*/
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { cookieHeaderCarriesSession } from "@/lib/auth/session-cookie";
|
||||
|
||||
const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
|
||||
|
||||
@@ -51,6 +62,7 @@ export const CROSS_SITE_EXEMPT_PREFIXES = [
|
||||
"/api/internal/deploy-webhook",
|
||||
"/api/notifications/action/",
|
||||
"/api/monitoring/csp-report",
|
||||
"/api/c/",
|
||||
] as const;
|
||||
|
||||
function hostOf(value: string | null | undefined): string | null {
|
||||
@@ -92,7 +104,12 @@ export function crossSiteWriteReason(
|
||||
if (CROSS_SITE_EXEMPT_PREFIXES.some((p) => pathname.startsWith(p))) {
|
||||
return null;
|
||||
}
|
||||
if (request.headers.has("authorization")) return null;
|
||||
if (
|
||||
request.headers.get("authorization")?.startsWith("Bearer ") &&
|
||||
!cookieHeaderCarriesSession(request.headers.get("cookie"))
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const site = request.headers.get("sec-fetch-site")?.trim().toLowerCase();
|
||||
if (site) {
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Which connections may tell the server who the client is.
|
||||
*
|
||||
* A reverse proxy reports the client's address in `X-Forwarded-For` or
|
||||
* `X-Real-IP`. Anyone else who sends those headers is choosing the address
|
||||
* every per-IP rate limit, the audit log and the sign-in notices see, so the
|
||||
* client-IP resolver (`getClientIpOrTrustWarning` in `@/lib/api-response`)
|
||||
* reads them only from a peer this module says may forward.
|
||||
*
|
||||
* Two rules:
|
||||
*
|
||||
* - `TRUSTED_PROXY_CIDRS` set (comma-separated CIDRs, IPv4 or IPv6): only
|
||||
* a peer inside one of them may forward. Every other connection is its
|
||||
* own client, whatever address it comes from.
|
||||
* - unset: any peer whose address is not public (loopback, private,
|
||||
* CGNAT, unique-local, link-local) may forward. This is the long-standing
|
||||
* default and stays for compatibility, but it is only as good as the
|
||||
* network: behind Docker Desktop, OrbStack or `docker-proxy` every
|
||||
* connection to a published port arrives from the gateway address, a
|
||||
* private one, so a caller that reaches the port directly is believed
|
||||
* again. Setting the list closes that.
|
||||
*
|
||||
* `TRUST_PROXY_HOPS` says how many proxies the `X-Forwarded-For` chain
|
||||
* passed through. A value that is not a non-negative integer used to throw
|
||||
* on every request that asked for the client address, `/api/health`
|
||||
* included; it now resolves no address and is named once at boot.
|
||||
*
|
||||
* Both are read per call, so the suites can change them; the parsed CIDR
|
||||
* list is cached by its raw value.
|
||||
*/
|
||||
import { BlockList, isIP } from "node:net";
|
||||
|
||||
import { isPublicIp } from "@/lib/validations/notifications";
|
||||
|
||||
// ── TRUST_PROXY_HOPS ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The configured hop count, `1` when unset or empty, or `null` when the
|
||||
* value is not a non-negative integer.
|
||||
*/
|
||||
export function parseTrustProxyHops(raw: string | undefined): number | null {
|
||||
// Empty is unset. The shipped docker-compose.yml passes
|
||||
// `TRUST_PROXY_HOPS: "${TRUST_PROXY_HOPS:-}"`, which is an empty string for
|
||||
// every operator who never set it.
|
||||
const trimmed = raw?.trim() ?? "";
|
||||
if (trimmed === "") return 1;
|
||||
if (!/^\d+$/.test(trimmed)) return null;
|
||||
return parseInt(trimmed, 10);
|
||||
}
|
||||
|
||||
// ── TRUSTED_PROXY_CIDRS ───────────────────────────────────────────────────
|
||||
|
||||
export type TrustedProxyList =
|
||||
/** Unset: the private-address default applies. */
|
||||
| { kind: "unset" }
|
||||
| { kind: "list"; entries: string[]; blockList: BlockList }
|
||||
/** Set, but an entry is not a CIDR. No peer may forward. */
|
||||
| { kind: "invalid"; invalid: string[] };
|
||||
|
||||
/** `10.0.0.0/8`, `127.0.0.1`, `fd00::/8`; a bare address is a /32 or /128. */
|
||||
function parseCidr(
|
||||
entry: string,
|
||||
): { address: string; prefix: number; family: "ipv4" | "ipv6" } | null {
|
||||
const [address = "", prefixText, extra] = entry.split("/");
|
||||
if (extra !== undefined) return null;
|
||||
const version = isIP(address);
|
||||
if (version === 0) return null;
|
||||
const max = version === 4 ? 32 : 128;
|
||||
if (prefixText !== undefined && !/^\d{1,3}$/.test(prefixText)) return null;
|
||||
const prefix = prefixText === undefined ? max : Number(prefixText);
|
||||
if (prefix > max) return null;
|
||||
return { address, prefix, family: version === 4 ? "ipv4" : "ipv6" };
|
||||
}
|
||||
|
||||
let cached: { raw: string; list: TrustedProxyList } | null = null;
|
||||
|
||||
export function parseTrustedProxyCidrs(
|
||||
raw: string | undefined,
|
||||
): TrustedProxyList {
|
||||
const value = raw?.trim() ?? "";
|
||||
if (cached && cached.raw === value) return cached.list;
|
||||
let list: TrustedProxyList;
|
||||
if (value === "") {
|
||||
list = { kind: "unset" };
|
||||
} else {
|
||||
const entries = value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
const blockList = new BlockList();
|
||||
const invalid: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const cidr = parseCidr(entry);
|
||||
if (!cidr) {
|
||||
invalid.push(entry);
|
||||
continue;
|
||||
}
|
||||
blockList.addSubnet(cidr.address, cidr.prefix, cidr.family);
|
||||
}
|
||||
list =
|
||||
invalid.length > 0 || entries.length === 0
|
||||
? { kind: "invalid", invalid: invalid.length > 0 ? invalid : [value] }
|
||||
: { kind: "list", entries, blockList };
|
||||
}
|
||||
cached = { raw: value, list };
|
||||
return list;
|
||||
}
|
||||
|
||||
/** Whether a connection from `peer` may report the client's address. */
|
||||
export function peerMayForward(peer: string): boolean {
|
||||
const list = parseTrustedProxyCidrs(process.env.TRUSTED_PROXY_CIDRS);
|
||||
if (list.kind === "unset") return !isPublicIp(peer);
|
||||
if (list.kind === "invalid") return false;
|
||||
const family = isIP(peer) === 6 ? "ipv6" : "ipv4";
|
||||
return list.blockList.check(peer, family);
|
||||
}
|
||||
|
||||
// ── The boot line ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One line naming the rule in force, for the boot log: which peers may
|
||||
* forward, how many hops are read, and what is wrong with a value that
|
||||
* cannot be used.
|
||||
*/
|
||||
export function describeProxyTrust(): {
|
||||
level: "info" | "error";
|
||||
message: string;
|
||||
} {
|
||||
const hopsRaw = process.env.TRUST_PROXY_HOPS;
|
||||
const hops = parseTrustProxyHops(hopsRaw);
|
||||
const list = parseTrustedProxyCidrs(process.env.TRUSTED_PROXY_CIDRS);
|
||||
const problems: string[] = [];
|
||||
if (hops === null) {
|
||||
problems.push(
|
||||
`TRUST_PROXY_HOPS=${JSON.stringify(hopsRaw)} is not a non-negative integer, so no client address is read from forwarding headers`,
|
||||
);
|
||||
}
|
||||
if (list.kind === "invalid") {
|
||||
problems.push(
|
||||
`TRUSTED_PROXY_CIDRS has entries that are not CIDRs (${list.invalid.map((e) => JSON.stringify(e)).join(", ")}), so no connection may report a client address`,
|
||||
);
|
||||
}
|
||||
const peers =
|
||||
list.kind === "list"
|
||||
? `only connections from ${list.entries.join(", ")} (TRUSTED_PROXY_CIDRS)`
|
||||
: list.kind === "unset"
|
||||
? "connections from any loopback, private, CGNAT, unique-local or link-local address (TRUSTED_PROXY_CIDRS unset; behind Docker Desktop, OrbStack or docker-proxy that includes every caller of a published port)"
|
||||
: "no connection";
|
||||
if (problems.length > 0) {
|
||||
return {
|
||||
level: "error",
|
||||
message: `[proxy-trust] ${problems.join("; ")}. Fix the value; see docs/self-hosting/reverse-proxy.md.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
level: "info",
|
||||
message:
|
||||
hops === 0
|
||||
? "[proxy-trust] TRUST_PROXY_HOPS=0: forwarding headers are never read; the client address is the connection's own."
|
||||
: `[proxy-trust] forwarding headers are read from ${peers}, ${hops} hop${hops === 1 ? "" : "s"} deep (TRUST_PROXY_HOPS).`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Log the rule in force once, at boot (`src/instrumentation.ts`). The
|
||||
* logger is a parameter, as in the readiness summary: the production build
|
||||
* strips direct `console.log` calls.
|
||||
*/
|
||||
export function logProxyTrustAtBoot(
|
||||
log: Pick<Console, "log" | "error"> = console,
|
||||
): void {
|
||||
const { level, message } = describeProxyTrust();
|
||||
if (level === "error") log.error(message);
|
||||
else log.log(message);
|
||||
}
|
||||
@@ -64,12 +64,74 @@ function readPng(bytes: Buffer): ImageDimensions | null {
|
||||
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The area a GIF decoder has to allocate: the logical screen, grown to cover
|
||||
* every frame's image descriptor.
|
||||
*
|
||||
* The logical screen alone is not that size. A decoder sizes its buffer from
|
||||
* the frame it decodes, so a file declaring a 10 x 10 screen and a
|
||||
* 20000 x 20000 frame passed a screen-only check and allocated 1.6 GB. Every
|
||||
* block is walked (GIF89a, section 17 onwards): extensions and image data are
|
||||
* skipped sub-block by sub-block, each image descriptor is read, and anything
|
||||
* that is not a block a decoder accepts makes the file unreadable. The walk
|
||||
* is linear in the file's length.
|
||||
*
|
||||
* A file that ends inside a frame's image data is still read up to there,
|
||||
* since decoders draw what they got; one that ends inside a header or a
|
||||
* descriptor, or has no frame at all, has no size.
|
||||
*/
|
||||
function readGif(bytes: Buffer): ImageDimensions | null {
|
||||
// "GIF87a" / "GIF89a", then the logical screen: width, height (LE).
|
||||
if (bytes.length < 10 || bytes.toString("ascii", 0, 4) !== "GIF8") {
|
||||
return null;
|
||||
// "GIF87a" / "GIF89a", then the logical screen descriptor (7 bytes).
|
||||
if (bytes.length < 13) return null;
|
||||
const signature = bytes.toString("ascii", 0, 6);
|
||||
if (signature !== "GIF87a" && signature !== "GIF89a") return null;
|
||||
let width = bytes.readUInt16LE(6);
|
||||
let height = bytes.readUInt16LE(8);
|
||||
const screenFlags = bytes[10]!;
|
||||
let offset = 13;
|
||||
if (screenFlags & 0x80) offset += 3 << ((screenFlags & 0x07) + 1);
|
||||
|
||||
/** Past a run of data sub-blocks; `bytes.length` when the file ends in it. */
|
||||
const skipSubBlocks = (from: number): number => {
|
||||
let at = from;
|
||||
while (at < bytes.length) {
|
||||
const size = bytes[at]!;
|
||||
at += 1;
|
||||
if (size === 0) return at;
|
||||
at += size;
|
||||
}
|
||||
return bytes.length;
|
||||
};
|
||||
|
||||
let frames = 0;
|
||||
while (offset < bytes.length) {
|
||||
const introducer = bytes[offset]!;
|
||||
if (introducer === 0x3b) break; // trailer
|
||||
if (introducer === 0x21) {
|
||||
// Extension: introducer, label, then data sub-blocks.
|
||||
if (offset + 2 > bytes.length) return null;
|
||||
offset = skipSubBlocks(offset + 2);
|
||||
continue;
|
||||
}
|
||||
if (introducer !== 0x2c) return null;
|
||||
// Image descriptor: left, top, width, height (LE), flags.
|
||||
if (offset + 10 > bytes.length) return null;
|
||||
const left = bytes.readUInt16LE(offset + 1);
|
||||
const top = bytes.readUInt16LE(offset + 3);
|
||||
const frameWidth = bytes.readUInt16LE(offset + 5);
|
||||
const frameHeight = bytes.readUInt16LE(offset + 7);
|
||||
const frameFlags = bytes[offset + 9]!;
|
||||
if (frameWidth === 0 || frameHeight === 0) return null;
|
||||
width = Math.max(width, left + frameWidth);
|
||||
height = Math.max(height, top + frameHeight);
|
||||
frames += 1;
|
||||
offset += 10;
|
||||
if (frameFlags & 0x80) offset += 3 << ((frameFlags & 0x07) + 1);
|
||||
// The LZW minimum code size, then the image data sub-blocks.
|
||||
if (offset >= bytes.length) break;
|
||||
offset = skipSubBlocks(offset + 1);
|
||||
}
|
||||
return { width: bytes.readUInt16LE(6), height: bytes.readUInt16LE(8) };
|
||||
return frames > 0 ? { width, height } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -285,6 +285,32 @@ describe("runDocumentAiRun", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reads as interactive work of the run's owner, so the document reader serves it first", async () => {
|
||||
let seen: unknown = null;
|
||||
executeDocumentIndex.mockImplementation(async () => {
|
||||
const { currentPdfWork } = await import("@/lib/documents/pdf-isolation");
|
||||
seen = currentPdfWork();
|
||||
return { ok: true, data: {} };
|
||||
});
|
||||
await runDocumentAiRun("run1", NOW + 2_700_000, now);
|
||||
expect(seen).toMatchObject({ owner: "u1", lane: "interactive" });
|
||||
});
|
||||
|
||||
it("answers a busy document reader with a retryable code and hands the reservation back", async () => {
|
||||
const { PdfReaderUnavailableError } = await import("@/lib/documents/pdfjs");
|
||||
executeDocumentIndex.mockRejectedValue(
|
||||
new PdfReaderUnavailableError("busy"),
|
||||
);
|
||||
expect(await runDocumentAiRun("run1", NOW + 2_700_000, now)).toBe("failed");
|
||||
expect(settleAiRunBudget).toHaveBeenCalledWith("u1", budget, 0, null);
|
||||
expect(failAiRun).toHaveBeenCalledWith(
|
||||
"run1",
|
||||
expect.objectContaining({ status: 503, errorCode: "aiRuns.readerBusy" }),
|
||||
"RUNNING",
|
||||
new Date(NOW),
|
||||
);
|
||||
});
|
||||
|
||||
it("fails a run whose document is gone and hands the reservation back", async () => {
|
||||
loadOwnedDocument.mockResolvedValue(null);
|
||||
expect(await runDocumentAiRun("run1", NOW + 2_700_000, now)).toBe("failed");
|
||||
|
||||
@@ -177,4 +177,47 @@ describe("thumbnailRetryDue", () => {
|
||||
thumbnailRetryDue({ reason: "error", attempts: 3, failedAt: at }, before),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("gives a renderer that ended for no measured reason the ordinary attempts, not a final verdict", async () => {
|
||||
const { thumbnailRetryDue } =
|
||||
await import("../document-thumbnail-backfill");
|
||||
expect(
|
||||
thumbnailRetryDue(
|
||||
{ reason: "interrupted", attempts: 1, failedAt: at },
|
||||
before,
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
thumbnailRetryDue(
|
||||
{ reason: "interrupted", attempts: 3, failedAt: at },
|
||||
before,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(
|
||||
thumbnailRetryDue(
|
||||
{ reason: "resource-limit", attempts: 1, failedAt: at },
|
||||
before,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runDocumentThumbnail when the reader cannot take the document now", () => {
|
||||
it("throws for a busy reader so the job runs again, and records nothing", async () => {
|
||||
generateThumbnail.mockResolvedValue({ ok: false, reason: "reader-busy" });
|
||||
await expect(
|
||||
runDocumentThumbnail({ userId: "user-1", documentId: "doc-1" }),
|
||||
).rejects.toMatchObject({ name: "PdfReaderUnavailableError" });
|
||||
expect(failureUpsert).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("records an interrupted render as a failure the backfill tries again", async () => {
|
||||
generateThumbnail.mockResolvedValue({ ok: false, reason: "interrupted" });
|
||||
await runDocumentThumbnail({ userId: "user-1", documentId: "doc-1" });
|
||||
expect(failureUpsert).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
create: expect.objectContaining({ reason: "interrupted" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,8 @@ import { loadOwnedDocument } from "@/lib/documents/ai-route-support";
|
||||
import { loadDocumentChatText } from "@/lib/documents/content-index";
|
||||
import { refusalAsFailure } from "@/lib/documents/ai-runs/http";
|
||||
import { executeDocumentExtract } from "@/lib/documents/ai-runs/extract-run";
|
||||
import { withPdfWork } from "@/lib/documents/pdf-isolation";
|
||||
import { PdfReaderUnavailableError } from "@/lib/documents/pdfjs";
|
||||
import {
|
||||
DOCUMENT_INDEX_MODEL_CALLS,
|
||||
executeDocumentIndex,
|
||||
@@ -185,6 +187,34 @@ async function armDeadline(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a running run's deadline by `ms`, never past the job's own expiry.
|
||||
* The document reader calls it (`PdfWork.extendDeadline`): the deadline is
|
||||
* model time, and a wait in the reader's queue or the read itself is not, so
|
||||
* a person's read is not timed out because other work held the reader.
|
||||
*/
|
||||
export async function moveRunDeadline(
|
||||
runId: string,
|
||||
ms: number,
|
||||
deadlineAt: number | undefined,
|
||||
): Promise<void> {
|
||||
const delta = Math.round(ms);
|
||||
if (deadlineAt === undefined) {
|
||||
await prisma.$executeRaw`
|
||||
UPDATE document_ai_runs
|
||||
SET expires_at = expires_at + (${delta}::double precision * interval '1 millisecond')
|
||||
WHERE id = ${runId} AND status = 'RUNNING'`;
|
||||
return;
|
||||
}
|
||||
await prisma.$executeRaw`
|
||||
UPDATE document_ai_runs
|
||||
SET expires_at = LEAST(
|
||||
expires_at + (${delta}::double precision * interval '1 millisecond'),
|
||||
${new Date(deadlineAt)}
|
||||
)
|
||||
WHERE id = ${runId} AND status = 'RUNNING'`;
|
||||
}
|
||||
|
||||
async function runDocumentIndex(
|
||||
run: ClaimedAiRun,
|
||||
deadlineAt: number | undefined,
|
||||
@@ -614,7 +644,16 @@ export async function runDocumentAiRun(
|
||||
|
||||
let outcome: AiRunOutcome<AiRunResult>;
|
||||
try {
|
||||
outcome = await RUN_DISPATCH[run.kind](run, deadlineAt, now);
|
||||
// A person asked for this read and is waiting on it: the document
|
||||
// reader serves it ahead of background work, in turn with other people.
|
||||
outcome = await withPdfWork(
|
||||
{
|
||||
owner: run.userId,
|
||||
lane: "interactive",
|
||||
extendDeadline: (ms) => moveRunDeadline(run.id, ms, deadlineAt),
|
||||
},
|
||||
() => RUN_DISPATCH[run.kind](run, deadlineAt, now),
|
||||
);
|
||||
} catch (err) {
|
||||
// Everything that throws here threw before the body took over the
|
||||
// reservation, so it goes back here.
|
||||
@@ -622,6 +661,13 @@ export async function runDocumentAiRun(
|
||||
const failure: AiRunFailure = (err instanceof RunRefused
|
||||
? err.failure
|
||||
: null) ??
|
||||
(err instanceof PdfReaderUnavailableError
|
||||
? {
|
||||
status: 503,
|
||||
errorCode: AI_RUN_ERROR_CODES.readerBusy,
|
||||
message: "The document reader is busy. Try again in a minute.",
|
||||
}
|
||||
: null) ??
|
||||
refusalAsFailure(err) ?? {
|
||||
status: 500,
|
||||
errorCode: AI_RUN_ERROR_CODES.failed,
|
||||
|
||||
@@ -55,6 +55,7 @@ import {
|
||||
upsertContentIndex,
|
||||
} from "@/lib/documents/content-index";
|
||||
import { transcribeDocument } from "@/lib/documents/describe";
|
||||
import { withPdfWork } from "@/lib/documents/pdf-isolation";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
@@ -320,7 +321,10 @@ export async function runContentIndexBackfillForUser(
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const vision = await prepareVisionInput(document, pick.pdfSupported);
|
||||
const vision = await withPdfWork(
|
||||
{ owner: userId, lane: "background" },
|
||||
() => prepareVisionInput(document, pick.pdfSupported),
|
||||
);
|
||||
if (!vision.ok) {
|
||||
// Not vision-indexable after all (undecryptable, unreadable bytes,
|
||||
// or a PDF the rasteriser could not render) — refund and move on so
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
import { STATUS_MODEL_CALLS } from "@/lib/ai/ai-budgets";
|
||||
import { maybeAutoStageLabFacts } from "@/lib/documents/auto-stage-labs";
|
||||
import { indexDocumentContent } from "@/lib/documents/index-document";
|
||||
import { withPdfWork } from "@/lib/documents/pdf-isolation";
|
||||
import type { OcrSchedule } from "@/lib/documents/ocr-plan";
|
||||
import { ocrReadCallMs } from "@/lib/documents/ocr-read";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -50,9 +51,9 @@ export async function runDocumentIndex(
|
||||
const { userId, documentId } = payload;
|
||||
if (!userId || !documentId) return;
|
||||
const localOnly = payload.localOnly === true;
|
||||
const outcome = await indexDocumentContent(userId, documentId, {
|
||||
localOnly,
|
||||
});
|
||||
const outcome = await withPdfWork({ owner: userId, lane: "background" }, () =>
|
||||
indexDocumentContent(userId, documentId, { localOnly }),
|
||||
);
|
||||
annotate({
|
||||
action: { name: "documents.autoIndex.run" },
|
||||
meta: outcome.indexed
|
||||
@@ -72,9 +73,9 @@ export async function runDocumentIndex(
|
||||
options.deadlineAt === undefined
|
||||
? undefined
|
||||
: await stagingSchedule(userId, options.deadlineAt, options.now);
|
||||
await maybeAutoStageLabFacts(userId, documentId, { schedule }).catch(
|
||||
caughtAs("documents.auto_index.lab_staging_failed"),
|
||||
);
|
||||
await withPdfWork({ owner: userId, lane: "background" }, () =>
|
||||
maybeAutoStageLabFacts(userId, documentId, { schedule }),
|
||||
).catch(caughtAs("documents.auto_index.lab_staging_failed"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,6 +43,8 @@ import type { Locale } from "@/lib/i18n/config";
|
||||
import { resolveJobLocale } from "@/lib/i18n/job-locale";
|
||||
import { documentAutoReadEnabled } from "@/lib/documents/document-settings";
|
||||
import { resolveDocumentVisionProvider } from "@/lib/documents/provider-order";
|
||||
import { withPdfWork } from "@/lib/documents/pdf-isolation";
|
||||
import { PdfReaderUnavailableError } from "@/lib/documents/pdfjs";
|
||||
import { encryptDocumentSummary } from "@/lib/documents/store";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
@@ -81,6 +83,25 @@ export interface DocumentSummaryPayload {
|
||||
enqueuedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored original as provider input, read as this account's background
|
||||
* work. A busy reader is not a property of the document: the job throws, and
|
||||
* pg-boss runs it again.
|
||||
*/
|
||||
async function prepareSummaryVision(
|
||||
userId: string,
|
||||
document: Parameters<typeof prepareVisionInput>[0],
|
||||
pick: { pdfSupported: boolean },
|
||||
): Promise<ReturnType<typeof prepareVisionInput>> {
|
||||
const vision = await withPdfWork({ owner: userId, lane: "background" }, () =>
|
||||
prepareVisionInput(document, pick.pdfSupported),
|
||||
);
|
||||
if (!vision.ok && vision.reason === "readerBusy") {
|
||||
throw new PdfReaderUnavailableError("busy");
|
||||
}
|
||||
return vision;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate + persist ONE document's background summary (owner-scoped). Never
|
||||
* throws for an expected outcome — every gate that fails resolves to a tagged
|
||||
@@ -210,7 +231,7 @@ export async function runDocumentSummaryJob(
|
||||
const document = await loadOwnedDocument(userId, documentId);
|
||||
if (!document) return;
|
||||
|
||||
const vision = await prepareVisionInput(document, pick.pdfSupported);
|
||||
const vision = await prepareSummaryVision(userId, document, pick);
|
||||
if (!vision.ok) {
|
||||
// Cannot read this file with the picked provider (decrypt miss, unsupported
|
||||
// type, or a PDF needing an Anthropic provider). Graceful no-op.
|
||||
|
||||
@@ -51,9 +51,15 @@ const THUMBNAILABLE_MIMES = [
|
||||
/**
|
||||
* Render refusals that the same bytes will always get again: the first page
|
||||
* renders blank, the render comes out empty, the image is past the pixel cap
|
||||
* or its size cannot be read, the PDF crossed the renderer's memory or time bound. One attempt settles
|
||||
* them; queueing a document that exhausts the renderer again at every boot
|
||||
* would only repeat the exhaustion.
|
||||
* or its size cannot be read, the document crossed a bound the renderer
|
||||
* measured (its memory ceiling, its time limit, its heap cap). One attempt
|
||||
* settles them; queueing a document that exhausts the renderer again at
|
||||
* every boot would only repeat the exhaustion.
|
||||
*
|
||||
* `interrupted` is deliberately not here: a renderer that ended without a
|
||||
* measured cause may have been taken by the kernel because the whole
|
||||
* container ran short, which says nothing about the document. It gets the
|
||||
* ordinary bounded attempts.
|
||||
*/
|
||||
const FINAL_THUMBNAIL_FAILURES = [
|
||||
"raster-failed",
|
||||
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
decryptDocumentContent,
|
||||
encryptThumbnail,
|
||||
} from "@/lib/documents/store";
|
||||
import { withPdfWork } from "@/lib/documents/pdf-isolation";
|
||||
import { PdfReaderUnavailableError } from "@/lib/documents/pdfjs";
|
||||
import { generateThumbnail } from "@/lib/documents/thumbnail";
|
||||
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
|
||||
import { withBackgroundEvent } from "@/lib/logging/background";
|
||||
@@ -55,10 +57,12 @@ export async function runDocumentThumbnail(
|
||||
if (!userId || !documentId) return;
|
||||
await withBackgroundEvent("job.document_thumbnail", async (evt) => {
|
||||
evt.addMeta("document_id", documentId);
|
||||
await renderThumbnail(userId, documentId, (reason) => {
|
||||
evt.addMeta("thumbnail_missing_reason", reason);
|
||||
evt.elevateLevel("warn");
|
||||
});
|
||||
await withPdfWork({ owner: userId, lane: "background" }, () =>
|
||||
renderThumbnail(userId, documentId, (reason) => {
|
||||
evt.addMeta("thumbnail_missing_reason", reason);
|
||||
evt.elevateLevel("warn");
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -107,6 +111,11 @@ async function renderThumbnail(
|
||||
}
|
||||
|
||||
const result = await generateThumbnail(bytes, document.mimeType);
|
||||
if (!result.ok && result.reason === "reader-busy") {
|
||||
// The reader was taken by other work for longer than a background item
|
||||
// waits. Nothing is wrong with the document: pg-boss runs the job again.
|
||||
throw new PdfReaderUnavailableError("busy");
|
||||
}
|
||||
if (!result.ok) {
|
||||
annotate({
|
||||
action: { name: "documents.thumbnail.run" },
|
||||
|
||||
@@ -20,6 +20,7 @@ import { AI_BUDGETS } from "@/lib/ai/ai-budgets";
|
||||
import { refusalAsFailure } from "@/lib/documents/ai-runs/http";
|
||||
import type { ProviderChainType } from "@/lib/ai/provider-chain";
|
||||
import { meteredReadBudget } from "@/lib/documents/ai-runs/metered-budget";
|
||||
import { readerBusyFailure } from "@/lib/documents/ai-runs/run-support";
|
||||
import {
|
||||
AI_RUN_ERROR_CODES,
|
||||
type AiRunBudget,
|
||||
@@ -221,6 +222,10 @@ export async function executeOcrExtraction(args: {
|
||||
input.pick.pdfSupported,
|
||||
schedule,
|
||||
);
|
||||
if (!plan.ok && plan.busy) {
|
||||
await settle(false);
|
||||
return readerBusyFailure();
|
||||
}
|
||||
if (!plan.ok) {
|
||||
annotate({
|
||||
action: { name: "labs.ocr.fileRejected" },
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
type AIProvider,
|
||||
type CompletionParams,
|
||||
} from "@/lib/ai/types";
|
||||
import { readerBusyFailure } from "@/lib/documents/ai-runs/run-support";
|
||||
import { settleAiRunBudget } from "@/lib/documents/ai-runs/store";
|
||||
import type {
|
||||
AiRunBudget,
|
||||
@@ -232,6 +233,13 @@ export async function executeSupplementLabelExtraction(args: {
|
||||
];
|
||||
} else {
|
||||
const raster = await rasterizePdf(bytes);
|
||||
if (
|
||||
!raster.ok &&
|
||||
(raster.reason === "busy" || raster.reason === "interrupted")
|
||||
) {
|
||||
await settleAiRunBudget(userId, budget, 0, null);
|
||||
return readerBusyFailure();
|
||||
}
|
||||
if (!raster.ok) {
|
||||
await settleAiRunBudget(userId, budget, 0, null);
|
||||
return failure({
|
||||
|
||||
@@ -57,6 +57,7 @@ export const ERROR_CODE_CATALOGUE: Readonly<Record<string, readonly string[]>> =
|
||||
aiRuns: [
|
||||
"aiRuns.failed",
|
||||
"aiRuns.notFound",
|
||||
"aiRuns.readerBusy",
|
||||
"aiRuns.timedOut",
|
||||
"aiRuns.workerUnavailable",
|
||||
],
|
||||
|
||||
@@ -64,7 +64,7 @@ const aiRun = z
|
||||
})
|
||||
.nullable()
|
||||
.describe(
|
||||
"Present once `status` is `FAILED`: the `meta.errorCode`, message and HTTP status the queuing route would have answered with, or one of the run's own codes: `aiRuns.workerUnavailable` (no worker took the run within fifteen minutes), `aiRuns.timedOut` (the read outlived the calls the AI response time allows), `aiRuns.failed` (anything else).",
|
||||
"Present once `status` is `FAILED`: the `meta.errorCode`, message and HTTP status the queuing route would have answered with, or one of the run's own codes: `aiRuns.workerUnavailable` (no worker took the run within fifteen minutes), `aiRuns.timedOut` (the read outlived the calls the AI response time allows), `aiRuns.readerBusy` (the server's document reader was busy past the wait a read gets, or stopped before it answered; nothing was read or charged, try again in a minute), `aiRuns.failed` (anything else).",
|
||||
),
|
||||
createdAt: z.string(),
|
||||
startedAt: z.string().nullable(),
|
||||
|
||||
@@ -288,7 +288,8 @@ export const openApiComponents: NonNullable<ZodOpenApiObject["components"]> = {
|
||||
type: "apiKey",
|
||||
in: "cookie",
|
||||
name: "healthlog_session",
|
||||
description: "Browser session cookie — set by /api/auth/login.",
|
||||
description:
|
||||
"Browser session cookie — set by /api/auth/login. Named `__Host-healthlog_session` wherever the cookie is `Secure` (every HTTPS install); a plain-HTTP install, which cannot hold a `__Host-` cookie, keeps `healthlog_session`.",
|
||||
},
|
||||
},
|
||||
};
|
||||
+2
-2
@@ -1,5 +1,6 @@
|
||||
import { NextResponse, type NextRequest } from "next/server";
|
||||
import { shouldRunWeb } from "@/lib/process-type";
|
||||
import { readSessionCookie } from "@/lib/auth/session-cookie";
|
||||
import {
|
||||
findRetiredRoute,
|
||||
retiredRouteEnvelope,
|
||||
@@ -560,8 +561,7 @@ export function proxy(request: NextRequest) {
|
||||
// can never be session-gated without also being uncacheable.
|
||||
const isSessionGatedPage = !isApiRoute && !isStaticFile && !isPublic;
|
||||
if (isSessionGatedPage) {
|
||||
const hasSession = request.cookies.has("healthlog_session");
|
||||
if (!hasSession) {
|
||||
if (!readSessionCookie(request.cookies)) {
|
||||
return applyBaselineSecurityHeaders(
|
||||
NextResponse.redirect(new URL("/auth/login", request.url)),
|
||||
);
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -540,6 +540,68 @@ describe("the reaper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A run's deadline is model time. The document reader moves it by the time
|
||||
* a read waited for the reader and took, so a person's lab scan is not
|
||||
* timed out because other work held the reader; never past the job's own
|
||||
* expiry, and only while the run is running.
|
||||
*/
|
||||
describe("the deadline while the document reader works", () => {
|
||||
async function runningRun(expiresAt: Date) {
|
||||
const user = await makeUser();
|
||||
await grantConsent(user.id);
|
||||
await signIn(user.id);
|
||||
const queued = await postLabScanText("Glucose 95 mg/dL");
|
||||
const { runId } = queued.body.data as { runId: string };
|
||||
await getPrismaClient().documentAiRun.update({
|
||||
where: { id: runId },
|
||||
data: { status: "RUNNING", expiresAt },
|
||||
});
|
||||
return runId;
|
||||
}
|
||||
const expiry = async (runId: string) =>
|
||||
(
|
||||
await getPrismaClient().documentAiRun.findUniqueOrThrow({
|
||||
where: { id: runId },
|
||||
})
|
||||
).expiresAt.getTime();
|
||||
|
||||
it("moves a running run's deadline by the reader's time, so the reaper leaves it alone", async () => {
|
||||
const { moveRunDeadline } = await import("@/lib/jobs/document-ai-run");
|
||||
const { reapAiRuns } = await import("@/lib/documents/ai-runs/store");
|
||||
const armed = new Date(Date.now() + 5_000);
|
||||
const runId = await runningRun(armed);
|
||||
|
||||
// The slot is granted after a 60 s wait, with 70 s allowed for the read.
|
||||
await moveRunDeadline(runId, 130_000, undefined);
|
||||
expect(await expiry(runId)).toBe(armed.getTime() + 130_000);
|
||||
// Without the move the reaper would have taken it a minute later.
|
||||
expect(
|
||||
(await reapAiRuns(new Date(armed.getTime() + 60_000))).timedOut,
|
||||
).toBe(0);
|
||||
|
||||
// The read took 10 s of its 70: the unused 60 s go back.
|
||||
await moveRunDeadline(runId, -60_000, undefined);
|
||||
expect(await expiry(runId)).toBe(armed.getTime() + 70_000);
|
||||
});
|
||||
|
||||
it("never moves it past the job's own expiry, and leaves a run that is not running alone", async () => {
|
||||
const { moveRunDeadline } = await import("@/lib/jobs/document-ai-run");
|
||||
const armed = new Date(Date.now() + 5_000);
|
||||
const runId = await runningRun(armed);
|
||||
const jobEnds = armed.getTime() + 20_000;
|
||||
await moveRunDeadline(runId, 130_000, jobEnds);
|
||||
expect(await expiry(runId)).toBe(jobEnds);
|
||||
|
||||
await getPrismaClient().documentAiRun.update({
|
||||
where: { id: runId },
|
||||
data: { status: "FAILED" },
|
||||
});
|
||||
await moveRunDeadline(runId, 130_000, undefined);
|
||||
expect(await expiry(runId)).toBe(jobEnds);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a summary in the background", () => {
|
||||
it("marks a stored summary PENDING while queued and READY when it lands", async () => {
|
||||
const user = await makeUser();
|
||||
|
||||
@@ -22,7 +22,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { GET } from "@/app/api/auth/oidc/callback/route";
|
||||
import { encrypt } from "@/lib/crypto";
|
||||
import { OIDC_STATE_COOKIE } from "@/lib/auth/oidc-cookie";
|
||||
import { SESSION_COOKIE_NAME } from "@/lib/auth/session";
|
||||
import { sessionCookieName } from "@/lib/auth/session-cookie";
|
||||
|
||||
import { anonymous, callRoute } from "../../../kit/call-route";
|
||||
import { makeSession, makeUser } from "../../../kit/factories";
|
||||
@@ -312,7 +312,7 @@ describe("GET /api/auth/oidc/callback parity", () => {
|
||||
const session = await makeSession(first);
|
||||
await step("12-duplicate-with-session", {
|
||||
state: null,
|
||||
cookies: { [SESSION_COOKIE_NAME]: session.cookie },
|
||||
cookies: { [sessionCookieName()]: session.cookie },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -382,7 +382,7 @@ describe("GET /api/auth/oidc/callback parity", () => {
|
||||
identity("sub-claimer", "claimer@example.test", true);
|
||||
await step("23-claim-signed-in", {
|
||||
state: { intent: "claim", claimBinding: "binding-1" },
|
||||
cookies: { [SESSION_COOKIE_NAME]: session.cookie },
|
||||
cookies: { [sessionCookieName()]: session.cookie },
|
||||
});
|
||||
await step("24-claim-no-cookie", {
|
||||
state: { intent: "claim", claimBinding: "binding-1" },
|
||||
|
||||
@@ -25,7 +25,7 @@ import { NextRequest } from "next/server";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { openApiComponents, openApiPaths } from "@/lib/openapi/routes";
|
||||
import { SESSION_COOKIE_NAME } from "@/lib/auth/session";
|
||||
import { sessionCookieName } from "@/lib/auth/session-cookie";
|
||||
|
||||
import { cookieJar, headerJar } from "../integration/mock-next-headers";
|
||||
import type { BearerFixture, SessionFixture } from "./factories";
|
||||
@@ -122,7 +122,7 @@ export async function callRoute<T = unknown>(
|
||||
headers.set("authorization", `Bearer ${options.auth.token}`);
|
||||
}
|
||||
if (options.auth.kind === "session") {
|
||||
headers.set("cookie", `${SESSION_COOKIE_NAME}=${options.auth.cookie}`);
|
||||
headers.set("cookie", `${sessionCookieName()}=${options.auth.cookie}`);
|
||||
}
|
||||
|
||||
// `next/headers` is served from the shared jars; fill them for this call
|
||||
@@ -133,7 +133,7 @@ export async function callRoute<T = unknown>(
|
||||
const savedHeaders = new Map(headerJar);
|
||||
cookieJar.clear();
|
||||
if (options.auth.kind === "session") {
|
||||
cookieJar.set(SESSION_COOKIE_NAME, options.auth.cookie);
|
||||
cookieJar.set(sessionCookieName(), options.auth.cookie);
|
||||
}
|
||||
headerJar.delete("authorization");
|
||||
headers.forEach((value, name) => headerJar.set(name.toLowerCase(), value));
|
||||
|
||||
@@ -27,7 +27,8 @@ import type {
|
||||
Workout,
|
||||
} from "@/generated/prisma/client";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { createSession, SESSION_COOKIE_NAME } from "@/lib/auth/session";
|
||||
import { createSession } from "@/lib/auth/session";
|
||||
import { sessionCookieName } from "@/lib/auth/session-cookie";
|
||||
import { issueApiToken } from "@/lib/auth/issue-token";
|
||||
|
||||
import { cookieJar } from "../integration/mock-next-headers";
|
||||
@@ -91,7 +92,7 @@ export async function makeSession(
|
||||
"healthlog-kit",
|
||||
options.mfaVerifiedAt ?? null,
|
||||
);
|
||||
const cookie = cookieJar.get(SESSION_COOKIE_NAME);
|
||||
const cookie = cookieJar.get(sessionCookieName());
|
||||
cookieJar.clear();
|
||||
for (const [name, value] of before) cookieJar.set(name, value);
|
||||
if (!cookie) {
|
||||
|
||||
Reference in new issue
Block a user