diff --git a/.env.example b/.env.example index 20f090f7b..e1bc76e38 100644 --- a/.env.example +++ b/.env.example @@ -210,6 +210,20 @@ API_TOKEN_HMAC_KEY="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32" # BACKUP_ENCRYPTION_KEY="" # 64-hex-char or base64 32-byte; SEPARATE from ENCRYPTION_KEY +# ----------------------------------------------------------------------------- +# Audit-trail retention (optional) +# ----------------------------------------------------------------------------- +# How long rows in `audit_logs` are kept. Default 365 days; the floor is 7 and +# anything below it is ignored, so a window set in seconds by mistake cannot +# empty a fresh table. A daily pg-boss job does the pruning. +# +# The number is not internal. Settings -> Shared access states it under the +# activity list, and the dialog that ends somebody's access says how long +# "who entered what" stays answerable. Both read the value the server +# resolved, so an instance running 90 says 90 everywhere. +# AUDIT_LOG_RETENTION_DAYS="365" + + # ----------------------------------------------------------------------------- # APNs — Apple Push Notification service (v1.4.23+, optional) # ----------------------------------------------------------------------------- diff --git a/.env.production.example b/.env.production.example index 76c50df27..93aa99e1e 100644 --- a/.env.production.example +++ b/.env.production.example @@ -184,6 +184,17 @@ API_TOKEN_HMAC_KEY="" # SMTP_SECURE="false" +# ----------------------------------------------------------------------------- +# Session cookie Secure flag -- the most common self-host snag +# ----------------------------------------------------------------------------- +# Unset, the flag tracks NODE_ENV, so a production image sets `Secure` and a +# browser on plain HTTP silently drops the session cookie: login appears to +# succeed and every following request is anonymous. Set it to false ONLY on a +# LAN, Tailscale or VPN-only host that deliberately serves HTTP. Behind a TLS +# reverse proxy, leave it alone. +# SESSION_COOKIE_SECURE="false" + + # ----------------------------------------------------------------------------- # Deploy webhook -- Coolify -> HealthLog auto-deploy feedback # ----------------------------------------------------------------------------- @@ -230,6 +241,21 @@ API_TOKEN_HMAC_KEY="" # BACKUP_ENCRYPTION_KEY="" +# ----------------------------------------------------------------------------- +# Audit-trail retention -- optional +# ----------------------------------------------------------------------------- +# How long rows in `audit_logs` survive, in days. Default 365. The floor is 7: +# a smaller value is ignored rather than obeyed, so a window entered in seconds +# by mistake cannot empty a fresh table. A daily job does the pruning. +# +# This number is shown to users, not just enforced. Settings -> Shared access +# prints the window under the activity list, and the dialog that ends somebody +# else's access states how long "who entered what" stays answerable. Both take +# the value the server resolved from this variable, so an instance running 90 +# says 90 on both surfaces. +# AUDIT_LOG_RETENTION_DAYS="365" + + # ----------------------------------------------------------------------------- # FHIR health-record export -- optional # ----------------------------------------------------------------------------- diff --git a/docker-compose.yml b/docker-compose.yml index 503e87ed8..345c19d88 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -98,6 +98,16 @@ x-healthlog-env: &healthlog_env BACKUP_S3_SECRET_KEY: "${BACKUP_S3_SECRET_KEY:-}" BACKUP_S3_REGION: "${BACKUP_S3_REGION:-auto}" BACKUP_RETENTION_DAYS: "${BACKUP_RETENTION_DAYS:-30}" + # How long the audit trail is kept, in days. Default 365, floor 7 (a value + # below the floor is ignored rather than obeyed, so a window set in seconds + # by mistake cannot empty a fresh table). It is not only a retention knob: + # the shared-access panel PUBLISHES this number — "who entered what stays + # answerable for N days" is on the dialog that ends somebody's access, and + # the activity view states the same window. An instance running 90 says 90, + # so the var has to actually reach the container. Listed here for the reason + # the block exists: `environment:` is a whitelist, and a value in .env that + # is not named here never arrives. + AUDIT_LOG_RETENTION_DAYS: "${AUDIT_LOG_RETENTION_DAYS:-}" # Per-export ceiling on MedicationAdministration rows in the FHIR # health-record Bundle. Bounded 1..50000, default 5000. Listed here so # an operator override in .env actually reaches the container (the diff --git a/src/__tests__/published-env-reaches-the-container.test.ts b/src/__tests__/published-env-reaches-the-container.test.ts new file mode 100644 index 000000000..1d7ca6afa --- /dev/null +++ b/src/__tests__/published-env-reaches-the-container.test.ts @@ -0,0 +1,101 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +/** + * An environment variable the product SHOWS a user has to reach the container. + * + * ## The failure this exists for + * + * `docker-compose.yml`'s `environment:` block is a whitelist. Docker Compose + * reads `.env` for `${VAR}` substitution, so a value an operator sets there + * appears to be configured, and a variable not named in the block never + * arrives in the process. v1.5.2 shipped that exact defect for + * `SESSION_COOKIE_SECURE`: documented, settable, and silently absent, which on + * a plain-HTTP self-host reads as "login is broken". + * + * `AUDIT_LOG_RETENTION_DAYS` is a sharper version of the same class, because + * the number is not merely enforced — it is PUBLISHED. `GET + * /api/account/grants` and `GET /api/account/activity` both send it, the + * activity list prints it, and the dialog that ends somebody's access states + * how long "who entered what" stays answerable. An operator who sets 90 and + * does not get it told every user 365, in a sentence about their own data. + * + * ## What this checks, and what it cannot + * + * The three places an operator meets a variable: the compose whitelist, the + * self-host example, and the production example. It cannot check that the + * value is honoured — `getAuditLogRetentionDays()` has its own tests — only + * that a value set the documented way is not dropped on the floor between + * `.env` and the process. + * + * The list is deliberately short. It is not every variable in the manifest: + * `scripts/check-env.ts` owns that question and CI runs it against + * `.env.production.example`. This is the narrower one that check-env cannot + * answer, because it reads a file rather than the compose whitelist. + */ +const ROOT = process.cwd(); +const COMPOSE = readFileSync(join(ROOT, "docker-compose.yml"), "utf8"); +const ENV_EXAMPLE = readFileSync(join(ROOT, ".env.example"), "utf8"); +const PROD_EXAMPLE = readFileSync( + join(ROOT, ".env.production.example"), + "utf8", +); + +/** + * Variables whose value the product states back to a user. + * + * A variable earns a place here by being readable somewhere in the interface, + * not by being important. That is the property that makes a missing whitelist + * entry a lie rather than an inconvenience. + */ +const PUBLISHED_TO_USERS = [ + { + name: "AUDIT_LOG_RETENTION_DAYS", + shownBy: "the shared-access activity window and the revoke dialog", + }, + { + name: "SESSION_COOKIE_SECURE", + shownBy: + "nothing directly — the original of this defect class, kept as a control", + }, +] as const; + +describe("a variable the product publishes reaches the container", () => { + it("has a matcher that can fail", () => { + // The pins. Every leg below asserts a substring is PRESENT, and a file + // that failed to load would be an empty string in which nothing is + // present — so the failure would be loud rather than silent. These make + // the fixtures' own size the evidence. + expect(PUBLISHED_TO_USERS.length).toBeGreaterThan(1); + expect(COMPOSE).toContain("environment:"); + expect(ENV_EXAMPLE.length).toBeGreaterThan(1000); + expect(PROD_EXAMPLE.length).toBeGreaterThan(1000); + // And a name nothing declares is genuinely absent from all three, so the + // matchers below are not passing on a substring that is everywhere. + for (const file of [COMPOSE, ENV_EXAMPLE, PROD_EXAMPLE]) { + expect(file).not.toContain("HEALTHLOG_NOT_A_REAL_VARIABLE"); + } + }); + + for (const { name, shownBy } of PUBLISHED_TO_USERS) { + describe(name, () => { + it(`is on the compose whitelist, so a value in .env arrives (${shownBy})`, () => { + // The substitution form, not merely the name: a bare mention in a + // comment is how this class of defect hides. + expect(COMPOSE).toMatch( + new RegExp(`^\\s*${name}:\\s*"\\$\\{${name}`, "m"), + ); + }); + + it("is documented in the self-host example", () => { + expect(ENV_EXAMPLE).toContain(name); + }); + + it("is documented in the production example", () => { + expect(PROD_EXAMPLE).toContain(name); + }); + }); + } +});