mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
Merge pull request #845 from MBombeck/docs/truth-audit
docs: truth audit against the current code — correct and prune the operator docs
This commit is contained in:
28 files changed
+299
-467
No files matched your search
+15
-9
@@ -13,9 +13,10 @@ notes, and machine-readable specs that ship alongside the source:
|
||||
[`api/mcp-capabilities.md`](./api/mcp-capabilities.md) (tools,
|
||||
resources, prompts, write model) and
|
||||
[`api/mcp-skills.md`](./api/mcp-skills.md) (building a connector / skill).
|
||||
- [`ops/`](./ops/) — backup / restore + encryption-key-rotation
|
||||
playbooks. These are the runbooks an operator reaches for during an
|
||||
incident.
|
||||
- [`ops/`](./ops/) — operator runbooks: deploy, backup / restore,
|
||||
encryption-key rotation, password reset and account recovery, env
|
||||
checks, TLS pinning, data repair. These are what an operator reaches
|
||||
for during an incident.
|
||||
- [`self-hosting/`](./self-hosting/) — horizontal-scaling notes
|
||||
(`HEALTHLOG_PROCESS_TYPE=web|worker|all`) and the deploy-pipeline
|
||||
recipe. Routine install steps stay on `docs.healthlog.dev`. Includes
|
||||
@@ -33,14 +34,19 @@ notes, and machine-readable specs that ship alongside the source:
|
||||
managed profiles for somebody with no login and the guardians who look
|
||||
after them, what revocation does and does not undo, and which trust
|
||||
properties of the instance are unchanged by any of it.
|
||||
- [`migration/`](./migration/) — release-by-release migration notes.
|
||||
Read the entry for the version you're upgrading from.
|
||||
- [`audit/`](./audit/) — per-release audit summaries archived for
|
||||
forensic traceability.
|
||||
- [`migration/`](./migration/) — migration notes. Currently only
|
||||
v1.3 to v1.4; later releases are covered by `CHANGELOG.md` and
|
||||
<https://docs.healthlog.dev>.
|
||||
- [`integrations/`](./integrations/) — per-integration reference
|
||||
(Withings, Fitbit, Google Health, WHOOP, Apple Health, AI providers,
|
||||
data import).
|
||||
- [`adr/`](./adr/) — architecture decision records.
|
||||
- [`security/`](./security/) — threat-model notes.
|
||||
- [`diagrams/`](./diagrams/) — architecture diagrams rendered through
|
||||
docs.healthlog.dev.
|
||||
- [`releases/`](./releases/) — release imagery.
|
||||
- [`apple-store-connect-checklist.md`](./apple-store-connect-checklist.md)
|
||||
— the iOS submission gate.
|
||||
- [`doctor-report.md`](./doctor-report.md) — PDF generator design
|
||||
notes.
|
||||
- [`codex-protocol-spec.md`](./codex-protocol-spec.md) — Codex / ChatGPT
|
||||
OAuth protocol reverse-engineering notes.
|
||||
- [`ui-guidelines.md`](./ui-guidelines.md) — UI / a11y house style.
|
||||
|
||||
+39
-29
@@ -1,8 +1,12 @@
|
||||
# HealthLog API Spec
|
||||
|
||||
`openapi.yaml` is the canonical OpenAPI 3.1 description for the HealthLog
|
||||
Next.js API. It is the source of truth for the native iOS client's
|
||||
DTO codegen.
|
||||
`openapi.yaml` is the OpenAPI 3.1 description for the HealthLog
|
||||
Next.js API and the source of truth for the native iOS client's DTO
|
||||
codegen. The file is **generated**: `pnpm openapi:generate` emits it
|
||||
from the Zod registry under `src/lib/openapi/` (`registry.ts` plus the
|
||||
route modules in `src/lib/openapi/routes/`). Do not edit the YAML by
|
||||
hand; the next generation overwrites it, and CI (`openapi:check`)
|
||||
fails the build when the committed YAML drifts from the registry.
|
||||
|
||||
The MCP server is documented separately — it is a distinct contract over
|
||||
JSON-RPC, not part of the REST OpenAPI spec:
|
||||
@@ -34,36 +38,38 @@ npx @redocly/cli build-docs docs/api/openapi.yaml --output docs/api/index.html
|
||||
npx @redocly/cli lint docs/api/openapi.yaml
|
||||
```
|
||||
|
||||
The spec is expected to be lint-clean (errors = 0). Some warnings are
|
||||
intentional:
|
||||
|
||||
- `no-server-example.com` — `http://localhost:3000` is the dev server.
|
||||
- `operation-2xx-response` / `operation-4xx-response` — webhook + redirect
|
||||
endpoints intentionally omit one or the other.
|
||||
- `no-unused-components` — `ReminderPhase` is exposed for client codegen
|
||||
even though no path response references it directly.
|
||||
The spec is expected to be lint-clean (errors = 0). Warnings such as
|
||||
`no-server-example.com` for the localhost dev server are tolerated;
|
||||
re-run the lint to see what it produces against the current file
|
||||
before treating a warning as a regression.
|
||||
|
||||
## Layout
|
||||
|
||||
- **`info`** — title, version (mirrored from `package.json`), description.
|
||||
- **`servers`** — production `https://healthlog.bombeck.io` plus
|
||||
`http://localhost:3000` for local development.
|
||||
- **`tags`** — domain groupings (`Auth`, `Measurements`, `Medications`,
|
||||
`Mood`, `Dashboard`, `Insights`, `Achievements`, `DoctorReport`,
|
||||
`Integrations`, `User`, `Tokens`, `Notifications`, `Onboarding`,
|
||||
`Health`, `Settings`, `Analytics`, `Export`, `AuditLog`,
|
||||
`Monitoring`, `Webhooks`, `Ingest`, `Admin`).
|
||||
- **`info`** — title, description, and `version` mirrored automatically
|
||||
from `package.json` at generation time. Never bump it by hand.
|
||||
- **`servers`** — `https://healthlog.example.com` as the self-hosted
|
||||
placeholder plus `http://localhost:3000` for local development.
|
||||
- **`tags`** — domain groupings; the current set lives at the top of
|
||||
the generated file (`Auth`, `Measurements`, `Medications`, `Mood`,
|
||||
`Analytics`, `Insights`, `Consent`, `Dashboard`, `Notifications`,
|
||||
`Devices`, `Export`, `Sync`, `Cycle`, `MeasurementReminders`,
|
||||
`Labs`, `Illness`, `Documents`, `Records`, `Admin`, `Meta`,
|
||||
`Retired`).
|
||||
- **`securitySchemes`**:
|
||||
- `bearerAuth` — long-lived `hlk_*` API tokens issued via `POST /api/tokens`.
|
||||
Required on `/api/ingest/medication`.
|
||||
- `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.
|
||||
- **`components.schemas`** — DTOs for all routes plus the underlying
|
||||
Prisma models (e.g. `Measurement`, `Medication`, `MedicationIntakeEvent`,
|
||||
`MoodEntry`, `User`, `DashboardLayout`, `ApiEnvelope`, `ApiError`).
|
||||
- **`paths`** — all routes from `src/app/api/**/route.ts`, including
|
||||
the `/api/admin/*` family which is tagged `Admin` and marked internal.
|
||||
- **`components.schemas`** — DTOs for the documented routes plus the
|
||||
underlying Prisma models (e.g. `Measurement`, `Medication`,
|
||||
`MedicationIntakeEvent`, `MoodEntry`, `User`, `DashboardLayout`,
|
||||
`ApiEnvelope`, `ApiError`).
|
||||
- **`paths`** — the client-facing REST surface. Not every
|
||||
`src/app/api/**/route.ts` is in the contract: most `/api/admin/*`
|
||||
routes, the OAuth connect/callback legs, and the webhook surfaces
|
||||
are not documented here. The `Retired` tag lists removed paths that
|
||||
answer `410 Gone`.
|
||||
|
||||
## Conventions
|
||||
|
||||
@@ -79,8 +85,12 @@ intentional:
|
||||
When adding or changing routes:
|
||||
|
||||
1. Update the relevant route handler under `src/app/api/**/route.ts`.
|
||||
2. Mirror the change in `openapi.yaml`. Keep `operationId` camelCase and
|
||||
stable — the iOS codegen keys off it.
|
||||
3. Run `npx @redocly/cli lint docs/api/openapi.yaml` and resolve all
|
||||
errors. Warnings are tolerated when documented above.
|
||||
4. Bump `info.version` if the change is breaking for clients.
|
||||
2. Change the Zod schemas and their `.meta()` registrations under
|
||||
`src/lib/openapi/` (registry entry plus the matching module in
|
||||
`src/lib/openapi/routes/`). Keep `operationId` camelCase and
|
||||
stable; the iOS codegen keys off it.
|
||||
3. Run `pnpm openapi:generate` and commit the regenerated
|
||||
`openapi.yaml` alongside the schema change. CI's `openapi:check`
|
||||
fails on drift.
|
||||
4. Never edit the YAML directly and never bump `info.version` by
|
||||
hand; the version follows `package.json` at generation time.
|
||||
@@ -87,6 +87,9 @@ server-authoritative path; no new analytics is computed at the wire.
|
||||
| `get_integration_status` | Sync health of connected devices/services — connected, last sync, reauth-required/failing — to answer "why is my data stale?". No secrets or tokens. |
|
||||
| `get_preventive_care` | What preventive care is coming up: the user's own configured Vorsorge reminders (upcoming/overdue checkups with next-due dates) plus the appointments booked as future visits. Surfaces configured items, invents nothing. |
|
||||
| `get_visits` | The user's own past doctor visits over a bounded window (default 12 months, optional practitioner-name filter): date, status, kind, practitioner + specialty, the visit's own reason/outcome, and any linked condition labels. `{ present: false }` when none was ever recorded. |
|
||||
| `get_intraday_pulse` | The 10-minute heart-rate shape for one local day (hourly grain outside the dense-retention window), with at most one cautious descriptive elevated-at-rest window. Gated on the `insights` module. |
|
||||
| `get_ecg_recordings` | Metadata for the user's ECG recordings (time, duration, sampling rate, lead, the device's own rhythm classification). Never the waveform, never a re-classification. Gated on the `insights` module. |
|
||||
| `get_nutrients` | Without an argument, the presence overview per logged nutrient code; with one, that code's per-day summed series plus its EFSA reference. Gated on the opt-in `nutrients` module. |
|
||||
| `search` | Free-text search over the user's record (metric domains, medications, lab analytes). Returns `{ results: [{ id, title, url }], nextCursor? }`. |
|
||||
| `fetch` | Hydrate one record by the id `search` returned (`metric:weight`, `med:<id>`, `lab:LDL`). Returns `{ id, title, text, url, metadata }` with a citation deep-link. |
|
||||
|
||||
@@ -189,13 +192,14 @@ completion and listing are user-scoped, so a host only ever sees the
|
||||
metrics / analytes / medications the user actually has — completion
|
||||
doubles as honest discovery.
|
||||
|
||||
| Template | Resolves to |
|
||||
| ------------------------------------------ | --------------------------------------------------------------------------------------- |
|
||||
| `healthlog://metric/{type}` | One metric's series over the default window. |
|
||||
| `healthlog://metric/{type}/{window}` | One metric's series over a chosen trailing window. |
|
||||
| `healthlog://lab/{analyte}` | One lab analyte's recent readings (value, unit, reference range, status). |
|
||||
| `healthlog://medication/{id}` | One medication by id, with its schedules. User-scoped (`{present:false}` if not yours). |
|
||||
| `healthlog://report/doctor-visit/{window}` | The doctor-visit summary over a chosen window. |
|
||||
| Template | Resolves to |
|
||||
| ------------------------------------------ | ----------------------------------------------------------------------------------------- |
|
||||
| `healthlog://metric/{type}` | One metric's series over the default window. |
|
||||
| `healthlog://metric/{type}/{window}` | One metric's series over a chosen trailing window. |
|
||||
| `healthlog://lab/{analyte}` | One lab analyte's recent readings (value, unit, reference range, status). |
|
||||
| `healthlog://medication/{id}` | One medication by id, with its schedules. User-scoped (`{present:false}` if not yours). |
|
||||
| `healthlog://report/doctor-visit/{window}` | The doctor-visit summary over a chosen window. |
|
||||
| `healthlog://nutrient/{code}` | One nutrient's per-day intake series plus its EFSA reference (opt-in `nutrients` module). |
|
||||
|
||||
## Prompts (skills)
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ Plus pre-submit final checks.
|
||||
- [ ] Add the description above to the medical-device-justification field if Apple shows one
|
||||
- [ ] Cross-reference the in-app `/privacy` page (https://<your-instance>/privacy) §7 MDR-boundary
|
||||
|
||||
**Why this matters**: Apple's first-submission rejection rate for health apps is ~40%. The most common reason is unclear medical-device classification. The above wording mirrors the in-app disclaimer + the per-feature gating; consistency between the form and the app is what Apple looks for.
|
||||
**Why this matters**: health apps get rejected on first submission most commonly for unclear medical-device classification. The above wording mirrors the in-app disclaimer + the per-feature gating; consistency between the form and the app is what Apple looks for.
|
||||
|
||||
## Section 2 — Privacy Nutrition Labels
|
||||
|
||||
|
||||
@@ -630,8 +630,9 @@ Confirmed-rejected slugs (despite the source-tree references in the table above)
|
||||
|
||||
Confirmed-accepted slugs on a ChatGPT Plus subscription (per `available_in_plans` in `models.json`):
|
||||
|
||||
- `gpt-5.3-codex` — coding-optimized, accepted; HealthLog uses this as default
|
||||
- `gpt-5.5` / `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.2` — also expected to work per plan
|
||||
- `gpt-5.5` — HealthLog's default (first chain entry) as of 2026-06
|
||||
- `gpt-5.4` / `gpt-5.4-mini` / `gpt-5.2` — accepted per plan
|
||||
- `gpt-5.3-codex` — was the default (verified 2026-05-09); the backend rotated off the `-codex` line on 2026-06-02 and this slug is now only accepted intermittently, so it sits on a late chain rung
|
||||
|
||||
The `codex-rs/tui/src/model_migration.rs` references to `gpt-5-codex` etc. are _migration prompts_ shown to existing users — they are NOT the slugs sent on the wire after migration. The wire slugs come from `models.json`. Always cross-check `models.json` (`gh api repos/openai/codex/contents/codex-rs/models-manager/models.json`) before picking a default.
|
||||
|
||||
@@ -641,17 +642,20 @@ The ChatGPT-Codex allow-list is rotated by OpenAI without prior notice (`gpt-5-c
|
||||
|
||||
To eliminate that risk class, HealthLog's `CodexClient` walks an ordered chain on every fresh request series.
|
||||
|
||||
**Chain** (top = first try; default order is the empirically-safest most-current-first ordering as of 2026-05):
|
||||
**Chain** (top = first try; default order is the empirically-safest most-current-first ordering as of 2026-06, after the backend rotated off the `-codex` line on 2026-06-02):
|
||||
|
||||
```ts
|
||||
const DEFAULT_SLUG_FALLBACK_CHAIN = [
|
||||
"gpt-5.3-codex", // current allow-list entry (verified 2026-05-09)
|
||||
"gpt-5-codex", // historical default (rejected as of 2026-05 but safe to retry — backend may flip)
|
||||
"gpt-5", // bare slug — rejected on ChatGPT-auth as of 2026-05 but lives on api-key auth
|
||||
"gpt-4o", // ladder-rung for last-ditch capability
|
||||
"gpt-5.5", // current default across paid tiers as of 2026-06-02
|
||||
"gpt-5.4", // documented fallback
|
||||
"gpt-5.4-mini", // documented lighter-weight fallback
|
||||
"gpt-5.3-codex", // late rung — still accepted intermittently
|
||||
"gpt-5.2", // legacy floor
|
||||
];
|
||||
```
|
||||
|
||||
The shipped constant in `src/lib/ai/codex-client.ts` is authoritative; when the upstream rotates again, that file moves first and this listing follows.
|
||||
|
||||
The chain is overridable via the env var `CODEX_MODEL_FALLBACK_CHAIN` (comma-separated). The first env-var-named slug supersedes `CODEX_MODEL`; if both are set, `CODEX_MODEL` is folded into position 0 and de-duplicated.
|
||||
|
||||
**Trigger condition** (when to walk to the next slug):
|
||||
|
||||
@@ -45,8 +45,8 @@ fallback for users who skip the setup.
|
||||
## The fallback chain
|
||||
|
||||
`User.aiProviderChain` is a JSON array of providers tried in priority
|
||||
order. The default is encoded in
|
||||
`src/lib/ai/provider-chain.ts:60-66`:
|
||||
order. The default is encoded as `PROVIDER_CHAIN_DEFAULT` in
|
||||
`src/lib/ai/provider-chain.ts`:
|
||||
|
||||
1. `codex` — ChatGPT OAuth (covered by your ChatGPT Plus / Pro
|
||||
subscription; no per-token cost on top).
|
||||
@@ -79,7 +79,7 @@ the default rather than 500-ing.
|
||||
4. In HealthLog, open `/settings/ai`. Set provider to **OpenAI**,
|
||||
paste the key, optionally override the model.
|
||||
|
||||
The default model is `gpt-4o` (`src/lib/ai/provider.ts:80`) — a
|
||||
The default model is `gpt-4o` (see `src/lib/ai/provider.ts`) — a
|
||||
full-size model that keeps the headline Insights assessments grounded
|
||||
and well-reasoned out of the box. Override to a lighter model such as
|
||||
`gpt-4o-mini` if you want to trade some reasoning headroom for a lower
|
||||
@@ -234,7 +234,7 @@ UI setup:
|
||||
`connected`.
|
||||
5. Token refresh runs automatically; the client triggers a refresh
|
||||
on 401 and persists the new tokens transparently
|
||||
(`src/lib/ai/provider.ts:122-217`).
|
||||
(`src/lib/ai/provider.ts`).
|
||||
|
||||
Disconnect cleanly from `/settings/ai` — `POST /api/auth/codex/disconnect`
|
||||
clears the encrypted token columns. The wire-level protocol is
|
||||
@@ -410,12 +410,12 @@ All three variables must also be listed in the compose
|
||||
## Connection test
|
||||
|
||||
`POST /api/ai/test` runs a one-shot probe against the resolved
|
||||
provider and returns the latency in milliseconds plus the model the
|
||||
provider routed to. The Settings UI surfaces a **Test connection**
|
||||
button next to each provider section that fires this endpoint. A
|
||||
failed probe surfaces a localisable `errorCode` so the UI renders
|
||||
the failure in the user's language rather than the raw upstream
|
||||
error.
|
||||
provider and returns the resolved provider type, the model, the token
|
||||
usage, and a short response sample. The Settings UI surfaces a
|
||||
**Test connection** button next to each provider section that fires
|
||||
this endpoint. A failed probe carries a categorised `reasonCode` so
|
||||
the UI renders the failure in the user's language rather than the raw
|
||||
upstream error.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -265,7 +265,8 @@ IMPORT** for cumulative metrics (steps, active energy, distance,
|
||||
flights, sleep, HRV, resting HR) and **WITHINGS ≻ APPLE_HEALTH ≻
|
||||
MANUAL** for point measurements where the Withings device is the
|
||||
primary sensor (weight, BP, body fat, body temperature, SpO₂, VO₂
|
||||
max). The defaults live in `src/lib/validations/source-priority.ts:205-220`.
|
||||
max). The defaults live in `DEFAULT_SOURCE_PRIORITY` in
|
||||
`src/lib/validations/source-priority.ts`.
|
||||
|
||||
Concrete consequences:
|
||||
|
||||
@@ -283,7 +284,7 @@ Concrete consequences:
|
||||
source tag — the lowest rank — is reserved for legacy CSV/JSON
|
||||
imports that pre-date the Apple Health passthrough.
|
||||
|
||||
Override per-user via the Sources section of `/settings/thresholds`.
|
||||
Override per-user via Settings → Sources (`/settings/sources`).
|
||||
|
||||
## Failure modes
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Data import
|
||||
|
||||
HealthLog has three ways to bring data in, all reachable from
|
||||
HealthLog has four ways to bring data in, all reachable from
|
||||
**Settings → Export & Import**:
|
||||
|
||||
1. An **Apple Health `export.zip`** upload — the full archive from the
|
||||
@@ -12,8 +12,11 @@ HealthLog has three ways to bring data in, all reachable from
|
||||
from another tracker. This page documents that format.
|
||||
3. A **CSV import** — one measurement per row, for a spreadsheet or a
|
||||
meter / sensor export. Documented in section 3 below.
|
||||
4. A **medication dose-history import** — its own card in the same
|
||||
panel, posting to `POST /api/medications/intake/dose-history-import`
|
||||
with its own `?dryRun=1` preview.
|
||||
|
||||
All three paths skip rows that already exist, so re-running an import is
|
||||
All paths skip rows that already exist, so re-running an import is
|
||||
safe — it merges rather than duplicating.
|
||||
|
||||
Every import reports what it actually wrote. A file where nothing was
|
||||
@@ -33,9 +36,10 @@ the browser — and a background job parses it while a progress indicator
|
||||
polls for the imported / skipped counts.
|
||||
|
||||
Re-uploading the same archive resolves to the same job by content hash,
|
||||
so it merges instead of creating duplicates. Clinical records
|
||||
(electrocardiograms, lab documents) are intentionally skipped. The full
|
||||
type mapping lives in [`apple-health.md`](./apple-health.md).
|
||||
so it merges instead of creating duplicates. ECG CSVs and metric-typed
|
||||
clinical records are imported too; see
|
||||
[`apple-health.md`](./apple-health.md) for the details and the full
|
||||
type mapping.
|
||||
|
||||
## 2. Generic JSON import
|
||||
|
||||
@@ -56,15 +60,16 @@ skipped and counted under `skipped` in the response.
|
||||
|
||||
### Measurement entry
|
||||
|
||||
| Field | Required | Type | Notes |
|
||||
| ---------------- | -------- | ------ | --------------------------------------------------------------------------------- |
|
||||
| `type` | yes | enum | One of the `MeasurementType` values in the table below. |
|
||||
| `value` | yes | number | Plausibility-checked per type; out-of-range values are rejected. |
|
||||
| `unit` | yes | string | The canonical unit for the type (see the table). |
|
||||
| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. |
|
||||
| `source` | no | string | Free-text origin label. Imported rows are tagged `IMPORT` server-side regardless. |
|
||||
| `notes` | no | string | Optional free text. |
|
||||
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE`. See [Glucose context](#glucose-context). |
|
||||
| Field | Required | Type | Notes |
|
||||
| ---------------- | -------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `type` | yes | enum | One of the `MeasurementType` values in the table below. |
|
||||
| `value` | yes | number | Plausibility-checked per type; out-of-range values are rejected. |
|
||||
| `unit` | yes | string | The canonical unit for the type (see the table). |
|
||||
| `measuredAt` | yes | string | ISO-8601 datetime, e.g. `2026-05-01T08:00:00.000Z`. |
|
||||
| `source` | no | string | Free-text origin label. Imported rows are tagged `IMPORT` server-side regardless. |
|
||||
| `notes` | no | string | Optional free text. |
|
||||
| `glucoseContext` | no | enum | Only for `BLOOD_GLUCOSE`. See [Glucose context](#glucose-context). |
|
||||
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, type, source, externalId)` so an edit upstream is reflected rather than skipped. |
|
||||
|
||||
### Glucose context
|
||||
|
||||
@@ -95,7 +100,7 @@ moment you know it.
|
||||
| `mood` | yes | enum | One of `SUPER_GUT`, `GUT`, `OKAY`, `SCHLECHT`, `LAUSIG`. |
|
||||
| `score` | yes | integer | `1`–`5`. |
|
||||
| `tags` | no | string | Comma-separated tags. |
|
||||
| `loggedAt` | no | string | ISO-8601 datetime; defaults to now. |
|
||||
| `loggedAt` | no | string | ISO-8601 datetime; defaults to the start of the given `date` in the default timezone. |
|
||||
| `externalId` | no | string | A source-stable id (1–120 chars). When present, a re-import upserts on `(user, source, externalId)` so an edit upstream is reflected rather than skipped. |
|
||||
|
||||
### `MeasurementType` values and canonical units
|
||||
@@ -172,9 +177,10 @@ hand-authored.
|
||||
| `WALKING_STEADINESS_EVENT` | event |
|
||||
| `BREATHING_DISTURBANCE_EVENT` | event |
|
||||
|
||||
The live, machine-readable list of writable types and their units is
|
||||
also served by `GET /api/meta/capabilities` under `ingest.quantityTypes`
|
||||
— the authoritative source if this table ever lags a release.
|
||||
The table is a curated subset and lags the enum by a few
|
||||
device-derived types. The live, machine-readable list of writable
|
||||
types and their units is served by `GET /api/meta/capabilities` under
|
||||
`ingest.quantityTypes` and is the authoritative source.
|
||||
|
||||
### Worked example
|
||||
|
||||
@@ -234,15 +240,15 @@ mints a file with the right header.
|
||||
A header row is required; column order does not matter and header
|
||||
matching is case-insensitive.
|
||||
|
||||
| Column | Required | Notes |
|
||||
| ---------------- | -------- | --------------------------------------------------------------------------------------------------------- |
|
||||
| `type` | yes | A `MeasurementType` from the table above. |
|
||||
| `value` | yes | A number, in the row's `unit`. |
|
||||
| `unit` | yes | The canonical unit, or a recognised alias: `mmol/L` → `mg/dL`, `lb` → `kg`, `in` → `cm`, `°F` → `°C`. |
|
||||
| `measuredAt` | yes | ISO-8601 **with an explicit offset** (`Z` or `±HH:MM`, and `±HHMM` is accepted). No offset, no import. |
|
||||
| `glucoseContext` | no | Only for `BLOOD_GLUCOSE`; blank means no context. See [Glucose context](#glucose-context). |
|
||||
| `notes` | no | Free text, up to 200 characters. |
|
||||
| `externalId` | no | A source-stable id. With it, a re-upload updates the same row; without it, a re-upload creates a new one. |
|
||||
| Column | Required | Notes |
|
||||
| ---------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `type` | yes | A `MeasurementType` from the table above. |
|
||||
| `value` | yes | A number, in the row's `unit`. |
|
||||
| `unit` | yes | The canonical unit, or a recognised alias: `mmol/L` → `mg/dL`, `lb` → `kg`, `in` → `cm`, `°F` → `°C`. |
|
||||
| `measuredAt` | yes | ISO-8601 **with an explicit offset** (`Z` or `±HH:MM`, and `±HHMM` is accepted). No offset, no import. |
|
||||
| `glucoseContext` | no | Only for `BLOOD_GLUCOSE`; blank means no context. See [Glucose context](#glucose-context). |
|
||||
| `notes` | no | Free text, up to 200 characters. |
|
||||
| `externalId` | no | A source-stable id. With it, a re-upload updates the same row even when the value changed; without it, an identical re-upload is skipped via the natural key `(user, type, measuredAt, source)`. |
|
||||
|
||||
A sensor export therefore needs no glucose column at all:
|
||||
|
||||
|
||||
@@ -93,8 +93,9 @@ The Fitbit client id/secret are per-user (Settings), not env vars.
|
||||
|
||||
If Fitbit and another source (Withings, WHOOP, Apple Health) both supply the same
|
||||
vital — resting heart rate, blood oxygen, body temperature or respiratory rate —
|
||||
you may see both values until a future update settles on a single preferred
|
||||
source.
|
||||
the source priority ladder in **Settings → Sources** decides which one wins
|
||||
per metric. FITBIT is ranked in every default metric ladder; override
|
||||
per metric on that page.
|
||||
|
||||
## Disconnect
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ triggers the verification + CASA requirements above.)
|
||||
|
||||
If Google Health and another source (Withings, WHOOP, Apple Health) both supply
|
||||
the same vital — resting heart rate, blood oxygen, respiratory rate — the source
|
||||
priority ladder in **Settings → Integrations → Sources** decides which one wins
|
||||
priority ladder in **Settings → Sources** decides which one wins
|
||||
per metric.
|
||||
|
||||
## Disconnect
|
||||
|
||||
@@ -73,7 +73,7 @@ only when the OAuth callback must differ from that default.
|
||||
|
||||
When HealthLog calls Withings `Notify.subscribe()` for a newly-linked
|
||||
account, it hands Withings the callback URL constructed by
|
||||
`getWithingsWebhookCallbackUrl()` (`src/lib/withings/sync.ts:38-48`).
|
||||
`getWithingsWebhookCallbackUrl()` (`src/lib/withings/sync.ts`).
|
||||
The shape is:
|
||||
|
||||
```
|
||||
@@ -107,8 +107,9 @@ With the credentials saved in Settings, open
|
||||
5. The settings page now shows **Connected** with the timestamp of
|
||||
the last successful sync.
|
||||
|
||||
Subsequent syncs run automatically on every webhook delivery plus a
|
||||
safety-net cron pull every few hours.
|
||||
Subsequent syncs run automatically on every webhook delivery plus an
|
||||
hourly safety-net cron pull (measurements at :00, sleep at :15, ECG
|
||||
at :41).
|
||||
|
||||
## Source-priority interaction
|
||||
|
||||
@@ -117,7 +118,7 @@ for point measurements (weight, BP, pulse, body fat, body
|
||||
temperature, SpO₂, VO₂ max) and **APPLE_HEALTH ≻ WITHINGS ≻ MANUAL**
|
||||
for cumulative metrics (steps, active energy, distance, flights,
|
||||
sleep, HRV, resting HR). The defaults live in
|
||||
`src/lib/validations/source-priority.ts:205-220`.
|
||||
`DEFAULT_SOURCE_PRIORITY` in `src/lib/validations/source-priority.ts`.
|
||||
|
||||
Concrete consequences if you run Withings alongside an Apple Health
|
||||
import:
|
||||
@@ -130,7 +131,7 @@ import:
|
||||
when Apple Health is also linked, so the Apple Health stream wins
|
||||
for the cumulative axis. The Withings rows stay in the DB.
|
||||
|
||||
Override per-user via the Sources section of `/settings/thresholds`.
|
||||
Override per-user via Settings → Sources (`/settings/sources`).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
@@ -169,7 +170,7 @@ fixes the mismatch by updating the URL Withings holds.
|
||||
Withings revokes refresh tokens on password change, app un-authorise,
|
||||
or after extended inactivity. The `WithingsConnection` row flips to
|
||||
`error_reauth` status and the scheduled sync no-ops until the user
|
||||
re-runs the OAuth flow (`src/lib/withings/sync.ts:155-161`).
|
||||
re-runs the OAuth flow (`src/lib/withings/sync.ts`).
|
||||
Click **Connect Withings** again from the settings page; the
|
||||
callback exchanges fresh tokens and the next sync runs through the
|
||||
last 30 days of history to catch up.
|
||||
@@ -179,20 +180,23 @@ last 30 days of history to catch up.
|
||||
The Settings page **Disconnect** button:
|
||||
|
||||
1. Calls `POST /api/withings/disconnect`.
|
||||
2. Server-side: revokes the access token via Withings'
|
||||
`Notify.revoke`, deletes the `WithingsConnection` row, and audits
|
||||
the action.
|
||||
2. Server-side: unsubscribes the webhook categories (best-effort
|
||||
Withings `Notify` revoke per category), deletes the
|
||||
`WithingsConnection` row with its stored encrypted tokens, and
|
||||
audits the action. The Withings-side grant itself persists until
|
||||
the user un-authorises HealthLog in their Withings account.
|
||||
3. Existing `Measurement` rows tagged `source = WITHINGS` stay in
|
||||
the database — disconnect does not delete data. Re-linking the
|
||||
same Withings account merges back onto the same metric
|
||||
timeline; the upsert path matches on `(userId, type, measuredAt,
|
||||
source)` so previously-synced rows update in place.
|
||||
|
||||
## Admin probe
|
||||
## Connection test
|
||||
|
||||
`POST /api/integrations/withings/test` (admin-only) runs a
|
||||
read-only round-trip against the user's stored credentials and
|
||||
returns the latency in milliseconds. Use it from the admin panel
|
||||
when a user reports stale data — a green test plus a stale
|
||||
`lastSyncedAt` usually means the webhook URL Withings holds is out
|
||||
of sync (re-subscribe per the steps above).
|
||||
`POST /api/integrations/withings/test` runs a read-only round-trip
|
||||
against the calling user's own stored credentials and returns the
|
||||
latency in milliseconds (rate-limited to 5/min per user). The
|
||||
Withings settings card surfaces it as the connection test. Use it
|
||||
when data looks stale — a green test plus a stale `lastSyncedAt`
|
||||
usually means the webhook URL Withings holds is out of sync
|
||||
(re-subscribe per the steps above).
|
||||
@@ -7,6 +7,12 @@ new env var in §Environment variables below has a safe default. This
|
||||
guide documents what changes for operators and integrators, in case
|
||||
you're extending the API or scaling the deployment.
|
||||
|
||||
> Historical note: route paths on this page reflect v1.4. Several have
|
||||
> since moved or gone: `/api/admin/status-overview` is now
|
||||
> `/api/admin/status`, the Umami probe is
|
||||
> `/api/admin/monitoring/umami-test`, and the moodLog integration was
|
||||
> removed entirely.
|
||||
|
||||
## TL;DR
|
||||
|
||||
```bash
|
||||
@@ -64,9 +70,10 @@ worker`. Default `all` keeps the single-container behaviour.
|
||||
- **`ENCRYPTION_KEY`** _(unchanged)_ — still honoured as a fallback
|
||||
when `ENCRYPTION_KEYS` is empty. No action needed for existing
|
||||
installs.
|
||||
- Rotation: `pnpm tsx scripts/rotate-encryption-key.ts <newKeyId>`
|
||||
- Rotation: `pnpm dlx tsx scripts/rotate-encryption-key.ts <newKeyId>`
|
||||
re-encrypts every ciphertext under the new key without downtime.
|
||||
Walk-through in `docs/ops/encryption-key-rotation.md`.
|
||||
Walk-through in `docs/ops/encryption-key-rotation.md` (the current
|
||||
playbook; the production image strips `tsx`, hence `pnpm dlx`).
|
||||
|
||||
### Off-host backup target _(optional)_
|
||||
|
||||
|
||||
@@ -17,7 +17,8 @@ that captures how many failures the bucket ledger has accumulated since
|
||||
the last success.
|
||||
|
||||
The persistent-failure alert dispatcher in
|
||||
`src/lib/integrations/status.ts:453` uses `Math.max(...buckets)` — the
|
||||
`src/lib/integrations/status.ts` (the v1.4.43 W14 block) uses
|
||||
`Math.max(...Object.values(buckets))` — the
|
||||
**max** across the three buckets. A 3-deep persistent streak pages even
|
||||
when the transient bucket is empty, and a flapping integration that
|
||||
ping-pongs between transient and reauth_required never trips a
|
||||
@@ -44,7 +45,8 @@ we want to wake someone up for.
|
||||
|
||||
If you see an audit row with `attemptNumber >= threshold` but no alert
|
||||
was dispatched, check the per-bucket counts on
|
||||
`IntegrationStatus.failureBuckets` for that user/integration pair. The
|
||||
`IntegrationStatus.consecutiveFailuresByKind` (DB column
|
||||
`consecutive_failures_by_kind`) for that user/integration pair. The
|
||||
sum may be high while every individual bucket sits below the threshold.
|
||||
This is working as designed; manually investigate only if a single
|
||||
bucket is at or above the threshold and `alertedAt` is unset (broken
|
||||
|
||||
@@ -54,12 +54,15 @@ performs a 1-byte PUT + GET round-trip and returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"endpoint": "https://...r2.cloudflarestorage.com",
|
||||
"bucket": "healthlog-backups",
|
||||
"region": "auto",
|
||||
"putLatencyMs": 142,
|
||||
"getLatencyMs": 38,
|
||||
"ok": true
|
||||
"data": {
|
||||
"endpoint": "https://...r2.cloudflarestorage.com",
|
||||
"bucket": "healthlog-backups",
|
||||
"region": "auto",
|
||||
"putLatencyMs": 142,
|
||||
"getLatencyMs": 38,
|
||||
"ok": true
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
+8
-5
@@ -16,10 +16,12 @@ canonical command sequence on a single-instance host:
|
||||
# 1. Pull the new image.
|
||||
docker pull ghcr.io/mbombeck/healthlog:vX.Y.Z
|
||||
|
||||
# 2. Run migrations against the live DB using the new image.
|
||||
# 2. Run migrations against the live DB using the new image. The
|
||||
# production image strips pnpm/npm; use the entrypoint's node
|
||||
# invocation of the bundled Prisma CLI instead.
|
||||
docker run --rm --env-file .env.production \
|
||||
ghcr.io/mbombeck/healthlog:vX.Y.Z \
|
||||
pnpm prisma migrate deploy
|
||||
sh -c 'NODE_PATH=/opt/prisma-cli/node_modules node /opt/prisma-cli/node_modules/prisma/build/index.js migrate deploy'
|
||||
|
||||
# 3. Recreate the app container so it picks up the new image.
|
||||
docker compose up -d app
|
||||
@@ -40,9 +42,10 @@ them in order on boot like any other.
|
||||
WHOOP introduces two optional instance env vars, `WHOOP_REDIRECT_URI`
|
||||
and `WHOOP_WEBHOOK_SECRET` (HMAC secret for WHOOP webhook signature
|
||||
verification). Per-user WHOOP client id/secret live in Settings, not
|
||||
in the environment. See `docs/integrations/whoop.md`. Add the two vars
|
||||
to the compose `environment:` whitelist if you set them — vars not on
|
||||
the whitelist never reach the container.
|
||||
in the environment. See `docs/integrations/whoop.md`. The shipped
|
||||
`docker-compose.yml` already whitelists both under `environment:`; only
|
||||
a forked or older compose file needs them added by hand, since vars not
|
||||
on the whitelist never reach the container.
|
||||
|
||||
### What goes wrong if you reverse it
|
||||
|
||||
|
||||
@@ -47,8 +47,13 @@ under `v1` (the synthetic id assigned to the existing `ENCRYPTION_KEY`).
|
||||
pnpm dlx tsx scripts/rotate-encryption-key.ts v2
|
||||
```
|
||||
The script is idempotent — running it again is a no-op for rows already
|
||||
prefixed with `v2.`. It rotates User, WithingsConnection, AppSettings,
|
||||
NotificationChannel, and PushSubscription rows. The summary line at the
|
||||
prefixed with `v2.`. It rotates every registered encrypted column across
|
||||
all models, plus the two non-`*Encrypted` members:
|
||||
`IntegrationStatus.lastError` and `CoachMessage.encryptedContent`
|
||||
(Bytes). The canonical registry lives in
|
||||
`src/lib/crypto/encrypted-columns.ts`; a guard test fails CI if any
|
||||
`*Encrypted` column in the schema is missing from the registry or not
|
||||
referenced by the script. The summary line at the
|
||||
end shows `scanned`, `rotated`, and `errors` per table+field; treat
|
||||
`errors > 0` as a hard failure and re-run after fixing the cause.
|
||||
4. **Drop the old key (optional, recommended).** Once the script has run
|
||||
|
||||
+19
-12
@@ -46,8 +46,10 @@ states:
|
||||
| `[missing-optional]` | Variable is optional. The script reports it but does not fail. |
|
||||
|
||||
The grep-friendly format lets a CI pipeline do `grep -q
|
||||
'\[MISSING-REQUIRED\]'` to gate a deploy. The CI integration itself
|
||||
is deferred to v1.4.43 — this release ships the CLI tool only.
|
||||
'\[MISSING-REQUIRED\]'` to gate a deploy. CI already does: the
|
||||
`env-check.yml` workflow runs `pnpm check-env --file
|
||||
.env.production.example` on every PR and fails on missing required
|
||||
vars.
|
||||
|
||||
## All-or-none groups
|
||||
|
||||
@@ -65,11 +67,15 @@ Example output for a partial APNs config:
|
||||
[OK] APNS_TEAM_ID
|
||||
[OK] APNS_BUNDLE_ID
|
||||
[missing-optional] APNS_KEY — Satisfied by any of: APNS_KEY, APNS_KEY_FILE
|
||||
[MISSING-REQUIRED] <all-or-none> — Group is all-or-none but only 3/4 variables are set. Either set all or unset all.
|
||||
```
|
||||
|
||||
A reviewer immediately sees that `APNS_KEY` (or its `APNS_KEY_FILE`
|
||||
alternative) is missing while the other three APNs vars are set — the
|
||||
exact configuration that silently disables iOS push.
|
||||
The APNs group is `allOrNone: true`, so the partial config also emits
|
||||
the synthetic `<all-or-none>` row at REQUIRED severity and the run
|
||||
exits 1. A reviewer immediately sees that `APNS_KEY` (or its
|
||||
`APNS_KEY_FILE` alternative) is missing while the other three APNs
|
||||
vars are set — the exact configuration that silently disables iOS
|
||||
push.
|
||||
|
||||
## Editing the manifest
|
||||
|
||||
@@ -88,12 +94,13 @@ so changes go through a PR. To add a new variable:
|
||||
if the manifest change introduces a new classification branch
|
||||
(anyOf, allOrNone, …).
|
||||
|
||||
## Future: CI gate
|
||||
## CI gate
|
||||
|
||||
Deferred to v1.4.43:
|
||||
The GitHub Actions workflow `.github/workflows/env-check.yml` runs
|
||||
`pnpm check-env --file .env.production.example` on every PR, blocking
|
||||
changes that introduce new required vars without updating the example
|
||||
file.
|
||||
|
||||
- GitHub Actions workflow that runs `pnpm check-env --file
|
||||
.env.production.example` on every PR, blocking changes that
|
||||
introduce new required vars without updating the example file.
|
||||
- Coolify pre-deploy hook that runs `pnpm check-env` inside the
|
||||
target container and aborts the deploy on exit code 1.
|
||||
Still an idea only: a Coolify pre-deploy hook that runs
|
||||
`pnpm check-env` inside the target container and aborts the deploy on
|
||||
exit code 1.
|
||||
@@ -2,9 +2,13 @@
|
||||
|
||||
A self-hosted operator can reset a single user's password from inside the
|
||||
running container. This is an **operator-only** maintenance path — there is no
|
||||
user-facing or API route for it, and it bypasses the normal account-recovery
|
||||
flow. Use it only for an account you administer (for example, after a forgotten
|
||||
password on a single-user instance).
|
||||
self-service reset route for users, and it bypasses the normal
|
||||
account-recovery flow. Admins do have an in-app alternative:
|
||||
`POST /api/admin/users/{id}/reset-password` (cookie-admin only) sets a new
|
||||
password and destroys the user's sessions, tokens, and trusted devices. The
|
||||
CLI remains the path when no admin can log in. Use it only for an account you
|
||||
administer (for example, after a forgotten password on a single-user
|
||||
instance).
|
||||
|
||||
> **Operator-only.** The script writes directly to the `users` table and is
|
||||
> intended to be run by the host operator with database access. Treat the new
|
||||
|
||||
+10
-11
@@ -1,11 +1,10 @@
|
||||
# TLS SPKI pinning — coupling, alarm, and re-pin runbook
|
||||
|
||||
The native client SPKI-pins the server's TLS chain at the **CA level**. It
|
||||
ships a small pin set — currently the issuing intermediate plus the root —
|
||||
and accepts a connection only when the served chain passes baseline X.509
|
||||
evaluation AND at least one certificate anywhere in the chain matches a pin
|
||||
(any-match). The leaf is deliberately **not** pinned, so routine leaf
|
||||
renewals are a client no-op. The outage risk is a **chain change**: the
|
||||
The native client SPKI-pins the server's TLS chain at the **CA level**: it
|
||||
ships a small pin set and rejects a connection whose validated chain
|
||||
contains no pinned key. The leaf is deliberately **not** pinned, so routine
|
||||
leaf renewals are a client no-op. The pin set itself and its enforcement
|
||||
rules live in the client repository, not here. The outage risk is a **chain change**: the
|
||||
served chain stops terminating in the pinned CA keys — the server moves to a
|
||||
different CA, or the CA rotates its intermediate / root keys. This page
|
||||
explains the coupling, the server-side alarm that watches the served leaf,
|
||||
@@ -15,11 +14,11 @@ and the operator / release-owner steps when it fires.
|
||||
|
||||
A SPKI pin is `base64(sha256(DER subjectPublicKeyInfo))` — a hash of a
|
||||
certificate's public key, not of the whole certificate. The native client
|
||||
carries a set of these pins (build-time config, ≥ 2 well-formed pins
|
||||
enforced for release builds) and rejects any TLS connection whose validated
|
||||
chain contains no pinned key. Hosts outside the client's pinned-host list
|
||||
fall back to plain system-trust validation, which is the documented model
|
||||
for self-hosted instances that ship their own chain.
|
||||
carries a set of these pins in its build-time config and rejects any TLS
|
||||
connection whose validated chain contains no pinned key; the exact pin-set
|
||||
rules are the client repository's contract. Self-hosted instances that ship
|
||||
their own chain sit outside the pinned-host list and validate against
|
||||
system trust.
|
||||
|
||||
The app host's certificate auto-renews roughly every 90 days and each
|
||||
renewal mints a new leaf keypair. Because the client pins the CA keys rather
|
||||
|
||||
@@ -1,260 +0,0 @@
|
||||
# v1.4.x follow-up issues
|
||||
|
||||
Items identified by the v1.4.1 audit pass that warrant deeper work
|
||||
than a hardening release should land. Each one carries enough context
|
||||
that a future contributor can pick it up without re-running the audit.
|
||||
|
||||
## Security — deferred
|
||||
|
||||
### S-FOLLOW-1 — Idempotency-Key concurrent duplicate side-effects (HIGH)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-security-audit.md` H1.
|
||||
|
||||
The `withIdempotency()` wrapper currently does
|
||||
`findCached → handler → persistCached`. Two requests with the same
|
||||
`Idempotency-Key` arriving in parallel both miss the SELECT, both
|
||||
execute the handler (duplicate measurements / intake events), and
|
||||
one INSERT loses on the unique constraint silently. The repo-doc
|
||||
contract ("retries with the same Idempotency-Key … replay the
|
||||
original response — no second side-effect") is therefore violated
|
||||
whenever a flaky mobile client double-sends.
|
||||
|
||||
**Fix shape**: acquire the slot before the handler runs.
|
||||
`INSERT … ON CONFLICT DO NOTHING RETURNING id` reserving the
|
||||
`(userId, key, method, path)` row with a sentinel
|
||||
`responseStatus = 0`, then UPDATE on completion; concurrent losers
|
||||
SELECT-loop with backoff for the row to be filled. Or wrap the
|
||||
whole request in a Postgres advisory lock keyed by hash(userId, key)
|
||||
via `pg_advisory_xact_lock`.
|
||||
|
||||
Why deferred: needs a careful integration test against the new
|
||||
testcontainers suite (two concurrent inserts → exactly one handler
|
||||
invocation) and a public-API contract check that the
|
||||
sentinel-status row never bleeds into a 2xx replay. Fits a v1.4.2
|
||||
PR scope on its own.
|
||||
|
||||
---
|
||||
|
||||
### S-FOLLOW-2 — Encryption key fallback NODE_ENV gate (MEDIUM)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-security-audit.md` M1.
|
||||
|
||||
`src/lib/crypto.ts:61-74` only requires the 64-hex production key
|
||||
when `process.env.NODE_ENV === "production"`. A fresh Docker run
|
||||
that forgets to set `NODE_ENV=production` (raw `node …` invocations,
|
||||
some operator setups) silently accepts a 32-character key and pads
|
||||
it deterministically with a SHA-256 of itself, halving the entropy
|
||||
and making the resulting key recoverable from any leaked partial.
|
||||
|
||||
**Fix shape**: invert the gate to `NODE_ENV !== "development" &&
|
||||
NODE_ENV !== "test"` — fail-closed by default — or require an
|
||||
explicit `ALLOW_WEAK_DEV_KEYS=1` opt-in.
|
||||
|
||||
Why deferred: needs a migration note for self-hosters whose `.env`
|
||||
files lack `NODE_ENV` (default Coolify deploys do set it; bespoke
|
||||
setups may not). Worth a release note + .env.example call-out.
|
||||
|
||||
---
|
||||
|
||||
### S-FOLLOW-3 — Refresh-token reuse-detection serialisation (MEDIUM)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-security-audit.md` M2.
|
||||
|
||||
`src/lib/auth/refresh-token.ts` reuse-detection is a `findUnique`
|
||||
followed by a non-transactional `findMany + updateMany`. Concurrent
|
||||
first-time refreshes can briefly have both clients holding
|
||||
unrevoked access tokens before the loser's are revoked, and a
|
||||
parallel reuse attempt doubles the audit-log noise.
|
||||
|
||||
**Fix shape**: wrap the rotation in a single `prisma.$transaction`
|
||||
with `Serializable` isolation, OR reorder to "claim first, mint
|
||||
second": `prisma.refreshToken.update({ where: { id, usedAt: null } })`
|
||||
fails atomically if the row was already consumed; only after the
|
||||
update succeeds, call `issueAccessAndRefresh`.
|
||||
|
||||
Why deferred: needs an integration test that fires N parallel
|
||||
refreshes for the same token and asserts exactly one new pair is
|
||||
issued. The testcontainers suite is the right home; v1.4.2 PR.
|
||||
|
||||
---
|
||||
|
||||
### S-FOLLOW-4 — moodLog webhook HMAC lookup column (MEDIUM)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-security-audit.md` M3.
|
||||
|
||||
`src/app/api/integrations/moodlog/webhook/route.ts` performs an
|
||||
O(n) AES-GCM decrypt sweep across all enabled users for every
|
||||
webhook hit. Side-channels: timing oracle for ordering, DoS
|
||||
amplification (30/min IP rate-limit × n decrypts).
|
||||
|
||||
**Fix shape**: add a `moodLogWebhookSecretLookupHash` column on
|
||||
`User` (HMAC-SHA-256 keyed by `API_TOKEN_HMAC_KEY`, populated
|
||||
alongside the encrypted secret on every write). Webhook does a
|
||||
single indexed `findUnique` on the lookup hash, bypassing the
|
||||
candidate iteration. The encryption stays as defence-in-depth.
|
||||
|
||||
Why deferred: schema migration required. Backfill needs a one-off
|
||||
script that decrypts every existing row and writes the lookup
|
||||
hash. Owners need to approve the migration window. v1.4.2 or
|
||||
v1.5.
|
||||
|
||||
---
|
||||
|
||||
### S-FOLLOW-5 — moodLog `readMoodLogSecret` legacy fallback (MEDIUM)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-security-audit.md` M4.
|
||||
|
||||
If `decrypt()` throws inside `src/lib/moodlog-secret.ts:27-37`, the
|
||||
function returns the stored value as-is. Intent: legacy plaintext
|
||||
rows keep working. Side effect: an attacker with read access to
|
||||
the DB obtains the ciphertext blob and can submit it as the literal
|
||||
webhook secret.
|
||||
|
||||
**Fix shape**: detect "looks like a v1.4 envelope"
|
||||
(`/^[A-Za-z0-9_-]{1,32}\..+$/`) and refuse the legacy fallback for
|
||||
envelope-shaped values. Log a metric so the rotation-on-write
|
||||
contract can be verified to drain.
|
||||
|
||||
Why deferred: needs a small data-audit run to confirm no production
|
||||
row has a non-envelope value before the strict check goes live.
|
||||
|
||||
---
|
||||
|
||||
### S-FOLLOW-6 — Restore script writes decrypted JSON with default permissions (MEDIUM)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-security-audit.md` M5.
|
||||
|
||||
`scripts/restore-backup.ts:73-122` writes the decrypted JSON via
|
||||
`writeFileSync(out, plaintext, "utf8")` with default mode (0644
|
||||
typical). The file contains decrypted PHI for the restored user.
|
||||
|
||||
**Fix shape**:
|
||||
`writeFileSync(out, plaintext, { encoding: "utf8", mode: 0o600 })`.
|
||||
Optional: warn if `out` is in a world-readable directory.
|
||||
|
||||
Why deferred: tiny one-line fix; bundling with the next ops-script
|
||||
update keeps the PR coherent.
|
||||
|
||||
---
|
||||
|
||||
## Performance — deferred
|
||||
|
||||
### P-FOLLOW-1 — Recharts top-level import on `/insights` (P0)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-performance-audit.md` P0.
|
||||
|
||||
`src/app/insights/page.tsx:42-50` static-imports
|
||||
`{ ScatterChart, Scatter, XAxis, … } from "recharts"` even though
|
||||
`HealthChart` and `MoodChart` 8 lines above are already wrapped in
|
||||
`dynamic(…, { ssr: false })`. The static import pulls the whole
|
||||
recharts bundle into the route's first JS payload, defeating the
|
||||
dynamic split.
|
||||
|
||||
**Fix shape**: extract the BP-mood scatter into
|
||||
`src/components/charts/bp-mood-scatter.tsx` and import it via
|
||||
`dynamic(…, { ssr: false })` like the others.
|
||||
|
||||
Why deferred: trivial mechanical change but needs a Lighthouse
|
||||
before/after to quantify the bundle-size win for the v1.4.2
|
||||
release notes.
|
||||
|
||||
---
|
||||
|
||||
### P-FOLLOW-2 — `/api/insights/targets` unbounded glucose history scan (P0)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-performance-audit.md` P0.
|
||||
|
||||
`src/app/api/insights/targets/route.ts:600-604` pulls every glucose
|
||||
measurement the user ever logged (no `gte: thirtyDaysAgo`) and
|
||||
filters in JS. Diabetic users with multi-year history will
|
||||
materialise tens of thousands of rows on every targets fetch.
|
||||
|
||||
**Fix shape**: split into "latest per context"
|
||||
(`distinct: ["glucoseContext"], orderBy: { measuredAt: "desc" }`)
|
||||
and a `measuredAt: { gte: thirtyDaysAgo }` window query, mirroring
|
||||
the pattern at lines 102-121 for `latestEverByType`.
|
||||
|
||||
Why deferred: needs a per-context test asserting the new query
|
||||
returns the same payload shape. Pairs nicely with the MoodEntry
|
||||
index migration (P-FOLLOW-3).
|
||||
|
||||
---
|
||||
|
||||
### P-FOLLOW-3 — `MoodEntry @@index` is on `(userId, date)` not `(userId, moodLoggedAt)` (P0)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-performance-audit.md` P0.
|
||||
|
||||
`prisma/schema.prisma:438` declares `@@index([userId, date])`, but
|
||||
active queries sort/filter by `moodLoggedAt`:
|
||||
|
||||
- `src/app/api/insights/targets/route.ts:495`
|
||||
- `src/app/api/insights/comprehensive/route.ts:52`
|
||||
- `src/app/api/export/route.ts:87`
|
||||
|
||||
The unique constraint `@@unique([userId, date, moodLoggedAt])` is
|
||||
not selectable by Postgres for windowed `moodLoggedAt`-range reads.
|
||||
|
||||
**Fix shape**: add `@@index([userId, moodLoggedAt])` (do NOT drop
|
||||
the existing index — the unique still serves the moodlog webhook
|
||||
upsert path). New migration `0026_moodentry_moodloggedat_index`.
|
||||
|
||||
Why deferred: schema migration. Owner approval for the migration
|
||||
window.
|
||||
|
||||
---
|
||||
|
||||
### P-FOLLOW-4 to -7 — P1 / P2 perf items
|
||||
|
||||
- N+1 count() per medication on admin reminder-check
|
||||
(`src/app/api/admin/notifications/reminder-check/route.ts:84`).
|
||||
Replace with `groupBy` on `medicationIntakeEvent`.
|
||||
- `/api/analytics` 8-parallel full-history scan
|
||||
(`src/app/api/analytics/route.ts:20-40`). Single `findMany` with
|
||||
`type: { in: types }` and JS-side group.
|
||||
- `/api/import` sequential `prisma.measurement.create` per row
|
||||
(`src/app/api/import/route.ts:77-96`). Replace with
|
||||
`createMany({ skipDuplicates: true })`.
|
||||
- `pairByTimestamp` not used in `/api/analytics` BP correlation
|
||||
(`src/app/api/analytics/route.ts:80-86`). Replace the quadratic
|
||||
loop with the helper from `@/lib/analytics/correlations`.
|
||||
|
||||
---
|
||||
|
||||
## Test-quality polish (5 minor items)
|
||||
|
||||
Audit reference: `~/infra/reports/v14-test-theatre-audit.md`.
|
||||
|
||||
- `src/app/api/gamification/__tests__/ios-format.test.ts` L111-112:
|
||||
upgrade `toBeDefined()` to shape predicates.
|
||||
- `src/lib/analytics/__tests__/classifications.test.ts` L169-170:
|
||||
add `toMatch(/60/)` body-content assertion to compliance-alert tests.
|
||||
- `src/lib/__tests__/idempotency.test.ts` L145: upgrade bare
|
||||
`toHaveBeenCalled()` to `toHaveBeenCalledWith({ where: …key… })`.
|
||||
- `src/app/api/telegram/webhook/__tests__/route.test.ts` L457: same
|
||||
shape — upgrade or drop.
|
||||
|
||||
---
|
||||
|
||||
## i18n hardcoded strings
|
||||
|
||||
Audit reference: `~/infra/reports/v14-i18n-drift-audit.md`.
|
||||
|
||||
35+ hardcoded strings in `src/components/settings/ai-section.tsx`
|
||||
need to be moved into `messages/{en,de}.json` keys. The settings
|
||||
section was extracted in #143 with strings inline as a known
|
||||
follow-up; this is the cleanup PR.
|
||||
|
||||
Why deferred: low blast-radius (only the AI provider settings page
|
||||
shows English to a German user; the dashboard, dialogs, and
|
||||
critical UX paths all flow through `t(…)`). v1.4.x or v1.5.
|
||||
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
These are the items the v1.4.1 audit identified that the hardening
|
||||
release deliberately did NOT take. The audit also found 0 hallucinated
|
||||
medical claims, 0 i18n key-parity gaps, and the test theatre rate
|
||||
across the entire codebase was low (5 minor patterns). The codebase
|
||||
is in healthy shape; this list is the punch-list for the next
|
||||
proactive iteration, not a backlog of bugs we know about and ignore.
|
||||
@@ -95,10 +95,15 @@ fails soft.
|
||||
|
||||
```yaml
|
||||
volumes:
|
||||
- /srv/healthlog/geolite2:/var/lib/geolite2:ro
|
||||
- ./geolite2:/opt/geolite2:ro
|
||||
```
|
||||
|
||||
3. Point the runtime at the container path:
|
||||
Swap the host side (`./geolite2`) for your directory, e.g.
|
||||
`/srv/healthlog/geolite2`.
|
||||
|
||||
3. `/opt/geolite2` is the default value of `GEOLITE2_DIR`, so mounting
|
||||
at that container path needs no env var at all. Only if you mount at
|
||||
a different container path do you set it:
|
||||
|
||||
```env
|
||||
GEOLITE2_DIR="/var/lib/geolite2"
|
||||
@@ -163,7 +168,8 @@ and it is served without a session for exactly that reason.
|
||||
**System status still says the online provider.** The readiness check
|
||||
requires `GeoLite2-City.mmdb` to exist in `GEOLITE2_DIR` and no `.empty`
|
||||
marker file beside it. Check the path from inside the container:
|
||||
`docker compose exec app ls -l /var/lib/geolite2`. An empty listing means
|
||||
`docker compose exec app ls -l /opt/geolite2` (or your `GEOLITE2_DIR`
|
||||
value). An empty listing means
|
||||
the bind mount did not land; a `.empty` file means the image was built
|
||||
without a licence key and you are still reading the baked-in directory
|
||||
rather than your own.
|
||||
|
||||
@@ -17,7 +17,8 @@ already installed, longer if a TLS-fronted public hostname is in scope
|
||||
- **Outbound HTTPS** to `ghcr.io` (image pulls) plus whichever
|
||||
integration endpoints you plan to enable (Withings, OpenAI, etc.).
|
||||
The opt-in environmental-context module additionally reaches
|
||||
`api.open-meteo.com` for weather and daylight; it stays off until you
|
||||
`archive-api.open-meteo.com` and `geocoding-api.open-meteo.com` for
|
||||
weather and daylight; it stays off until you
|
||||
enable it and set a home location, and every outbound call goes
|
||||
through the SSRF-guarded egress wrapper.
|
||||
- **Optional but recommended:** a reverse proxy that terminates TLS
|
||||
@@ -191,8 +192,9 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build
|
||||
`app_settings.singleton.registrationEnabled`. The admin panel can
|
||||
flip it back if a previous owner closed it.
|
||||
- **Service unreachable on `localhost:3000`.** Check `docker compose
|
||||
ps` — the `app` healthcheck polls `/api/version` every 30 seconds
|
||||
and will mark the container unhealthy if Postgres is still
|
||||
ps` — the `app` healthcheck polls `/api/health` every 30 seconds
|
||||
(it returns 503 while the database or worker is down) and will mark
|
||||
the container unhealthy if Postgres is still
|
||||
starting. Tail `docker compose logs db` to confirm Postgres came up
|
||||
cleanly.
|
||||
- **OAuth callbacks loop back to localhost.** Confirm `APP_URL` and
|
||||
|
||||
@@ -30,7 +30,7 @@ Three things must be true before `/mcp` will answer:
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **`APP_URL` is set** | The endpoint fails closed without a pinned canonical origin. `APP_URL` (or `NEXT_PUBLIC_APP_URL`) is the origin every OAuth document and audience check is anchored to — `Host` is never trusted. Without it the surface stays invisible (`404`). |
|
||||
| **The global API is on** | The operator's instance-wide API switch must be enabled (it is by default). With it off, every token path — including `/mcp` — returns `404`. |
|
||||
| **The `mcp` module is on** | A per-account opt-in. It is the only module that ships dark; you turn it on in Settings. Until then `/mcp` answers `404` for that account. |
|
||||
| **The `mcp` module is on** | A per-account opt-in that ships off by default; you turn it on in Settings. Until then `/mcp` answers `404` for that account. |
|
||||
|
||||
`APP_URL` is the same origin you already use for passkeys and invite
|
||||
links — for example `https://health.example`. It must be reachable by the
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
The bundled `docker-compose.yml` exposes HealthLog on plain HTTP at
|
||||
`localhost:3000`. Production deployments front the container with a
|
||||
reverse proxy that terminates TLS, sets the forwarded headers the
|
||||
rate-limiter trusts, and preserves the WebSocket upgrade path the
|
||||
streaming Coach surface depends on.
|
||||
rate-limiter trusts, and passes the Coach's Server-Sent Events stream
|
||||
through unbuffered with a generous read timeout.
|
||||
|
||||
This guide gives copy-paste blocks for five common fronts. Pick one;
|
||||
the env-var pairing is identical across them.
|
||||
@@ -21,6 +21,12 @@ 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.
|
||||
|
||||
Note for bundled-compose users: `TRUST_PROXY_HOPS` is not on the
|
||||
shipped `docker-compose.yml` `environment:` whitelist, and a var not on
|
||||
the whitelist never reaches the container. If you need a non-default
|
||||
value, add `TRUST_PROXY_HOPS: "${TRUST_PROXY_HOPS:-1}"` to the `app`
|
||||
service's `environment:` block yourself.
|
||||
|
||||
### `TRUST_PROXY_HOPS` — what to set it to
|
||||
|
||||
`src/lib/api-response.ts` (`getClientIp`) reads the `X-Forwarded-For`
|
||||
@@ -56,8 +62,8 @@ your-instance.example.com {
|
||||
}
|
||||
```
|
||||
|
||||
Caddy handles the WebSocket upgrade automatically. `flush_interval -1`
|
||||
disables response buffering so the streaming insight events surface
|
||||
`flush_interval -1`
|
||||
disables response buffering so the SSE events surface
|
||||
without batching delays. With Caddy as the only hop, leave
|
||||
`TRUST_PROXY_HOPS=1`.
|
||||
|
||||
@@ -78,8 +84,9 @@ services:
|
||||
- "traefik.http.routers.healthlog.middlewares=healthlog-headers"
|
||||
```
|
||||
|
||||
Traefik forwards `X-Forwarded-For` by default and respects the WebSocket
|
||||
upgrade. Pair with `TRUST_PROXY_HOPS=1`. If Traefik sits behind
|
||||
Traefik forwards `X-Forwarded-For` by default and does not buffer
|
||||
responses, so the SSE stream passes through as-is. Pair with
|
||||
`TRUST_PROXY_HOPS=1`. If Traefik sits behind
|
||||
Cloudflare, set it to `2` so the rate-limiter reads the real client IP
|
||||
that Cloudflare placed in the chain.
|
||||
|
||||
@@ -91,8 +98,9 @@ In the NPM UI:
|
||||
2. **Domain Names:** `your-instance.example.com`.
|
||||
3. **Scheme:** `http`; **Forward Hostname / IP:** the Docker host or
|
||||
internal hostname; **Forward Port:** `3000`.
|
||||
4. Enable **Websockets Support** (required for streaming Coach
|
||||
responses).
|
||||
4. **Websockets Support** may stay on or off; the Coach stream is
|
||||
SSE over plain HTTP, not WebSockets. What matters is the buffering
|
||||
and timeout config in step 6.
|
||||
5. **SSL tab:** request a Let's Encrypt certificate, force SSL.
|
||||
6. **Advanced tab — Custom Nginx Configuration:**
|
||||
|
||||
|
||||
@@ -88,10 +88,14 @@ endless spinner.
|
||||
|
||||
## Healthchecks
|
||||
|
||||
- `app` (web) healthcheck: `wget /api/version` every 30s.
|
||||
- `app-worker` does not expose a port; its liveness is reported into
|
||||
the `worker_status` table on every reminder pass and surfaced through
|
||||
`/api/admin/status`.
|
||||
- `app` (web) healthcheck: `wget /api/health` every 30s.
|
||||
- `app-worker` does not expose a port; its liveness is tracked
|
||||
in-memory inside the process (`src/lib/jobs/worker-status.ts`) and
|
||||
read back by `/api/admin/status`. In a web/worker split this means
|
||||
the web container cannot see the separate worker container's
|
||||
liveness: `/api/admin/status` only reports the worker embedded in
|
||||
the container that serves the request. Watch the worker container's
|
||||
own logs and restart policy instead.
|
||||
|
||||
The web container does NOT depend on the worker, and vice versa, so
|
||||
neither container's startup can deadlock the other. The shared
|
||||
@@ -112,13 +116,17 @@ dependency is Postgres; both wait on `db: condition: service_healthy`.
|
||||
|
||||
## Postgres connection-pool sizing
|
||||
|
||||
Every container that boots the HealthLog image opens its own
|
||||
`pg.Pool` against the configured `DATABASE_URL`. The pool ceiling is
|
||||
**20 connections per container by default** (raised from the library
|
||||
default of 10 in v1.4.40 to keep the dashboard's analytics fan-out
|
||||
from starving every other query during a cold mount).
|
||||
Every container that boots the HealthLog image owns a connection
|
||||
budget against the configured `DATABASE_URL`. The budget is
|
||||
**20 connections per container by default** and is split between the
|
||||
Prisma pool and pg-boss (2 connections). The bundled compose exposes
|
||||
the knob as `DB_CONNECTION_LIMIT`, baked into `DATABASE_URL` as
|
||||
`connection_limit`; `DATABASE_POOL_MAX` remains a working explicit
|
||||
override. Resolution order: `DB_CONNECTION_LIMIT`, then
|
||||
`DATABASE_POOL_MAX`, then the `connection_limit` URL parameter, then
|
||||
the default of 20.
|
||||
|
||||
Plan total Postgres slots as **container_count × `DATABASE_POOL_MAX`**.
|
||||
Plan total Postgres slots as **container_count × budget**.
|
||||
A stock Postgres 16 container ships with `max_connections = 100`, so
|
||||
the safe envelope is:
|
||||
|
||||
@@ -139,9 +147,10 @@ transaction-pooling mode.
|
||||
|
||||
### Overriding the per-container pool ceiling
|
||||
|
||||
Set `DATABASE_POOL_MAX` to a positive integer on every container that
|
||||
needs a non-default ceiling — both web and worker pick up the same
|
||||
env var:
|
||||
With the bundled compose, set `DB_CONNECTION_LIMIT` in `.env`; it
|
||||
flows into `DATABASE_URL`'s `connection_limit`. On a hand-rolled
|
||||
setup, `DATABASE_POOL_MAX` as a plain env var works the same way on
|
||||
every container, web and worker alike:
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml — example for a 6-container deployment
|
||||
@@ -176,6 +185,7 @@ tenant, starving every other dashboard query for the duration. The
|
||||
fan-out — keeps 16 slots free for the rest of the dashboard while
|
||||
still sitting well under Postgres's 100-slot stock ceiling.
|
||||
|
||||
The implementation lives in `src/lib/db.ts → getPoolMax()`; the
|
||||
The implementation lives in `src/lib/db.ts → getConnectionBudget()`
|
||||
(split into `getPrismaPoolMax()` and `getPgBossPoolMax()`); the
|
||||
20-slot default was chosen after measuring on a 4-CPU production
|
||||
container — see the inline rationale in `getPoolMax()`.
|
||||
container — see the inline rationale in `src/lib/db.ts`.
|
||||
@@ -115,9 +115,11 @@ Client registration essentials only — the IdP's own docs cover the rest.
|
||||
(the routes return 403; hiding the buttons is not the boundary).
|
||||
Consequences to accept before turning it on:
|
||||
|
||||
- **The native iOS app cannot sign in.** It authenticates via
|
||||
password/passkey only; there is no native SSO flow yet. Do not enable
|
||||
`OIDC_ONLY` on a deployment with active iOS users.
|
||||
- **The native iOS app needs a build that ships the SSO flow.** The app
|
||||
signs in via the native OIDC handoff (`GET /api/auth/oidc/login?client=native`
|
||||
with PKCE and the fixed `healthlog://` callback) or via password/passkey.
|
||||
`OIDC_ONLY` works with app builds that carry the handoff; older
|
||||
password/passkey-only builds are locked out.
|
||||
- **Existing sessions survive.** The flag gates new sign-ins, not live
|
||||
sessions — nobody is thrown out at flip time, including
|
||||
password-authenticated sessions.
|
||||
|
||||
@@ -7,7 +7,8 @@ Unraid UI, but because CA templates describe a single container you install
|
||||
PostgreSQL separately and wire the two together.
|
||||
|
||||
HealthLog stores all persistent state in PostgreSQL — the app container itself is
|
||||
stateless (the offline geo-IP databases are baked into the image). A backup of
|
||||
stateless (the offline geo-IP databases are either baked in at build time or
|
||||
mounted read-only; replaceable either way, not state). A backup of
|
||||
the database is a backup of everything.
|
||||
|
||||
## What you need
|
||||
|
||||
+16
-19
@@ -320,24 +320,18 @@ tile itself, to avoid accidental hide.
|
||||
|
||||
### 3.3 Settings — `/settings/[section]`
|
||||
|
||||
The 1.4 release splits the legacy 3150-LOC settings monolith into one
|
||||
route per top-level section:
|
||||
The 1.4 release split the legacy 3150-LOC settings monolith into one
|
||||
route per top-level section. The pattern holds: every section is a
|
||||
route under `/settings/[section]` with its component under
|
||||
`src/components/settings/`. The section list has grown far past the
|
||||
original eight (account, integrations, notifications, ai, api,
|
||||
advanced, about now sit alongside security, access, sources, coach,
|
||||
environment, export, mcp, and more); the registry in
|
||||
`src/components/settings/settings-shell.tsx` is authoritative.
|
||||
Cross-section search lives in the section sidebar header.
|
||||
|
||||
- `/settings/account` — profile, password, passkeys, account deletion.
|
||||
- `/settings/integrations` — Withings, Nightscout, Telegram, ntfy, Web Push.
|
||||
- `/settings/notifications` — channel matrix + quiet hours + reminders.
|
||||
- `/settings/dashboard` — tile layout editor, default ranges, threshold
|
||||
overrides.
|
||||
- `/settings/ai` — AI provider selection + connection + test.
|
||||
- `/settings/api` — API tokens for headless ingest + Bearer issuance.
|
||||
- `/settings/advanced` — exports, imports, danger zone.
|
||||
- `/settings/about` — version, build, license, links to docs/changelog.
|
||||
|
||||
Each section's component lives at `src/components/settings/<section>.tsx`
|
||||
and stays under 400 LOC. Cross-section search lives in the section
|
||||
sidebar header.
|
||||
|
||||
The legacy `/settings` route 301-redirects to `/settings/account`.
|
||||
The legacy `/settings` route is a permanent (308) redirect to
|
||||
`/settings/account`.
|
||||
|
||||
### 3.4 Admin — `/admin/[section]`
|
||||
|
||||
@@ -505,8 +499,11 @@ is the point.
|
||||
|
||||
## 7. Internationalization
|
||||
|
||||
All user-facing strings come from `messages/en.json` (default) and
|
||||
`messages/de.json`. Use `useTranslations()` on the client and
|
||||
All user-facing strings come from `messages/en.json` (default) plus
|
||||
the `de`, `es`, `fr`, `it`, and `pl` bundles beside it. Guard tests
|
||||
(`i18n-call-site-coverage`, `i18n-locale-integrity`) enforce that every
|
||||
`t()` key resolves in EN and propagates across all six locales. Use
|
||||
`useTranslations()` on the client and
|
||||
`getServerTranslator()` (`src/lib/i18n/server-translator.ts`) on the
|
||||
server. Numbers, dates, currencies, and units always go through
|
||||
`useFormatters()` — never hand-roll `Intl.NumberFormat(...)` with a
|
||||
|
||||
Reference in new issue
Block a user