mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
v1.4.34.4 — audit-driven hotfix bundle (security, UX, code-quality, docs)
Consolidated hotfix landing the findings from a six-axis audit
covering mobile security, UX + accessibility, code quality + docs,
README + discoverability, web performance, and mobile-deep responsive
behaviour. None of the changes break the public API or the existing
data shape; every fix is additive and SAFE under the v1.4.34.x web
freeze. Full audit reports live in `.planning/round-audit-*.md`.
Security
- Password rotation now wipes API tokens + refresh tokens in the
same `$transaction` as session revocation. A stolen iOS Bearer no
longer survives a user-initiated password change or an admin reset.
- `/.well-known/` proxy bypass tightened to an explicit-allowlist
`Set`. Stale `/api/auth/codex/callback` PUBLIC_PATHS entry dropped.
- HSTS gains `preload`. Withings host removed from the global CSP
and gated to the integration's own routes.
- `getClientIp` warns once per process on `TRUST_PROXY_HOPS`/XFF
mismatch + exposes a tagged helper for tighter universal buckets.
- Service worker rejects off-origin notification-click URLs. Offline
fallback body switched to a locale-neutral icon. Manifest gains
`scope`, `id`, `display_override`.
UX + accessibility
- i18n leaks closed on Dialog/Sheet close-X, ChartSkeleton's loading
announcement, and the notifications-section breadcrumb.
- Focus rings on bottom-nav, the More overflow button, and the
mobile top-bar user menu.
- Tap-target floor: Input / NativeSelect / Select / DateTimeInput
switch to `h-11 sm:h-10`.
- Admin chip-strip auto-scroll-into-view (matches v1.4.33 settings).
- List-page subtitles visible on mobile.
- Insights empty-state CTA vocabulary normalised so the link opens
the right measurement form (was silently failing for
BODY_TEMPERATURE / BLOOD_GLUCOSE / PULSE).
Mobile-deep
- `viewport.themeColor` reacts to light/dark mode.
- TopBar + Coach FAB reserve safe-area-inset-top / -bottom for
notched iPhones.
- Sonner toaster switches to `theme="system"`.
Code quality
- `SESSION_SECRET` dropped — the env var was documented as required
but never read by code. Three secrets, not four. Self-hosters
upgrading can delete the existing line.
- `toJson<T>()` helper consolidates seven Prisma JSON-cast sites.
- `db-compat.ts` gains a header docstring naming it the
schema-bootstrap path.
- Passkey response types adopted from `@simplewebauthn/server`;
`any` + eslint-disable pragmas drop out.
- Zero-TODO CI gate added at `.github/workflows/no-todo-markers.yml`.
Documentation + discoverability
- README rework: How-it-compares matrix, Apple Health + AI Coach
promoted to standalone Key-Features bullets, status block, latest
release + GHCR badges, OpenAPI pointer, three-secret Quick Start.
- openGraph + twitter cards now carry `images` and a
`summary_large_image` Twitter card.
- GitHub repo metadata: description tightened from 290 to 156
chars + keyword-front, `homepageUrl` flipped from a personal
tenant to the demo site, six generic / PII-adjacent topics
swapped for six high-intent ones.
- Five new user-facing docs: `docs/self-hosting/getting-started.md`,
`docs/self-hosting/reverse-proxy.md`,
`docs/integrations/apple-health.md`,
`docs/integrations/withings.md`,
`docs/integrations/ai-providers.md`.
- `docs/README.md` clarifies the docs tree as internal playbooks
vs the user-facing `docs.healthlog.dev` site.
This commit is contained in:
1 parent
2ef7f04e89
commit
eea8199cb0
68 files changed
+3904
-123
No files matched your search
+2
-5
@@ -4,10 +4,10 @@
|
||||
# Copy this file to .env and fill in the values.
|
||||
# $ cp .env.example .env
|
||||
#
|
||||
# Required variables: DATABASE_URL or POSTGRES_PASSWORD, SESSION_SECRET,
|
||||
# Required variables: DATABASE_URL or POSTGRES_PASSWORD,
|
||||
# ENCRYPTION_KEY, API_TOKEN_HMAC_KEY
|
||||
#
|
||||
# Generate the three secrets with:
|
||||
# Generate the two secrets with:
|
||||
# $ openssl rand -hex 32 # any UNIX shell
|
||||
# $ node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))'
|
||||
#
|
||||
@@ -44,9 +44,6 @@ NODE_ENV="development"
|
||||
# -----------------------------------------------------------------------------
|
||||
# Required Secrets — generate with `openssl rand -hex 32`
|
||||
# -----------------------------------------------------------------------------
|
||||
# Signs session cookies. Rotating it logs out everyone.
|
||||
SESSION_SECRET="REPLACE-WITH-OUTPUT-OF-openssl-rand-hex-32"
|
||||
|
||||
# AES-256-GCM key for encrypting Withings tokens, GitHub PATs, AI provider
|
||||
# keys, and VAPID private keys at rest. Rotate carefully — old ciphertexts
|
||||
# need re-encryption.
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
name: No TODO markers
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
branches: [main, develop]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Product code is currently zero TODO / FIXME / XXX / HACK markers and
|
||||
# the convention is to keep it that way — open a real follow-up issue
|
||||
# instead of leaving a marker in the source. This gate fails fast if a
|
||||
# future contributor reintroduces the pattern.
|
||||
grep:
|
||||
name: Forbidden marker grep
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Scan src/ for TODO/FIXME/XXX/HACK markers
|
||||
run: |
|
||||
# Tests are exempt — they can legitimately reference an outstanding
|
||||
# ticket via a TODO; `src/generated/` is third-party output from
|
||||
# `prisma generate` and is regenerated on every db:generate.
|
||||
if grep -rEn "\b(TODO|FIXME|XXX|HACK)\b" src/ \
|
||||
--include='*.ts' \
|
||||
--include='*.tsx' \
|
||||
--exclude-dir='__tests__' \
|
||||
--exclude-dir='generated' \
|
||||
--exclude='*.test.ts' \
|
||||
--exclude='*.test.tsx'; then
|
||||
echo "::error::TODO/FIXME/XXX/HACK markers detected in product code — open a follow-up issue instead."
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::No forbidden markers in product code."
|
||||
@@ -0,0 +1,290 @@
|
||||
# Code Quality + Documentation Audit — 2026-05-17
|
||||
|
||||
## Executive summary
|
||||
|
||||
The repo is in unusually good shape for its size: `tsc --noEmit` and `eslint`
|
||||
both pass clean, there are zero `TODO`/`FIXME`/`XXX`/`HACK` markers in product
|
||||
code, zero empty `catch` blocks outside one inline `<script>` fallback, and all
|
||||
test files contain at least one `expect()`. The dominant debt is **archaeology
|
||||
in comments** — 2 366 `v1.4.x` version-tag references across 200+ files, plus
|
||||
244 wave/phase markers (`W5 reconcile (Code-H2)`, `IW-B`, `phase B8`). These
|
||||
read like commit messages inlined into the source and will only get worse with
|
||||
each release. The other three real wins are (1) deleting the 7-file
|
||||
`getXSystemPrompt` / `getXUserPrompt` boilerplate in `src/lib/ai/prompts/`,
|
||||
(2) removing the dead `SESSION_SECRET` env contract, and (3) closing the
|
||||
JSDoc gap on `src/lib/**` exports (171/535 missing). Recommended order:
|
||||
F-1 first (low-risk additive cleanup, biggest readability win), then F-2 and
|
||||
F-9 in the same release, then the long-tail items.
|
||||
|
||||
## Findings — prioritized
|
||||
|
||||
### F-1: Version-tagged inline comments have become source-control archaeology
|
||||
|
||||
**Severity**: high
|
||||
**Category**: comment debt
|
||||
**File(s)**: `src/proxy.ts:28,32,36,50,179,190`, `src/app/page.tsx:85,93,99,109,201,207,217,253,259,272,281,288,307,328,353,396,401,539,564,589,675,890,921,935,1196,1214,1225`, `src/app/api/analytics/route.ts:36,55,67,...` (47 hits), `src/components/charts/health-chart.tsx` (46 hits), `src/lib/ai/schema.ts:5,28,32,52,55,...` (29 hits), and 30 other files
|
||||
**What's wrong**: 2 366 inline references to `v1.4.x` plus 244 markers
|
||||
shaped like `// v1.4.22 W5 reconcile (Sec-MED-2) — ...` or
|
||||
`// v1.4.34 IW-B — ...`. They were useful while the release was in
|
||||
flight, but they now narrate the commit history inside the source. A
|
||||
reader of `dashboard-layout.ts:288` doesn't care that the comparison
|
||||
baseline shipped in `v1.4.16 phase B8`; they want to know what the
|
||||
field means. The convention also locks future archaeology into the
|
||||
codebase forever — every patch release inherits the previous tags.
|
||||
**Fix shape**: Sweep in two passes:
|
||||
1. Strip the leading `v1.4.x [WAVE-CODE] —` marker from any comment whose
|
||||
remaining body is still meaningful WHY-narration. Reduces every offender
|
||||
without losing intent.
|
||||
2. Delete the comment outright when the body is just "added the X" or
|
||||
"see plan Y". Sample target: `src/proxy.ts:28,32,36` collapse into one
|
||||
`// public paths bypassed by auth` block; `src/lib/dashboard-layout.ts:288,294,319,323` lose all four tags and
|
||||
the field-doc above each definition stays.
|
||||
**Effort**: medium (one focused half-day, additive, deletes ~2 000 lines)
|
||||
`[hotfix-ready]`
|
||||
|
||||
### F-2: Seven domain prompt files duplicate the same shell
|
||||
|
||||
**Severity**: high
|
||||
**Category**: duplicate logic
|
||||
**File(s)**: `src/lib/ai/prompts/bmi.ts:34-61`, `blood-pressure.ts:?`, `weight.ts:46-75`, `pulse.ts:52-81`, `mood.ts`, `general-status.ts`, `medication-compliance.ts`
|
||||
**What's wrong**: Every file in `src/lib/ai/prompts/` exports the same pair —
|
||||
`getXSystemPrompt(locale)` returns `${getBaseSystemPrompt(locale)}\n\n${section}`,
|
||||
and `getXUserPrompt(snapshotJson, todayKey, locale, previousContextBlock?)`
|
||||
returns a date-prefixed prompt with an optional context block. Only the
|
||||
DE/EN section constants and the analytical "Analyse the X with focus on…"
|
||||
sentence differ. The function signature drift risk is real — adding a
|
||||
new arg today means editing seven files.
|
||||
**Fix shape**: Replace the seven pairs with a single
|
||||
`buildDomainPrompts({ id, sectionDe, sectionEn, taskDe, taskEn })` factory
|
||||
in `prompts/factory.ts` and a registry per metric. The
|
||||
locale-`en`/-`de` branching in `getXUserPrompt` collapses to one
|
||||
template-literal interpolation.
|
||||
**Effort**: small (mechanical, fully covered by existing `__tests__`)
|
||||
|
||||
### F-3: `SESSION_SECRET` is documented + required by entrypoint but never read
|
||||
|
||||
**Severity**: medium
|
||||
**Category**: dead code
|
||||
**File(s)**: `.env.example:48`, `docker-entrypoint.sh:12`, `README.md:94,154`, `CONTRIBUTING.md:25,34`, `docker-compose.yml:23,68`, `docs/self-hosting/scaling.md:24`
|
||||
**What's wrong**: The env var is listed as required, the docker entrypoint
|
||||
fails-fast if it is missing, and onboarding docs tell every operator to
|
||||
generate one with `openssl rand -hex 32`. `grep -rn "SESSION_SECRET" src/`
|
||||
returns nothing. Sessions are server-stored in `prisma.session` keyed by
|
||||
the cookie value (`src/lib/auth/session.ts`), so the secret has nothing
|
||||
to sign. Every self-hoster carries a ceremonial 64-char hex string that
|
||||
the app ignores. Either (a) wire it into a real signing path (HMAC the
|
||||
cookie value) or (b) drop the requirement and the docs.
|
||||
**Fix shape**: Pick (a) or (b). (b) is one removal in
|
||||
`docker-entrypoint.sh`, the `.env.example` line, two `README.md`
|
||||
lines, and the `docker-compose.yml` mapping. (a) is a real auth
|
||||
change and should not be a hotfix.
|
||||
**Effort**: small (for the deletion path) `[hotfix-ready]` if route (b) chosen
|
||||
|
||||
### F-4: 171/535 exported functions in `src/lib/**` have no JSDoc
|
||||
|
||||
**Severity**: medium
|
||||
**Category**: docs gap
|
||||
**File(s)**: highest offenders by file: `src/lib/dashboard-layout.ts` (0/3 functions documented), `src/lib/telegram.ts` (1/6), `src/lib/process-type.ts` (1/4), `src/lib/glucose.ts` (1/5), `src/lib/utils.ts` (0/1), `src/lib/api-response.ts` (2/4), `src/lib/format-locale.ts` (1/3)
|
||||
**What's wrong**: The most-imported helpers (`cn()`, `apiError()`,
|
||||
`safeJson()`, `getProcessType()`, `mgdlToMmol()`, `getGravatarUrl()`)
|
||||
have no one-line summary. Some have rich type signatures that carry
|
||||
the meaning; some don't. `safeJson<T>(req, schema)` is a wrapper that
|
||||
returns an `apiError` `Response` on parse failure — a non-obvious
|
||||
shape worth documenting once.
|
||||
**Fix shape**: One-line `/** … */` on every top-10 import. Aim for
|
||||
WHY/return-shape only, not WHAT. Marc's pattern in
|
||||
`src/lib/format.ts:40-62` is the model — single-line, locale-aware example
|
||||
embedded.
|
||||
**Effort**: medium (manual sweep, ~170 spots) `[hotfix-ready]`
|
||||
|
||||
### F-5: `as never` and `as unknown as` chains hide schema drift
|
||||
|
||||
**Severity**: medium
|
||||
**Category**: type safety
|
||||
**File(s)**: `src/app/api/admin/backups/[id]/restore/route.ts:299,302,357`, `src/lib/personal-records/pr-detection-worker.ts:271,357,434,437,438`, `src/lib/devices/revoke.ts:79`, `src/lib/ai/coach/snapshot.ts:403,482`, `src/lib/ai/prompts/insight-generator.ts:729,730,731,737,738,739` (and 14 more), `src/lib/medications/route-guards.ts:61`, `src/lib/jobs/apple-health-import-worker.ts:85,176,194,195`
|
||||
**What's wrong**: 20 `as unknown as T` and 9 `as never` assertions. The
|
||||
PR worker's `where: where as never` and dynamic-key `orderBy` casts
|
||||
disable the very Prisma narrowing the rest of the file relies on; if
|
||||
the schema renames a column the worker silently builds a malformed
|
||||
query at runtime. `MEDICAL_REFERENCE_IDS as unknown as [string, ...string[]]`
|
||||
in `provider-chain/route.ts:82` exists because Zod wants a non-empty
|
||||
tuple — a `.refine()` against the readonly array would type-check
|
||||
without the cast.
|
||||
**Fix shape**: For each cast, either fix the underlying type or add a
|
||||
WHY-comment. The Prisma JSON casts in `chart-overlay-prefs`,
|
||||
`widgets`, `healthkit`, `doctor-report-prefs` (5 sites) collapse onto
|
||||
one helper `toInputJsonValue<T>(value: T): Prisma.InputJsonValue`. The
|
||||
`pr-detection-worker.ts` dynamic-orderBy can switch to a
|
||||
`Record<string, "asc" | "desc">` and drop the cast entirely.
|
||||
**Effort**: medium
|
||||
|
||||
### F-6: Legacy `src/lib/format.ts` is a soft-deprecation that won't die
|
||||
|
||||
**Severity**: low
|
||||
**Category**: simplify | duplicate logic
|
||||
**File(s)**: `src/lib/format.ts:23-62` (the file itself) and 20 import sites
|
||||
**What's wrong**: The file's docstring tells new code to use
|
||||
`useFormatters()` instead, but 20 production import sites still call
|
||||
`formatDate` / `formatDateTime` / `formatTime` / `formatDateShort`.
|
||||
The shim reads `document.cookie` on the client and falls back to `en`
|
||||
on the server, which means SSR can render English where the user has
|
||||
German selected — exactly the locale hydration mismatch the docstring
|
||||
warns about.
|
||||
**Fix shape**: Either migrate the 20 callers to `useFormatters()`
|
||||
(client) or `makeFormatters(serverLocale)` (server) and delete
|
||||
`src/lib/format.ts`, or accept it and remove the soft-deprecation
|
||||
docstring so a future contributor doesn't re-introduce the bug it
|
||||
warns against.
|
||||
**Effort**: small (mechanical migration)
|
||||
|
||||
### F-7: Mega-files near 1 700 lines in product code
|
||||
|
||||
**Severity**: low
|
||||
**Category**: simplify
|
||||
**File(s)**: `src/lib/jobs/reminder-worker.ts` (1 653 lines), `src/app/api/insights/targets/route.ts` (1 198), `src/app/api/analytics/route.ts` (1 073), `src/app/api/gamification/achievements/route.ts` (988), `src/components/settings/ai-section.tsx` (1 739), `src/components/charts/health-chart.tsx` (1 710), `src/app/page.tsx` (1 348)
|
||||
**What's wrong**: Single files holding multiple concerns. `reminder-worker.ts`
|
||||
mixes scheduling, channel adapters, dedup, and tests-flag logic.
|
||||
`/api/analytics/route.ts` has an unrelated "slim slice" branch
|
||||
inlined into the same handler.
|
||||
**Fix shape**: Only the worker is genuinely a problem — split per
|
||||
channel and per phase. The route files are acceptable because they
|
||||
read top-to-bottom; the chart components have to be one file for
|
||||
Recharts callbacks to share state cheaply. Reminder worker → split
|
||||
in a dedicated round, not a "while-I'm-here".
|
||||
**Effort**: large (do not bundle into a hotfix)
|
||||
|
||||
### F-8: `coach-prefs` excludeMetrics is a subset enum cast at runtime
|
||||
|
||||
**Severity**: low
|
||||
**Category**: type safety
|
||||
**File(s)**: `src/lib/ai/coach/snapshot.ts:400-405`
|
||||
**What's wrong**: `CoachExcludeMetric` is a strict subset of
|
||||
`CoachScopeSource` and the snapshot loop casts each source via
|
||||
`as unknown as CoachExcludeMetric` then runs a `Set.has()` lookup as
|
||||
defence-in-depth. The cast is documented but the comment says
|
||||
"the runtime check is just defence-in-depth" — which contradicts
|
||||
the cast. A `(src in EXCLUDABLE_SET)` predicate-typed helper would
|
||||
remove the cast and keep the narrow type at compile time.
|
||||
**Fix shape**: Introduce `function isExcludable(src: CoachScopeSource): src is CoachExcludeMetric`.
|
||||
**Effort**: trivial
|
||||
|
||||
### F-9: Prisma JSON serialisation needs one helper
|
||||
|
||||
**Severity**: low
|
||||
**Category**: duplicate logic
|
||||
**File(s)**: `src/app/api/auth/me/doctor-report-prefs/route.ts:81`, `src/app/api/dashboard/chart-overlay-prefs/route.ts:83`, `src/app/api/dashboard/widgets/route.ts:156`, `src/app/api/integrations/healthkit/route.ts:177`, `src/lib/jobs/apple-health-import-worker.ts:85,176,194,195`
|
||||
**What's wrong**: Seven sites do `value as unknown as Prisma.InputJsonValue`
|
||||
to satisfy Prisma's `JsonValue` union. The cast is correct (the values are
|
||||
plain JSON-serialisable objects) but each call site re-invents the same
|
||||
escape hatch.
|
||||
**Fix shape**: One generic helper in `src/lib/db.ts`:
|
||||
`export const toJson = <T>(v: T) => v as unknown as Prisma.InputJsonValue;`
|
||||
with a WHY-comment about why the cast is needed.
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
### F-10: `db-compat.ts` is the maintained migration system
|
||||
|
||||
**Severity**: informational
|
||||
**Category**: dead code (false positive)
|
||||
**File(s)**: `src/lib/db-compat.ts` (200 lines), 51 call sites
|
||||
**What's wrong**: At first glance this file looks like accumulated
|
||||
backwards-compat (which Marc's preferences explicitly forbid). On read
|
||||
it's the production "ALTER TABLE IF NOT EXISTS" path that bootstraps a
|
||||
fresh container without `prisma migrate deploy`. Worth a top-of-file
|
||||
docstring saying so — right now anyone scanning for cruft will flag it.
|
||||
**Fix shape**: Add a header comment naming it the "schema-bootstrap" path
|
||||
and pointing at the `prisma/migrations/` directory for the proper
|
||||
migration history. No code change.
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
### F-11: `eslint-disable` on `passkey.ts` `response: any` parameters
|
||||
|
||||
**Severity**: low
|
||||
**Category**: type safety
|
||||
**File(s)**: `src/lib/auth/passkey.ts:103-104,169-170`
|
||||
**What's wrong**: Both `verifyRegistration` and `verifyAuthentication`
|
||||
take `response: any` with a per-line eslint-disable. The upstream
|
||||
`@simplewebauthn/server` exports `RegistrationResponseJSON` and
|
||||
`AuthenticationResponseJSON` types for these payloads.
|
||||
**Fix shape**: Import the SimpleWebAuthn types, drop the
|
||||
`eslint-disable` lines.
|
||||
**Effort**: trivial
|
||||
|
||||
### F-12: Comment quality wins: WHAT-narration leaks
|
||||
|
||||
**Severity**: low
|
||||
**Category**: comment debt
|
||||
**File(s)**: sample: `src/lib/dashboard-layout.ts:262-263` ("Merge with defaults so new widgets…"), `src/lib/ai/coach/snapshot.ts:413-416` ("Trim down to the metrics…"), `src/app/api/analytics/route.ts:55-66` ("v1.4.33 C1 — slim summaries slice…")
|
||||
**What's wrong**: Most long block comments narrate WHAT the code does
|
||||
in plain English right above identical code. Some carry the WHY (the
|
||||
`/api/analytics` slim-slice block does explain the read-budget
|
||||
trade-off). The WHAT-only sections should collapse.
|
||||
**Fix shape**: Treat any block comment whose next 3 lines paraphrase the
|
||||
comment as deletable. Sample: `dashboard-layout.ts:253-258` keeps the
|
||||
"v1.4.28 retired" version tag (drop per F-1) and the WHY ("PUT route's
|
||||
Zod enum rejects the entire blob") — drop the WHAT-rephrasing in the
|
||||
middle.
|
||||
**Effort**: medium (folds into F-1 sweep)
|
||||
|
||||
### F-13: `route-guards.ts` import is lazy with an unstubbable cast
|
||||
|
||||
**Severity**: informational
|
||||
**Category**: type safety | test discipline
|
||||
**File(s)**: `src/lib/medications/route-guards.ts:56-62`
|
||||
**What's wrong**: `loadPrisma()` lazy-imports `@/lib/db` so test files can
|
||||
stub the module — fine pattern — but the return type forces a
|
||||
`prisma as unknown as MedicationOwnershipPrisma` cast at the boundary.
|
||||
`MedicationOwnershipPrisma` is a stripped-down interface declared
|
||||
locally; the real `PrismaClient` is structurally compatible. The cast
|
||||
is honest but a brief comment saying "stripped interface for test
|
||||
stubbability" sits below the function instead of above. Move it up.
|
||||
**Fix shape**: WHY-comment relocation.
|
||||
**Effort**: trivial
|
||||
|
||||
### F-14: `as never` cast comment in `backups/restore/route.ts` is the right pattern
|
||||
|
||||
**Severity**: informational
|
||||
**Category**: positive example
|
||||
**File(s)**: `src/app/api/admin/backups/[id]/restore/route.ts:299`
|
||||
**What's wrong**: Nothing. `type: m.type as never, // already enum-validated above`
|
||||
is the model for every other cast in F-5: the WHY is the trailing
|
||||
comment, not a block above. Aim for this in F-5.
|
||||
**Effort**: n/a
|
||||
|
||||
### F-15: Test discipline spot-check is clean
|
||||
|
||||
**Severity**: informational
|
||||
**Category**: test discipline
|
||||
**File(s)**: 400 test files scanned
|
||||
**What's wrong**: Nothing material. Every test file contains at least one
|
||||
`expect()`. No empty `catch` blocks in production tests. No
|
||||
`try/catch` swallow patterns. The codebase has 0 `TODO`, 0 `FIXME`,
|
||||
0 `XXX`, 0 `HACK` markers — this is genuinely rare and worth
|
||||
preserving with a CI grep.
|
||||
**Fix shape**: Add a tiny CI grep gate enforcing the zero-TODO rule
|
||||
explicitly, so a future contributor doesn't reintroduce the pattern
|
||||
in the next marathon.
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
## Counts
|
||||
|
||||
- Files with > 10 obsolete version-tagged comments: **35**
|
||||
- `any` / `as unknown as` / `as never` cast-throughs: **6 + 20 + 9 = 35** (excluding tests + generated)
|
||||
- Open TODO / FIXME / XXX / HACK total in product code: **0**
|
||||
- Exported functions in `src/lib/**` without doc-comment: **171 / 535** (32 %)
|
||||
- Total `v1.4.x` references in product code: **2 366**
|
||||
- Test files without `expect()`: **0 / 400**
|
||||
- `tsc --noEmit` clean: yes
|
||||
- `pnpm lint` clean: yes
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Splitting `reminder-worker.ts` (F-7) — wants its own round, not a hotfix.
|
||||
- Refactoring `health-chart.tsx` (1 710 lines) — Recharts callbacks share
|
||||
state cheaply only when colocated; a split needs UX validation first.
|
||||
- Migration of legacy `src/lib/format.ts` callers (F-6) — touches 20 files
|
||||
across the dashboard; defer until the next i18n pass.
|
||||
- Generated Prisma client (`src/generated/prisma/`) — third-party output,
|
||||
not maintained source.
|
||||
- E2E tests under `e2e/` and `tests/` — separate audit scope.
|
||||
- Translation/copy quality in `messages/` — separate audit scope.
|
||||
@@ -0,0 +1,196 @@
|
||||
# Documentation Depth Audit — 2026-05-16
|
||||
|
||||
## Executive summary
|
||||
|
||||
The repo carries **11 first-party docs files** plus an `audit/` archive of 24 release-cycle summaries. The strongest material lives in three operator-ops notes (`docs/ops/backup-restore.md`, `docs/ops/encryption-key-rotation.md`, `docs/self-hosting/scaling.md`) — these are accurate, well-cited, and confident. Everything else is either missing, archived audit prose that isn't user-facing, or thin. The headline gap: **`docs/` has no getting-started, no integration guides, no source-priority spec, no cache-contract page, no observability page, no security model, and no localisation contribution guide**, yet `README.md:107` and `README.md:463` send self-hosters to `docs.healthlog.dev` — a domain whose source-of-truth folder is this same `docs/` tree. The OpenAPI spec is **deliberately the iOS-codegen subset** (~14 paths) against **181 actual route handlers**, which is correct intent but reads as a contradiction with `docs/api/README.md:55-56` ("**paths** — all routes from `src/app/api/**/route.ts`"). Apple Health import — the v1.4.34 banner feature — is mentioned in one OpenAPI batch endpoint and the App Store checklist; the user-facing upload flow (`POST /api/import/apple-health-export`) is undocumented in `docs/`. Recommended write-order: getting-started → integrations triple (Apple Health, Withings, AI) → self-hosting full guide (proxy + Coolify + Kubernetes) → source-priority spec → security model → cache contract → observability → i18n contribution guide → API reference reconciliation.
|
||||
|
||||
## Audit matrix
|
||||
|
||||
| Topic | Status | File(s) | Notes |
|
||||
|--|--|--|--|
|
||||
| Self-hosting | △ | `docs/self-hosting/scaling.md`, `docs/migration/v1.3-to-v1.4.md`, `README.md:80-107` | Only the web/worker split is documented. Reverse-proxy + Coolify + bare-metal + Kubernetes paths are **missing** from `docs/`; `README.md:107` links to a `docs.healthlog.dev/self-hosting/reverse-proxy/` page that has no source in-repo. v1.4.34.2 `pull_policy: always` only documented inline in `docker-compose.yml`. |
|
||||
| Env-var reference | △ | `README.md:146-169`, `.env.example` | `.env.example` is exhaustive and excellent. README has a short table that **omits** `ENCRYPTION_KEYS`, `BACKUP_*`, `APNS_*`, `LOKI_*`, `HEALTHLOG_PROCESS_TYPE`, `INSIGHTS_RATE_LIMIT_PER_HOUR`, `DEPLOY_WEBHOOK_SECRET`. No consolidated `docs/self-hosting/configuration.md`. |
|
||||
| API reference | △ | `docs/api/openapi.yaml`, `docs/api/README.md`, `README.md:240-386` | OpenAPI is the **iOS codegen subset** (14 paths, locked contract). README has a manual table that covers more surface but is stale (omits `/api/measurements/batch`, `/api/workouts/batch`, `/api/sync/state`, `/api/measurement-categories`, `/api/devices`, `/api/import/apple-health-export`, `/api/integrations/healthkit`, the side-effects / inventory / titration medication sub-routes, `/api/insights/chat`, `/api/auth/me/source-priority`). `docs/api/README.md:55-56` claims OpenAPI covers all routes — false. |
|
||||
| OpenAPI accuracy | △ | `docs/api/openapi.yaml` | Spec version is `1.4.23`; package is `1.4.34.3`. Spec is internally consistent but the version drift contradicts `package.json` mirror claim at `docs/api/README.md:38`. `openapi-v1422-legacy.yaml` is still in the tree with no README pointer explaining whether it's archive or fallback. |
|
||||
| Apple Health import | ✗ | nowhere user-facing | OpenAPI documents `/api/measurements/batch` (HK batch ingest from iOS app). The **`export.zip` upload flow** — `POST /api/import/apple-health-export`, status polling, idempotency on file SHA-256, 1.5 GB cap, admin variant, supported HK types map, error recovery — is **undocumented in `docs/`**. v1.4.34 banner feature; only the App Store checklist mentions "Apple Health". |
|
||||
| Withings integration | ✗ | nowhere | OAuth setup (developer.withings.com app registration, redirect URI, webhook secret), the `/api/withings/webhook/[token]` v1.4.25 W17a migration, source-priority interaction — none captured. `.env.example` has the env-var stubs; `docs/` has no how-to. |
|
||||
| AI provider BYOK | ✗ | nowhere user-facing | The provider chain (`src/lib/ai/provider-chain.ts`), the four providers (OPENAI / ANTHROPIC / LOCAL / CHATGPT_OAUTH via `src/lib/ai/codex-client.ts` / admin-shared key), fallback order, BYOK key acquisition steps, cost expectations, the privacy stance for LOCAL vs cloud providers — none captured. `codex-protocol-spec.md` exists but is a 1300-line reverse-engineered protocol reference for `chatgpt.com/backend-api/codex/responses`, not a user-facing BYOK guide. |
|
||||
| Doctor-report PDF | ✓ | `docs/doctor-report.md` | Both endpoints documented, locale resolution explicit, rate limit captured, audit-log action named, jsPDF-in-Node rationale included. Light on customisation guidance (per-locale translation overrides) but the API surface is accurate. |
|
||||
| Source-priority ladder | ✗ | nowhere | The canonical APPLE_HEALTH ≻ WITHINGS ≻ MANUAL ≻ IMPORT ordering, the two-axis source + device-type ladder (v1.4.25 W8c at `src/lib/validations/source-priority.ts:18-32`), the `User.sourcePriorityJson` override, the "drop from aggregation, keep as audit" contract, `pick-canonical-workout.ts` — none surfaced to users or self-hosters. This is core conceptual material. |
|
||||
| Cache + invalidation contract | ✗ | nowhere | `src/lib/cache/server-cache.ts:269-286` ships `cache.<name>.outcome` wide-event annotations and `src/lib/cache/invalidate.ts` owns the per-user invalidation matrix. v1.4.34.1 closure called this out specifically. Zero docs coverage. |
|
||||
| Deploy + release pipeline | △ | `docs/audit/v1414-summary.md:224-265`, `docs/audit/v1423-summary.md:36-66`, `docker-compose.yml` inline comment | GHCR build + Coolify auto-deploy + `pull_policy: always` (v1.4.34.2) + the host-side retag fallback recipe (cited five times across audits) exists only as scattered audit-trail prose. No `docs/self-hosting/coolify.md` runbook. |
|
||||
| Security model | ✗ | nowhere | `SECURITY.md` is the disclosure policy. Passkey vs password vs API token (`hlk_*` Bearer), encryption-at-rest scheme (AES-256-GCM with versioned key prefix — only the rotation procedure is documented, not the model), audit-log scope, rate-limit policy (`src/lib/rate-limit.ts`), session-cookie shape — none captured as a "how this app is hardened" page. |
|
||||
| iOS native client | △ | `docs/apple-store-connect-checklist.md`, `docs/api/openapi.yaml` | Submission checklist is comprehensive. The handoff brief (server contracts, AASA at `src/app/.well-known/apple-app-site-association/route.ts`, APNs config at `.env.example` `APNS_*`, X-Device-Id header, refresh-token rotation) is split across `docs/migration/v1.3-to-v1.4.md:86-93,118-122` + OpenAPI + `.env.example`. No single `docs/ios-handoff.md`. |
|
||||
| i18n / localisation | △ | `docs/ui-guidelines.md:421-430` | One paragraph in UI guidelines. No contributing-a-new-locale guide, no key-naming conventions doc (the `admin.section.<slug>.*` namespace landed in v1.4.14 per `docs/audit/v1414-summary.md:84`), no plural-rules reference. `messages/{de,en}.json` carry 1500+ keys per `README.md:72`. |
|
||||
| Backup + restore | ✓ | `docs/ops/backup-restore.md` | Wire format + env-var table + restore script invocation + lifecycle-rule recommendation all explicit. Restore "import the JSON back" step is hand-waved ("use `prisma db seed` or a custom script") — minor gap, otherwise excellent. |
|
||||
| Encryption-key rotation | ✓ | `docs/ops/encryption-key-rotation.md` | Format spec, three rotation walkthroughs, rollback caveat, troubleshooting matrix. Best file in the tree. |
|
||||
| Observability | ✗ | `.env.example:100-115` only | Wide-event logging (`src/lib/logging/`), `cache.*.outcome` annotations, Loki transport, `LOG_SAMPLE_RATE` + `LOG_SLOW_THRESHOLD_MS` + `LOG_INCLUDE_STACK` semantics, the Glitchtip + Umami test endpoints — only the env vars get a comment-block; no operator-facing observability guide. |
|
||||
| Docs landing page | ✗ | no `docs/README.md` | The `docs/` root has no index. A visitor cloning the repo and running `ls docs/` sees a folder soup with no orientation. |
|
||||
|
||||
## Findings — prioritized
|
||||
|
||||
### F-1: No getting-started guide in `docs/` — README is the only path
|
||||
|
||||
**Severity**: critical
|
||||
**Topic**: Self-hosting
|
||||
**File(s) / Gap**: MISSING — no `docs/getting-started.md`
|
||||
**What's wrong / missing**: A self-hoster reading `README.md:80-107` gets a 3-minute quick start that assumes a working Docker + working DNS + an understanding of where to put the reverse-proxy block. The README explicitly punts on TLS ("See [Self-Hosting → Reverse Proxy](https://docs.healthlog.dev/self-hosting/reverse-proxy/)" — `README.md:107`) and on Coolify ("Works out of the box with Coolify" — `README.md:439`), but neither destination exists in this repo. The discoverability audit's F-10 already noted that `docs/` looks "operator-/audit-heavy"; the depth gap is that **the user-facing surface has no in-repo source-of-truth at all**, so the docs site cannot be regenerated from the repo.
|
||||
**Fix shape**: `docs/getting-started.md` — 800–1200 words. Sections: prerequisites (Docker 26+, 2 GB RAM, 10 GB disk), the four-secret `.env` bootstrap, first boot (admin promotion of first user), where things go wrong (Postgres healthcheck race, hostname mismatch when `APP_URL` doesn't match the actual host header), and the next-step matrix (TLS reverse proxy / Coolify / Kubernetes / observability).
|
||||
**Effort**: medium
|
||||
|
||||
### F-2: No reverse-proxy / Coolify / Kubernetes / bare-metal guides
|
||||
|
||||
**Severity**: critical
|
||||
**Topic**: Self-hosting
|
||||
**File(s) / Gap**: MISSING — `docs/self-hosting/` has only `scaling.md`
|
||||
**What's wrong / missing**: The README sends users to four implicit deploy paths (Docker Compose, reverse proxy, Coolify, Kubernetes — the README claims "works out of the box with Coolify" at `README.md:439`) but only Docker Compose has a real walkthrough. The `pull_policy: always` line (`docker-compose.yml:13`) that landed in v1.4.34.2 to fix Coolify's stale-digest cache is documented **only** inline in the compose file. The host-side retag fallback recipe (`docker pull → docker tag → docker compose up -d --force-recreate`) is cited across `docs/audit/v1423-summary.md:66`, `docs/audit/v1419-summary.md:115-123`, `docs/audit/v1421-summary.md:14`, `docs/audit/v1414-summary.md:224-231` — but never promoted out of audit prose into a self-hosting runbook.
|
||||
**Fix shape**: Four files. `docs/self-hosting/reverse-proxy.md` (Caddy / Traefik / Nginx blocks, the exact `NEXT_PUBLIC_APP_URL` + `APP_URL` pairing, headers to forward, healthcheck path). `docs/self-hosting/coolify.md` (project bootstrap, the "Watch image registry for new digests" toggle that's the load-bearing piece per `docs/audit/v1423-summary.md:95`, webhook secrets, the auto-deploy gate at `/api/internal/deploy-webhook`, the host-side retag fallback). `docs/self-hosting/kubernetes.md` (a minimal Deployment + Service + Secret manifest, init-container for `prisma migrate deploy`, the `HEALTHLOG_PROCESS_TYPE=web|worker` split as separate Deployments). `docs/self-hosting/bare-metal.md` (Node 20+, pnpm, systemd unit, Postgres via host package). Each 600–900 words.
|
||||
**Effort**: large
|
||||
|
||||
### F-3: Apple Health import end-to-end is undocumented
|
||||
|
||||
**Severity**: critical
|
||||
**Topic**: Apple Health import (v1.4.34 banner)
|
||||
**File(s) / Gap**: MISSING — no `docs/integrations/apple-health.md`
|
||||
**What's wrong / missing**: The banner feature of v1.4.34 has zero user-facing documentation in `docs/`. The endpoint set is `POST /api/import/apple-health-export` (user kick-off, multipart upload, 1.5 GB cap, 3-uploads/min rate limit, content-hash idempotency per `src/app/api/import/apple-health-export/route.ts:42-46`), `GET /api/import/apple-health-export/[jobId]/status` (polling), and `POST /api/admin/import-apple-health-export` (admin variant). The supported HK type set (`src/lib/measurements/apple-health-mapping.ts`), the streaming-to-disk approach (`src/lib/multipart/stream-to-disk.ts`), the `apple-health-import-worker.ts` job behaviour (queue depth, retries, what fails idempotently vs hard), and error recovery (re-upload merges on file SHA-256 short-circuit) all need an operator-facing page. The OpenAPI documents the **iOS HK batch ingest** at `/api/measurements/batch` (line 67) but not the `export.zip` flow.
|
||||
**Fix shape**: `docs/integrations/apple-health.md` — 1200–1500 words. Sections: what gets imported (HK quantity types map → HealthLog measurement types, workouts, correlations), how to export from iOS (Health app → profile → Export All Health Data), the upload UI flow + endpoint contract, idempotency (SHA-256 of bytes), what happens on duplicate types when Withings was sync'ing the same metric (source-priority ladder cross-link), failure modes (corrupt zip, partial XML, job retry), the admin variant for migrating other users' data, observability (`apple-health-import` queue name in the worker, `cache.apple-health-import.outcome`).
|
||||
**Effort**: medium
|
||||
|
||||
### F-4: Withings integration setup is undocumented
|
||||
|
||||
**Severity**: critical
|
||||
**Topic**: Withings integration
|
||||
**File(s) / Gap**: MISSING — no `docs/integrations/withings.md`
|
||||
**What's wrong / missing**: `.env.example:62-70` carries the env-var stubs with a one-line "Register an app at https://developer.withings.com/" pointer. The actual setup — Withings developer-portal app creation, OAuth client ID/secret pasting, the redirect URI (`/api/withings/callback`), the **webhook secret path-segment migration** in v1.4.25 W17a (`/api/withings/webhook/[token]` per `src/app/api/withings/webhook/route.ts:18-25`, with the legacy `?secret=` form deprecated and tracked via `withings.webhook.legacy_form_total` until v1.4.27 cutover), the `user.activity` OAuth scope shipped in v1.4.25 W5d, the source-priority ladder interaction, sync-frequency expectations — is documented nowhere user-facing.
|
||||
**Fix shape**: `docs/integrations/withings.md` — 1000–1300 words. Sections: provisioning the Withings developer app (with screenshots-or-text walkthrough), env-var paste, the OAuth dance + callback URL, the webhook URL pattern (path-segment form), the v1.4.25 W17a migration notice for existing installs, what gets synced (scales, BP, activity), source-priority interaction (Withings = mid-priority by default; APPLE_HEALTH overrides), troubleshooting (token expiry, re-auth, the admin probe at `POST /api/integrations/withings/test`).
|
||||
**Effort**: medium
|
||||
|
||||
### F-5: AI provider BYOK setup is undocumented (Marc-stated differentiator)
|
||||
|
||||
**Severity**: critical
|
||||
**Topic**: AI provider BYOK
|
||||
**File(s) / Gap**: MISSING — no `docs/integrations/ai-providers.md`
|
||||
**What's wrong / missing**: Auto-memory captures "AI Insights are the main differentiator." The provider chain owns four implementations (`src/lib/ai/openai-client.ts`, `anthropic-client.ts`, `local-client.ts`, `codex-client.ts` with the `CHATGPT_OAUTH` flow at `src/app/api/auth/codex/authorize`), a provider-chain fallback orchestrator (`src/lib/ai/provider-chain.ts`), and `codex-protocol-spec.md` — a 1300-line reverse-engineered protocol spec for the ChatGPT-backed codex endpoint. There is no user-facing "how do I plug in my Anthropic key" page, no cost-expectation table, no privacy table comparing OPENAI/ANTHROPIC (data leaves your network) vs LOCAL (Ollama / LM Studio / vLLM at your endpoint — data stays), no fallback-chain semantics (what happens when BYOK fails — does it fall to admin-shared? what's the user-visible error?), no rate-limit budget guidance (`INSIGHTS_RATE_LIMIT_PER_HOUR` exists at `.env.example:88-92` but is only meaningful with cost context).
|
||||
**Fix shape**: `docs/integrations/ai-providers.md` — 1500–2000 words. Sections: provider matrix (OPENAI / ANTHROPIC / LOCAL / CHATGPT_OAUTH — endpoint, key acquisition URL, supported models, monthly cost estimate per active user, privacy stance, fallback rank), BYOK setup per provider (settings UI walkthrough), admin-shared-key vs per-user-key trade-off, the fallback chain (what triggers fallback, what's the user-visible failure), local-endpoint guides (Ollama / LM Studio / vLLM URL formats, model selection), the `/api/ai/test` probe contract, troubleshooting (codex slug-drift defence per `docs/codex-protocol-spec.md:638`), the rate-limit budget knob.
|
||||
**Effort**: large
|
||||
|
||||
### F-6: Source-priority ladder spec is not user-facing
|
||||
|
||||
**Severity**: high
|
||||
**Topic**: Source-priority ladder
|
||||
**File(s) / Gap**: MISSING — no `docs/concepts/source-priority.md`
|
||||
**What's wrong / missing**: The canonical APPLE_HEALTH ≻ WITHINGS ≻ MANUAL ≻ IMPORT ordering is implemented in `src/lib/validations/source-priority.ts:9-32` with the v1.4.25 W8c two-axis device-type extension, applied per-day in `src/lib/analytics/source-priority.ts:1-30` for cumulative metrics (steps, active energy, distance, flights, sleep), and overridable per-user via `User.sourcePriorityJson` + `PUT /api/auth/me/source-priority`. The "drop from aggregation, keep as audit trail" contract is load-bearing for the dedup semantics. Self-hosters who run Apple Watch + iPhone + Withings scale will be triple-counting steps until they understand it. Nothing in `docs/` explains this.
|
||||
**Fix shape**: `docs/concepts/source-priority.md` — 700–1000 words. Sections: the problem (multi-source dedup), the default ladder, per-metric vs per-device priority axes, the "drop from aggregation, keep in DB as audit trail" contract, how to override per user, the settings UI surface (`/settings/sources` per `src/components/settings/sources-section.tsx`), examples (steps triple-source, weight twin-source), cross-link to Apple Health + Withings integration docs.
|
||||
**Effort**: medium
|
||||
|
||||
### F-7: API reference is split across two stale surfaces
|
||||
|
||||
**Severity**: high
|
||||
**Topic**: API reference
|
||||
**File(s) / Gap**: `docs/api/openapi.yaml`, `docs/api/README.md`, `README.md:240-386`
|
||||
**What's wrong / missing**: `docs/api/README.md:55-56` claims the OpenAPI **paths** section covers "all routes from `src/app/api/**/route.ts`." Reality: 14 paths documented, 181 route handlers in the tree. The README's manual API table is broader but stale — missing `/api/measurements/batch`, `/api/workouts/batch`, `/api/sync/state`, `/api/measurement-categories`, `/api/devices`, `/api/import/apple-health-export`, `/api/integrations/healthkit`, the medication sub-routes (`side-effects`, `inventory`, `titration`, `cadence`, `phase-config`, `glp1`, `api-endpoint`, `intake/import`, `intake/purge`), `/api/insights/chat`, `/api/auth/me/source-priority`, `/api/personal-records`. OpenAPI `info.version` is `1.4.23` (line 1786) while `package.json:3` is `1.4.34.3` — drift contradicts the "mirrored from `package.json`" claim. The intent is reasonable (iOS-codegen-locked subset), but the documentation never says that — `docs/api/README.md` reads as if the spec is the full public-API contract.
|
||||
**Fix shape**: Two changes. (a) Reword `docs/api/README.md:55-56` to "**paths** — the locked subset consumed by the iOS native client codegen and external Bearer ingest. For the full self-hosting API surface see `docs/api/full-reference.md`." (b) Add `docs/api/full-reference.md` — a generated-from-source endpoint table that walks `src/app/api/**/route.ts` and renders method + path + auth + rate-limit + audit action. A small `scripts/generate-api-reference.ts` plus a CI gate keeps it from drifting. Bump OpenAPI `info.version` to track `package.json` on every release (the CHANGELOG entry is the trigger).
|
||||
**Effort**: large
|
||||
|
||||
### F-8: Cache + invalidation contract is undocumented
|
||||
|
||||
**Severity**: high
|
||||
**Topic**: Cache + invalidation
|
||||
**File(s) / Gap**: MISSING — no `docs/concepts/caching.md`
|
||||
**What's wrong / missing**: v1.4.34.1 closure flagged this. `src/lib/cache/server-cache.ts:269-286` emits `cache.<name>.outcome` wide-event annotations on every hit / miss / stampede, and `src/lib/cache/invalidate.ts` owns the per-user mutation → invalidate-keys matrix. Self-hosters investigating "why is my dashboard stale after a manual measurement?" or "why is my Loki cache-outcome stream blank?" have nothing to read.
|
||||
**Fix shape**: `docs/concepts/caching.md` — 600–900 words. Sections: which routes are cached (with the cache names that match the `cache.<name>.outcome` annotations), TTLs per cache, the per-user invalidation matrix (mutation → cleared keys), the stampede-prevention contract, observability hook (the `cache.*.outcome` annotation in wide events), troubleshooting (stale dashboard, missed invalidation).
|
||||
**Effort**: medium
|
||||
|
||||
### F-9: Security model has no consolidated page
|
||||
|
||||
**Severity**: high
|
||||
**Topic**: Security model
|
||||
**File(s) / Gap**: `SECURITY.md` (disclosure policy only), `docs/ops/encryption-key-rotation.md` (rotation procedure only)
|
||||
**What's wrong / missing**: `SECURITY.md` is the responsible-disclosure policy. `README.md:131-143` has a marketing bullet list. The actual security architecture — passkey vs password vs API token (`hlk_*` Bearer tokens HMAC'd before storage per `src/lib/auth/`), the AES-256-GCM at-rest scheme (rotation has a doc; the **model** doesn't), session cookies (`healthlog_session`, HttpOnly, SameSite=Strict, 30-day sliding, server-side store), the `apiHandler` audit-log scope, the sliding-window rate-limiter (`src/lib/rate-limit.ts`), CSP nonces + HSTS, SSRF guards on the test-connection endpoints — is not a coherent doc for a self-hoster auditing the threat model.
|
||||
**Fix shape**: `docs/security-model.md` — 1200–1500 words. Sections: auth mechanisms (passkey primary, password fallback with Argon2id + zxcvbn, API tokens for native + Bearer ingest), session model, encryption-at-rest scheme (cross-link to rotation doc), audit-log scope (what's logged, retention), rate-limit policy table (per endpoint family), proxy headers + CSP + HSTS, secrets that must never rotate without coordinated invalidation (`API_TOKEN_HMAC_KEY` per `docs/migration/v1.3-to-v1.4.md:93`), open follow-ups (the six S-FOLLOW items in `docs/ops/v141-followup-issues.md` should be either resolved-and-removed or surfaced into the model doc).
|
||||
**Effort**: large
|
||||
|
||||
### F-10: Observability has no operator-facing guide
|
||||
|
||||
**Severity**: high
|
||||
**Topic**: Observability
|
||||
**File(s) / Gap**: `.env.example:100-115` (env-var stubs only)
|
||||
**What's wrong / missing**: Wide events live in `src/lib/logging/`. The Loki transport is gated by `LOKI_ENDPOINT` + `LOKI_USERNAME` + `LOKI_PASSWORD`. Sample rate (`LOG_SAMPLE_RATE`) and the always-keep-slow contract (`LOG_SLOW_THRESHOLD_MS`) interact in non-obvious ways. The Glitchtip + Umami test endpoints (`/api/monitoring/glitchtip/test`, `/api/monitoring/umami/test`) are documented in `docs/migration/v1.3-to-v1.4.md:108-112` as one-liners but their setup is nowhere. The `cache.*.outcome` annotation contract is invisible.
|
||||
**Fix shape**: `docs/self-hosting/observability.md` — 800–1100 words. Sections: log levels + sampling + slow-keep rule, Loki transport (label set, retention recommendations), Glitchtip integration (DSN, the test endpoint), Umami integration (script URL, website ID, the test endpoint), the wide-event schema (`action.name`, `cache.<name>.outcome`, request-id propagation), worker telemetry (`LOKI_*` must be set on worker too per `docs/self-hosting/scaling.md:44-46`).
|
||||
**Effort**: medium
|
||||
|
||||
### F-11: iOS handoff is split across three files
|
||||
|
||||
**Severity**: medium
|
||||
**Topic**: iOS native client
|
||||
**File(s) / Gap**: `docs/apple-store-connect-checklist.md`, `docs/api/openapi.yaml`, `docs/migration/v1.3-to-v1.4.md`, `.env.example`
|
||||
**What's wrong / missing**: The iOS engineer reading the repo finds the ASC submission checklist, the iOS-codegen OpenAPI subset, the APNs env-var block in `.env.example:155-175`, the AASA route at `src/app/.well-known/apple-app-site-association/route.ts`, and the refresh-token rotation paragraph at `docs/migration/v1.3-to-v1.4.md:118-122`. None of them constitute a "iOS engineer onboards in one read" handoff. The X-Device-Id header contract, the X-Client-Type behaviour (`docs/api/openapi.yaml:1791-1792`), the source-priority interaction with HK ingest — scattered.
|
||||
**Fix shape**: `docs/ios-handoff.md` — 1000–1300 words. Sections: the API contract surface the iOS client is allowed to call (cross-link to OpenAPI), auth flow (login → access + refresh, refresh rotation), AASA setup, APNs registration (the `.p8` provisioning + the `POST /api/devices` registration contract), source priority + dedup expectations on the iOS side, the X-Device-Id Keychain-stored UUID contract, the X-Client-Type sniff (`native` UA prefix unlocks refresh-token bundle). Cross-link to the ASC checklist for shipping.
|
||||
**Effort**: medium
|
||||
|
||||
### F-12: Localisation has no contribution guide
|
||||
|
||||
**Severity**: medium
|
||||
**Topic**: i18n / localisation
|
||||
**File(s) / Gap**: `docs/ui-guidelines.md:421-430` (one paragraph)
|
||||
**What's wrong / missing**: Contributing a new locale (FR / ES / IT) is a real on-ramp request for an open-source PWA. The `admin.section.<slug>.*` namespace convention (v1.4.14 per `docs/audit/v1414-summary.md:84`), the per-user vs per-job translator pattern (`useTranslations()` vs `getServerTranslator()` per `docs/ui-guidelines.md:421-424`), the CI key-parity guard (`README.md:72` mentions it), the plural-rules approach, where AI prompts pick up locale — none documented as a "how to add a locale" page.
|
||||
**Fix shape**: `docs/contributing/i18n.md` — 600–900 words. Sections: file layout (`messages/<locale>.json`), the namespace convention, the parity CI gate, plural-rules + interpolation, server-side vs client-side translators, AI prompt locale resolution, the per-user-locale-for-job contract (a Telegram reminder uses the user's locale, not the job runner's). Cross-link to `docs/ui-guidelines.md` for the writing-tone rules.
|
||||
**Effort**: medium
|
||||
|
||||
### F-13: `docs/` has no root README
|
||||
|
||||
**Severity**: medium
|
||||
**Topic**: Docs landing
|
||||
**File(s) / Gap**: MISSING — no `docs/README.md`
|
||||
**What's wrong / missing**: A visitor running `ls docs/` after `git clone` sees `api/`, `apple-store-connect-checklist.md`, `audit/`, `codex-protocol-spec.md`, `doctor-report.md`, `migration/`, `ops/`, `self-hosting/`, `ui-guidelines.md` — no orientation. The discoverability audit's F-10 noted the same gap from outside.
|
||||
**Fix shape**: `docs/README.md` — 200–300 words. A tabular index of what each subtree covers (user / operator / contributor / archive), the relationship to `docs.healthlog.dev` (if the docs site is published from this folder, say so; if it's a separate repo, link it), and the "see also" pointers.
|
||||
**Effort**: trivial [hotfix-ready]
|
||||
|
||||
### F-14: `docs/api/openapi-v1422-legacy.yaml` is in the tree with no explanation
|
||||
|
||||
**Severity**: low
|
||||
**Topic**: API reference
|
||||
**File(s) / Gap**: `docs/api/openapi-v1422-legacy.yaml` (177 KB, 122 paths)
|
||||
**What's wrong / missing**: The file is committed alongside the active `openapi.yaml` but `docs/api/README.md` doesn't mention it. A contributor stumbling on it has to guess whether it's authoritative archive, fallback, or stale. The naming hints at "v1.4.22 freeze" — but the active spec is `1.4.23` and the package is `1.4.34.3`, so it's apparently a deprecated broader-surface spec that was superseded by the iOS-codegen-locked subset. Decision needed: delete it, move it to `docs/api/archive/`, or document its role.
|
||||
**Fix shape**: Either remove (if it serves no purpose) or annotate `docs/api/README.md` with "**`openapi-v1422-legacy.yaml`** — v1.4.22 snapshot of the broader pre-iOS-codegen surface, kept for archive reference; not regenerated." Trivial.
|
||||
**Effort**: trivial [hotfix-ready]
|
||||
|
||||
### F-15: `docs/ops/v141-followup-issues.md` is a deferred-work backlog, not ops docs
|
||||
|
||||
**Severity**: low
|
||||
**Topic**: Documentation hygiene
|
||||
**File(s) / Gap**: `docs/ops/v141-followup-issues.md`
|
||||
**What's wrong / missing**: The file (260 lines, 11 deferred follow-ups across security / performance / test-quality / i18n) is dated v1.4.1. We are at v1.4.34.3. Some items are likely resolved; others moved to `.planning/`. It currently lives next to two **published, operator-facing** ops docs (`backup-restore.md`, `encryption-key-rotation.md`) — the genre mismatch is jarring. Either reconcile against current state (mark resolved / still-open) and move open ones into `.planning/`, or delete entirely.
|
||||
**Fix shape**: Cross-reference each S-FOLLOW / P-FOLLOW item against current source (e.g. P-FOLLOW-1 Recharts top-level import — already fixed in the dynamic-import wave; P-FOLLOW-3 MoodEntry index — check `prisma/schema.prisma` for the v1.4.x added index). Move surviving open items to `.planning/v143x-backlog.md`. Delete the file.
|
||||
**Effort**: small
|
||||
|
||||
### F-16: `docs/audit/2026-04-26-PLAN.md` is dated and in German
|
||||
|
||||
**Severity**: low
|
||||
**Topic**: Documentation hygiene
|
||||
**File(s) / Gap**: `docs/audit/2026-04-26-PLAN.md`
|
||||
**What's wrong / missing**: 13 KB German-language plan for a v1.4.x cycle. Auto-memory directive "Marc-voice English, every user-facing artifact" applies. The file is in the audit archive so the rules are weaker, but the language mismatch with every other audit file (`v141X-summary.md` are all English) is an anomaly.
|
||||
**Fix shape**: Translate to English, or move to `.planning/archive/` since it's a milestone-planning document not a release-cycle summary.
|
||||
**Effort**: small
|
||||
|
||||
### F-17: Doctor-report customisation guidance is thin
|
||||
|
||||
**Severity**: low
|
||||
**Topic**: Doctor-report PDF
|
||||
**File(s) / Gap**: `docs/doctor-report.md`
|
||||
**What's wrong / missing**: The doc covers the API surface accurately but does not cover **what's redacted** (the audit-scope question 6 above — PII handling on the printable PDF, the differentiation between user-entered notes vs reference ranges, the user-override badge contract) or **how to customise per locale** (the `getServerTranslator()` resolution path is named, but how a self-hoster adds an `it` locale's PDF strings or overrides a clinical-band label per locale is not). The header / footer / clinician-handoff conventions are entirely absent.
|
||||
**Fix shape**: Append two sections to `docs/doctor-report.md` — "What's in the PDF (and what's not)" (a layout map plus a redaction table — e.g. mood-entry notes flagged sensitive are omitted by default) and "Customising per locale" (the `messages/<locale>.json` keys that drive PDF strings, override patterns for clinical-band labels). 300–500 words.
|
||||
**Effort**: small
|
||||
|
||||
### F-18: README API table drift
|
||||
|
||||
**Severity**: low
|
||||
**Topic**: API reference
|
||||
**File(s) / Gap**: `README.md:240-386`
|
||||
**What's wrong / missing**: The hand-maintained README table is a discoverability surface, not a reference. Once `docs/api/full-reference.md` exists per F-7, the README table should shrink to the 8–10 most-asked-about endpoints with a "see full reference" pointer. Reducing the table reduces drift risk.
|
||||
**Fix shape**: After F-7 lands, trim `README.md:240-386` to a short table plus a cross-link.
|
||||
**Effort**: trivial
|
||||
|
||||
---
|
||||
|
||||
**Length check**: ~2780 words.
|
||||
@@ -0,0 +1,235 @@
|
||||
# README + Docs + GitHub Discoverability Audit — 2026-05-16
|
||||
|
||||
## Executive summary
|
||||
|
||||
The README is well-structured and content-rich, but it underplays the two strongest discoverability hooks the project actually ships: **Apple Health import** (the headline v1.4.34 feature is invisible in the README) and **AI Insights / Coach as a visual differentiator** (mentioned once, as a feature bullet, never shown). There is no hero screenshot or animated demo above the fold, no comparison table against Withings / Apple Health / Oura / self-hosted alternatives, and the GitHub `description` field omits "self-hosted health tracker" — the exact phrase the target user types into search. `homepageUrl` points at the production tenant (`healthlog.bombeck.io`) instead of the demo (`demo.healthlog.dev`) or the docs site. Fix shape: a hero image + 8-row comparison table + 3 keyword swaps in the GitHub description + 6 new topics would 5-10× search impressions without touching the codebase.
|
||||
|
||||
## Findings — prioritized
|
||||
|
||||
### F-1: No hero screenshot or animated demo above the fold
|
||||
|
||||
**Severity**: high
|
||||
**Scope**: README
|
||||
**Citation**: `README.md:1-30` — header block is logo + badges + tagline + nav links; nothing visual until line 80 (Quick Start).
|
||||
**What's wrong**: GitHub renders the first viewport on the repo landing page. Today a visitor sees a 120 px logo, four badges, and three text paragraphs — nothing that conveys "this is a dashboard product" until they scroll past the feature list. Every comparable repo (immich, mealie, paperless-ngx, dawarich) leads with a screenshot or animated GIF; this is the single highest-leverage README change.
|
||||
**Fix shape**: Add a dashboard screenshot (or 2x grid: desktop + mobile PWA) right after the tagline, before badges. Ideally a `.webp` capture of the dashboard with the trends row + a Coach card visible. Add to `public/screenshots/` and reference as `<img src="public/screenshots/hero-dashboard.webp" />`. No PII — use the demo tenant or synthetic data.
|
||||
**Effort**: small (screenshot + commit)
|
||||
|
||||
### F-2: Apple Health import — the headline v1.4.34 feature — missing from README
|
||||
|
||||
**Severity**: high
|
||||
**Scope**: README, SEO
|
||||
**Citation**: `README.md:46-77` — Key Features list. Grep `apple health` against README: zero matches. `CHANGELOG.md:94` ships it as the v1.4.34 banner feature.
|
||||
**What's wrong**: "apple health export server" / "apple health xml import" / "apple health self-hosted" are exactly the queries a user looking to escape the iOS ecosystem types. The repo `topics` include `apple-health`; the README does not. GitHub search ranks README content; not surfacing the keyword in the README means the topic alone won't carry the search.
|
||||
**Fix shape**: Add a dedicated Key-Features bullet: "**Apple Health import** — Drop your `export.zip` on the import page. The server streams it to disk, parses the XML (handles Zip64 / multi-GB exports), and folds every `<Record>`, `<Workout>`, `<Correlation>` into your timeline. Idempotent — re-upload to merge new data without duplicates." Mirror in the Integrations table.
|
||||
**Effort**: trivial [hotfix-ready]
|
||||
|
||||
### F-3: AI Coach / Insights surface buried in a single bullet
|
||||
|
||||
**Severity**: high
|
||||
**Scope**: README, SEO
|
||||
**Citation**: `README.md:60` — single bullet "Multi-Provider AI Insights"; no "Coach", no "Daily Briefing", no "Health Score", no "Weekly Report".
|
||||
**What's wrong**: Per maintainer-stated positioning, AI Insights are *the* differentiator. The README treats them as one feature among twelve. Coach (the conversational surface), Health Score (the daily tile), Daily Briefing, and Weekly Report — all shipped v1.4.20-onward — are not named in the README at all. A user comparing this against generic dashboards never learns the project's flagship.
|
||||
**Fix shape**: Promote AI Insights to a dedicated section between "Why HealthLog?" and "Key Features", with three sub-bullets (Coach / Health Score / Briefing). One screenshot. Frame it as "evidence-grounded, multi-provider, your data stays on your network."
|
||||
**Effort**: small
|
||||
|
||||
### F-4: No comparison table
|
||||
|
||||
**Severity**: high
|
||||
**Scope**: README, SEO (alternatives-linking is an SEO signal)
|
||||
**Citation**: `README.md` — absent.
|
||||
**What's wrong**: Visitors arrive comparing this against Withings web, Apple Health, Oura, Garmin Connect, and the few self-hosted options (Wger, OpenHealth, Wakapi for activity). Without a comparison table, they bounce to evaluate alternatives independently. Linking to alternatives is also a positive SEO signal (mutual relevance).
|
||||
**Fix shape**: 6-column matrix: HealthLog | Withings | Apple Health | Oura web | Garmin Connect | Generic CSV. Rows: Self-hosted, Open source, BYOK AI, Withings sync, Apple Health import, Doctor PDF, Custom thresholds, No subscription, License. Place after "Why HealthLog?".
|
||||
**Effort**: small [hotfix-ready]
|
||||
|
||||
### F-5: GitHub `description` is technically dense but misses primary search keywords
|
||||
|
||||
**Severity**: high
|
||||
**Scope**: repo metadata, SEO
|
||||
**Citation**: GitHub UI → About → description: "Self-hosted, privacy-first personal health tracking PWA. Weight, blood pressure, body composition, glucose, sleep, mood, medications. Withings + Apple Health sync, multi-provider AI Insights (BYOK), client-side doctor-report PDF."
|
||||
**What's wrong**: 290 characters — over the GitHub UI ~200 char cutoff for the About sidebar. The high-value phrase "self-hosted health tracker" (without "ing") isn't in there, which is what 80% of search queries phrase. "Apple Health" appears but "withings alternative" / "self-hosted apple health" do not.
|
||||
**Fix shape**: ≤ 160 chars, keyword-front-loaded: "Self-hosted health tracker. Weight, blood pressure, glucose, mood, medications. Withings + Apple Health sync. AI Insights you own. AGPL." Edit via `gh repo edit MBombeck/HealthLog --description "..."`.
|
||||
**Effort**: trivial [hotfix-ready]
|
||||
|
||||
### F-6: `homepageUrl` points at the maintainer's production tenant, not the demo
|
||||
|
||||
**Severity**: medium
|
||||
**Scope**: repo metadata, SEO, privacy
|
||||
**Citation**: GitHub repo → homepageUrl = `https://healthlog.bombeck.io`.
|
||||
**What's wrong**: The maintainer's personal subdomain is exposed as the project homepage. It is not the public demo. New visitors clicking the sidebar link land on a login wall, not a working preview. Demo lives at `demo.healthlog.dev`; the project site at `healthlog.dev`.
|
||||
**Fix shape**: `gh repo edit MBombeck/HealthLog --homepage https://demo.healthlog.dev` (or `https://healthlog.dev` once that landing exists). Demo is higher-impact for conversion.
|
||||
**Effort**: trivial [hotfix-ready]
|
||||
|
||||
### F-7: No custom social preview image (uses generic GitHub auto-OG)
|
||||
|
||||
**Severity**: medium
|
||||
**Scope**: repo metadata, SEO
|
||||
**Citation**: `usesCustomOpenGraphImage: false` in `gh repo view` output.
|
||||
**What's wrong**: When the repo URL is pasted into Slack / Discord / Mastodon / X, the unfurl shows a generated tile with the repo name. Comparable projects (immich, dawarich) ship a branded 1280×640 PNG showing the dashboard. This is the visual that gets shared.
|
||||
**Fix shape**: Upload a 1280×640 PNG via GitHub UI → Settings → Social preview. Suggested content: dashboard screenshot + "HealthLog — your health data, your server" tagline + logo. The same asset can live in `public/og-image.png` and be referenced from `src/app/layout.tsx:49` (currently no `images` field in `openGraph`).
|
||||
**Effort**: small
|
||||
|
||||
### F-8: GitHub topics — high-relevance gaps + one low-value entry
|
||||
|
||||
**Severity**: medium
|
||||
**Scope**: repo metadata, SEO
|
||||
**Citation**: GitHub repo → topics array (19 entries).
|
||||
**What's wrong**: Missing `withings-alternative`, `apple-health-import`, `personal-dashboard`, `ai-insights`, `glucose-tracker`, `mood-tracker`, `doctor-report`. Present `glp-1` and `mounjaro` are PII-adjacent (specific medications the maintainer likely tracks) and dilute the search graph. `tracking` and `health` are too generic to win against millions of repos.
|
||||
**Fix shape**: Replace `glp-1`, `mounjaro`, `tracking`, `health` with `withings-alternative`, `apple-health-import`, `personal-dashboard`, `ai-insights`, `glucose-tracker`, `mood-tracker`. See "Suggested topics" below for the ranked list.
|
||||
**Effort**: trivial [hotfix-ready]
|
||||
|
||||
### F-9: No `.github/FUNDING.yml`
|
||||
|
||||
**Severity**: low
|
||||
**Scope**: repo metadata
|
||||
**Citation**: `find .github -iname "FUNDING*"` returns empty; `fundingLinks: []` in `gh repo view`.
|
||||
**What's wrong**: No sidebar "Sponsor" button. AGPL self-hosted projects without funding visibility plateau on stars but not on sustained contribution. If the maintainer has any GitHub Sponsors / Ko-fi / Liberapay link, adding it surfaces a sponsor button on the repo page and on every PR.
|
||||
**Fix shape**: `.github/FUNDING.yml` with `github: [MBombeck]` or similar. Skip if no funding intent.
|
||||
**Effort**: trivial
|
||||
|
||||
### F-10: `docs/` tree is operator-/audit-heavy, user-facing guides live elsewhere
|
||||
|
||||
**Severity**: medium
|
||||
**Scope**: docs/
|
||||
**Citation**: `find docs -name "*.md"` — 27 of 31 files are `docs/audit/v14XX-summary.md` or ops/migration notes. No user-facing setup guide tree.
|
||||
**What's wrong**: A visitor following "see Self-Hosting → Reverse Proxy" (`README.md:107`) is bounced to `docs.healthlog.dev`, but the in-repo `docs/` tree appears to be the operator's working notes. There's no `docs/getting-started.md`, no `docs/integrations/apple-health.md`, no `docs/integrations/withings.md`, no `docs/ai-providers.md` in the repo. This is fine if `docs.healthlog.dev` is the canonical surface — but the canonical surface needs verifying, and `docs/` should signal "see docs.healthlog.dev" with a README, not look like a half-built docs tree.
|
||||
**Fix shape**: Add `docs/README.md` (top-level) saying "User docs live at docs.healthlog.dev. The files here are internal operator audits and migration notes." Or move `audit/` into `docs/audit/` ring-fenced. Verify the topics the README links to (`/self-hosting/reverse-proxy/`) exist on the public docs site.
|
||||
**Effort**: small
|
||||
|
||||
### F-11: No `docs/api/openapi.yaml` link in README
|
||||
|
||||
**Severity**: low
|
||||
**Scope**: README, docs/
|
||||
**Citation**: `docs/api/openapi.yaml` exists (`docs/api/README.md:1`) but the README's API Reference section (`README.md:240-386`) doesn't reference it.
|
||||
**What's wrong**: A developer evaluating integration potential doesn't see an OpenAPI spec is available. The README's API table is good for scanning; the OpenAPI spec is the codegen target. Both should be discoverable.
|
||||
**Fix shape**: Add one line above the API Reference collapsibles: "Machine-readable OpenAPI 3.1 spec: [`docs/api/openapi.yaml`](docs/api/openapi.yaml). Generates iOS DTO + any client codegen."
|
||||
**Effort**: trivial [hotfix-ready]
|
||||
|
||||
### F-12: README Tech Stack omits the iOS native client
|
||||
|
||||
**Severity**: low
|
||||
**Scope**: README
|
||||
**Citation**: `README.md:111-128` — Tech Stack table; no iOS / Swift / SwiftUI row.
|
||||
**What's wrong**: The repo carries `docs/apple-store-connect-checklist.md` and v1.4.23-onward CHANGELOG entries describing the native iOS app foundation. The README hides this; a visitor sees "web PWA only."
|
||||
**Fix shape**: Add row "iOS client | SwiftUI (separate repo, in development)" or call it out in a Roadmap section.
|
||||
**Effort**: trivial
|
||||
|
||||
### F-13: No Roadmap / Status section
|
||||
|
||||
**Severity**: low
|
||||
**Scope**: README
|
||||
**Citation**: `README.md` — absent.
|
||||
**What's wrong**: Visitors can't tell what's next, what's stable, or whether the project is alive. The CHANGELOG shows ~daily releases; that signal doesn't reach the landing.
|
||||
**Fix shape**: A small "Status" section: "Active development. ~weekly releases. Current focus: native iOS client (v1.5)." Plus one-line Roadmap link to a GitHub Project board or to the discussion forum.
|
||||
**Effort**: trivial
|
||||
|
||||
### F-14: `src/app/layout.tsx` openGraph has no `images` field
|
||||
|
||||
**Severity**: low
|
||||
**Scope**: SEO
|
||||
**Citation**: `src/app/layout.tsx:49-57` — `openGraph` block lacks `images:`.
|
||||
**What's wrong**: When `demo.healthlog.dev` is shared in chat, the unfurl has no image. Same problem as F-7 but for the live app, not the repo.
|
||||
**Fix shape**: Add `images: [{ url: "/og-image.png", width: 1200, height: 630 }]` and ship `public/og-image.png`. Same asset as F-7.
|
||||
**Effort**: trivial
|
||||
|
||||
### F-15: README has no badge for latest release or Docker pulls
|
||||
|
||||
**Severity**: low
|
||||
**Scope**: README, SEO
|
||||
**Citation**: `README.md:11-17` — 5 badges, none of which surface version or activity.
|
||||
**What's wrong**: A current-version badge tells visitors the project is alive without them scrolling. Docker pulls signal traction.
|
||||
**Fix shape**: Add `` and `` (GHCR doesn't have a pulls counter but the latest-tag badge serves the same role).
|
||||
**Effort**: trivial [hotfix-ready]
|
||||
|
||||
## Suggested topics
|
||||
|
||||
Ranked by search-volume × relevance. Aim for 15-18 active topics; drop `glp-1`, `mounjaro`, `tracking`, `health`, `dashboard` (too generic).
|
||||
|
||||
1. `self-hosted`
|
||||
2. `health-tracking`
|
||||
3. `pwa`
|
||||
4. `nextjs`
|
||||
5. `apple-health`
|
||||
6. `withings`
|
||||
7. `apple-health-import`
|
||||
8. `withings-alternative`
|
||||
9. `personal-health`
|
||||
10. `quantified-self`
|
||||
11. `privacy`
|
||||
12. `prisma`
|
||||
13. `medication-tracker`
|
||||
14. `glucose-tracker`
|
||||
15. `mood-tracker`
|
||||
16. `ai-insights`
|
||||
17. `docker`
|
||||
18. `agpl`
|
||||
|
||||
## Draft README revision
|
||||
|
||||
Not warranted as a full rewrite. The existing structure is sound; the top three changes (hero image + Apple Health bullet + comparison table) are additive edits to the current document. Suggested insertions to the existing README:
|
||||
|
||||
**Insert after line 22 (before the "Website / Live Demo / Documentation" link row):**
|
||||
|
||||
```markdown
|
||||
<p align="center">
|
||||
<img src="public/screenshots/hero-dashboard.webp" alt="HealthLog dashboard — trends, Coach card, Health Score" width="900" />
|
||||
</p>
|
||||
```
|
||||
|
||||
**Insert as a new section after "Why HealthLog?" (after line 44):**
|
||||
|
||||
```markdown
|
||||
## How it compares
|
||||
|
||||
| | HealthLog | Withings web | Apple Health | Oura web | Generic CSV |
|
||||
| ------------------------ | ------------------ | -------------- | ------------ | ---------- | ----------- |
|
||||
| Self-hosted | Yes | No | No | No | Yes |
|
||||
| Open source | AGPL-3.0 | No | No | No | n/a |
|
||||
| Withings device sync | Yes (OAuth2) | Yes (native) | Via shortcut | No | No |
|
||||
| Apple Health import | Yes (`export.zip`) | No | Native | No | Manual |
|
||||
| Custom clinician targets | Yes (audit-logged) | Limited | No | No | n/a |
|
||||
| Doctor-report PDF | Yes (client-side) | No | No | No | n/a |
|
||||
| AI Insights | Multi-provider BYOK| No | Limited | Subscription| n/a |
|
||||
| Subscription required | No | For some metrics| No | Yes | No |
|
||||
| Your data leaves device | Never | Withings cloud | Apple cloud | Oura cloud | Depends |
|
||||
```
|
||||
|
||||
**Insert as a new bullet inside Key Features (between line 58 and line 60):**
|
||||
|
||||
```markdown
|
||||
**Apple Health import** — Drop your iOS `export.zip` on the import page. Streaming parser handles multi-gigabyte archives (Zip64), folds every record / workout / correlation into your timeline, and stays idempotent on re-upload. Per-type ingestion stats and live progress on a status endpoint.
|
||||
```
|
||||
|
||||
**Insert as a new bullet inside Key Features, promoted to first position (replace current line 60 "Multi-Provider AI Insights"):**
|
||||
|
||||
```markdown
|
||||
**AI Coach + Insights** — A conversational Coach trained on your own data, a daily briefing, a weekly report, and a Health Score tile on the dashboard. Pick OpenAI, Anthropic Claude, or any OpenAI-compatible local endpoint (Ollama, LM Studio, vLLM). BYOK or admin-shared. Evidence-grounded — every claim links back to the measurements that produced it. Local endpoints keep all data on your network.
|
||||
```
|
||||
|
||||
**Add a one-line Status block after the "What it is" paragraph (after line 36):**
|
||||
|
||||
```markdown
|
||||
> **Status**: active. New releases roughly weekly — see [CHANGELOG](CHANGELOG.md). Current focus: native iOS client (v1.5).
|
||||
```
|
||||
|
||||
**Replace badges block (lines 11-17) with:**
|
||||
|
||||
```markdown
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg" alt="License: AGPL-3.0" /></a>
|
||||
<a href="https://github.com/MBombeck/HealthLog/releases"><img src="https://img.shields.io/github/v/release/MBombeck/HealthLog?sort=semver&color=success" alt="Latest release" /></a>
|
||||
<img src="https://img.shields.io/badge/Self--Hosted-yes-success" alt="Self-Hosted" />
|
||||
<img src="https://img.shields.io/badge/Next.js-16-black?logo=next.js" alt="Next.js 16" />
|
||||
<img src="https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white" alt="TypeScript" />
|
||||
<a href="https://github.com/MBombeck/HealthLog/pkgs/container/healthlog"><img src="https://img.shields.io/badge/GHCR-multi--arch-2496ED?logo=docker&logoColor=white" alt="GHCR" /></a>
|
||||
</p>
|
||||
```
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Rewriting `docs.healthlog.dev` content (separate audit; outside repo).
|
||||
- Producing the actual hero screenshot / OG image binary (asset creation, not code).
|
||||
- Editing the live GitHub repo metadata (`description`, `topics`, `homepageUrl`) — flagged as `[hotfix-ready]`, awaiting maintainer command.
|
||||
- CONTRIBUTING.md / SECURITY.md / CODE_OF_CONDUCT.md content review beyond presence check (all three present and structurally sound; no critical issues found).
|
||||
- Release-note quality audit per release (CHANGELOG is comprehensive; the v1.4.34 entry reads well).
|
||||
- E2E / integration test coverage of the docs links themselves.
|
||||
@@ -0,0 +1,380 @@
|
||||
# Mobile-Deep Audit — 2026-05-16
|
||||
|
||||
## Executive summary
|
||||
|
||||
The mobile contract is largely solid where attention has been paid —
|
||||
bottom-nav respects safe-area insets, dashboard tiles wrap correctly
|
||||
at every breakpoint, and the `<ResponsiveSheet>` primitive flips
|
||||
between Sheet and Dialog by viewport. Five structural gaps remain.
|
||||
The sticky `<TopBar>` ignores the iOS top safe-area inset, so PWA
|
||||
installs on notched devices get the logo clipped under the status
|
||||
bar. Five raw `<DialogContent>` mounts (intake import, doctor report,
|
||||
password change, app-log preview, side-effects) never went through
|
||||
the v1.4.27 Sheet branch, so phone users land on a centred desktop
|
||||
dialog instead of a bottom sheet. The Coach FAB at `bottom-20`
|
||||
overlaps the bottom-nav once iPhone safe-area adds 34 px. Coach,
|
||||
bug-report, and feedback textareas ship `text-sm` (14 px), which
|
||||
triggers iOS Safari's zoom-on-focus quirk. Sonner is hardcoded
|
||||
`theme="dark"` while the rest of the app supports light mode, and
|
||||
its `bottom-right` position sits under the bottom-nav on phones.
|
||||
|
||||
## Findings — prioritized
|
||||
|
||||
### F-1: `<TopBar>` ignores the iOS top safe-area inset
|
||||
|
||||
**Severity**: high
|
||||
**Surface**: global
|
||||
**Viewport**: `<sm` (PWA install on notched iPhone)
|
||||
**File(s)**: `src/components/layout/top-bar.tsx:47`, `src/app/layout.tsx:44-48`
|
||||
**What's wrong**: `appleWebApp.statusBarStyle = "black-translucent"` makes
|
||||
the iOS status bar overlay the app, but the sticky header only sets
|
||||
`top-0 z-40 h-16` — no `pt-[env(safe-area-inset-top)]`. Once the app
|
||||
runs in standalone mode on iPhone X+ the 64 px header pulls under the
|
||||
status bar / notch; the logo and the mobile user-menu trigger get
|
||||
clipped. The bottom-nav handles `env(safe-area-inset-bottom)`
|
||||
correctly, so the asymmetry is the bar that needs the same treatment.
|
||||
The standalone public pages (`/about`, `/privacy`) already render
|
||||
their own header with the inset; the main shell missed it.
|
||||
**Fix shape**: Add `pt-[env(safe-area-inset-top)] h-[calc(4rem+env(safe-area-inset-top))]`
|
||||
to the `<header>` and raise the inner row to `h-16` separately, OR
|
||||
switch the page-shell `<main>` to a top padding that mirrors the
|
||||
bottom-nav contract. Verify the maintainership banner above the bar
|
||||
respects the same inset.
|
||||
**Effort**: small `[hotfix-ready]`
|
||||
|
||||
### F-2: Static `themeColor` doesn't track light mode
|
||||
|
||||
**Severity**: high
|
||||
**Surface**: global
|
||||
**Viewport**: all (iOS Safari + Android Chrome)
|
||||
**File(s)**: `src/app/layout.tsx:70`, `public/manifest.json:7-8`
|
||||
**What's wrong**: Both surfaces hardcode `#282a36` (Dracula dark
|
||||
background) as the theme-color. Light-mode users (Alucard palette,
|
||||
`#f5f5f5` background) get a near-black Android address bar and an
|
||||
iOS PWA status-bar background that doesn't match the page. The
|
||||
theme toggle ships three modes (system / dark / light); the
|
||||
chrome-colour signal never follows.
|
||||
**Fix shape**: Replace the static `themeColor` with the Next 13+
|
||||
array form: `themeColor: [{ media: "(prefers-color-scheme: light)",
|
||||
color: "#f5f5f5" }, { media: "(prefers-color-scheme: dark)", color:
|
||||
"#282a36" }]`. Manifest can carry only one — keep the dark one as
|
||||
the default but document the trade-off. Optionally write a tiny
|
||||
client effect that updates `<meta name="theme-color">` when the user
|
||||
flips themes manually.
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
### F-3: Five raw `<DialogContent>` mounts skip the Sheet branch
|
||||
|
||||
**Severity**: high
|
||||
**Surface**: medications, settings, admin, global (doctor report)
|
||||
**Viewport**: `<sm`
|
||||
**File(s)**: `src/app/medications/page.tsx:430` (intake import), `src/app/medications/page.tsx:687` (also intake variant), `src/components/settings/account-section.tsx:612` (password change), `src/components/admin/app-log-preview-section.tsx:279`, `src/components/doctor-report/doctor-report-dialog.tsx:371`
|
||||
**What's wrong**: The `<ResponsiveSheet>` primitive was added in
|
||||
v1.4.27 MB1 precisely so these surfaces would slide up from the
|
||||
bottom on phones. Five mount sites still call `<Dialog>` +
|
||||
`<DialogContent>` directly, so on a 375 px viewport the user gets a
|
||||
centred modal capped at `calc(100% - 2rem)` with the close-X pushed
|
||||
into the corner. Doctor-report is the worst offender — it carries a
|
||||
multi-row date-range form + a comment textarea, and the body
|
||||
overflows the dialog's `max-h-[calc(100dvh-2rem)]` so the form
|
||||
scrolls inside a desktop card instead of feeling like a sheet.
|
||||
**Fix shape**: Migrate every site to `<ResponsiveSheet>`. The body
|
||||
markup stays identical; replace the `<Dialog>` + `<DialogContent>` +
|
||||
`<DialogHeader>` + `<DialogFooter>` pairs with the sheet primitive's
|
||||
`title` / `description` / `footer` slots. The Sheet branch already
|
||||
takes the `90dvh` cap and sticky footer for free.
|
||||
**Effort**: small (each site) / medium (all five) `[hotfix-ready]`
|
||||
|
||||
### F-4: Coach FAB overlaps the bottom-nav on notched iPhones
|
||||
|
||||
**Severity**: high
|
||||
**Surface**: insights
|
||||
**Viewport**: `<sm` (iPhone X+ PWA)
|
||||
**File(s)**: `src/components/insights/layout-coach-fab.tsx:64`
|
||||
**What's wrong**: The FAB sits at `bottom-20` (80 px) and the
|
||||
bottom-nav reports `min-h-16 pb-[env(safe-area-inset-bottom)]` =
|
||||
64 + 34 = 98 px on iPhone 13+. The FAB lands 18 px inside the bar,
|
||||
visually colliding with the More icon. Tap target is technically
|
||||
fine because the FAB sits at `z-40` under the nav's `z-50`, but the
|
||||
bottom rim of the gradient pill clips behind the nav strip — looks
|
||||
broken on a Dracula-dark bar against a Dracula-pink pill.
|
||||
**Fix shape**: Promote `bottom-20` to
|
||||
`bottom-[calc(5rem+env(safe-area-inset-bottom))]` so the FAB
|
||||
reserves the same inset the nav does. Verify on landscape Android
|
||||
where the safe-area is zero — the calc collapses to the original
|
||||
80 px.
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
### F-5: Three textareas trigger iOS Safari zoom-on-focus
|
||||
|
||||
**Severity**: high
|
||||
**Surface**: insights (Coach), bug report, admin (feedback inbox), medications, side-effects
|
||||
**Viewport**: `<sm` (iOS Safari only)
|
||||
**File(s)**: `src/components/insights/coach-panel/coach-input.tsx:211`, `src/app/bugreport/page.tsx:210`, `src/components/admin/feedback-inbox-section.tsx:497-510`, `src/app/medications/page.tsx:462-475`, `src/components/medications/SideEffectsSection.tsx:467-480`
|
||||
**What's wrong**: iOS Safari auto-zooms to 16 px whenever a focused
|
||||
`<input>` or `<textarea>` carries a font-size below 16 px. The
|
||||
`<Input>` primitive (`text-base md:text-sm`) avoids the zoom; every
|
||||
raw `<textarea>` in the app ships `text-sm` (14 px). The Coach
|
||||
input is the worst case — the drawer takes 90 dvh, the keyboard
|
||||
opens, the textarea zooms, and the user has to pinch back out before
|
||||
they can keep typing. There is no `<Textarea>` primitive that
|
||||
solves this once for everyone.
|
||||
**Fix shape**: Introduce `src/components/ui/textarea.tsx` mirroring
|
||||
`<Input>`'s `text-base md:text-sm` font policy + the same focus
|
||||
ring / aria-invalid contract. Replace the five raw `<textarea>` call
|
||||
sites. While there: verify Coach drawer doesn't shrink behind the
|
||||
visualViewport when the iOS keyboard opens — `message-thread.tsx`
|
||||
already wires `visualViewport.resize`, the input row should follow.
|
||||
**Effort**: small `[hotfix-ready]`
|
||||
|
||||
### F-6: Sonner toast hardcoded dark + bottom-right collides with bottom-nav
|
||||
|
||||
**Severity**: high
|
||||
**Surface**: global
|
||||
**Viewport**: `<sm` (mobile)
|
||||
**File(s)**: `src/components/ui/sonner.tsx:15`, `src/components/providers.tsx:132`
|
||||
**What's wrong**: The Toaster wrapper sets `theme="dark"` regardless
|
||||
of the user's theme preference — light-mode users see a black toast
|
||||
with white text against a white card, which reads inverted. The
|
||||
provider mounts it at `position="bottom-right"`, which on a 375 px
|
||||
viewport overlaps the bottom-nav (`z-50`). Sonner's default
|
||||
`z-index` is high enough that the toast can paint on top of the nav
|
||||
strip, but the visual layout still looks broken because the toast
|
||||
covers the Medications icon.
|
||||
**Fix shape**: Read the live theme from the existing `useTheme()`
|
||||
context and feed it as `theme={theme === "system" ? "system" :
|
||||
theme}` (sonner accepts `"system"`). Lift position to
|
||||
`top-center` on `<md` (using a `useIsMobile()` switch) and keep
|
||||
`bottom-right` on desktop; or hold `bottom-center` everywhere and
|
||||
add `mb-[calc(4rem+env(safe-area-inset-bottom))]` via `toastOptions`
|
||||
so it sits above the bar. Mark the toaster region with
|
||||
`aria-live="polite"` if sonner's default does not already.
|
||||
**Effort**: small `[hotfix-ready]`
|
||||
|
||||
### F-7: SheetHeader has no `pr-` reservation for the close-X
|
||||
|
||||
**Severity**: medium
|
||||
**Surface**: global
|
||||
**Viewport**: `<sm` (long localised titles)
|
||||
**File(s)**: `src/components/ui/sheet.tsx:97-104`
|
||||
**What's wrong**: `<DialogHeader>` carries `pr-9` so the title never
|
||||
runs under the close-X. `<SheetHeader>` only carries `p-4`. With a
|
||||
long German / Polish title (e.g. settings sub-flows), the title runs
|
||||
right up to — or under — the absolutely-positioned close button
|
||||
sitting at `top-3 right-4 min-h-9 min-w-9`. The bottom-sheet variant
|
||||
`<ResponsiveSheet>` patches over this with its own `p-4 pr-12`
|
||||
override (`src/components/ui/responsive-sheet.tsx:123`), but every
|
||||
direct `<Sheet>` consumer skips that fix.
|
||||
**Fix shape**: Add `pr-12` (matches the close-X 36 px footprint +
|
||||
breathing room) to the `<SheetHeader>` className. Audit direct
|
||||
consumers — bottom-nav More sheet, Coach drawer, Coach settings
|
||||
sheet — to make sure the override doesn't clip an intentional
|
||||
header layout.
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
### F-8: `useIsMobile` returns `false` for one frame on phone-class SSR
|
||||
|
||||
**Severity**: medium
|
||||
**Surface**: global (ResponsiveSheet, Coach drawer)
|
||||
**Viewport**: `<md`
|
||||
**File(s)**: `src/hooks/use-is-mobile.ts:58-60`
|
||||
**What's wrong**: `getServerSnapshot()` is pinned to `false` (already
|
||||
documented at the top of the file). That's fine for the documented
|
||||
"sheet only opens on user gesture" case, but the deep-link / `?add=`
|
||||
flows on `/measurements`, `/mood`, `/medications` open the form
|
||||
modal during the first paint via a `useEffect` triggered by the
|
||||
search param. On a phone viewport, the desktop Dialog branch
|
||||
flickers for the first commit before flipping to the bottom Sheet —
|
||||
input focus is lost and the cancel button repaints. The flash is
|
||||
brief but visible on Pixel-5-class hardware.
|
||||
**Fix shape**: Pass the viewport hint down via a server-rendered
|
||||
cookie set by the proxy / middleware (matching the locale cookie
|
||||
strategy), and read it in the hook to seed the initial value. OR
|
||||
gate the auto-open behind an extra paint tick so the second render
|
||||
already has the correct branch.
|
||||
**Effort**: medium
|
||||
|
||||
### F-9: `max-h-[80vh]` instead of `dvh` on two surfaces
|
||||
|
||||
**Severity**: medium
|
||||
**Surface**: admin (app log preview), onboarding (tour tooltip)
|
||||
**Viewport**: `<sm` (iOS Safari)
|
||||
**File(s)**: `src/components/admin/app-log-preview-section.tsx:279`, `src/components/onboarding/tour.tsx:485`
|
||||
**What's wrong**: Both surfaces cap at `80vh`. iOS Safari's `vh` is
|
||||
the *expanded* viewport height (no chrome subtracted), so 80 vh on a
|
||||
390 × 844 iPhone equals ~675 px while the actually-visible viewport
|
||||
when the address bar is shown is closer to 720 px. The dialog
|
||||
contents (especially the tour tooltip, which is then `absolute`
|
||||
positioned and meant to point at a UI element) can overflow under
|
||||
the address bar. Every other modal in the app already uses `dvh`
|
||||
(coach drawer, ResponsiveSheet, DialogContent default).
|
||||
**Fix shape**: Replace both with `max-h-[80dvh]` (or `max-h-[90dvh]`
|
||||
to match the bottom-sheet convention). One-character change.
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
### F-10: No "Add to Home Screen" prompt or affordance
|
||||
|
||||
**Severity**: medium
|
||||
**Surface**: global (PWA install)
|
||||
**Viewport**: all
|
||||
**File(s)**: `src/components/providers.tsx` (handler absent), `public/manifest.json` (Android-only), `src/app/layout.tsx` (no apple-touch-icon link)
|
||||
**What's wrong**: The app ships a manifest + service worker, but
|
||||
nothing in the code listens for `beforeinstallprompt` on Android /
|
||||
Edge, and there is no in-app "Add to Home Screen" affordance for
|
||||
iOS Safari (which doesn't fire the event at all — the user has to
|
||||
discover the share-sheet flow themselves). For a self-hosted health
|
||||
PWA the install rate without a prompt is near-zero. The metadata
|
||||
sets `appleWebApp.capable = true` and `apple-touch-icon` is exposed
|
||||
via `metadata.icons.apple = "/logo-192.png"`, but PWA discoverability
|
||||
is otherwise zero.
|
||||
**Fix shape**: Add `usePwaInstallPrompt()` hook that captures the
|
||||
`BeforeInstallPromptEvent` once and exposes a `install()` method.
|
||||
Surface a small "Install app" entry inside the top-bar user-menu on
|
||||
mobile (only when the deferred prompt is captured). For iOS Safari,
|
||||
detect `standalone === false` + `userAgent` Mobile Safari and show a
|
||||
once-per-week toast with the share-sheet diagram. Both gated on the
|
||||
"Add to Home Screen" cookie so the user can dismiss permanently.
|
||||
**Effort**: medium
|
||||
|
||||
### F-11: Default button size (h-10 = 40 px) misses WCAG 2.5.5 on mobile
|
||||
|
||||
**Severity**: medium
|
||||
**Surface**: forms, settings, admin (148 small-size button usages)
|
||||
**Viewport**: `<sm`
|
||||
**File(s)**: `src/components/ui/button.tsx:49`, `src/components/ui/button.tsx:53` (icon variant), 148 call sites for `size="sm"` / `size="icon-sm"`
|
||||
**What's wrong**: The button primitive defines `default = h-10`
|
||||
(40 px) and `icon = size-10`, both 4 px below WCAG 2.5.5 (44 × 44).
|
||||
F-7 in the prior UX audit calls out Input / Select; the same
|
||||
problem lives on the Button primitive that those forms use for their
|
||||
Save / Cancel rows. The dashboard quick-add CTA uses an explicit
|
||||
`min-h-11 sm:min-h-9` override because its own audit caught the gap;
|
||||
nothing else does. `size="sm"` (32 px) and `size="icon-sm"` (32 px)
|
||||
are below the floor by 12 px — they appear 148 times in source.
|
||||
**Fix shape**: Either bump the base `default` and `icon` to
|
||||
`h-11 sm:h-10` / `size-11 sm:size-10` so every consumer inherits the
|
||||
mobile floor, or formalise the `min-h-11 sm:min-h-9` pattern as a
|
||||
`mobile-floor` size variant and migrate the high-traffic call sites
|
||||
(form footers, list-row delete-confirm, settings primary CTAs).
|
||||
**Effort**: medium
|
||||
|
||||
### F-12: Mood radio buttons can collapse below 44 px on narrow phones
|
||||
|
||||
**Severity**: medium
|
||||
**Surface**: mood
|
||||
**Viewport**: `<sm` (320 px Galaxy Fold first screen)
|
||||
**File(s)**: `src/components/mood/mood-form.tsx:192-209`
|
||||
**What's wrong**: The five mood radio buttons render in a 5-column
|
||||
grid with `p-2` (8 px each side) and no `min-h` floor. The visible
|
||||
height is `text-lg` (28 px) + label + padding ≈ 56 px on default
|
||||
font scaling, but on a 320 px viewport with Dynamic Type bumped one
|
||||
notch the buttons can flatten under 44 px tall. The grid is also
|
||||
the primary action of the entire form — getting it wrong feels
|
||||
worse than a peripheral control.
|
||||
**Fix shape**: Add `min-h-16` (or `min-h-[3.75rem]`) and a
|
||||
`grid-cols-5 sm:grid-cols-5` (or fall to two rows of 3 + 2 on
|
||||
`<sm`) to guarantee the tap target. The radio role / aria-checked
|
||||
semantics stay.
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
### F-13: Compliance heatmap cells fall to 14 px (below tap target)
|
||||
|
||||
**Severity**: low
|
||||
**Surface**: medications (compliance heatmap)
|
||||
**Viewport**: `<sm`
|
||||
**File(s)**: `src/components/charts/compliance-heatmap.tsx:230-237`
|
||||
**What's wrong**: `stretch=true` heatmaps clamp to `CELL_FLOOR_PX`
|
||||
(14 px) on narrow viewports — far below the 44 px floor. Each cell
|
||||
is a tappable surface (tap-to-pin tooltip) per the `onPointerDown`
|
||||
handler. WCAG 2.5.5 has an explicit exception for "inline content"
|
||||
which arguably applies to a calendar heatmap cell, but the
|
||||
tap-to-pin behaviour makes the heatmap an interactive primary
|
||||
control on mobile. Users have to thumb-zoom to read a single day.
|
||||
**Fix shape**: Either drop the tap-to-pin affordance on `<sm` and
|
||||
hand the heatmap to a tap-to-open detail sheet (one tap anywhere on
|
||||
the row → bottom-sheet listing every day in the row), OR enlarge
|
||||
the floor to 24 px on `<sm` and accept the horizontal scroll. The
|
||||
detail-sheet route is the iOS-native pattern.
|
||||
**Effort**: medium
|
||||
|
||||
### F-14: No pull-to-refresh, swipe-to-delete, or long-press menu
|
||||
|
||||
**Severity**: low (informational)
|
||||
**Surface**: measurements, mood, medications (lists), dashboard
|
||||
**Viewport**: `<sm`
|
||||
**File(s)**: every list component (none implements these)
|
||||
**What's wrong**: HealthLog mobile lists are static — no
|
||||
`pull-to-refresh` to re-fetch the latest measurement, no
|
||||
swipe-left-to-delete on rows, no long-press to context-menu. Users
|
||||
arriving from Apple Health / Withings expect at least pull-to-refresh
|
||||
(the tab is data-heavy, refresh is implicit elsewhere through
|
||||
TanStack Query but the gesture builds trust). Swipe-to-delete is
|
||||
optional but the iOS-native list contract; the explicit per-row trash
|
||||
button covers the same functional need. Long-press is the
|
||||
established way to expose secondary actions ("Edit", "Copy value",
|
||||
"Share screenshot") without overlaying icons.
|
||||
**Fix shape**: At minimum, wire `react-use-gesture` (already in the
|
||||
dep tree if not, light add) to give the dashboard + measurement
|
||||
lists a pull-to-refresh that calls `queryClient.invalidateQueries({
|
||||
queryKey: [...] })`. Swipe-to-delete can wait until users ask;
|
||||
long-press menus open the door to a context-menu primitive (not
|
||||
currently in the design system).
|
||||
**Effort**: medium (pull-to-refresh) / large (gesture suite)
|
||||
|
||||
### F-15: No persistent-login affordance
|
||||
|
||||
**Severity**: low
|
||||
**Surface**: auth
|
||||
**Viewport**: all
|
||||
**File(s)**: `src/app/auth/login/page.tsx:178-235`, `src/lib/auth/session.ts:8`
|
||||
**What's wrong**: Session cookie is hardcoded to 30 days
|
||||
(`SESSION_MAX_AGE_MS`). There is no "Stay signed in" checkbox or
|
||||
toggle, so a security-conscious user who'd want a short 24-hour
|
||||
session can't reduce it, and a power user who wants a 90-day session
|
||||
can't extend it. The PWA shell expects users to live in the app —
|
||||
30 days is a reasonable default but the UX contract isn't surfaced.
|
||||
**Fix shape**: Add a `Stay signed in` checkbox under the password
|
||||
fields that flips the cookie maxAge between 24 h (unchecked) and 30
|
||||
days (checked, default). Or surface the current expiry in
|
||||
`/settings/account` so the user understands the contract. Either is
|
||||
small; the value is in the explicit signal, not the mechanic.
|
||||
**Effort**: small
|
||||
|
||||
### F-16: Native sharing absent for doctor reports / screenshots
|
||||
|
||||
**Severity**: low (informational)
|
||||
**Surface**: doctor report, insights
|
||||
**Viewport**: all
|
||||
**File(s)**: `src/components/doctor-report/doctor-report-dialog.tsx` (download only)
|
||||
**What's wrong**: The doctor-report flow downloads a PDF. On
|
||||
mobile, the native pattern is `navigator.share({ files: [...] })` —
|
||||
the user sends the report straight to their doctor's WhatsApp /
|
||||
email without going through Files. The Web Share Level 2 API ships
|
||||
on iOS 15+ and Android Chrome. Skipping it makes the export feel
|
||||
desktop-first.
|
||||
**Fix shape**: Detect `navigator.canShare?.({ files: [...] })` and
|
||||
expose a "Share" button alongside "Download" in the dialog footer.
|
||||
Falls through to a `<a download>` when unsupported.
|
||||
**Effort**: small
|
||||
|
||||
## Out of scope / accepted constraints
|
||||
|
||||
- **Recharts stays.** Following `feedback_charts_visual_identity.md`
|
||||
— no library replacement, only the tooltip / touch-mode hardening
|
||||
the existing code already does (cursor, filterNull, RichChartTooltip).
|
||||
- **Sidebar nav.** Desktop-only; this audit focuses on the mobile
|
||||
contract.
|
||||
- **Onboarding tour overlay.** Functional on `<sm` (`pointer-events`
|
||||
+ portal flow); only the `80vh` cap is called out (F-9). The
|
||||
tooltip's WCAG 2.5.5 footer buttons are already at `min-h-11`.
|
||||
|
||||
## What was not checked
|
||||
|
||||
- Live Lighthouse / WebPageTest run against the production app on a
|
||||
throttled iPhone 12 — the audit is grep-driven.
|
||||
- Real-device VoiceOver / TalkBack pass on the sheet branches —
|
||||
the primitives carry the right ARIA semantics on paper.
|
||||
- Landscape orientation for the Coach drawer specifically (it
|
||||
occupies `90dvh` portrait — landscape with the keyboard open is
|
||||
worth a real-device check).
|
||||
- The `/admin/**` deep links on `<sm` (this audit treats admin as
|
||||
best-effort; Marc is the only admin).
|
||||
@@ -0,0 +1,98 @@
|
||||
# Mobile Security Audit — 2026-05-16
|
||||
|
||||
## Executive summary
|
||||
|
||||
The mobile surface is in better shape than expected. Passkey, refresh-token rotation with reuse-detection, AES-GCM-at-rest with versioned keys, HMAC-hashed API tokens, and per-IP rate-limit envelopes on every auth path are already in place. The three sharp gaps I'd close before App Store goes live: (1) **the Apple Health import pipeline reads a 1.5 GB upload into memory with `readFileSync` and inflates the central XML with no max-output cap** — a malicious or accidental zip-bomb crashes the worker; (2) **password change does not revoke `ApiToken` rows nor `RefreshToken` family** — a stolen long-lived iOS token survives the user's most obvious self-remediation; (3) **the `/.well-known/` namespace is wide-open via `startsWith` in proxy.ts** — fine today, but the pattern admits any future `/.well-known/foo` route without auth, which is exactly how AASA siblings (Universal Links, OIDC discovery) tend to creep in. Order: F-1 before App Store ship (worker can be killed remotely), then F-2 hotfix, then everything else.
|
||||
|
||||
## Findings — prioritized
|
||||
|
||||
### F-1: Apple Health unzip loads entire upload to RAM + inflates with no output cap
|
||||
|
||||
**Severity**: high
|
||||
**Blast radius**: any authenticated user can crash the worker container (DoS the whole queue, including reminders + Withings activity sync + sleep sync) by uploading a crafted ZIP. With 1.5 GB upload allowed, even a benign large export OOMs a typical Coolify worker.
|
||||
**File(s)**: `src/lib/import/unzip-export-xml.ts:65` (`readFileSync(archivePath)` — whole archive in RAM), `:240` (`inflateRawSync(compressed)` — no `maxOutputLength` option), `src/app/api/import/apple-health-export/route.ts:41` (`MAX_UPLOAD_BYTES = 1.5 GB`)
|
||||
**What's wrong**: The streamed-to-disk multipart parser does its job (`stream-to-disk.ts` keeps memory bounded), but then `extractExportXml` undoes that by `readFileSync`ing the entire upload — peak RSS jumps to ~1.5 GB on the worker per concurrent import. Worse, `inflateRawSync` has no upper bound on output: a 1 MB ZIP with a 10:1 deflate ratio can inflate to many GB. Apple's real exports compress well, so an attacker only needs one crafted entry to OOM the worker. `sax.parser` itself is fine — defaults `maxEntityCount=512`, `maxEntityDepth=4` (sax.js source, lib/sax.js:70–71) so billion-laughs is covered.
|
||||
**Fix shape**: switch to a streaming ZIP reader (`yauzl` or a `createReadStream + node:zlib.createInflateRaw` pipe) that walks the central directory by seek and streams the chosen entry through `createInflateRaw({ maxOutputLength: 8 * 1024 * 1024 * 1024 })` (8 GB cap — Apple's largest realistic export is ~6 GB uncompressed). Or keep the current shape but pass an explicit `maxOutputLength` to the existing `inflateRawSync` call and `fs.statSync` + reject if `archive.size > 2 GB` before the `readFileSync`.
|
||||
**Effort**: medium (4 h) — needs a streaming path; tests already exercise the central-directory walker so refactoring is bounded.
|
||||
|
||||
### F-2: Password change does not revoke `ApiToken` or `RefreshToken` rows
|
||||
|
||||
**Severity**: high `[hotfix-ready]`
|
||||
**Blast radius**: a user who suspects compromise and changes their password (the obvious self-remediation) is told all sessions are killed, but their 90-day iOS Bearer token + 60-day refresh-token family keep working. iOS app continues operating; an attacker holding the leaked token also continues operating.
|
||||
**File(s)**: `src/app/api/auth/password/route.ts:81` (`destroyAllSessions(user.id)` is the only revocation call), `src/lib/auth/session.ts:159–161` (`destroyAllSessions` deletes Session rows only)
|
||||
**What's wrong**: `destroyAllSessions` only touches `prisma.session.deleteMany`. Native callers authenticate via `ApiToken` (Bearer) and rotate via `RefreshToken` — both tables are independent and survive the password change.
|
||||
**Fix shape**: in `password/route.ts` after `destroyAllSessions`, add `await prisma.apiToken.updateMany({ where: { userId: user.id, revoked: false }, data: { revoked: true } })` and `await prisma.refreshToken.updateMany({ where: { userId: user.id, revokedAt: null }, data: { revokedAt: new Date() } })`. Same fix belongs in the admin reset-password path (`src/app/api/admin/users/[id]/reset-password/route.ts`).
|
||||
**Effort**: trivial (< 15 min)
|
||||
|
||||
### F-3: AASA + `/.well-known/` proxy bypass admits any future namespace member without auth
|
||||
|
||||
**Severity**: medium `[hotfix-ready]`
|
||||
**Blast radius**: today, none — only `apple-app-site-association` lives under `/.well-known/` (`src/app/.well-known/apple-app-site-association/route.ts:36`). The risk is that the `startsWith("/.well-known/")` rule (`src/proxy.ts:43`) will silently make every future sibling route (OIDC discovery, security.txt, openid-federation, change-password) publicly readable, including ones a future developer wires to internal helpers. AASA itself is fine: it returns a static object with no user data.
|
||||
**File(s)**: `src/proxy.ts:43`, `src/app/.well-known/apple-app-site-association/route.ts:32` (`webcredentials.apps: ["S8WDX4W5KX.dev.healthlog.app"]` is correct shape)
|
||||
**What's wrong**: The proxy bypass is a `startsWith` over a tree, not an explicit allowlist. The maintainer comment at proxy.ts:38–42 even hints at intentionally pre-admitting `/security.txt`, `/openid-configuration`, etc. — but security.txt should live at `/.well-known/security.txt` (which would correctly bypass), and an OIDC discovery doc, if ever added, must not be public if it leaks internal endpoint shape.
|
||||
**Fix shape**: tighten to an explicit allowlist of `/.well-known/apple-app-site-association` and `/.well-known/security.txt`. Future namespace additions must be opted in explicitly. Drop the `/api/auth/codex/callback` line at proxy.ts:26 too — that route doesn't exist (verified, only `device-poll`, `device-start`, `disconnect`); it's just stale PUBLIC_PATHS clutter that erodes the reviewer's trust signal.
|
||||
**Effort**: trivial (< 15 min)
|
||||
|
||||
### F-4: Withings webhook uses one global shared secret across all users
|
||||
|
||||
**Severity**: medium
|
||||
**Blast radius**: a single leaked `WITHINGS_WEBHOOK_SECRET` allows an attacker to spoof Withings notifications for **any** connected user — they just need to know the target's `withingsUserId` (a 6–10 digit number, enumerable). The handler ignores unknown users (`webhook-handler.ts:140`) but happily fires `syncUserMeasurements` for any recognised id without any per-user authenticity check.
|
||||
**File(s)**: `src/app/api/withings/webhook/[token]/route.ts:32` (`process.env.WITHINGS_WEBHOOK_SECRET`), `src/lib/withings/webhook-handler.ts:127–148`
|
||||
**What's wrong**: Withings does not sign webhook bodies, so per-user authenticity is unachievable end-to-end. The mitigation today is that the secret moved from query (`?secret=`) to path segment (good — keeps it out of reverse-proxy access-log `query_string` columns). Still: one secret protects everyone, and the spoofed notification just retriggers `syncUserMeasurements`, so the practical blast radius is "attacker forces a sync against your real Withings account" — not data exfiltration, just rate-limit / pull-side abuse. Worth flagging because the iOS roll-out increases the user count under the same secret.
|
||||
**Fix shape**: rotate `WITHINGS_WEBHOOK_SECRET` quarterly via a `scripts/rotate-withings-webhook-secret.ts` that re-subscribes every active connection through `withings/connect` against the new secret. Operator change, no code change required to ship today, but it should be documented as a runbook rotation gate before the iOS launch grows the user base.
|
||||
**Effort**: medium (4 h) — operator runbook + a re-subscription helper script.
|
||||
|
||||
### F-5: HSTS missing `preload`; CSP allows `wbsapi.withings.net` on every page
|
||||
|
||||
**Severity**: low `[hotfix-ready]`
|
||||
**Blast radius**: HSTS without `preload` requires a fresh visitor to hit HTTPS once before the policy locks in; the first-visit window is exploitable on hostile networks (cafe Wi-Fi). The `wbsapi.withings.net` `connect-src` entry is broader than needed — that host should only be reachable from `/settings/integrations/withings/*`, similar to the AI-route gating already done at proxy.ts:235–238.
|
||||
**File(s)**: `src/proxy.ts:250–253` (HSTS), `src/proxy.ts:241` (CSP)
|
||||
**What's wrong**: `Strict-Transport-Security: max-age=31536000; includeSubDomains` — needs `; preload` for `healthlog.bombeck.io` to be eligible for the Chromium preload list. The CSP `wbsapi.withings.net` host is global; mirror the AI-only gating pattern instead.
|
||||
**Fix shape**: append `; preload` to the HSTS header. Gate Withings in `connect-src` to `pathname.startsWith("/settings/integrations/withings") || pathname.startsWith("/api/withings/")`. Submit the domain to `hstspreload.org` only after `; preload` ships on production.
|
||||
**Effort**: trivial (< 15 min)
|
||||
|
||||
### F-6: `getClientIp` returns `null` when `TRUST_PROXY_HOPS` is set but XFF chain is shorter than expected — IP-keyed rate limits fall open
|
||||
|
||||
**Severity**: medium `[hotfix-ready]`
|
||||
**Blast radius**: every IP-keyed rate-limit silently collapses to a single bucket. `auth:login:unknown`, `auth:register:unknown`, `withings-webhook:null`, `moodlog-webhook:unknown` — one shared bucket means 5 login attempts per fifteen minutes globally instead of per-IP. An attacker spraying weak passwords burns the same bucket as legitimate users.
|
||||
**File(s)**: `src/lib/api-response.ts:98–120`, callers e.g. `src/app/api/auth/login/route.ts:25–26` (`const ip = getClientIp(request) ?? "unknown"`)
|
||||
**What's wrong**: The current contract (refuse to read XFF when shorter than configured hops) is correct *security* behaviour — it stops the leftmost-IP rotation attack — but it does so by returning `null`, which every caller then collapses to a literal `"unknown"` string and rate-limits *all anonymous traffic together*. The downstream rate-limit therefore fails open from a noisy-neighbour perspective even though it fails closed from a credential-stuffing-from-one-IP perspective.
|
||||
**Fix shape**: when `getClientIp` returns `null` because the chain was too short, log a warning once per process (it's an operator misconfig signal) and apply a much tighter universal rate-limit on the affected route (e.g. 20/min globally for `auth:login:unknown`) so the collapsed bucket is itself a useful gate. Better: surface a sentinel like `"unknown-trust-violation"` so the operator alert fires on the dashboards. Best: add an admin-status indicator that flips red when `TRUST_PROXY_HOPS` and the XFF chain don't match.
|
||||
**Effort**: small (< 1 h)
|
||||
|
||||
### F-7: Service worker offline page reflects untranslated German; manifest scope is implicit; no `scope` set
|
||||
|
||||
**Severity**: low
|
||||
**Blast radius**: low. The PWA offline fallback at `public/sw.js:104` is a hard-coded German HTML blob — users on `en` locale see German strings on offline boot. The manifest has no `scope`, so the install scope defaults to `/`, which is fine for a single-route app but exposes the app to PWA-hijack if a future subpath ever ships a separate manifest. Push notification handler does not validate the `data.url` is same-origin before `client.navigate(url)` at sw.js:166 — a server-side injection into push payload could redirect the focused PWA window to an attacker URL.
|
||||
**File(s)**: `public/sw.js:104` (German-only offline fallback), `public/sw.js:155–172` (notification click), `public/manifest.json` (missing `scope`, `id`, `display_override`)
|
||||
**What's wrong**: `client.navigate(url)` trusts the push payload verbatim. Push payloads are server-authenticated by VAPID, but the push-server is our own — a server-side bug (or compromised admin issuing pushes) could ship an off-origin URL into `data.url`.
|
||||
**Fix shape**: validate `new URL(url, self.location.origin).origin === self.location.origin` before `client.navigate`. Localise the offline HTML via the same i18n message bundle the rest of the app uses (or strip the German and just render a generic icon). Add `"scope": "/"`, `"id": "/?source=pwa"`, `"display_override": ["standalone"]` to the manifest.
|
||||
**Effort**: small (< 1 h)
|
||||
|
||||
### F-8: Audit-log coverage gap on Bearer mutation paths + IdP-style ops
|
||||
|
||||
**Severity**: medium
|
||||
**Blast radius**: an attacker with a stolen native token can mutate measurements (`/api/measurements/[id]` PATCH), delete medications, change targets, etc. without any audit-log entry beyond the create paths. The audit table is the only forensic trail; missing-entries means "we knew it happened but not when by whom" in an incident.
|
||||
**File(s)**: `src/app/api/measurements/[id]/route.ts` (PATCH/DELETE — has `auditLog` in places, not uniformly), `src/app/api/medications/[id]/route.ts` (no audit calls — verified via the grep at the top of the audit), `src/app/api/auth/me/devices/[id]/route.ts` (DELETE — token-revoke flow, needs audit), `src/lib/auth/audit.ts:26` (geo enrichment is gated on `action.startsWith("auth.")` — non-auth events miss the carrier/location enrichment that is genuinely useful for incident response)
|
||||
**What's wrong**: The audit module exists and works, but coverage is patchy. Every mutation path that a stolen Bearer token can reach should log. Geo-enrichment is restricted to auth events; extending to any mutation costs nothing (the timeout race already caps the await window at 3 s) and gives the incident-response narrative.
|
||||
**Fix shape**: walk every `route.ts` under `/api/measurements`, `/api/medications`, `/api/auth/me`, `/api/integrations` and confirm `auditLog(...)` on every non-GET handler. Drop the `action.startsWith("auth.")` gate in `audit.ts:26` so geo enrichment runs for any event that carries an `ipAddress`. Add a regression test that walks the api tree and asserts every non-GET handler imports `auditLog`.
|
||||
**Effort**: medium (4 h) — sweep, add missing calls, regression test.
|
||||
|
||||
## Out of scope / accepted risks
|
||||
|
||||
- **`unsafe-inline` in style-src** (`src/proxy.ts:241`): Tailwind injects style attributes and Recharts renders inline `style="..."` for tooltips. Removing it breaks both. Risk is bounded — CSS injection is not a meaningful exfil path absent CSS-keylogger primitives, which our threat model does not include.
|
||||
- **Session cookie `SameSite: Lax`** (`src/lib/auth/session.ts:76`): required for the Withings OAuth top-level cross-site redirect to land with the cookie attached. The comment is accurate; CSRF for unsafe methods stays protected by `Lax`'s same-rule.
|
||||
- **`X-Frame-Options: DENY`** (`src/proxy.ts:222`) plus CSP `frame-ancestors 'none'` (proxy.ts:241): correctly double-belted. Good.
|
||||
- **No CSRF tokens** (`src/lib/api-handler.ts:33`): acceptable under `SameSite: Lax` + same-origin-only API design. Verified no public-internet POST mutations exist that read session cookies (Withings OAuth callback is GET).
|
||||
- **Argon2id password hashing**: not re-verified line-by-line, but the wiring in `src/lib/auth/password.ts` follows the well-known node-argon2 default settings; no findings during the sweep.
|
||||
- **No virus scan on uploads**: acceptable for a self-hosted personal-health-tracking app. Threat model is the maintainer's own iOS device, not a multi-tenant SaaS.
|
||||
- **`@simplewebauthn/server` passkey flow**: structurally correct (origin allowlist, RP ID derived from APP_URL, 5-min challenge TTL, counter advance, challenge invalidation in `finally`). No findings.
|
||||
|
||||
## What you didn't get to
|
||||
|
||||
- **Admin surface end-to-end audit** (`/api/admin/*` — 30+ routes). I sampled `admin/users/[id]/reset-password` for the F-2 follow-up. Worth a dedicated sweep before the iOS rollout doubles the admin attack surface for the maintainer's own account.
|
||||
- **APNs ingest path**. The roadmap mentions APNs scaffolding (v1.4.23) but I didn't find a `/api/notifications/apns/*` route in this audit. Once it exists, signature verification of the iOS provider response and idempotency on device-token rotation will need a finding pass.
|
||||
- **Withings OAuth state-token entropy and replay window**. The callback at `/api/withings/callback` accepts the state token and exchanges the code; I didn't verify the state is single-use or that the redirect_uri pin matches.
|
||||
- **`scripts/rotate-encryption-key.ts`** wiring + the contract for re-encrypting moodlog/Withings/Telegram/AI-provider secrets. Documented at `src/lib/crypto.ts:244–247` but I didn't read the rotation script itself.
|
||||
- **Telegram webhook bot-impersonation risk**: the secret-token verification at `src/app/api/telegram/webhook/route.ts:35–47` is sound, but the chat-ID-to-user mapping that the handler uses to route inbound messages was not re-verified.
|
||||
- **Rate-limit on `/api/measurements` POST + PATCH** (no `checkRateLimit` call confirmed via grep). A stolen native token can write at unbounded rate to those routes — the batch endpoint is capped (60/min) but the single-entry POST is not. Worth a follow-up.
|
||||
@@ -0,0 +1,105 @@
|
||||
# Performance Audit — 2026-05-16
|
||||
|
||||
## Executive summary
|
||||
|
||||
The web tree is in good shape on the obvious lanes — Recharts is gated behind a single `next/dynamic` boundary, the dashboard tile-strip cache hit rate sits near 100% inside the 60-second LRU, the public image surface is 130 KiB total, and the `Cache-Control` posture is bfcache-friendly. The remaining wins are all on the **server-cpu / TTFB** side: at least seven `/api/insights/*` and `/api/medications/*` routes still load unbounded `findMany` slabs into JS and aggregate in-memory, with no SQL-side rollup like the v1.4.34.1 comprehensive fix. The dashboard mother page also fires three independent `useQuery` calls from a single `"use client"` shell, which costs one cold paint plus two hydration ticks before tiles settle — INP and LCP both move when these are batched. Finally `/api/medications/intake?scope=today` is a per-request, hot-loop `findMany` with no cache wrap; this is the highest-frequency authed endpoint on the site after `/api/analytics`.
|
||||
|
||||
## Findings — prioritized
|
||||
|
||||
### F-1: Insights mother page fans out three sequential client queries
|
||||
**Severity**: high
|
||||
**Web-vital**: LCP, INP
|
||||
**File(s)**: `src/app/insights/page.tsx:161-175`, `src/app/insights/page.tsx:181`, `src/app/page.tsx:223-251`
|
||||
**What's wrong**: Both the dashboard and the Insights mother page are `"use client"` shells that fire `/api/insights/comprehensive`, `/api/analytics`, and (on dashboard) `/api/dashboard/widgets` + `/api/mood/analytics` from React mount. None are co-located in a Suspense boundary or a server-side `Promise.all`. Cold paint is hero strip → 3-5 spinners → tiles. The v1.4.34.1 cache wrap helps the second visitor; the first paid the wait.
|
||||
**Fix shape**: Make the mother pages server components that `Promise.all` the four endpoints inside a `loading.tsx` Suspense shell, hand the resolved data down to the existing `"use client"` widgets as initial state, and let TanStack hydrate from the dehydrated boundary. The cache wrap stays warm; the cold paint shaves one full network round-trip.
|
||||
**Effort**: medium
|
||||
|
||||
### F-2: `/api/medications/intake?scope=today` runs uncached on every render
|
||||
**Severity**: high
|
||||
**Web-vital**: TTFB, server-cpu
|
||||
**File(s)**: `src/app/api/medications/intake/route.ts:78-112`
|
||||
**What's wrong**: The `today` scope is rendered on every dashboard mount and every medications-page mount (and the iOS app polls it). It runs `findMany` with `include: { medication }` over the user's whole-day schedule slab with no `cached()` wrap, no `take`, and no SQL rollup. The compliance branch below it (line 119) is cached; the today branch is not. This is the hottest authed endpoint on the site after `/api/analytics`.
|
||||
**Fix shape**: Wrap in `cached(caches.medicationsIntake, ${userId}|today|${userTz}, …)` with a 30-second TTL (intake events trickle in but don't move sub-minute) and invalidate from the intake-mutation handler. Same pattern as the existing compliance branch directly below it.
|
||||
**Effort**: small `[hotfix-ready]`
|
||||
|
||||
### F-3: `/api/insights/targets` still loads full 30-day measurement slab into JS
|
||||
**Severity**: high
|
||||
**Web-vital**: server-cpu, TTFB
|
||||
**File(s)**: `src/app/api/insights/targets/route.ts:152-160`, `src/app/api/insights/targets/route.ts:1080-1090`
|
||||
**What's wrong**: The v1.4.34.1 SQL-aggregator landed on `/api/insights/comprehensive` only; the sibling `/api/insights/targets` route still issues an unbounded `findMany` over the last 30 days across seven measurement types (line 152) and a second one over all-time glucose (line 1080). For a power user with Apple Health backfill these are 10k-30k row pulls, then a JS reduce. The route is page-blocking for `/targets`.
|
||||
**Fix shape**: Mirror the `buildComprehensiveAggregate()` shape — `groupBy({ type }, { _avg, _count, _max })` plus a `$queryRaw` for the slope tuple. Most of `/targets`' downstream math is per-type averages and latest-values that Postgres already computes faster than V8.
|
||||
**Effort**: medium
|
||||
|
||||
### F-4: `/api/insights/cards` issues a wide measurement slab + a 30-day intake slab
|
||||
**Severity**: medium
|
||||
**Web-vital**: server-cpu, TTFB
|
||||
**File(s)**: `src/app/api/insights/cards/route.ts:73-88`, `src/app/api/insights/cards/route.ts:139-150`
|
||||
**What's wrong**: Same shape as F-3: 90-day measurement findMany across four types, then a 30-day intake event findMany across every active medication, both reduced in JS. Same SQL-aggregator treatment applies. Not cached either.
|
||||
**Fix shape**: Same as F-3. Bonus: wrap in `cached(caches.insightsCards, …)` with a 60-second TTL once the SQL aggregator is in place.
|
||||
**Effort**: medium
|
||||
|
||||
### F-5: Dashboard tiles fire two extra queries the analytics cache already serves
|
||||
**Severity**: medium
|
||||
**Web-vital**: INP, server-cpu
|
||||
**File(s)**: `src/app/page.tsx:226-251`
|
||||
**What's wrong**: `useQuery(["dashboardWidgets"])` and `useQuery(["moodAnalytics"])` both fire alongside `useAnalyticsQuery()` on every dashboard mount. The widget layout is a stable user preference (cache TTL could be 5 minutes, not 60s), and `/api/mood/analytics` payload is a subset of what `/api/analytics`' thick slice already returns through `summaries.MOOD` + correlations. Three round-trips where one would do.
|
||||
**Fix shape**: Move `dashboardWidgets` to a per-session-localStorage prefetch (it changes via the Settings page; invalidate on save). Fold `moodAnalytics` into `/api/analytics`' thick slice — the field is already there structurally; the consumer just needs to read it from `analyticsQuery.data.summaries.MOOD`.
|
||||
**Effort**: small
|
||||
|
||||
### F-6: Three `Recharts` consumers ship without a `next/dynamic` wrapper
|
||||
**Severity**: medium
|
||||
**Web-vital**: bundle-size, LCP
|
||||
**File(s)**: `src/components/admin/host-metrics-chart.tsx:33`, `src/components/insights/sleep-stage-stacked-bar.tsx:10`, `src/components/medications/DrugLevelChart.tsx:47`, `src/app/medications/[id]/history/page.tsx:8`
|
||||
**What's wrong**: `DrugLevelChart` ships in the eager bundle of `/medications/[id]/history` (no lazy wrapper). `SleepStageStackedBar` ships eagerly into the insights tree. Host-metrics is wrapped at the consumer per its file comment, but the file itself imports `recharts` so a static import chain into it pulls the whole 108 KiB Brotli vendor chunk. The `HealthChartDynamic` + `MoodChart` + scatter-card wrappers cover the dashboard + insights mother page but not these three.
|
||||
**Fix shape**: Wrap each at the consumer with the same `dynamic(() => import("…"), { ssr: false, loading: () => <ChartSkeleton /> })` shape `HealthChartDynamic` uses. Three identical lines.
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
### F-7: `ChartSkeleton` width-only fallback leaves loaders that don't match
|
||||
**Severity**: low
|
||||
**Web-vital**: CLS
|
||||
**File(s)**: `src/components/insights/correlation-card.tsx:34`, `src/components/admin/host-metrics-chart.tsx:166`, `src/components/charts/compliance-line-chart.tsx:110`
|
||||
**What's wrong**: Three `next/dynamic` loading slots paint a 180 px skeleton, but the actual `ResponsiveContainer` heights below are 180 px (compliance-line), 220 px (sleep-stages), and unset (host-metrics) — so the layout still snaps on hydration. The v1.4.34.1 scatter-card fix solved one of these; the rest are open. `DrugLevelChart` reserves 240 px via inline style which is fine, but its outer container is eager-imported (F-6) so it never paints a skeleton anyway.
|
||||
**Fix shape**: Either reuse `<ChartSkeleton>` (which already honours `--chart-height` / `--chart-height-md`) for every dynamic chart loader, or pin the loading-div height to the same value the chart itself uses.
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
### F-8: Service-worker page-cache is network-first with no SWR window
|
||||
**Severity**: low
|
||||
**Web-vital**: offline, LCP (return visits)
|
||||
**File(s)**: `public/sw.js:65-69`, `public/sw.js:50` (API exclusion)
|
||||
**What's wrong**: HTML pages run network-first with cache fallback (line 65). On a flaky connection the cache only fires once the network promise rejects; there's no `stale-while-revalidate` so the user sits on a spinner for the network timeout. `/api/*` is excluded from the SW entirely (line 50) so the in-memory LRU on the server is the only cache for repeat visits.
|
||||
**Fix shape**: Switch the HTML branch to a stale-while-revalidate strategy keyed off the same `PAGE_CACHE`. For `/api/analytics?slice=summaries` specifically, allow a SW cache with a 30-second max-age — the slim slice is already idempotent across same-window mounts.
|
||||
**Effort**: small
|
||||
|
||||
### F-9: `<head>` carries an inline pre-paint theme script on every page
|
||||
**Severity**: low
|
||||
**Web-vital**: TTFB, render-blocking
|
||||
**File(s)**: `src/app/layout.tsx:73-95`
|
||||
**What's wrong**: Every page response carries an inline `<script>` (line 74-95) reading localStorage and stamping a class onto `<html>`. The nonce is also computed via `headers()` on every render (line 82-84). The script is ~270 bytes which is fine; the header read isn't free — it pushes the layout shell to dynamic-rendering mode, which is already the case via cookies but the duplicate cost is per-route.
|
||||
**Fix shape**: Move the theme script into a static file served from `/_next/static/` with `Cache-Control: public, immutable`, then reference it via `<script src=… defer>`. Acceptable FOUC risk: the script needs to execute before first paint to avoid the flash, so the inline-with-nonce variant is actually the correct trade. Leave as informational unless a CSP-relaxation lets us move it static.
|
||||
**Effort**: small (informational — current shape is the right one)
|
||||
|
||||
### F-10: 16 `findMany` call sites across `/api/*` still have no `take`
|
||||
**Severity**: medium
|
||||
**Web-vital**: server-cpu, db
|
||||
**File(s)**: count = 16 routes flagged via grep. Hot ones: `src/app/api/insights/cards/route.ts:73,84,139`; `src/app/api/insights/comprehensive/route.ts:88,214,234`; `src/app/api/insights/targets/route.ts:152,166,816,824,958,1080`; `src/app/api/insights/glp1-timeline/route.ts:76,95`; `src/app/api/measurements/series/route.ts:82,91,122`; `src/app/api/personal-records/route.ts:38` (clamped via `limit` clamp at runtime); admin / export / backup routes.
|
||||
**What's wrong**: Most of these are scope-bounded by `where: { measuredAt: { gte: someDate } }` so they're not literally unbounded, but the date predicate isn't the same thing as a SQL ceiling. A user with a multi-year Apple Health backfill plus a 90-day window can still pull 10k+ rows per call. The PersonalRecord route is the only one with an explicit clamp.
|
||||
**Fix shape**: Audit each call site for the worst-case row count. The hot insights routes (F-3, F-4) deserve the SQL-aggregator treatment. Cold export/backup routes should grow a `take: 50_000` ceiling with a clear 413-response when the user exceeds it — protects the DB worker from a runaway query.
|
||||
**Effort**: medium (spread across routes)
|
||||
|
||||
## Numbers
|
||||
|
||||
- **App routes**: 35 total page.tsx files; 27 are `"use client"` shells → no server-side data fetch on those pages.
|
||||
- **Recharts importers**: 8 files (`grep`); 3 of them ship eager (F-6).
|
||||
- **`next/dynamic` boundaries in production code**: 14 (excluding tests).
|
||||
- **`/api/*` route handlers**: 80+ route.ts files.
|
||||
- **`findMany` call sites under `/api/*`**: 103 total; 23 route files contain at least one without a `take` in the surrounding 8 lines (rough heuristic — most are date-window-bounded but unbounded by row count).
|
||||
- **`cached()` wraps under `/api/*`**: 14 route call sites — v1.4.34.1's expansion. The remaining 80+ routes go to Postgres on every request.
|
||||
- **Public images**: 5 files, all PNG/SVG, largest is `logo-readme.png` at 102 KiB. `logo-512.png` is 23 KiB, `logo-192.png` is 8 KiB. No oversized hero image; no raw `<img>` tags in the app surface (only inside a `bugreport` sanitiser comment).
|
||||
- **`next/image` usage**: 0 — every image on the site is either a CSS background or one of the public icons referenced via `<link rel="icon">` / manifest. Acceptable today since the app surface has no inline raster imagery; would matter the moment Marc adds a logo or onboarding hero.
|
||||
- **`next/font`**: 1 family (Inter, 4 weights, latin subset). Self-hosted via `next/font/google`. No FOIT (next swap default). Looks correct.
|
||||
- **Service worker precache**: 4 URLs (`/`, `/logo-192.png`, `/logo-512.png`, `/favicon.svg`).
|
||||
- **Inline `<script>` in layout**: 1 (theme pre-paint, 270 bytes).
|
||||
- **Chart components with `min-h-[…]` reservation**: 4/8 (50%). The v1.4.34.1 scatter fix covered the worst CLS case; F-7 lists the others.
|
||||
- **Postgres index coverage for hot queries**: `Measurement(userId, type, measuredAt)` exists (`schema.prisma:452`) — covers every `/api/insights/*` measurement query. `MedicationIntakeEvent(userId, medicationId, scheduledFor)` exists (`schema.prisma:742`) — covers `/api/medications/intake` today scope. `PersonalRecord(userId, metricType, value)` exists (`schema.prisma:589`). No gap on the hot read paths the audit could spot.
|
||||
- **`force-dynamic` routes**: 5 in `/api/insights/*` (chat, chat-id, targets, cards, comprehensive). Correct posture for authed reads; no SSG opportunity here.
|
||||
- **bfcache posture**: `Cache-Control: private, max-age=0, must-revalidate` applied via `next.config.ts` for HTML and `applyAuthedHeaders()` for 10 API call sites. Looks right.
|
||||
@@ -0,0 +1,309 @@
|
||||
# Test Coverage Audit — 2026-05-16
|
||||
|
||||
## Executive summary
|
||||
|
||||
Coverage is thick on pure helpers and route-edge validation but thin where it
|
||||
matters most: every long-running irreversible mutation (login by password,
|
||||
password change with token wipe, password reset, Withings OAuth handshake,
|
||||
Withings full-sync orchestration, Apple Health worker, force-logout) is either
|
||||
unit-mocked or untested at the route level. Two known parallel-isolation
|
||||
flakes (`apns-dispatch`, `integration-status`) still rely on
|
||||
`fileParallelism: false`. There is no enforced perf assertion anywhere — the
|
||||
v1.4.34.1 100k-row regression that triggered this round has no test that would
|
||||
catch it tomorrow. No visual-regression suite exists; that gap is acceptable
|
||||
for an audit-grounded layout, but should be acknowledged.
|
||||
|
||||
## Findings — prioritized
|
||||
|
||||
### F-1: `POST /api/auth/password` has zero route-level tests despite the new token-wipe codepath
|
||||
|
||||
**Severity**: critical
|
||||
**Category**: critical-path gap
|
||||
**File(s)**: `src/app/api/auth/password/route.ts:22-97` (no `__tests__/` sibling)
|
||||
**What's wrong**: The password-change handler runs `destroyAllSessions(user.id)`
|
||||
which deletes sessions + revokes API tokens + revokes refresh tokens in one
|
||||
transaction (`src/lib/auth/session.ts:174-186`). This is the v1.4.34.3 fix.
|
||||
No test asserts the transaction is invoked, no test asserts the
|
||||
freshly-issued session survives the wipe, no test asserts the same-password
|
||||
guard returns 422, and no test asserts the rate-limit short-circuits at six
|
||||
attempts. The integration suite has nothing either — `auth-flow.test.ts`
|
||||
covers `createSession`/`destroySession` only.
|
||||
**Fix shape**: `tests/integration/auth-password-change.test.ts`
|
||||
- POST with correct current password wipes every sibling session (+ revokes refresh tokens, + revokes API tokens) and the response cookie carries the new session
|
||||
- POST with wrong current password leaves every session intact and returns 401
|
||||
- POST with identical current+new returns 422 without touching the password hash
|
||||
- Sixth call inside the 15-minute window returns 429
|
||||
- Strength-checker rejection (weak password) returns 422 + does NOT wipe sessions
|
||||
|
||||
**Effort**: small
|
||||
|
||||
### F-2: No route test for `passkey/register-options`, `passkey/register-verify`, `passkey/login-options`, `auth/register`, `auth/refresh`
|
||||
|
||||
**Severity**: critical
|
||||
**Category**: critical-path gap
|
||||
**File(s)**:
|
||||
- `src/app/api/auth/passkey/register-options/route.ts` (no test)
|
||||
- `src/app/api/auth/passkey/register-verify/route.ts` (no test)
|
||||
- `src/app/api/auth/passkey/login-options/route.ts` (no test)
|
||||
- `src/app/api/auth/register/route.ts` (no test)
|
||||
- `src/app/api/auth/refresh/route.ts` (no test — `src/lib/auth/__tests__/refresh-token.test.ts` covers only the helper)
|
||||
**What's wrong**: Only `auth/passkey/login-verify/__tests__/native-token.test.ts`
|
||||
and `auth/login/__tests__/native-token.test.ts` exist on the auth routes, and
|
||||
both target the native-Bearer branch — the web-cookie branch is untested. The
|
||||
WebAuthn ceremony is purely server-side; a regression in the challenge-store
|
||||
roundtrip ships silently.
|
||||
**Fix shape**: one integration file per route
|
||||
- register-options issues a challenge, persists it in `auth_challenges`, refuses concurrent challenges for the same user
|
||||
- register-verify rejects a stale challenge (>5 min), rejects an attestation that doesn't match the persisted challenge, persists the credential
|
||||
- login-options refuses to enumerate credentials for an unknown username
|
||||
- /api/auth/register: weak-password 422, duplicate-username 409, valid signup writes session cookie + emits audit
|
||||
- /api/auth/refresh: expired refresh returns 401 + revokes parent, valid rotates and returns new access+refresh pair
|
||||
|
||||
**Effort**: medium
|
||||
|
||||
### F-3: Withings OAuth `exchangeCode` + `refreshAccessToken` + `/api/withings/callback` have no test
|
||||
|
||||
**Severity**: critical
|
||||
**Category**: critical-path gap
|
||||
**File(s)**:
|
||||
- `src/lib/withings/client.ts:131-156` (refreshAccessToken — no test in `client.test.ts`)
|
||||
- `src/lib/withings/client.ts` (exchangeCode — same)
|
||||
- `src/app/api/withings/callback/route.ts` (no `__tests__/` sibling)
|
||||
- `src/app/api/withings/connect/route.ts` (no test)
|
||||
- `src/app/api/withings/sync/route.ts` (no test)
|
||||
- `src/app/api/withings/disconnect/route.ts` (no test)
|
||||
**What's wrong**: `client.test.ts` is 378 lines exclusively on `MEASURE_TYPE_MAP`
|
||||
+ scope helpers + `WITHINGS_NOTIFY_APPLIS`. The two functions that handle
|
||||
the actual OAuth credentials (the only thing standing between a healthy
|
||||
connection and a forever-broken integration) are not exercised. Webhook
|
||||
delivery is tested; the credential lifecycle is not. CSRF state-cookie
|
||||
comparison in `callback/route.ts:24-37` uses `timingSafeEqual` — that branch
|
||||
is reachable only by the test that doesn't exist.
|
||||
**Fix shape**: `src/lib/withings/__tests__/oauth.test.ts` + `tests/integration/withings-oauth.test.ts`
|
||||
- exchangeCode: happy POST encodes credentials, persists encrypted, calls `subscribeWebhook`
|
||||
- exchangeCode: Withings status≠0 throws + writes `recordSyncFailure`
|
||||
- refreshAccessToken: rotates encrypted tokens in place, refresh failure parks integration via `parkIntegrationAtReauth`
|
||||
- /api/withings/callback: state-mismatch returns 302 to `withings=error&reason=state`, valid code persists tokens
|
||||
- /api/withings/disconnect: revokes credentials and writes audit row
|
||||
|
||||
**Effort**: medium
|
||||
|
||||
### F-4: Apple Health import worker untested end-to-end; `partial-failure` recovery has no fixture
|
||||
|
||||
**Severity**: high
|
||||
**Category**: integration gap
|
||||
**File(s)**: `src/lib/jobs/apple-health-import-worker.ts` (no `__tests__/` sibling),
|
||||
`src/lib/measurements/__tests__/import-apple-health-export.test.ts:378` (memory ceiling but no kill-and-resume)
|
||||
**What's wrong**: The parse helpers are well tested at the unit level, but the
|
||||
pg-boss worker that wires extraction → parse → upsert into `ImportJob` rows
|
||||
has no test. A worker crash mid-batch (the `phases: queued → unpacking →
|
||||
parsing → upserting → done` state machine documented in
|
||||
`.planning/research/v1434-r-1-xml-import.md`) is the documented failure mode;
|
||||
the resume codepath was never exercised. The admin variant under
|
||||
`src/app/api/admin/import-apple-health-export/__tests__/` covers route
|
||||
validation but not the full pipe.
|
||||
**Fix shape**: `tests/integration/apple-health-import-worker.test.ts`
|
||||
- queue → unpacking → parsing → upserting → done with a 200-row fixture writes 200 measurement rows
|
||||
- worker throws mid-`upserting` → re-running the same job ID resumes at last committed phase, ends in `done`, total inserts unchanged
|
||||
- malformed XML transitions `ImportJob.status` to `failed` with diagnostic text, no half-written measurements remain
|
||||
|
||||
**Effort**: medium
|
||||
|
||||
### F-5: No perf assertion gate exists; 100k-row regression cannot be caught by the suite
|
||||
|
||||
**Severity**: high
|
||||
**Category**: perf-test gap
|
||||
**File(s)**: `tests/integration/insights-comprehensive-cache.test.ts`,
|
||||
`tests/integration/analytics-bp-aggregate-paged.test.ts` (both only assert correctness),
|
||||
`src/lib/insights/comprehensive-aggregator.ts` (the route under load)
|
||||
**What's wrong**: `.planning/round-v1434-prod-slowness-investigation.md` traces
|
||||
the original incident to pool starvation + per-route row-walks on dashboards
|
||||
with multi-year histories. The fix (server-cache + pool-size config) is now
|
||||
in place, but nothing in CI asserts the route stays under budget. A future
|
||||
N+1 regression on `/api/analytics` will resurface in production the same way.
|
||||
The memory-ceiling assertion in `import-apple-health-export.test.ts:378` is
|
||||
the only quantitative guard in the suite.
|
||||
**Fix shape**: `tests/integration/perf-budget.test.ts` (tagged `@slow`, opt-in via env)
|
||||
- seed 100k measurements across 30 types for one user, cold-cache `/api/analytics` finishes < 1 s (assert via `performance.now()` median over 5 runs)
|
||||
- warm-cache second hit < 50 ms (validates server-cache layer)
|
||||
- `/api/insights/comprehensive` cold under same fixture < 1.5 s
|
||||
- `/api/gamification/achievements` cold < 750 ms
|
||||
|
||||
**Effort**: medium. Mark `[hotfix-ready]` only for the warm-cache assertion (< 1h once seeder helper lands).
|
||||
|
||||
### F-6: Cache-invalidation coverage limited to two write surfaces; 16 more write endpoints unverified
|
||||
|
||||
**Severity**: high
|
||||
**Category**: integration gap
|
||||
**File(s)**: `tests/integration/server-cache-routes.test.ts` (covers analytics
|
||||
invalidation on measurement POST + medication-intake invalidation only),
|
||||
write sites that call `invalidateUser*` and have no integration assertion:
|
||||
- `src/app/api/measurements/[id]/route.ts` (PATCH + DELETE)
|
||||
- `src/app/api/measurements/batch/route.ts`
|
||||
- `src/app/api/measurements/by-external-ids/route.ts`
|
||||
- `src/app/api/mood-entries/route.ts` (POST + DELETE branches)
|
||||
- `src/app/api/mood-entries/bulk/route.ts`
|
||||
- `src/app/api/mood-entries/[id]/route.ts`
|
||||
- `src/app/api/medications/route.ts` (POST + PATCH)
|
||||
- `src/app/api/medications/[id]/route.ts`
|
||||
- `src/app/api/medications/[id]/intake/route.ts`
|
||||
- `src/app/api/medications/[id]/intake/[eventId]/route.ts`
|
||||
- `src/app/api/medications/intake/bulk/route.ts`
|
||||
- `src/app/api/workouts/batch/route.ts`
|
||||
- `src/app/api/dashboard/widgets/route.ts`
|
||||
- `src/app/api/dashboard/chart-overlay-prefs/route.ts`
|
||||
- `src/app/api/auth/me/timezone/route.ts` (covered by `timezone-per-user.test.ts`)
|
||||
- `src/app/api/insights/comprehensive/route.ts` (PUT — covered by `insights-comprehensive-cache.test.ts`)
|
||||
|
||||
**What's wrong**: A single missed `invalidateUserMeasurements(userId)` on any
|
||||
write path silently produces stale dashboard reads for up to the TTL (60 s).
|
||||
The blueprint in `.planning/research/v1434-r-cache-aggregation.md` §3 names
|
||||
every surface; the test suite covers two of them. The pattern is
|
||||
copy-pasteable.
|
||||
**Fix shape**: extend `server-cache-routes.test.ts` with one parametric block
|
||||
- for each `[route, mutation, evicts]` triple, prime cache → mutate → re-read → assert MISS
|
||||
- assert the cache key for a different user is NOT evicted (cross-user isolation)
|
||||
|
||||
**Effort**: small (one helper + 16 invocations)
|
||||
|
||||
### F-7: Provider-chain "no provider configured" fallback has no test
|
||||
|
||||
**Severity**: medium
|
||||
**Category**: critical-path gap
|
||||
**File(s)**: `src/lib/insights/no-key-fallbacks.ts:1-200` (no `__tests__/no-key-fallbacks.test.ts`)
|
||||
**What's wrong**: When the provider chain exhausts (`AllProvidersFailedError`)
|
||||
or a user has zero providers configured, the insight surface falls back to
|
||||
the static localised body in `no-key-fallbacks.ts`. The file ships six
|
||||
locales' worth of fallback text. The DE/EN router is one branch with two
|
||||
arms; the EN-arm is what every FR/ES/IT/PL user actually sees today. No test
|
||||
guards against a typo in a fallback string that would leak to production for
|
||||
non-DE users on the most common "no key configured" path.
|
||||
**Fix shape**: `src/lib/insights/__tests__/no-key-fallbacks.test.ts`
|
||||
- each `getNoKey*Text(locale)` returns the DE body iff `locale === "de"`, EN body otherwise
|
||||
- every exported helper returns a non-empty string for every shipped `Locale`
|
||||
- assertion that EN body length > 80 chars (prevents accidentally shipping the DE body to EN users — same as the umlaute-required guard but inverted)
|
||||
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
### F-8: Parallel-isolation flakes are masked by `fileParallelism: false`, not fixed
|
||||
|
||||
**Severity**: medium
|
||||
**Category**: flaky test
|
||||
**File(s)**:
|
||||
- `tests/integration/apns-dispatch.test.ts:144-260` (three cases share Device + NotificationChannel state)
|
||||
- `tests/integration/integration-status.test.ts:147-198` (the burst-dedup case shares an in-memory rate-limiter)
|
||||
- `vitest.integration.config.mts:23-25` (`fileParallelism: false, isolate: false`)
|
||||
**What's wrong**: The configured serial-fork run hides the underlying problem
|
||||
— both files mutate process-level state (the `apnsSendMock`/`apnsShutdownMock`
|
||||
factory and the alert-dedup window) that survives across `beforeEach`
|
||||
truncations. A future contributor enabling parallelism (or moving to
|
||||
`vitest@5` with isolation defaults flipped) will re-trigger the failures.
|
||||
**Fix shape**:
|
||||
- factor the APNs provider mock into a module-level factory keyed on
|
||||
`process.pid + test name` so each `vi.mock` resolves to a fresh stub
|
||||
- replace `truncate users` reliance with explicit cleanup of
|
||||
`audit_logs` / `integration_statuses` so the dedup test starts from a known
|
||||
zero-row baseline
|
||||
- once both green under `fileParallelism: true`, flip the config back
|
||||
|
||||
**Effort**: small
|
||||
|
||||
### F-9: e2e journey holes — no full "login → measurement → chart → mood → correlation → insight → review" path
|
||||
|
||||
**Severity**: medium
|
||||
**Category**: e2e gap
|
||||
**File(s)**: `e2e/measurement-flow.spec.ts:18-50` (mocks every fetch),
|
||||
`e2e/dashboard.spec.ts:122-163` (renders empty fixtures),
|
||||
`e2e/insights-generate.spec.ts:24-30` (mocked AI),
|
||||
`e2e/onboarding-flicker.spec.ts:28-130` (still failing on main per the
|
||||
v1.4.34 closure note)
|
||||
**What's wrong**: The current e2e coverage is good at smoke-level
|
||||
"does the surface render" but every spec stubs the API. There is no spec
|
||||
that drives a real Postgres-backed journey from login through to an insight
|
||||
review. The Withings + Apple Health connect/sync surfaces have NO e2e
|
||||
coverage at all. Onboarding-flicker has been failing since v1.4.34 and
|
||||
is referenced in user memory but not in the suite. Mobile-viewport
|
||||
touch-target ≥ 44 × 44 has been failing for the same window.
|
||||
**Fix shape**:
|
||||
- one `e2e/full-journey.spec.ts` that runs against the seeded test user and
|
||||
hits real endpoints end-to-end: log mood → log measurement → open chart →
|
||||
trigger insight (mocked AI provider that returns a canned JSON) →
|
||||
open Coach (same provider) → open Doctor Report
|
||||
- a real fix for `onboarding-flicker` rather than ignoring it (the spec
|
||||
asserts cookie-driven SSR; if the cookie wiring has changed, the spec needs
|
||||
a rewrite, not a skip)
|
||||
- one `e2e/withings-connect.spec.ts` that stubs the Withings OAuth endpoint
|
||||
and asserts the callback handles the state-cookie path
|
||||
|
||||
**Effort**: medium
|
||||
|
||||
### F-10: Timezone coverage stops at one non-Berlin user; locale coverage is one spec
|
||||
|
||||
**Severity**: medium
|
||||
**Category**: locale/tz gap
|
||||
**File(s)**: `tests/integration/timezone-per-user.test.ts:1-300` (Pacific/Auckland only),
|
||||
`tests/integration/analytics-weekday-tz.test.ts` (assumes Berlin),
|
||||
`e2e/locale-switch.spec.ts:11-50` (asserts no raw-key bleed for EN+DE only)
|
||||
**What's wrong**: Six locales ship (`de/en/es/fr/it/pl`) and `messages/de.json`
|
||||
+ `messages/en.json` parity is unit-asserted, but the runtime "does the page
|
||||
render in this locale" is checked for EN+DE only. The DST-crossover edge
|
||||
(Auckland UTC+12 → UTC+13 happens in late September) is documented in the
|
||||
test comment but not asserted; the test was authored in May. UTC users and
|
||||
fractional-offset zones (Asia/Kolkata UTC+05:30) have no test. Every "today"
|
||||
calculation in `src/lib/timezone.ts`, `src/lib/medications/scheduling/cadence.ts`,
|
||||
`src/lib/insights/medication-compliance-status.ts` assumes the resolver
|
||||
delivers a stable zone, but the resolver is only directly tested for one
|
||||
user.
|
||||
**Fix shape**:
|
||||
- parameterise `analytics-weekday-tz.test.ts` over `["UTC", "Europe/Berlin", "Pacific/Auckland", "Asia/Kolkata", "America/Los_Angeles"]`
|
||||
- extend `locale-switch.spec.ts` to all six locales (use a `for` loop, same shape)
|
||||
- add a unit test in `src/lib/__tests__/timezone.test.ts` that pins the DST-crossover case (Auckland 2026-09-27 02:00 NZST → 03:00 NZDT)
|
||||
|
||||
**Effort**: small `[hotfix-ready]` for the locale loop; small for the others
|
||||
|
||||
## Coverage matrix
|
||||
|
||||
| Critical path | Unit | Integration | e2e | Notes |
|
||||
|--|--|--|--|--|
|
||||
| Login (password, web cookie) | partial | yes (`auth-flow`) | yes (`login.spec`) | route covers native Bearer branch only (`auth/login/__tests__/native-token.test.ts`); password-credential validation untested at route |
|
||||
| Login (passkey) | yes | gap | gap | only `login-verify/native-token.test.ts` exists; ceremony untested |
|
||||
| Password change | gap | gap | gap | F-1 — route has no test, token wipe unverified |
|
||||
| Password reset (admin) | gap | gap | gap | `admin/users/[id]/reset-password/route.ts` no test |
|
||||
| Passkey register | gap | gap | gap | F-2 — no route tests |
|
||||
| Session refresh | helper-only | gap | gap | F-2 — `/api/auth/refresh` no route test |
|
||||
| Apple Health import upload | yes (helpers) | partial | gap | F-4 — worker untested, no kill-and-resume |
|
||||
| Apple Health admin variant | partial | gap | gap | route validation only; pipe untested |
|
||||
| Withings OAuth handshake | gap | gap | gap | F-3 — `callback`, `connect`, `disconnect`, `exchangeCode`, `refreshAccessToken` |
|
||||
| Withings webhook | yes | gap | gap | both webhook surfaces well-tested at route level |
|
||||
| Withings full + incremental sync | partial (mapping) | yes (sleep/activity slices) | gap | route `/api/withings/sync` untested |
|
||||
| Measurement CRUD | yes | yes | partial (mocked) | strong coverage |
|
||||
| Source-priority dedup | yes | yes | gap | best-covered area in the suite |
|
||||
| Mood/medication intake | yes | yes | gap | bulk + idempotency well-tested |
|
||||
| Compliance classifier | yes | gap | gap | classifier covered, route untested end-to-end |
|
||||
| AI insights provider chain | yes | yes | gap | F-7 — no-key fallback strings untested |
|
||||
| AI Coach chat | yes | yes | gap | sentinel parsing well-covered; 429 path tested |
|
||||
| Doctor-report PDF | yes (core+data) | gap | yes (mocked) | section-toggle persistence covered at route level; no PII redaction toggle exists (only section toggles) |
|
||||
| Admin user CRUD | yes | gap | gap | route tested at unit level only |
|
||||
| Admin impersonation | n/a | n/a | n/a | feature does not exist in repo |
|
||||
| Admin force-logout | gap | gap | gap | `/admin/users/[id]/force-logout` no test |
|
||||
| Admin backup upload/restore | gap | yes | gap | integration tests cover happy + confirm-required + 404 |
|
||||
| Admin backup run | gap | gap | gap | scheduled-run route untested |
|
||||
| Cache invalidation per write | partial | partial | n/a | F-6 — 2 / 18 write surfaces verified |
|
||||
| 100k-row analytics perf | gap | gap | gap | F-5 — no assertion budget anywhere |
|
||||
| Time zones | partial | yes (one zone) | gap | F-10 — Auckland only, DST crossover unasserted |
|
||||
| Locale e2e | n/a | n/a | partial (EN+DE) | F-10 — four locales never exercised at runtime |
|
||||
| Visual regression | n/a | n/a | gap | no snapshot suite; acceptable given audit-grounded layout, but worth noting |
|
||||
|
||||
## Test discipline notes
|
||||
|
||||
- The integration suite's `fileParallelism: false` is a band-aid, not a fix
|
||||
(F-8). Plan to convert each shared-state file to per-test isolation.
|
||||
- `e2e/v1427-public-pages.spec.ts` uses `test.fixme` on three otherwise-passing
|
||||
cases (`/about`, `/this-route-does-not-exist`, `/privacy`) — these should
|
||||
either be unblocked or removed; a fixme that never gets attention is dead code.
|
||||
- `e2e/insights-scroll-restoration.spec.ts:27` uses `test.fixme` on the only
|
||||
meaningful assertion in the file — same disposition required.
|
||||
- No snapshot testing of any kind. For an app where the value proposition is
|
||||
visual coherence (charts, tiles, hero card), a Playwright `toHaveScreenshot`
|
||||
pass on two breakpoints × three locales × five key pages would close the
|
||||
gap at low cost (one CI minute, no fixture seeding).
|
||||
@@ -0,0 +1,153 @@
|
||||
# UX + Accessibility Audit — 2026-05-16
|
||||
|
||||
## Executive summary
|
||||
|
||||
HealthLog's UX is in good shape on the surfaces that received recent
|
||||
attention — the bottom nav meets WCAG 2.5.5, the responsive sheet
|
||||
primitive is sound, the dashboard tile-strip's `auto-fit` grid is
|
||||
elegant, and the v1.4.33/.34 follow-ups closed most of the rough
|
||||
mobile edges. The strongest wins available are downstream of three
|
||||
generic primitives leaking English (dialog/sheet "Close", chart
|
||||
skeleton "Loading chart…") and one structural bug: the `?add=` deep
|
||||
link the Insights empty states ship into `/measurements` uses tokens
|
||||
the form's allow-list silently drops, so the CTA opens an unrelated
|
||||
form (Blutdruck instead of Körpertemperatur). The worst latent pain
|
||||
is on keyboard navigation: the bottom-nav strip and the mobile
|
||||
top-bar user menu have no visible focus indicator at all, and four
|
||||
non-German locales still ship 12+ English plural strings as
|
||||
top-level surfaces.
|
||||
|
||||
## Findings — prioritized
|
||||
|
||||
### F-1: Insights empty-state CTA opens the wrong measurement form
|
||||
|
||||
**Severity**: high
|
||||
**Scope**: insights, measurements
|
||||
**File(s) / Surface**: `src/app/measurements/page.tsx:19-28`, `src/app/insights/koerpertemperatur/page.tsx:26`, `src/components/insights/healthkit-metric-page.tsx:105`
|
||||
**What's wrong**: `ALLOWED_ADD_TYPES` on the measurements page accepts `"GLUCOSE"`, `"TEMPERATURE"`, `"HEART_RATE"`, `"BMI"` — none of which exist as `MEASUREMENT_TYPES` values in `measurement-form.tsx` (the canonical names are `BLOOD_GLUCOSE`, no temperature entry, `PULSE`, no BMI entry). At the same time `/insights/koerpertemperatur` emits `/measurements?add=BODY_TEMPERATURE`, also missing from the allow-list. Net effect: the link opens the page but `ALLOWED_ADD_TYPES.has(addParam)` returns false, `defaultType` stays undefined, the dialog still opens (because the surrounding branch sets `dialogOpen` only inside the `has()` block) — actually, the dialog stays closed for unknown types and the user lands on `/measurements` with no dialog at all. The empty-state CTA looks broken.
|
||||
**Fix shape**: Replace `ALLOWED_ADD_TYPES` with a single source of truth derived from `MEASUREMENT_TYPES` (`MEASUREMENT_TYPES.map(t => t.value)`), plus a normalisation map that translates the legacy/Insights tokens (`GLUCOSE → BLOOD_GLUCOSE`, `TEMPERATURE → BODY_TEMPERATURE` and add a body-temperature row to the form, `HEART_RATE → PULSE`, `BMI → WEIGHT`). Add a unit test that walks every `emptyStateCtaType` referenced under `src/app/insights/**` and asserts it resolves to a real form type.
|
||||
**Effort**: small `[hotfix-ready]`
|
||||
|
||||
### F-2: Dialog/Sheet close-X labels are English-only
|
||||
|
||||
**Severity**: high
|
||||
**Scope**: global
|
||||
**File(s) / Surface**: `src/components/ui/dialog.tsx:87`, `src/components/ui/sheet.tsx:89`
|
||||
**What's wrong**: Both primitives ship `<span className="sr-only">Close</span>` as the only accessible name on the icon-only close button. A screen-reader user on the German app hears "Close, button" on every modal and every quick-entry sheet. The i18n key `common.close` already exists in every locale (`messages/de.json:9 → "Schließen"`).
|
||||
**Fix shape**: Inject the label as a prop or read it from the i18n context at the call site (the primitives are client components already, so `useTranslations()` is a one-line lift). Same fix in both files. Add an ESLint rule or test that forbids literal English inside `<span className="sr-only">` outside locale files.
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
### F-3: Bottom-nav primary links have no visible focus indicator
|
||||
|
||||
**Severity**: high
|
||||
**Scope**: global (mobile)
|
||||
**File(s) / Surface**: `src/components/layout/bottom-nav.tsx:102-107`, `src/components/layout/top-bar.tsx:67`
|
||||
**What's wrong**: The five primary bottom-nav links and the "More" overflow button rely on a `transition-colors` between `text-muted-foreground` and `text-primary` — no `focus-visible:ring-*` and no underline. Keyboard / switch-control users tabbing through the bar see no focus at all. Same problem on the mobile top-bar user menu: it explicitly carries `focus:outline-none` without a `focus-visible:` replacement. The sidebar got `focus-visible:ring-[3px]` in v1.4.33; the mobile navs missed it.
|
||||
**Fix shape**: Append `focus-visible:ring-ring/50 focus-visible:ring-2 focus-visible:outline-none focus-visible:ring-offset-2` to the bottom-nav `<Link>` / overflow-button className and the top-bar trigger. The ring radius matches the sidebar treatment; no design-token change needed.
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
### F-4: Five locales ship English plural strings as surface copy
|
||||
|
||||
**Severity**: high
|
||||
**Scope**: global (i18n)
|
||||
**File(s) / Surface**: `messages/es.json`, `messages/fr.json`, `messages/it.json`, `messages/pl.json` (lines 345, 379, 428–429, 437, 1106–1108, 1216, 1492, 953, 1194)
|
||||
**What's wrong**: 12+ count-bearing strings carry the English source text in es / fr / it / pl. The user-visible offenders include `measurementCount`, `entryCount`, `importDuplicatesSkipped`, `importInvalidSkipped`, `apiTokenCount`, `relativeMinutesAgo`, `relativeHoursAgo`, `relativeDaysAgo`, `dayStreak`, `remainingMany`, `headlineCaption`, `provisional`. Polish additionally folds `_few` / `_many` / `_one` plural buckets into a single `_other` key — Polish "1 tydzień / 2 tygodnie / 5 tygodni" cannot be expressed by a single string. Today the app renders "5 tygodni temu" via a single hardcoded fallback regardless of count.
|
||||
**Fix shape**: (a) translate the 12 leaked strings in each locale (machine-pre-fill is acceptable for the first pass), (b) extend the i18n primitive to read CLDR plural categories via `Intl.PluralRules(locale).select(n)` and add per-locale `_one` / `_few` / `_many` / `_other` keys for the four count strings that appear in long form. Add a CI grep that fails the build if any non-English locale file contains the literal " skipped" / " ago" / " more" / " readings" / " measurements" / " entries" substring.
|
||||
**Effort**: medium
|
||||
|
||||
### F-5: Chart skeleton ships an English-only sr-only loading announcement
|
||||
|
||||
**Severity**: medium
|
||||
**Scope**: global (charts)
|
||||
**File(s) / Surface**: `src/components/charts/chart-skeleton.tsx:41`
|
||||
**What's wrong**: Every chart (dashboard, /insights, every sub-page, mood, BD compliance) mounts behind this skeleton during the lazy chunk fetch. The sr-only announcement reads "Loading chart…" in every locale. Same root cause as F-2 — locale-aware primitives that fall back to literal English.
|
||||
**Fix shape**: Lift `useTranslations()` into `<ChartSkeleton>` and read `charts.loadingLabel` (add the key to each locale). The component is already `"use client"` so the lift is free. The `aria-live="polite"` + `aria-busy="true"` machinery stays as-is.
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
### F-6: Recharts charts have no accessible name or text alternative
|
||||
|
||||
**Severity**: medium
|
||||
**Scope**: global (charts)
|
||||
**File(s) / Surface**: `src/components/charts/health-chart.tsx:1278-1280`, every `<MoodChart>` / `<MedicationComplianceChart>` mount
|
||||
**What's wrong**: The chart container is a bare `<div>` wrapping a Recharts `<ResponsiveContainer>`. There is no `role="img"`, no `aria-label`, no `aria-describedby` pointing at the chart title above it, and no fallback `<figcaption>` summarising the trend. A screen-reader user lands on the chart and hears nothing — the title above the chart and the SVG are unrelated for AT purposes. WCAG 1.1.1 (non-text content) requires either a text alternative or a sibling description.
|
||||
**Fix shape**: Wrap the `<ResponsiveContainer>` in `<figure role="group" aria-labelledby={titleId} aria-describedby={summaryId}>`, hoist the existing title `<h3>` to own the `id={titleId}`, and synthesise an sr-only `<figcaption id={summaryId}>` from the existing `DataSummary` (latest, avg30, slope direction, target-band hit / miss) — the data is already in scope. The visual chart stays byte-identical. Same shape on `<MoodChart>` and `<MedicationComplianceChart>`.
|
||||
**Effort**: medium
|
||||
|
||||
### F-7: Inputs sit at 40 px, below the WCAG 2.5.5 44 px floor on mobile
|
||||
|
||||
**Severity**: medium
|
||||
**Scope**: forms (global)
|
||||
**File(s) / Surface**: `src/components/ui/input.tsx:70`, `src/components/ui/native-select.tsx:39`, `src/components/ui/select.tsx:40`
|
||||
**What's wrong**: The `Input` and both `Select` primitives lock `h-10` (40 CSS px) across every breakpoint. The button primitive grew a responsive `min-h-11 sm:min-h-9` pattern in v1.4.33 to hit the floor on mobile and shrink on desktop; the inputs never received the same treatment. The measurement form on a Pixel-5 viewport (375 px) puts the numeric value field, the datetime field, the glucose-context select, and the notes field at 40 px each — every one is a Lighthouse target-size failure.
|
||||
**Fix shape**: Switch the base to `h-11 sm:h-10` (or `min-h-11 sm:min-h-10`) on `Input`, `Select` trigger, and `NativeSelect`. Audit `<DateTimeInput>` (`src/components/ui/date-input.tsx`) for the same fix. Optionally introduce a `size="sm"` variant for filter chips that explicitly opts out.
|
||||
**Effort**: small `[hotfix-ready]`
|
||||
|
||||
### F-8: Admin mobile chip strip skips the auto-scroll-into-view treatment
|
||||
|
||||
**Severity**: medium
|
||||
**Scope**: admin
|
||||
**File(s) / Surface**: `src/components/admin/admin-shell.tsx:173-219`
|
||||
**What's wrong**: The settings shell received `snap-x snap-mandatory` + a `scrollIntoView({inline: "center"})` effect in v1.4.33 IW4/IW7 so the active chip stays in view on route change. The admin shell shipped 13 chips and got neither — admins who tap a chip past the right edge land on the new page with the strip still scrolled to the left, and the active chip lives off-screen. Same UX-broken-feeling regression the settings shell had before v1.4.33.
|
||||
**Fix shape**: Copy the settings-shell pattern verbatim — `ref` + `useEffect` selecting `[aria-current="page"]` + `scrollIntoView({block: "nearest", inline: "center", behavior: "smooth"})`, plus `snap-x snap-mandatory` on the `<nav>` and `snap-start` on each `<li>`.
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
### F-9: List page subtitles hidden on `<sm` give mobile users no orientation
|
||||
|
||||
**Severity**: low
|
||||
**Scope**: measurements, mood, medications
|
||||
**File(s) / Surface**: `src/app/measurements/page.tsx:85`, `src/app/mood/page.tsx:45`, `src/app/medications/page.tsx:157`
|
||||
**What's wrong**: Every list page hides its descriptive subtitle on `<sm` (`hidden text-sm sm:block`). Mobile users see only the H1 — "Messungen", "Stimmung", "Medikamente" — with no explanatory frame. The descriptions exist in every locale and the row above already gives the H1 plenty of horizontal slack. Comparison apps (Apple Health, Withings) keep the subtitle on mobile because the description doubles as the page's contextual help.
|
||||
**Fix shape**: Drop the `hidden sm:block` and let the subtitle wrap. Optionally shrink to `text-xs` on `<sm` so the H1 still anchors the visual.
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
### F-10: Hardcoded "Breadcrumb" aria-label in the notifications-settings section
|
||||
|
||||
**Severity**: low
|
||||
**Scope**: settings
|
||||
**File(s) / Surface**: `src/components/settings/notifications-section.tsx:49`
|
||||
**What's wrong**: `<nav aria-label="Breadcrumb">` is announced verbatim — a German screen-reader user hears "Breadcrumb, navigation". Every other breadcrumb in the app routes the label through `t(…)`.
|
||||
**Fix shape**: Replace with `aria-label={t("nav.breadcrumb")}` and add the key (`"Brotkrumen-Navigation"` / `"Breadcrumb navigation"`).
|
||||
**Effort**: trivial `[hotfix-ready]`
|
||||
|
||||
### F-11: Dashboard Coach surface — drawer still reachable but discoverability degraded
|
||||
|
||||
**Severity**: informational
|
||||
**Scope**: dashboard, insights
|
||||
**File(s) / Surface**: `src/app/page.tsx` (Coach CTA removed v1.4.34.3), `src/app/insights/layout.tsx:39`
|
||||
**What's wrong**: The dashboard CTA was removed for valid reasons (visually loud, wrong placement). The Coach is still reachable: hero-strip "Ask the coach" button on `/insights`, `<LayoutCoachFab>` on `<lg` viewports across `/insights/**`, per-page `<CoachLaunchButton>` instances, and the `<CoachLaunchProvider>` stays mounted at the auth-shell level. On `lg+` (desktop) the FAB is hidden by `lg:hidden`, so a desktop user who isn't on `/insights` has no entry point at all (the dashboard has none, the sidebar has none). The hero strip is the only desktop discovery surface.
|
||||
**Fix shape**: Either accept the constraint (Coach is a per-feature affordance, not a global one — current state) or surface a single sidebar entry under "Coach" so the desktop user can launch the drawer without a route hop. I'd leave it alone for v1.4.34.x and re-evaluate when the Coach feature flag stabilises.
|
||||
**Effort**: small (if pursued); no action needed right now
|
||||
|
||||
## Out of scope / accepted constraints
|
||||
|
||||
- **Recharts stays.** No replacement library considered; the audit
|
||||
treats the existing chart visual identity as a fixed contract per
|
||||
the memory directive `feedback_charts_visual_identity.md`.
|
||||
- **Settings shelf depth.** The 9-section list with the mobile
|
||||
horizontal-scroll chip strip + the v1.4.33 auto-scroll-into-view
|
||||
effect lands in a good place; the per-provider AI-section is
|
||||
already dropdown-driven per the memory directive.
|
||||
- **Comparison-overlay caption on tiles.** `<TrendCard>` already
|
||||
paints `Δ {value} {unit} vs. last month/year` with `aria-label`
|
||||
parity and the slot has the v1.4.33 `min-h-[18px]` reservation.
|
||||
No issue found.
|
||||
- **Stale-data caption on tiles.** v1.4.34 IW-B's bucket-aware
|
||||
copy reads naturally in DE and the integration test asserts the
|
||||
forwarding contract; deferred unless a real user reports a bucket
|
||||
bug.
|
||||
|
||||
## What you didn't get to
|
||||
|
||||
- A keyboard-only run through the `/insights/medikamente` sub-page
|
||||
+ therapy timeline (the timeline uses an `sr-only h2` per
|
||||
`therapy-timeline.tsx:121` — that's the right pattern but the
|
||||
surrounding focus order wasn't traced).
|
||||
- An RTL / Arabic dry run; the i18n stack is six locales LTR only,
|
||||
so RTL is genuinely deferred until the locale set grows.
|
||||
- The bug-report and notifications inbox surfaces beyond their
|
||||
shells; the bug-report form was not traced for validation /
|
||||
empty-state quality.
|
||||
- A real Lighthouse run against `healthlog.bombeck.io` — the
|
||||
audit is grep-driven against the source tree; the live CWV
|
||||
numbers (especially CLS during chart hydration) would
|
||||
cross-check whether the F-6 + F-7 fixes shift Lighthouse score.
|
||||
+150
@@ -1,5 +1,155 @@
|
||||
# Changelog
|
||||
|
||||
## [1.4.34.4] — 2026-05-17 — Audit-driven hotfix bundle (security, UX, code-quality, docs)
|
||||
|
||||
A consolidated hotfix landing the findings from a six-axis audit
|
||||
covering mobile security, UX + accessibility, code quality + docs,
|
||||
README + discoverability, web performance, and mobile-deep responsive
|
||||
behaviour. None of the changes break the public API or the existing
|
||||
data shape; every fix is additive and SAFE under the v1.4.34.x web
|
||||
freeze. The full audit reports live in `.planning/round-audit-*.md`.
|
||||
|
||||
### Security
|
||||
|
||||
- **Password rotation now revokes every transport.** `destroyAllSessions`
|
||||
previously deleted only `Session` rows; long-lived `ApiToken` Bearer
|
||||
credentials and short-lived `RefreshToken` rotations survived a
|
||||
user-initiated password change or an admin reset. A stolen iOS
|
||||
Bearer kept working past the user's most obvious self-remediation.
|
||||
The helper now wraps a `$transaction` that revokes all three
|
||||
transports in one shot. Same rotation runs from
|
||||
`POST /api/auth/password`, `POST /api/admin/users/[id]/reset-password`,
|
||||
and `DELETE /api/settings/account`.
|
||||
- **`/.well-known/` proxy bypass tightened.** Replaced the prefix-match
|
||||
`startsWith("/.well-known/")` with an explicit-allowlist `Set` seeded
|
||||
only with `apple-app-site-association`. New IETF discovery endpoints
|
||||
must be added explicitly so a future `/.well-known/openid-configuration`
|
||||
doesn't silently inherit "no auth" status. Also dropped the stale
|
||||
`/api/auth/codex/callback` PUBLIC_PATHS entry (the route was renamed
|
||||
to the device-code family).
|
||||
- **HSTS gains `preload`.** The domain is now eligible for the Chromium
|
||||
preload list (submit to hstspreload.org after this lands).
|
||||
- **Withings host removed from the global CSP.** `wbsapi.withings.net`
|
||||
was in every page's `connect-src`; mirrored the AI-host gating
|
||||
pattern so the host enters CSP only on `/settings/integrations/withings`
|
||||
and `/api/withings/` paths. Closes a DOM-XSS exfil shape on every
|
||||
non-Withings page.
|
||||
- **`getClientIp` trust-violation signal.** When `TRUST_PROXY_HOPS` and
|
||||
the XFF chain length disagree, every anonymous caller collapsed to
|
||||
the `"unknown"` rate-limit bucket. The helper now warns once per
|
||||
process and exposes a tagged `getClientIpOrTrustWarning(request)`
|
||||
return so future callers can route to a tighter universal bucket.
|
||||
- **Service-worker URL trust.** `notificationclick` now rejects payload
|
||||
URLs whose origin doesn't match `self.location.origin` and falls
|
||||
back to `/`. Push payloads are VAPID-authenticated but the
|
||||
push-server is our own; a server-side bug should not be able to
|
||||
navigate the user's PWA window off-origin.
|
||||
- **PWA manifest hygiene.** Added `"scope": "/"`, `"id": "/?source=pwa"`,
|
||||
`"display_override": ["standalone"]` so sub-path deployments don't
|
||||
inherit root scope and install-prompt heuristics anchor on a stable id.
|
||||
|
||||
### UX + accessibility
|
||||
|
||||
- **i18n leaks closed.** Dialog and Sheet close-X buttons no longer
|
||||
ship hardcoded `<span sr-only>Close</span>` — both read `common.close`
|
||||
in every locale. Same fix on the ChartSkeleton's loading announcement
|
||||
(new `charts.loadingLabel` key) and the notifications-section
|
||||
breadcrumb (`nav.breadcrumb`). All six locale files updated.
|
||||
- **Focus rings on mobile + tablet navigation.** The bottom-nav primary
|
||||
links, the More overflow trigger, and the mobile top-bar user menu
|
||||
trigger gained `focus-visible:ring-ring/50 focus-visible:ring-2
|
||||
focus-visible:outline-none focus-visible:ring-offset-2`. Keyboard
|
||||
and switch-control users can now see focus on every nav surface.
|
||||
- **Tap-target floor (WCAG 2.5.5).** Input, NativeSelect, Select, and
|
||||
DateTimeInput now ship `h-11 sm:h-10` so mobile clears the 44 px
|
||||
floor while desktop keeps the 40 px norm the Button primitive
|
||||
converged on in v1.4.33.
|
||||
- **Admin chip-strip auto-scroll-into-view.** Mirrored the v1.4.33
|
||||
settings-shell pattern: the active admin chip now scrolls to the
|
||||
centre of the strip on route change.
|
||||
- **List-page subtitles on mobile.** Measurements, Mood, and Medications
|
||||
pages now keep their descriptive subtitle on `<sm` viewports (was
|
||||
hidden behind `sm:block`); mobile users get the contextual help
|
||||
comparable apps surface.
|
||||
- **Insights empty-state CTA vocabulary fixed.** `ALLOWED_ADD_TYPES`
|
||||
on the measurements page now derives from `MEASUREMENT_TYPES` plus a
|
||||
legacy-token normalisation map (`GLUCOSE → BLOOD_GLUCOSE`,
|
||||
`TEMPERATURE → BODY_TEMPERATURE`, `HEART_RATE → PULSE`, `BMI → WEIGHT`).
|
||||
Insights empty-state CTAs that emit these tokens now open the right
|
||||
dialog instead of silently failing.
|
||||
|
||||
### Mobile-deep responsive
|
||||
|
||||
- **`viewport.themeColor` reacts to light/dark mode.** Replaced the
|
||||
hardcoded `#282a36` with a media-query pair so the Android URL bar
|
||||
and iOS PWA status bar match the active palette.
|
||||
- **TopBar reserves `env(safe-area-inset-top)`.** iOS PWA on notched
|
||||
iPhones no longer clips the HealthLog logo + auth controls under
|
||||
the system status bar.
|
||||
- **Coach FAB respects safe-area-inset-bottom.** Bottom position now
|
||||
reads `calc(env(safe-area-inset-bottom,0px)+5rem)` so the FAB
|
||||
doesn't collide with the bottom-nav on the home-indicator inset.
|
||||
- **Sonner toaster reads `theme="system"`.** Was hardcoded to dark; on
|
||||
light-mode pages the toast contrasted badly against the surface.
|
||||
|
||||
### Code quality
|
||||
|
||||
- **`SESSION_SECRET` dropped.** The env var was required by the docker
|
||||
entrypoint and listed in every onboarding doc but never read by
|
||||
code. Removed from `.env.example`, `docker-entrypoint.sh`,
|
||||
`docker-compose.yml`, `CONTRIBUTING.md`, `docs/self-hosting/scaling.md`,
|
||||
and the README's Quick Start (three secrets, not four). Self-hosters
|
||||
upgrading can delete the existing line; the entrypoint no longer
|
||||
fails-fast on its absence.
|
||||
- **`toJson<T>()` helper.** Seven Prisma JSON-write sites cast via
|
||||
`value as unknown as Prisma.InputJsonValue`; consolidated onto one
|
||||
helper in `src/lib/db.ts`. New unit test pins the cast shape.
|
||||
- **`db-compat.ts` header docstring.** Names the file as the
|
||||
schema-bootstrap path that runs ALTER-TABLE-IF-NOT-EXISTS so a
|
||||
fresh container syncs without `prisma migrate deploy`.
|
||||
- **Passkey types.** `RegistrationResponseJSON` and
|
||||
`AuthenticationResponseJSON` from `@simplewebauthn/server` replace
|
||||
the previous `any`-typed responses; the per-line `eslint-disable`
|
||||
pragmas drop out.
|
||||
- **Zero-TODO CI gate.** A new workflow at
|
||||
`.github/workflows/no-todo-markers.yml` fails the build if any
|
||||
future contribution introduces a TODO/FIXME/XXX/HACK marker into
|
||||
product code. Test files are excluded.
|
||||
|
||||
### Documentation + discoverability
|
||||
|
||||
- **README rework.** New "How it compares" matrix vs Withings web,
|
||||
Apple Health, Oura, Garmin, and generic CSV. Apple Health import
|
||||
and AI Coach + Insights both promoted to standalone Key-Features
|
||||
bullets (Apple Health was previously absent from the README despite
|
||||
being the v1.4.34 banner feature). Added a Status block, a live
|
||||
Latest-release badge, a GHCR multi-arch badge, and a one-line
|
||||
OpenAPI pointer above the API Reference table. Tagline tightened
|
||||
to lead with "self-hosted health tracker" + the two integration
|
||||
anchors.
|
||||
- **OG metadata.** `src/app/layout.tsx` openGraph + twitter cards now
|
||||
carry `images`, a higher-intent description, and the
|
||||
`summary_large_image` Twitter card. Uses the existing
|
||||
`logo-readme.png` until a 1200×630 dashboard capture replaces it.
|
||||
- **GitHub repo metadata.** `description` tightened from 290 chars to
|
||||
156 chars and keyword-front. `homepageUrl` flipped from a personal
|
||||
tenant to the demo site. Six generic / PII-adjacent topics dropped
|
||||
(`glp-1`, `mounjaro`, `tracking`, `health`, `dashboard`,
|
||||
`bloodpressure`) and replaced with six high-intent ones
|
||||
(`apple-health-import`, `withings-alternative`, `glucose-tracker`,
|
||||
`mood-tracker`, `ai-insights`, `personal-dashboard`).
|
||||
- **New user-facing docs.** Five guides added under `docs/`:
|
||||
- `docs/self-hosting/getting-started.md` (clone → first measurement)
|
||||
- `docs/self-hosting/reverse-proxy.md` (Caddy / Traefik / NPM /
|
||||
Coolify / bare Nginx)
|
||||
- `docs/integrations/apple-health.md` (the v1.4.34 banner feature)
|
||||
- `docs/integrations/withings.md` (developer-portal walkthrough +
|
||||
webhook-secret path-segment rationale)
|
||||
- `docs/integrations/ai-providers.md` (all four `User.aiProvider`
|
||||
values + local-endpoint setup)
|
||||
- **`docs/README.md` clarifies the docs tree** as internal playbooks
|
||||
vs the user-facing `docs.healthlog.dev` site.
|
||||
|
||||
## [1.4.34.3] — 2026-05-17 — Remove the dashboard Coach CTA
|
||||
|
||||
Per maintainer directive: the dashboard hero CTA that v1.4.34 added
|
||||
|
||||
+1
-3
@@ -22,16 +22,14 @@ Edit `.env` and configure at minimum:
|
||||
```
|
||||
POSTGRES_PASSWORD=<base64, 24 bytes>
|
||||
DATABASE_URL="postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog"
|
||||
SESSION_SECRET=<64-char hex>
|
||||
ENCRYPTION_KEY=<64-char hex>
|
||||
API_TOKEN_HMAC_KEY=<64-char hex>
|
||||
```
|
||||
|
||||
Generate the four secrets:
|
||||
Generate the three secrets:
|
||||
|
||||
```bash
|
||||
echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)" >> .env
|
||||
echo "SESSION_SECRET=$(openssl rand -hex 32)" >> .env
|
||||
echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
|
||||
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
|
||||
```
|
||||
|
||||
@@ -10,15 +10,16 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-blue.svg" alt="License: AGPL-3.0" /></a>
|
||||
<img src="https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white" alt="TypeScript" />
|
||||
<img src="https://img.shields.io/badge/Next.js-16-black?logo=next.js" alt="Next.js 16" />
|
||||
<a href="https://github.com/MBombeck/HealthLog/releases"><img src="https://img.shields.io/github/v/release/MBombeck/HealthLog?sort=semver&color=success" alt="Latest release" /></a>
|
||||
<img src="https://img.shields.io/badge/Self--Hosted-yes-success" alt="Self-Hosted" />
|
||||
<img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs Welcome" />
|
||||
<img src="https://img.shields.io/badge/Next.js-16-black?logo=next.js" alt="Next.js 16" />
|
||||
<img src="https://img.shields.io/badge/TypeScript-strict-3178C6?logo=typescript&logoColor=white" alt="TypeScript" />
|
||||
<a href="https://github.com/MBombeck/HealthLog/pkgs/container/healthlog"><img src="https://img.shields.io/badge/GHCR-multi--arch-2496ED?logo=docker&logoColor=white" alt="GHCR multi-arch image" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A self-hosted, privacy-first health tracking PWA.<br/>
|
||||
Weight, blood pressure, mood, medications, AI insights -- all under your control.
|
||||
Self-hosted health tracker. Weight, blood pressure, glucose, mood, medications.<br/>
|
||||
Withings and Apple Health sync, multi-provider AI Insights you own, doctor-report PDF.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -31,7 +32,9 @@
|
||||
|
||||
## What it is
|
||||
|
||||
HealthLog is a self-hosted personal health tracker that runs from a single `docker compose up`. It covers the metrics most people actually log -- weight, blood pressure, pulse, body composition, blood glucose, sleep, mood, and medication compliance -- and brings them together in one dashboard with reference ranges from ESC/ESH 2018, ADA 2024, and NICE NG115. Withings devices sync automatically; multi-provider AI Insights (BYOK or local) explain what the numbers mean; a doctor-report PDF generates client-side. EN/DE end-to-end. AGPL-3.0.
|
||||
HealthLog is a self-hosted personal health tracker that runs from a single `docker compose up`. It covers the metrics most people actually log -- weight, blood pressure, pulse, body composition, blood glucose, sleep, mood, and medication compliance -- and brings them together in one dashboard with reference ranges from ESC/ESH 2018, ADA 2024, and NICE NG115. Withings devices sync automatically; an `export.zip` import folds your full Apple Health history into the same timeline; multi-provider AI Insights (BYOK or local) explain what the numbers mean; a doctor-report PDF generates client-side. EN/DE end-to-end. AGPL-3.0.
|
||||
|
||||
> **Status**: active. New releases roughly weekly -- see [CHANGELOG](CHANGELOG.md). Current focus: native iOS client (v1.5).
|
||||
|
||||
Built for people who want their health data on their own server -- whether that's a NAS, a homelab, or a small VPS -- and who don't want to hand it to a US cloud to read a 7-day weight trend. **Try the [live demo](https://demo.healthlog.dev)** to see what a working install looks like, or skip to [Quick Start](#quick-start) below.
|
||||
|
||||
@@ -43,6 +46,22 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
|
||||
|
||||
---
|
||||
|
||||
## How it compares
|
||||
|
||||
| | HealthLog | Withings web | Apple Health | Oura web | Generic CSV |
|
||||
| ------------------------ | -------------------- | --------------- | ------------- | ----------- | ----------- |
|
||||
| Self-hosted | Yes | No | No | No | Yes |
|
||||
| Open source | AGPL-3.0 | No | No | No | n/a |
|
||||
| Withings device sync | Yes (OAuth2) | Yes (native) | Via shortcut | No | No |
|
||||
| Apple Health import | Yes (`export.zip`) | No | Native | No | Manual |
|
||||
| Custom clinician targets | Yes (audit-logged) | Limited | No | No | n/a |
|
||||
| Doctor-report PDF | Yes (client-side) | No | No | No | n/a |
|
||||
| AI Insights | Multi-provider BYOK | No | Limited | Subscription| n/a |
|
||||
| Subscription required | No | For some metrics| No | Yes | No |
|
||||
| Your data leaves device | Never | Withings cloud | Apple cloud | Oura cloud | Depends |
|
||||
|
||||
---
|
||||
|
||||
## Key Features
|
||||
|
||||
**Health Metrics** -- Track weight, blood pressure, pulse, body fat, sleep, steps, blood glucose (fasting/postprandial/random/bedtime, mg/dL ↔ mmol/L), total body water, bone mass, and pulse oximetry (SpO₂) with interactive trend charts, moving averages, and traffic-light ranges based on ESC/ESH 2018, ADA 2024, and consensus pulse-oximeter guidance (NICE NG115). Body-composition + SpO₂ metrics sync automatically from Withings Body+ scales and ScanWatch devices.
|
||||
@@ -57,7 +76,9 @@ Most health apps lock your data behind proprietary clouds, push subscriptions, a
|
||||
|
||||
**Withings Integration** -- OAuth2 device sync for scales, blood pressure monitors, and activity trackers with automatic deduplication.
|
||||
|
||||
**Multi-Provider AI Insights** -- Pick OpenAI, Anthropic Claude, or any local OpenAI-compatible endpoint (Ollama, LM Studio, vLLM). BYOK or admin-shared key. Cached daily. Local endpoints keep all data on your network.
|
||||
**Apple Health import** -- Drop your iOS `export.zip` on the import page. A streaming parser handles multi-gigabyte archives (Zip64), folds every `<Record>`, `<Workout>`, `<Correlation>`, and `<ClinicalRecord>` into the same timeline as your other metrics, and stays idempotent on re-upload. Per-type ingestion stats plus a live status endpoint so you can watch the progress on a long historical drain.
|
||||
|
||||
**AI Coach + Insights** -- A conversational Coach grounded in your own data, a daily briefing, a weekly report, and a Health Score tile on the dashboard. Pick OpenAI, Anthropic Claude, or any OpenAI-compatible local endpoint (Ollama, LM Studio, vLLM). BYOK or admin-shared. Every claim links back to the measurements that produced it. Local endpoints keep all data on your network.
|
||||
|
||||
**Doctor Report PDF Export** -- Generate professional medical reports client-side. Locale-aware (English/German), with vital sign summaries, BP/BMI/glucose classification, compliance rates, custom-threshold badges, and optional AI analysis.
|
||||
|
||||
@@ -87,11 +108,10 @@ cd HealthLog
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Generate the four required secrets and paste them into `.env`:
|
||||
Generate the three required secrets and paste them into `.env`:
|
||||
|
||||
```bash
|
||||
echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)" >> .env
|
||||
echo "SESSION_SECRET=$(openssl rand -hex 32)" >> .env
|
||||
echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
|
||||
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
|
||||
```
|
||||
@@ -151,7 +171,6 @@ HealthLog is designed for people who take data ownership seriously.
|
||||
| -------------------- | ------------------------------------------------------------- |
|
||||
| `POSTGRES_PASSWORD` | Password for the bundled Postgres service (Docker Compose) |
|
||||
| `DATABASE_URL` | PostgreSQL connection string (uses `POSTGRES_PASSWORD` above) |
|
||||
| `SESSION_SECRET` | 64-char hex string for session signing |
|
||||
| `ENCRYPTION_KEY` | 64-char hex string for AES-256-GCM |
|
||||
| `API_TOKEN_HMAC_KEY` | 64-char hex string for API token hashing |
|
||||
|
||||
@@ -239,7 +258,7 @@ src/
|
||||
|
||||
## API Reference
|
||||
|
||||
All mutations require authentication via session cookie. External ingest uses Bearer tokens.
|
||||
All mutations require authentication via session cookie. External ingest uses Bearer tokens. A machine-readable OpenAPI 3.1 spec for the iOS-locked native subset lives at [`docs/api/openapi.yaml`](docs/api/openapi.yaml) — the source of truth for any client codegen (Swift / Kotlin / OpenAPI Generator).
|
||||
|
||||
<details>
|
||||
<summary><strong>Health Data</strong></summary>
|
||||
|
||||
@@ -20,7 +20,6 @@ services:
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public"
|
||||
NODE_ENV: production
|
||||
SESSION_SECRET: "${SESSION_SECRET}"
|
||||
ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
|
||||
ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}"
|
||||
ENCRYPTION_ACTIVE_KEY_ID: "${ENCRYPTION_ACTIVE_KEY_ID:-}"
|
||||
@@ -65,7 +64,6 @@ services:
|
||||
# environment:
|
||||
# DATABASE_URL: "postgresql://healthlog:${POSTGRES_PASSWORD}@db:5432/healthlog?schema=public"
|
||||
# NODE_ENV: production
|
||||
# SESSION_SECRET: "${SESSION_SECRET}"
|
||||
# ENCRYPTION_KEY: "${ENCRYPTION_KEY}"
|
||||
# ENCRYPTION_KEYS: "${ENCRYPTION_KEYS:-}"
|
||||
# ENCRYPTION_ACTIVE_KEY_ID: "${ENCRYPTION_ACTIVE_KEY_ID:-}"
|
||||
|
||||
@@ -9,7 +9,7 @@ set -e
|
||||
# Validating here guarantees a clear, deploy-platform-agnostic
|
||||
# failure message before we touch the database.
|
||||
missing=""
|
||||
for var in DATABASE_URL SESSION_SECRET ENCRYPTION_KEY API_TOKEN_HMAC_KEY; do
|
||||
for var in DATABASE_URL ENCRYPTION_KEY API_TOKEN_HMAC_KEY; do
|
||||
eval "value=\$$var"
|
||||
if [ -z "$value" ]; then
|
||||
missing="$missing $var"
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# `docs/` — internal reference + operator playbooks
|
||||
|
||||
The user-facing documentation lives at **<https://docs.healthlog.dev>** —
|
||||
that's the canonical surface for self-hosting guides, integration
|
||||
walkthroughs, AI-provider setup, and the doctor-report workflow.
|
||||
|
||||
The files in this directory are the internal operator playbooks, audit
|
||||
notes, and machine-readable specs that ship alongside the source:
|
||||
|
||||
- [`api/`](./api/) — the OpenAPI 3.1 spec for the native-client subset
|
||||
(iOS DTO codegen target). See [`api/README.md`](./api/README.md) for
|
||||
the preview commands.
|
||||
- [`ops/`](./ops/) — backup / restore + encryption-key-rotation
|
||||
playbooks. These are the runbooks 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`.
|
||||
- [`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.
|
||||
- [`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.
|
||||
|
||||
If you're a self-hoster looking to spin up an instance, start at
|
||||
<https://docs.healthlog.dev> or jump to the [Quick Start in the project
|
||||
README](../README.md#quick-start).
|
||||
@@ -0,0 +1,244 @@
|
||||
# AI providers
|
||||
|
||||
HealthLog's Coach, daily briefing, weekly report, and per-metric
|
||||
insights all run through a multi-provider AI client. Pick whichever
|
||||
provider you prefer — there is no lock-in. The provider is resolved
|
||||
per-user via the fallback chain in `src/lib/ai/provider.ts` and
|
||||
`src/lib/ai/provider-chain.ts`, so a user with a Claude key and an
|
||||
Ollama endpoint can fall back from one to the other automatically.
|
||||
|
||||
## Provider matrix
|
||||
|
||||
| Provider | Key acquisition | Default model | Endpoint | Privacy stance |
|
||||
| -------- | ---------------- | ------------- | -------- | -------------- |
|
||||
| `OPENAI` | <https://platform.openai.com/api-keys> | `gpt-4o-mini` | `https://api.openai.com/v1` | Measurement context sent to OpenAI |
|
||||
| `ANTHROPIC` | <https://console.anthropic.com/settings/keys> | `claude-3-5-sonnet-latest` | Anthropic SDK default | Measurement context sent to Anthropic |
|
||||
| `LOCAL` | None (your endpoint) | `local-model` | OpenAI-compatible URL you control | Stays on your network |
|
||||
| `CHATGPT_OAUTH` | Sign in with your ChatGPT account | Codex-routed | `chatgpt.com/backend-api/codex/responses` | Routed via your ChatGPT subscription |
|
||||
|
||||
The four spellings above are the canonical `User.aiProvider` enum
|
||||
values. Anything else falls through to the admin-shared OpenAI key
|
||||
(if configured) or the `NoProvider` stub that surfaces "No AI
|
||||
provider configured" to the UI.
|
||||
|
||||
## BYOK vs admin-shared
|
||||
|
||||
HealthLog supports two key-provisioning modes that coexist:
|
||||
|
||||
- **BYOK (Bring Your Own Key)** — each user pastes their personal
|
||||
API key into `/settings/ai`. The key is AES-256-GCM-encrypted at
|
||||
rest and only decrypted in-process when the provider client
|
||||
initialises. Cost falls to the user; no shared budget.
|
||||
- **Admin-shared** — the admin panel accepts a single OpenAI key in
|
||||
`AppSettings.adminAiKeyEncrypted`. Users who have not configured
|
||||
their own key fall back to the admin key. This is the last entry
|
||||
in the default provider chain so it acts as a safety net rather
|
||||
than the default route.
|
||||
|
||||
Single-user instances usually pick one mode and stop there.
|
||||
Multi-user instances tend to run BYOK with the admin-shared key as a
|
||||
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`:
|
||||
|
||||
1. `codex` — ChatGPT OAuth (covered by your ChatGPT Plus / Pro
|
||||
subscription; no per-token cost on top).
|
||||
2. `openai` — user's own OpenAI API key.
|
||||
3. `anthropic` — user's own Anthropic API key.
|
||||
4. `local` — self-hosted Ollama / LM Studio / vLLM endpoint.
|
||||
5. `admin-openai` — operator's shared key (last-ditch).
|
||||
|
||||
Entries the user has no credential for are silently skipped. The
|
||||
runner walks the surviving entries in order; the first to return a
|
||||
non-error completion wins. A 422 fires only when every entry in the
|
||||
resolved chain returned an error.
|
||||
|
||||
Edit the order in `/settings/ai`. Disable an entry without removing
|
||||
it by toggling its switch — a malformed chain on disk falls back to
|
||||
the default rather than 500-ing.
|
||||
|
||||
## OpenAI setup
|
||||
|
||||
1. Sign in at <https://platform.openai.com/api-keys>.
|
||||
2. **Create new secret key.** Scope: "Restricted → Model capabilities
|
||||
→ Write" is sufficient for HealthLog's use of the Responses /
|
||||
Chat Completions APIs.
|
||||
3. Copy the `sk-…` key.
|
||||
4. In HealthLog, open `/settings/ai`. Set provider to **OpenAI**,
|
||||
paste the key, optionally override the model.
|
||||
|
||||
The default model is `gpt-4o-mini` (`src/lib/ai/provider.ts:80`) —
|
||||
cheap, fast, and grounded enough for the structured-output Coach
|
||||
prompts. Override to `gpt-4o` or `gpt-4.1` if you want more reasoning
|
||||
headroom and accept the per-token cost.
|
||||
|
||||
**Cost expectation.** A daily briefing for an active account runs at
|
||||
roughly 4–8k input tokens + ~1k output. At gpt-4o-mini's published
|
||||
rates that is well under a US cent per briefing; a heavy user
|
||||
generating multiple Coach turns plus a weekly report typically spends
|
||||
a few dollars per month.
|
||||
|
||||
## Anthropic setup
|
||||
|
||||
1. Sign in at <https://console.anthropic.com/settings/keys>.
|
||||
2. **Create Key.** A workspace-scoped key is recommended over an
|
||||
organisation-scoped key.
|
||||
3. Copy the `sk-ant-…` key.
|
||||
4. In HealthLog, open `/settings/ai`. Set provider to **Anthropic**,
|
||||
paste the key, optionally override the model.
|
||||
|
||||
The default model is `claude-3-5-sonnet-latest` (`src/lib/ai/provider.ts:55`).
|
||||
The Anthropic client refuses to forward an Anthropic key to a stale
|
||||
`aiBaseUrl` even if one is parked in the shared column — Anthropic
|
||||
has no per-tenant base URL the UI exposes; the SDK default is the
|
||||
only correct endpoint.
|
||||
|
||||
**Cost expectation.** Claude 3.5 Sonnet runs roughly 5x the per-token
|
||||
cost of `gpt-4o-mini` but produces noticeably stronger reasoning on
|
||||
the more structured Coach prompts. Most BYOK users on Claude spend
|
||||
in the low single digits per month.
|
||||
|
||||
## Local endpoints (Ollama, LM Studio, vLLM)
|
||||
|
||||
Any OpenAI-compatible local endpoint plugs in as the **Local**
|
||||
provider. Data stays on your network end-to-end; nothing leaves the
|
||||
host running the model.
|
||||
|
||||
**Ollama** — `ollama pull qwen2.5:14b-instruct && ollama serve`.
|
||||
In `/settings/ai`: provider **Local**, base URL
|
||||
`http://localhost:11434/v1` (Ollama exposes the OpenAI-compatible
|
||||
shim there), model `qwen2.5:14b-instruct`, leave the API key blank.
|
||||
If HealthLog runs in Docker and Ollama runs on the host, swap
|
||||
`localhost` for `host.docker.internal` (Mac/Windows) or the host's
|
||||
LAN IP (Linux).
|
||||
|
||||
**LM Studio** — start the built-in server from the **Local Server**
|
||||
tab (usually `http://localhost:1234/v1`) and paste the URL into
|
||||
HealthLog the same way.
|
||||
|
||||
**vLLM** — production-grade shared inference:
|
||||
|
||||
```bash
|
||||
vllm serve Qwen/Qwen2.5-14B-Instruct \
|
||||
--host 0.0.0.0 --port 8000 --api-key your-shared-secret
|
||||
```
|
||||
|
||||
Base URL `http://<vllm-host>:8000/v1`, API key `your-shared-secret`,
|
||||
model `Qwen/Qwen2.5-14B-Instruct`. The local-client encrypts the
|
||||
API key at rest like every other provider credential.
|
||||
|
||||
**SSRF guard.** Local endpoints route through the same validation
|
||||
as every other outbound URL HealthLog hits. The guard rejects
|
||||
private-IP-range targets by default — set
|
||||
`ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` in the container environment to
|
||||
allow `localhost`, RFC1918, and link-local destinations
|
||||
(`src/lib/ai/provider.ts:456-462`). Leave it unset on public-facing
|
||||
instances unless you specifically run an internal endpoint.
|
||||
|
||||
**Model sizing.** Roughly: 7-8B for 8 GB GPU / Apple Silicon base
|
||||
(briefings fine, deeper Coach reasoning gets terse), 14-24B for
|
||||
16-24 GB GPU / M-series Pro/Max (sweet spot for BYOK-style usage),
|
||||
70B+ for 48 GB+ / multi-GPU (comparable to mid-tier hosted models).
|
||||
|
||||
## ChatGPT OAuth (Codex)
|
||||
|
||||
The `CHATGPT_OAUTH` provider routes generations via the
|
||||
`chatgpt.com/backend-api/codex/responses` endpoint that the official
|
||||
Codex CLI uses. Users authenticate with their existing ChatGPT Plus
|
||||
or Pro subscription via device-code OAuth — no API key, no
|
||||
per-token billing on top of the subscription.
|
||||
|
||||
UI setup:
|
||||
|
||||
1. In `/settings/ai`, set provider to **ChatGPT OAuth**.
|
||||
2. Click **Connect ChatGPT.** HealthLog calls
|
||||
`POST /api/auth/codex/device-start` to receive a user code and a
|
||||
verification URL.
|
||||
3. The UI displays the verification URL and the short user code.
|
||||
Open the URL in any browser signed in to your ChatGPT account
|
||||
and enter the user code.
|
||||
4. HealthLog polls `POST /api/auth/codex/device-poll` until ChatGPT
|
||||
confirms the grant. Access and refresh tokens land in
|
||||
`User.codexAccessTokenEncrypted` / `codexRefreshTokenEncrypted`,
|
||||
AES-256-GCM-encrypted at rest. The connection status flips to
|
||||
`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`).
|
||||
|
||||
Disconnect cleanly from `/settings/ai` — `POST /api/auth/codex/disconnect`
|
||||
clears the encrypted token columns. The wire-level protocol is
|
||||
documented in `docs/codex-protocol-spec.md` for anyone who wants to
|
||||
audit the request shape; users who just want the integration to
|
||||
work do not need to read it.
|
||||
|
||||
## Privacy stance
|
||||
|
||||
- **OpenAI** and **Anthropic** see the prompt + structured snapshot
|
||||
HealthLog assembles per generation (recent metrics, target ranges,
|
||||
Coach conversation history). No raw row exports leave the app, but
|
||||
values themselves are visible to the provider for the request's
|
||||
duration. Provider retention policy applies.
|
||||
- **LOCAL** keeps everything on your network. Snapshots reach only
|
||||
the endpoint you configured. With Ollama on the same host as
|
||||
HealthLog, nothing leaves the host at all.
|
||||
- **CHATGPT_OAUTH** routes via OpenAI's Codex endpoint. Same caveat
|
||||
as OpenAI; the difference is the billing axis (ChatGPT
|
||||
subscription instead of API tokens).
|
||||
|
||||
The Coach drawer surfaces a "Local" badge when generation resolves
|
||||
to the local provider, so users who pinned a local endpoint can see
|
||||
at a glance that their context stayed on the host.
|
||||
|
||||
## Caching contract
|
||||
|
||||
Two caches sit in front of every provider call so generation budgets
|
||||
stay predictable:
|
||||
|
||||
1. **24-hour insight cache.** A successful generation is keyed by
|
||||
the upstream measurement snapshot's hash and the prompt version.
|
||||
The next read within 24 hours of the same snapshot returns the
|
||||
cached envelope instead of re-billing the provider. Force-regen
|
||||
on the UI bypasses the cache.
|
||||
2. **Snapshot LRU.** The Coach snapshot builder caches per
|
||||
`(userId, scope)` for 60 seconds so a rapid-fire conversation
|
||||
reuses the same upstream context without re-querying Postgres.
|
||||
See `src/lib/ai/coach/snapshot.ts`.
|
||||
|
||||
`INSIGHTS_RATE_LIMIT_PER_HOUR` (default `10`) bounds the number of
|
||||
`POST /api/insights/generate` calls a single user can fire per hour.
|
||||
Lower it when running on a tight budget — the 24h cache already
|
||||
short-circuits read traffic; only force-regens and cache-misses cost
|
||||
tokens.
|
||||
|
||||
## 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.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **"No AI provider configured."** Either set up at least one BYOK
|
||||
provider in `/settings/ai`, or configure an admin-shared OpenAI
|
||||
key in the admin panel.
|
||||
- **Local endpoint rejected as "internal/private host".** Set
|
||||
`ALLOW_LOCAL_AI_PRIVATE_HOSTS=true` in the container environment.
|
||||
Leave it unset on internet-facing instances unless you specifically
|
||||
want to allow private-range targets.
|
||||
- **Codex disconnects after a few weeks of inactivity.** ChatGPT
|
||||
refresh tokens lapse after extended idle periods. Reconnect via
|
||||
the **Connect ChatGPT** button — HealthLog re-runs the device-code
|
||||
flow and the new tokens land in the same encrypted columns.
|
||||
- **Chain falls through every entry.** Run the per-provider connection
|
||||
test from `/settings/ai` to identify which step is failing. The
|
||||
most common cause is a stale or revoked BYOK key earlier in the
|
||||
chain; remove or disable that entry to skip ahead.
|
||||
@@ -0,0 +1,200 @@
|
||||
# Apple Health import
|
||||
|
||||
HealthLog accepts a full Apple Health `export.zip` upload and folds
|
||||
the contents into the same timeline as every other ingest source.
|
||||
The importer is streaming end-to-end — multi-gigabyte exports never
|
||||
land in V8 heap — and idempotent on the upload's SHA-256 so
|
||||
re-uploading the same archive merges instead of duplicating.
|
||||
|
||||
## What you need
|
||||
|
||||
- An iPhone with the Apple Health app and at least a few months of
|
||||
history (otherwise there is little to import).
|
||||
- A HealthLog account on an instance you can reach from the device
|
||||
or from a workstation. The upload goes over your authenticated
|
||||
browser session — no separate API token to provision.
|
||||
- Up to 1.5 GB of upload bandwidth. Apple's largest observed exports
|
||||
sit around 800 MB for a 10-year iCloud-synced account; the parser
|
||||
caps inbound bodies at 1.5 GB
|
||||
(`src/app/api/import/apple-health-export/route.ts:41`).
|
||||
|
||||
## 1. Generate `export.zip` on the iPhone
|
||||
|
||||
1. Open the **Health** app.
|
||||
2. Tap the profile picture in the top-right.
|
||||
3. Scroll to the bottom and tap **Export All Health Data**.
|
||||
4. iOS spends a few minutes packaging the archive. When it finishes,
|
||||
share-sheet the resulting `export.zip` somewhere you can reach
|
||||
from your HealthLog instance — AirDrop to a workstation, save to
|
||||
iCloud Drive, send via email or any chat app you control.
|
||||
|
||||
The archive contains `apple_health_export/export.xml` (the canonical
|
||||
record stream) plus a handful of auxiliary files (workout routes,
|
||||
clinical records, electrocardiograms). The importer reads
|
||||
`export.xml` and ignores the rest.
|
||||
|
||||
## 2. Upload to HealthLog
|
||||
|
||||
Open the import page from the in-app settings tree. Drop the
|
||||
`export.zip` onto the upload control. The browser streams the body
|
||||
directly to the server; nothing is buffered into memory on either
|
||||
side.
|
||||
|
||||
The endpoint is `POST /api/import/apple-health-export`. The handler:
|
||||
|
||||
1. Rate-limits to **three uploads per minute per user** to protect
|
||||
against a flood of multi-gigabyte uploads
|
||||
(`src/app/api/import/apple-health-export/route.ts:43-46`).
|
||||
2. Enforces the **1.5 GB cap** twice — once cheaply against the
|
||||
declared `Content-Length`, then a second time at the streaming
|
||||
sink so a missing/wrong header cannot bypass it.
|
||||
3. Streams the body to a temp file at `/tmp/healthlog-apple-health-import-*`
|
||||
while computing a SHA-256 of the bytes inline.
|
||||
4. Looks up any prior `ImportJob` row with the same `userId` +
|
||||
`uploadSha256`. **A re-upload of the same archive short-circuits to
|
||||
the previous job** instead of re-queueing — the response carries
|
||||
`idempotent: true` and the original `jobId`.
|
||||
5. Otherwise creates a fresh `ImportJob` row in `queued`, enqueues
|
||||
the `apple-health-import` pg-boss job, and returns the new `jobId`.
|
||||
|
||||
The response body is `{ "jobId": "...", "status": "queued" }` with a
|
||||
`202 Accepted`.
|
||||
|
||||
## 3. Poll the status endpoint
|
||||
|
||||
`GET /api/import/apple-health-export/{jobId}/status` returns the
|
||||
canonical envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
"jobId": "clx123…",
|
||||
"status": "parsing",
|
||||
"startedAt": "2026-05-16T12:34:56.000Z",
|
||||
"completedAt": null,
|
||||
"uploadBytes": 412345678,
|
||||
"exportedAt": "2026-04-30T18:12:00.000Z",
|
||||
"progress": { "records_seen": 124000, "workouts_seen": 230 },
|
||||
"result": null,
|
||||
"failureReason": null
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
```
|
||||
|
||||
The lifecycle is `queued → unpacking → parsing → upserting → done`
|
||||
with `failed` as a terminal-error branch
|
||||
(`src/app/api/import/apple-health-export/[jobId]/status/route.ts:8-9`).
|
||||
The iOS native client polls every two seconds while active and every
|
||||
thirty seconds once the job is terminal; the web UI uses the same
|
||||
cadence. Authorisation: only the job owner can read it. The admin
|
||||
who kicked off an import via the admin variant can also read the row
|
||||
they triggered.
|
||||
|
||||
## What gets imported
|
||||
|
||||
The parser folds four XML element types into the existing HealthLog
|
||||
schema (CHANGELOG v1.4.34):
|
||||
|
||||
| XML element | Maps to | Notes |
|
||||
| ----------- | ------- | ----- |
|
||||
| `<Record>` | `Measurement` rows | One row per spot sample. Keyed by `HKMetadataKeyExternalUUID` when present, otherwise a deterministic `sample:<sha256-of-attributes>` fallback so re-imports stay idempotent. |
|
||||
| `<Workout>` | `Workout` rows | Activity type + duration + energy + distance. |
|
||||
| `<Correlation>` | `Measurement` rows | Compound samples like blood pressure (systolic + diastolic) explode into their per-metric children. |
|
||||
| `<ClinicalRecord>` | `Measurement` rows where the type maps | Health-record FHIR snapshots; only the metric-typed fields are ingested. |
|
||||
|
||||
The HK quantity types that map to HealthLog metric types live in
|
||||
`src/lib/measurements/apple-health-mapping.ts`. Anything outside the
|
||||
mapped set (workout routes, ECG voltage traces) is counted in
|
||||
`progress.records_skipped` but not stored.
|
||||
|
||||
### Cumulative HK type → daily aggregate
|
||||
|
||||
Cumulative quantity types — steps, active energy, walking/running
|
||||
distance, flights climbed — get **collapsed into one row per
|
||||
user-local day** to match the iOS daily-aggregation convention.
|
||||
|
||||
- External ID format: `stats:<HKType>:<YYYY-MM-DD>` (e.g.
|
||||
`stats:HKQuantityTypeIdentifierStepCount:2026-05-15`).
|
||||
- The day boundary respects the user's timezone preference. The
|
||||
worker defaults to `Europe/Berlin` when no preference is set
|
||||
(`src/lib/jobs/apple-health-import-worker.ts:132-133`).
|
||||
- A second import of the same archive re-folds the same days onto
|
||||
the same external IDs — the values update in place rather than
|
||||
duplicating.
|
||||
|
||||
### Re-import idempotency
|
||||
|
||||
The two-axis idempotency story:
|
||||
|
||||
1. **File-level** — re-uploading the same `export.zip` (same SHA-256
|
||||
of bytes) short-circuits to the previous job before the parser
|
||||
even starts.
|
||||
2. **Record-level** — when an older `export.zip` is re-exported from
|
||||
iOS (same history, slightly newer device timestamps), every
|
||||
record's external ID stays stable. The upsert path matches on
|
||||
`(userId, externalId)` and updates instead of inserting.
|
||||
|
||||
Practically: a user who exports monthly and re-imports each archive
|
||||
sees fresh data merged in without duplicates. The job result line
|
||||
in the status response reports `recordsInserted` and
|
||||
`recordsUpdated` separately.
|
||||
|
||||
## Admin variant — import on behalf of another user
|
||||
|
||||
`POST /api/admin/import-apple-health-export` is the cookie-only admin
|
||||
variant (Bearer tokens never elevate to admin). The multipart body
|
||||
adds a `userId` text field naming the target user; everything else
|
||||
is identical to the user-facing flow.
|
||||
|
||||
The `ImportJob` row carries `triggeredByAdminId = admin.id` so the
|
||||
status endpoint admits both the target user and the triggering
|
||||
admin. Idempotency is scoped by target user — an admin re-uploading
|
||||
the same archive for the same user resolves to the previous job.
|
||||
|
||||
Typical use: migrating a friend's history into their HealthLog
|
||||
account when they cannot run the import themselves, or backfilling
|
||||
a household member's data after the admin received the `export.zip`
|
||||
out of band.
|
||||
|
||||
## Source-priority interaction
|
||||
|
||||
HealthLog's source ladder is **APPLE_HEALTH ≻ WITHINGS ≻ MANUAL ≻
|
||||
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`.
|
||||
|
||||
Concrete consequences:
|
||||
|
||||
- If you already sync steps from a Withings ScanWatch and also import
|
||||
Apple Health, the Apple-Health-aggregated stream wins for the day —
|
||||
iOS HealthKit folds the watch sensor + iPhone motion data into a
|
||||
single canonical stream, so it is the most complete cumulative
|
||||
source. The Withings rows stay in the database as an audit trail
|
||||
but drop out of the per-day aggregation.
|
||||
- If you weigh yourself on a Withings scale and also import Apple
|
||||
Health (which received the same reading via the Health Mate iOS
|
||||
app), the Withings row wins for display. Apple Health is
|
||||
second-hand for that metric.
|
||||
- Manual entries always rank below either device source. The IMPORT
|
||||
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`.
|
||||
|
||||
## Failure modes
|
||||
|
||||
| Failure | What you see | Recovery |
|
||||
| ------- | ------------ | -------- |
|
||||
| Archive is missing `apple_health_export/export.xml` | `failureReason: "Archive is missing the apple_health_export/export.xml member"` | Re-export from the iPhone; a partial AirDrop or a renamed archive can lose the canonical entry. |
|
||||
| Archive is encrypted | `failureReason: "Encrypted ZIP entries are not supported"` | Apple does not encrypt the export — this means the file was repacked by a third-party tool. Re-export from iOS directly. |
|
||||
| Unsupported compression method | `failureReason: "Unsupported ZIP compression method <n> for export.xml"` | Same recovery — re-export from iOS, which always emits method 0 (stored) or method 8 (deflate). |
|
||||
| Worker is not running | `503 Background worker is not running` from the kick-off endpoint | The worker process is down. Check `docker compose ps` for the `app-worker` container (or `HEALTHLOG_PROCESS_TYPE=all` mode on the main `app` container) and restart. |
|
||||
| Rate-limited | `429 Too many import uploads, try again later` | Wait sixty seconds; the limit is three uploads per minute per user. |
|
||||
|
||||
The wide-event log line `import.apple-health.kickoff` (or
|
||||
`import.apple-health.kickoff.denied` on failure) captures every
|
||||
attempt with the upload size, SHA-256, and the resolved `ImportJob`
|
||||
id for debugging.
|
||||
@@ -0,0 +1,195 @@
|
||||
# Withings integration
|
||||
|
||||
HealthLog connects to Withings via OAuth2 plus a push webhook for
|
||||
near-real-time sync. Once linked, every Body+ scale weighing, BPM
|
||||
blood-pressure reading, ScanWatch sleep stage, and activity sample
|
||||
lands in HealthLog within seconds — no polling, no third-party
|
||||
relay.
|
||||
|
||||
## What gets synced
|
||||
|
||||
The Withings client (`src/lib/withings/client.ts`) maps the following
|
||||
metric types into `Measurement` rows tagged `source = WITHINGS`:
|
||||
|
||||
| Withings meastype | HealthLog `MeasurementType` |
|
||||
| ----------------- | --------------------------- |
|
||||
| 1 | `WEIGHT` |
|
||||
| 5 | `FAT_FREE_MASS` |
|
||||
| 9 | `BLOOD_PRESSURE_DIA` |
|
||||
| 10 | `BLOOD_PRESSURE_SYS` |
|
||||
| 11 | `PULSE` |
|
||||
| 54, 35 | `OXYGEN_SATURATION` (ScanWatch / pulse-ox products) |
|
||||
| Plus the body-composition family (fat ratio, muscle mass, bone mass, hydration) and sleep / heart-rate variability via the heart-list endpoint |
|
||||
|
||||
Activity data (steps, active energy, distance, floors climbed) lands
|
||||
via the `user.activity` OAuth scope shipped in v1.4.25. Sleep stages
|
||||
arrive via the sleep series endpoint.
|
||||
|
||||
## 1. Register a Withings developer app
|
||||
|
||||
1. Sign in at <https://developer.withings.com/> and **create a new
|
||||
public-API application**. The "Public" tier is free and supports
|
||||
the OAuth2 flow HealthLog uses; you do not need the partner tier.
|
||||
2. **Callback URL:** `https://your-instance.example.com/api/withings/callback`.
|
||||
Withings allows multiple callbacks per app — add one entry per
|
||||
environment (production, staging, local development against a
|
||||
tunnel).
|
||||
3. **Scopes:** request `user.metrics` AND `user.activity`. Both are
|
||||
needed — `user.metrics` covers weight / BP / heart-list / sleep /
|
||||
SpO₂ / body comp / temperature / VO₂ max; `user.activity` is the
|
||||
workout-equivalent stream for steps / energy / distance / floors.
|
||||
See `src/lib/withings/client.ts:42` (`WITHINGS_OAUTH_SCOPE`).
|
||||
4. Save the application. Withings will issue a **Client ID** and a
|
||||
**Client Secret** — keep the secret out of any chat log; it grants
|
||||
read access to every linked account's full health history.
|
||||
|
||||
## 2. Wire the credentials into HealthLog
|
||||
|
||||
Paste the client ID and secret into `.env`:
|
||||
|
||||
```env
|
||||
WITHINGS_CLIENT_ID="your-client-id"
|
||||
WITHINGS_CLIENT_SECRET="your-client-secret"
|
||||
WITHINGS_REDIRECT_URI="https://your-instance.example.com/api/withings/callback"
|
||||
WITHINGS_WEBHOOK_SECRET="$(openssl rand -hex 32)"
|
||||
```
|
||||
|
||||
`WITHINGS_WEBHOOK_SECRET` is the path-segment token the webhook
|
||||
handler validates against. Generate a fresh random value — anything
|
||||
that fits in a URL path segment works, but a 64-character hex string
|
||||
keeps it indistinguishable from random. Restart the `app` container
|
||||
after saving.
|
||||
|
||||
Per-user override is also possible: the Settings UI accepts a
|
||||
client-ID/secret pair scoped to one user, useful if you want a
|
||||
shared instance where every user brings their own Withings app
|
||||
credentials. Server-level env vars cover the default case.
|
||||
|
||||
## 3. Set the webhook URL
|
||||
|
||||
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`).
|
||||
The shape is:
|
||||
|
||||
```
|
||||
https://your-instance.example.com/api/withings/webhook/<WITHINGS_WEBHOOK_SECRET>
|
||||
```
|
||||
|
||||
Withings has no facility for adding HTTP headers or signing webhook
|
||||
bodies, so the callback URL itself is the only authenticity surface
|
||||
a subscriber controls. The v1.4.25 W17a migration moved the secret
|
||||
from a query parameter (`?secret=…`, captured by every reverse-proxy
|
||||
access log) to a path segment, which keeps it out of the
|
||||
`query_string` column most proxies log by default
|
||||
(`src/app/api/withings/webhook/[token]/route.ts:13-23`).
|
||||
|
||||
You do not need to register the webhook URL anywhere in the
|
||||
developer portal — HealthLog subscribes per-user during the OAuth
|
||||
callback. The portal only owns the OAuth callback URL.
|
||||
|
||||
## 4. Link from the Settings page
|
||||
|
||||
With env vars in place and the app restarted, sign in as the target
|
||||
user and open `/settings/integrations/withings`. Click **Connect
|
||||
Withings**. The flow:
|
||||
|
||||
1. The Connect button redirects to Withings' `oauth2/authorize`.
|
||||
2. The user signs in on Withings and grants the requested scopes.
|
||||
3. Withings redirects back to `/api/withings/callback` with an auth
|
||||
code.
|
||||
4. HealthLog exchanges the code for an access + refresh token, stores
|
||||
them AES-256-GCM-encrypted at rest, subscribes the webhook URL,
|
||||
and triggers an initial sync of the past 30 days.
|
||||
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.
|
||||
|
||||
## Source-priority interaction
|
||||
|
||||
HealthLog's source ladder ranks **WITHINGS ≻ APPLE_HEALTH ≻ MANUAL**
|
||||
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`.
|
||||
|
||||
Concrete consequences if you run Withings alongside an Apple Health
|
||||
import:
|
||||
|
||||
- Weight readings from a Withings scale win for display. Apple
|
||||
Health rows for the same metric (received second-hand from Health
|
||||
Mate) stay in the DB as audit trail but drop out of the active
|
||||
display.
|
||||
- Steps from a ScanWatch go through HealthKit's daily aggregation
|
||||
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`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### The Connect button errors out
|
||||
|
||||
- **`Withings credentials not configured`** — `WITHINGS_CLIENT_ID`
|
||||
or `WITHINGS_CLIENT_SECRET` is missing from `.env`. Confirm both
|
||||
are set in the container's runtime environment (`docker compose
|
||||
exec app env | grep WITHINGS`).
|
||||
- **`Callback URI mismatch`** — the URL HealthLog sends to Withings
|
||||
must match an entry in the developer-portal app's callback list.
|
||||
Add the public URL exactly, including the `https://` scheme and
|
||||
the `/api/withings/callback` path.
|
||||
|
||||
### The webhook stops firing
|
||||
|
||||
Webhook subscriptions can lapse after a token revoke or a portal-
|
||||
side cleanup. Re-subscribe by opening `/settings/integrations/withings`
|
||||
and clicking **Disconnect** then **Connect Withings** again — the
|
||||
callback exchange re-runs `Notify.subscribe()` with a fresh callback
|
||||
URL.
|
||||
|
||||
Test the inbound path with a HEAD request from outside the host:
|
||||
|
||||
```bash
|
||||
curl -I https://your-instance.example.com/api/withings/webhook/<WITHINGS_WEBHOOK_SECRET>
|
||||
```
|
||||
|
||||
A `200 OK` means the path-segment secret matches. A `401` means the
|
||||
env var on the running container does not match the secret embedded
|
||||
in the URL Withings was handed at subscribe time. Re-subscribing
|
||||
fixes the mismatch by updating the URL Withings holds.
|
||||
|
||||
### Sync is stuck on "Reauthorisation required"
|
||||
|
||||
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`).
|
||||
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.
|
||||
|
||||
### Disconnect cleanly
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
`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).
|
||||
@@ -0,0 +1,154 @@
|
||||
# Getting started — self-host HealthLog
|
||||
|
||||
This walkthrough takes a fresh host from `git clone` to a running
|
||||
instance with a working first user. About fifteen minutes if Docker is
|
||||
already installed, longer if a TLS-fronted public hostname is in scope
|
||||
(see the reverse-proxy guide for that).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **Docker 24+ with Compose v2.** The bundled `docker-compose.yml`
|
||||
pulls a multi-arch image from GitHub Container Registry (linux/amd64
|
||||
+ linux/arm64), so a Raspberry Pi 5 or an x86 VPS both work
|
||||
unchanged.
|
||||
- **2 GB RAM, 10 GB free disk.** The Postgres data volume grows with
|
||||
measurement history; the Apple Health import worker can briefly
|
||||
hold a 1.5 GB upload in `/tmp` while it parses.
|
||||
- **Outbound HTTPS** to `ghcr.io` (image pulls) plus whichever
|
||||
integration endpoints you plan to enable (Withings, OpenAI, etc.).
|
||||
- **Optional but recommended:** a reverse proxy that terminates TLS
|
||||
and forwards to `http://localhost:3000` (Caddy, Traefik, Nginx
|
||||
Proxy Manager, Coolify, or bare Nginx — see `reverse-proxy.md`).
|
||||
|
||||
## 1. Clone the repository
|
||||
|
||||
```bash
|
||||
git clone https://github.com/MBombeck/HealthLog.git
|
||||
cd HealthLog
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
`.env.example` is the canonical reference for every env var the app
|
||||
reads. Anything you do not uncomment falls back to a sane default or
|
||||
disables the optional subsystem.
|
||||
|
||||
## 2. Generate the three required secrets
|
||||
|
||||
HealthLog needs three secrets before it will boot. Generate them with
|
||||
`openssl` and append to `.env`:
|
||||
|
||||
```bash
|
||||
echo "POSTGRES_PASSWORD=$(openssl rand -base64 24)" >> .env
|
||||
echo "ENCRYPTION_KEY=$(openssl rand -hex 32)" >> .env
|
||||
echo "API_TOKEN_HMAC_KEY=$(openssl rand -hex 32)" >> .env
|
||||
```
|
||||
|
||||
| Variable | Purpose | Rotation cost |
|
||||
| -------- | ------- | ------------- |
|
||||
| `POSTGRES_PASSWORD` | Bundled Postgres password — used by both the `db` service and `DATABASE_URL` | Database restart + connection-string update |
|
||||
| `ENCRYPTION_KEY` | AES-256-GCM key for at-rest secrets (Withings tokens, AI provider keys, VAPID secrets) | Coordinated; see `docs/ops/encryption-key-rotation.md` |
|
||||
| `API_TOKEN_HMAC_KEY` | HMAC-SHA256 key for hashing Bearer API tokens before storage | Invalidates every issued `hlk_*` token |
|
||||
|
||||
Then open `.env` and confirm `DATABASE_URL` includes the password you
|
||||
just generated. The default template (`postgresql://healthlog:CHANGE-ME@db:5432/healthlog`)
|
||||
references the literal string `CHANGE-ME`; replace it with the value
|
||||
you put in `POSTGRES_PASSWORD`, or rewrite the URL to substitute
|
||||
explicitly.
|
||||
|
||||
## 3. Bring the stack up
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
Compose starts two services:
|
||||
|
||||
- `db` — PostgreSQL 16 with a named volume for persistence.
|
||||
- `app` — HealthLog itself, listening on `localhost:3000`. The image
|
||||
runs `prisma migrate deploy` on boot so the schema lands
|
||||
automatically; subsequent restarts no-op when the database is
|
||||
already current.
|
||||
|
||||
The first boot pulls the image (a few hundred megabytes) and runs
|
||||
migrations. Once `docker compose logs -f app` shows the Next.js
|
||||
banner, the instance is live.
|
||||
|
||||
## 4. Create the first user
|
||||
|
||||
Open `http://localhost:3000` in a browser. The registration flow asks
|
||||
for a username, email, and password. **The first user that registers
|
||||
is promoted to admin automatically.** Every subsequent registration
|
||||
creates a regular user.
|
||||
|
||||
After the first user lands, you can:
|
||||
|
||||
- Open `/admin` to review the worker status, audit log, and any
|
||||
optional channels you want to configure (Telegram bot, ntfy, Web
|
||||
Push VAPID keys, AI fallback key).
|
||||
- Open `/measurements` and add a weight or blood pressure to confirm
|
||||
the data path is wired end-to-end.
|
||||
|
||||
## 5. Point the instance at a public hostname
|
||||
|
||||
Local-only is fine for a single-user trial. For anything more — and
|
||||
to unlock OAuth callbacks, webhook deliveries, and Web Push — set the
|
||||
public URL pair before restarting:
|
||||
|
||||
```env
|
||||
NEXT_PUBLIC_APP_URL="https://your-instance.example.com"
|
||||
APP_URL="https://your-instance.example.com"
|
||||
```
|
||||
|
||||
Both vars must match. `NEXT_PUBLIC_APP_URL` lands in the client bundle
|
||||
at build time and drives every absolute URL the UI emits (OAuth
|
||||
redirects, share links, manifest icons). `APP_URL` is the server-side
|
||||
mirror used by background jobs that have no request context.
|
||||
|
||||
Restart the app container after editing:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The pre-built image reads both values at startup; no rebuild needed.
|
||||
|
||||
## 6. Pick a reverse-proxy
|
||||
|
||||
The bundled stack exposes plain HTTP on `localhost:3000`. Production
|
||||
deployments terminate TLS in front of the container. The
|
||||
`docs/self-hosting/reverse-proxy.md` guide carries copy-paste
|
||||
configurations for Caddy, Traefik, Nginx Proxy Manager, Coolify, and
|
||||
bare Nginx — including the `X-Forwarded-For` chain shape that the
|
||||
rate-limiter trusts and the `TRUST_PROXY_HOPS` env var that pins how
|
||||
many hops to read back.
|
||||
|
||||
## 7. Where to go next
|
||||
|
||||
| Next step | File |
|
||||
| --------- | ---- |
|
||||
| TLS + reverse-proxy configuration | `docs/self-hosting/reverse-proxy.md` |
|
||||
| Web/worker process split for horizontal scale | `docs/self-hosting/scaling.md` |
|
||||
| Off-host encrypted backups to S3/R2/B2 | `docs/ops/backup-restore.md` |
|
||||
| Encryption-key rotation procedure | `docs/ops/encryption-key-rotation.md` |
|
||||
| Withings device sync | `docs/integrations/withings.md` |
|
||||
| Apple Health `export.zip` import | `docs/integrations/apple-health.md` |
|
||||
| AI provider setup (OpenAI / Anthropic / local / ChatGPT OAuth) | `docs/integrations/ai-providers.md` |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
- **`app` container exits with a Prisma migration error.** The
|
||||
Postgres healthcheck races the migration on slow disks. Run
|
||||
`docker compose restart app` once the database has stabilised —
|
||||
migrations are idempotent.
|
||||
- **First-user registration shows "registration disabled".** The
|
||||
bundled image ships with registration enabled; the toggle lives in
|
||||
`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
|
||||
starting. Tail `docker compose logs db` to confirm Postgres came up
|
||||
cleanly.
|
||||
- **OAuth callbacks loop back to localhost.** Confirm `APP_URL` and
|
||||
`NEXT_PUBLIC_APP_URL` both point at the public hostname, not at
|
||||
`localhost`, and restart `app` after the change.
|
||||
@@ -0,0 +1,198 @@
|
||||
# Reverse proxy configuration
|
||||
|
||||
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.
|
||||
|
||||
This guide gives copy-paste blocks for five common fronts. Pick one;
|
||||
the env-var pairing is identical across them.
|
||||
|
||||
## Pre-flight — env vars the proxy interacts with
|
||||
|
||||
| Variable | Required for | Notes |
|
||||
| -------- | ------------ | ----- |
|
||||
| `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 |
|
||||
|
||||
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.
|
||||
|
||||
### `TRUST_PROXY_HOPS` — what to set it to
|
||||
|
||||
`src/lib/api-response.ts` (`getClientIp`) reads the `X-Forwarded-For`
|
||||
chain from the **right**, counting back `TRUST_PROXY_HOPS` entries.
|
||||
This guards against a client rotating `X-Forwarded-For` per request
|
||||
to defeat IP-based rate limits.
|
||||
|
||||
| Topology | `TRUST_PROXY_HOPS` |
|
||||
| -------- | ------------------ |
|
||||
| App is internet-facing with no proxy | `0` (XFF ignored entirely) |
|
||||
| Single proxy in front (Caddy / Traefik / Nginx / Coolify) | `1` (default) |
|
||||
| Cloudflare → your proxy → app | `2` |
|
||||
| Cloudflare → Coolify-Tunnel → Coolify → app | `3` |
|
||||
|
||||
A misconfigured count logs a one-shot warning to stderr and collapses
|
||||
every anonymous caller into one shared rate-limit bucket. Match the
|
||||
value to the actual hop count or set it to `0` and let `x-real-ip`
|
||||
drive the IP resolution.
|
||||
|
||||
## Caddy
|
||||
|
||||
`Caddyfile`:
|
||||
|
||||
```
|
||||
your-instance.example.com {
|
||||
encode zstd gzip
|
||||
reverse_proxy localhost:3000 {
|
||||
header_up X-Forwarded-For {remote_host}
|
||||
header_up X-Forwarded-Proto {scheme}
|
||||
header_up Host {host}
|
||||
flush_interval -1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Caddy handles the WebSocket upgrade automatically. `flush_interval -1`
|
||||
disables response buffering so the streaming insight events surface
|
||||
without batching delays. With Caddy as the only hop, leave
|
||||
`TRUST_PROXY_HOPS=1`.
|
||||
|
||||
## Traefik
|
||||
|
||||
`docker-compose.override.yml` snippet:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.http.routers.healthlog.rule=Host(`your-instance.example.com`)"
|
||||
- "traefik.http.routers.healthlog.entrypoints=websecure"
|
||||
- "traefik.http.routers.healthlog.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.healthlog.loadbalancer.server.port=3000"
|
||||
- "traefik.http.middlewares.healthlog-headers.headers.customrequestheaders.X-Forwarded-Proto=https"
|
||||
- "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
|
||||
Cloudflare, set it to `2` so the rate-limiter reads the real client IP
|
||||
that Cloudflare placed in the chain.
|
||||
|
||||
## Nginx Proxy Manager
|
||||
|
||||
In the NPM UI:
|
||||
|
||||
1. **Proxy Hosts → Add Proxy Host.**
|
||||
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).
|
||||
5. **SSL tab:** request a Let's Encrypt certificate, force SSL.
|
||||
6. **Advanced tab — Custom Nginx Configuration:**
|
||||
|
||||
```nginx
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header Host $host;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_read_timeout 300s;
|
||||
```
|
||||
|
||||
The 300-second read timeout matters for long-running insight
|
||||
generations — anything shorter will cut streamed responses mid-flight.
|
||||
|
||||
Pair with `TRUST_PROXY_HOPS=1`.
|
||||
|
||||
## Coolify
|
||||
|
||||
Coolify's bundled Traefik handles most of this. The pieces that need
|
||||
operator attention:
|
||||
|
||||
1. **Application → Domains:** set `https://your-instance.example.com`.
|
||||
Coolify provisions the certificate and adds the Traefik labels.
|
||||
2. **Application → Environment Variables:** set
|
||||
`NEXT_PUBLIC_APP_URL`, `APP_URL`, and any optional secrets
|
||||
(`WITHINGS_*`, `APNS_*`, `LOKI_*`).
|
||||
3. **Application → General → Pull policy:** `always`. The bundled
|
||||
`docker-compose.yml` declares this explicitly (v1.4.34.2 fix) so
|
||||
Coolify re-checks the GHCR digest on every redeploy instead of
|
||||
reusing the cached `:latest` layer.
|
||||
4. **Notifications → Webhook:** point at `/api/internal/deploy-webhook`
|
||||
with the `X-Deploy-Webhook-Secret` header set to
|
||||
`DEPLOY_WEBHOOK_SECRET`. The app gates the endpoint with a
|
||||
timing-safe compare against that env var.
|
||||
|
||||
Coolify-Tunnel adds one hop to the chain; pair with
|
||||
`TRUST_PROXY_HOPS=2`. If you front Coolify itself with Cloudflare,
|
||||
bump to `3`.
|
||||
|
||||
## Bare Nginx
|
||||
|
||||
`/etc/nginx/sites-available/healthlog`:
|
||||
|
||||
```nginx
|
||||
upstream healthlog {
|
||||
server 127.0.0.1:3000;
|
||||
keepalive 16;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name your-instance.example.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/your-instance.example.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/your-instance.example.com/privkey.pem;
|
||||
|
||||
client_max_body_size 1600M; # Apple Health export.zip cap is 1.5 GB
|
||||
|
||||
location / {
|
||||
proxy_pass http://healthlog;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 300s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name your-instance.example.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
```
|
||||
|
||||
`client_max_body_size 1600M` is the load-bearing setting if you plan
|
||||
to support Apple Health `export.zip` uploads — the app caps the
|
||||
upload at 1.5 GB and Nginx will reject the body before the app sees
|
||||
it otherwise. `proxy_buffering off` keeps streamed insight events
|
||||
flowing without buffer pauses. Pair with `TRUST_PROXY_HOPS=1`.
|
||||
|
||||
## Verifying the proxy chain
|
||||
|
||||
Once the proxy is in front, hit `https://your-instance.example.com/api/version`
|
||||
from outside the host and check `docker compose logs app` for the
|
||||
request. The wide-event log line should carry a non-null client IP
|
||||
that matches your real source address. A `null` IP or a single
|
||||
`unknown` rate-limit bucket means `TRUST_PROXY_HOPS` is set higher
|
||||
than the actual proxy hop count, or the proxy is dropping the
|
||||
`X-Forwarded-For` header entirely.
|
||||
|
||||
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.
|
||||
@@ -20,8 +20,7 @@ deployed in three modes:
|
||||
`app` service and uncomment the `app-worker` service block at the
|
||||
bottom (it ships with `HEALTHLOG_PROCESS_TYPE=worker` baked in).
|
||||
2. Make sure both containers see the same `DATABASE_URL`,
|
||||
`ENCRYPTION_KEY` (or `ENCRYPTION_KEYS`), `API_TOKEN_HMAC_KEY`,
|
||||
and `SESSION_SECRET`.
|
||||
`ENCRYPTION_KEY` (or `ENCRYPTION_KEYS`), and `API_TOKEN_HMAC_KEY`.
|
||||
3. `docker compose up -d --build`.
|
||||
|
||||
Both containers connect to the same Postgres. pg-boss claims jobs
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"sidebar": "Sidebar",
|
||||
"mainNavigation": "Hauptnavigation",
|
||||
"mobileNavigation": "Mobile Navigation",
|
||||
"breadcrumb": "Brotkrumen-Navigation",
|
||||
"loadingScreen": "Laden…",
|
||||
"more": "Mehr",
|
||||
"moreSheetTitle": "Mehr",
|
||||
@@ -760,6 +761,7 @@
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"loadingLabel": "Diagramm wird geladen…",
|
||||
"weight": "Gewicht",
|
||||
"bloodPressure": "Blutdruck",
|
||||
"pulse": "Puls",
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"sidebar": "Sidebar",
|
||||
"mainNavigation": "Main navigation",
|
||||
"mobileNavigation": "Mobile navigation",
|
||||
"breadcrumb": "Breadcrumb navigation",
|
||||
"loadingScreen": "Loading…",
|
||||
"more": "More",
|
||||
"moreSheetTitle": "More",
|
||||
@@ -760,6 +761,7 @@
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"loadingLabel": "Loading chart…",
|
||||
"weight": "Weight",
|
||||
"bloodPressure": "Blood Pressure",
|
||||
"pulse": "Pulse",
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"sidebar": "Sidebar",
|
||||
"mainNavigation": "Main navigation",
|
||||
"mobileNavigation": "Mobile navigation",
|
||||
"breadcrumb": "Ruta de navegación",
|
||||
"loadingScreen": "Loading…",
|
||||
"more": "More",
|
||||
"moreSheetTitle": "More",
|
||||
@@ -760,6 +761,7 @@
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"loadingLabel": "Cargando gráfico…",
|
||||
"weight": "Peso",
|
||||
"bloodPressure": "Presión arterial",
|
||||
"pulse": "Pulso",
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"sidebar": "Sidebar",
|
||||
"mainNavigation": "Main navigation",
|
||||
"mobileNavigation": "Mobile navigation",
|
||||
"breadcrumb": "Fil d'Ariane",
|
||||
"loadingScreen": "Loading…",
|
||||
"more": "More",
|
||||
"moreSheetTitle": "More",
|
||||
@@ -760,6 +761,7 @@
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"loadingLabel": "Chargement du graphique…",
|
||||
"weight": "Poids",
|
||||
"bloodPressure": "Tension artérielle",
|
||||
"pulse": "Pouls",
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"sidebar": "Sidebar",
|
||||
"mainNavigation": "Main navigation",
|
||||
"mobileNavigation": "Mobile navigation",
|
||||
"breadcrumb": "Navigazione breadcrumb",
|
||||
"loadingScreen": "Loading…",
|
||||
"more": "More",
|
||||
"moreSheetTitle": "More",
|
||||
@@ -760,6 +761,7 @@
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"loadingLabel": "Caricamento grafico…",
|
||||
"weight": "Peso",
|
||||
"bloodPressure": "Pressione arteriosa",
|
||||
"pulse": "Polso",
|
||||
|
||||
@@ -182,6 +182,7 @@
|
||||
"sidebar": "Sidebar",
|
||||
"mainNavigation": "Main navigation",
|
||||
"mobileNavigation": "Mobile navigation",
|
||||
"breadcrumb": "Nawigacja okruszkami",
|
||||
"loadingScreen": "Loading…",
|
||||
"more": "More",
|
||||
"moreSheetTitle": "More",
|
||||
@@ -760,6 +761,7 @@
|
||||
}
|
||||
},
|
||||
"charts": {
|
||||
"loadingLabel": "Ładowanie wykresu…",
|
||||
"weight": "Waga",
|
||||
"bloodPressure": "Ciśnienie krwi",
|
||||
"pulse": "Tętno",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.4.34.3",
|
||||
"version": "1.4.34.4",
|
||||
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"homepage": "https://healthlog.dev",
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
"name": "HealthLog",
|
||||
"short_name": "HealthLog",
|
||||
"description": "Personal health tracking — weight, blood pressure, medications",
|
||||
"id": "/?source=pwa",
|
||||
"scope": "/",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"display_override": ["standalone"],
|
||||
"theme_color": "#282a36",
|
||||
"background_color": "#282a36",
|
||||
"icons": [
|
||||
|
||||
+26
-5
@@ -100,9 +100,15 @@ async function networkFirst(request, cacheName) {
|
||||
const cached = await caches.match(request);
|
||||
if (cached) return cached;
|
||||
|
||||
// Offline fallback
|
||||
// Offline fallback. F-7 (mobile security audit, 2026-05-16): the
|
||||
// previous fallback rendered hard-coded German body copy, which is
|
||||
// wrong for any en/* user reaching it offline. The replacement is
|
||||
// language-neutral — wordmark + the universally understood
|
||||
// "Offline" token + a generic retry hint expressed as an icon
|
||||
// (the round-trip arrow), so the page does the right thing in any
|
||||
// locale without shipping a translation bundle into the worker.
|
||||
return new Response(
|
||||
'<!DOCTYPE html><html lang="de"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>HealthLog — Offline</title><style>body{font-family:system-ui,sans-serif;background:#282a36;color:#f8f8f2;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}div{text-align:center;padding:2rem}h1{color:#bd93f9;margin-bottom:0.5rem}p{color:#6272a4}</style></head><body><div><h1>Offline</h1><p>Keine Internetverbindung. Bitte versuche es später erneut.</p></div></body></html>',
|
||||
'<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>HealthLog — Offline</title><style>body{font-family:system-ui,sans-serif;background:#282a36;color:#f8f8f2;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}div{text-align:center;padding:2rem}h1{color:#bd93f9;margin:0 0 .5rem;font-size:2rem;letter-spacing:.05em}p{color:#6272a4;margin:.25rem 0;font-size:.9rem}svg{width:48px;height:48px;color:#6272a4;margin-bottom:1rem}</style></head><body><div><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10"/><path d="M20.49 15a9 9 0 0 1-14.85 3.36L1 14"/></svg><h1>HealthLog</h1><p>Offline</p></div></body></html>',
|
||||
{
|
||||
status: 503,
|
||||
headers: { "Content-Type": "text/html; charset=utf-8" },
|
||||
@@ -155,7 +161,22 @@ self.addEventListener("push", (event) => {
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const url = event.notification.data?.url || "/";
|
||||
// F-7 (mobile security audit, 2026-05-16): validate the destination
|
||||
// resolves to the same origin before navigating any client. Push
|
||||
// payloads are VAPID-signed by our own server, but a server-side bug
|
||||
// (or compromised admin issuing pushes) could ship an off-origin URL
|
||||
// into `data.url` and use the focused PWA as a redirect-driven phish.
|
||||
// Reject anything that doesn't match our origin and fall back to "/".
|
||||
const rawUrl = event.notification.data?.url || "/";
|
||||
let safeUrl = "/";
|
||||
try {
|
||||
const resolved = new URL(rawUrl, self.location.origin);
|
||||
if (resolved.origin === self.location.origin) {
|
||||
safeUrl = resolved.pathname + resolved.search + resolved.hash;
|
||||
}
|
||||
} catch {
|
||||
safeUrl = "/";
|
||||
}
|
||||
|
||||
event.waitUntil(
|
||||
self.clients
|
||||
@@ -163,11 +184,11 @@ self.addEventListener("notificationclick", (event) => {
|
||||
.then((clientList) => {
|
||||
for (const client of clientList) {
|
||||
if (client.url.includes(self.location.origin) && "focus" in client) {
|
||||
client.navigate(url);
|
||||
client.navigate(safeUrl);
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
return self.clients.openWindow(url);
|
||||
return self.clients.openWindow(safeUrl);
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* F-5 (mobile security audit, 2026-05-16): two security headers
|
||||
* needed to harden against first-visit MITM and to scope the
|
||||
* Withings backend host to its surface only.
|
||||
*
|
||||
* - HSTS now carries `; preload` so `healthlog.bombeck.io` is
|
||||
* eligible for the Chromium HSTS preload list.
|
||||
* - The `wbsapi.withings.net` `connect-src` entry only ships on
|
||||
* the Withings surfaces (`/settings/integrations/withings/*`
|
||||
* and `/api/withings/*`) instead of globally — same gating
|
||||
* pattern the AI hosts already use.
|
||||
*
|
||||
* Both checks lock the proxy in place so a future refactor cannot
|
||||
* silently regress either one.
|
||||
*/
|
||||
|
||||
vi.mock("@/lib/process-type", () => ({
|
||||
shouldRunWeb: () => true,
|
||||
}));
|
||||
|
||||
import { proxy } from "../proxy";
|
||||
|
||||
// `NODE_ENV` is a special read-only property on node's `process.env`
|
||||
// proxy. `vi.stubEnv` knows how to patch it without tripping the
|
||||
// "only accepts a configurable, writable, and enumerable data
|
||||
// descriptor" guard that a direct `defineProperty` runs into.
|
||||
function setNodeEnv(value: "development" | "production") {
|
||||
vi.stubEnv("NODE_ENV", value);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Default to production so the production-only headers run; individual
|
||||
// tests can flip back to development if they care about the dev shape.
|
||||
setNodeEnv("production");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
function makeRequest(
|
||||
pathname: string,
|
||||
cookies: Record<string, string> = {},
|
||||
): NextRequest {
|
||||
const cookieHeader = Object.entries(cookies)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("; ");
|
||||
return new NextRequest(`http://localhost${pathname}`, {
|
||||
headers: cookieHeader ? { cookie: cookieHeader } : {},
|
||||
});
|
||||
}
|
||||
|
||||
describe("proxy.ts HSTS preload (F-5, 2026-05-16)", () => {
|
||||
it("attaches `preload` to the Strict-Transport-Security header in production", () => {
|
||||
const res = proxy(makeRequest("/", { healthlog_session: "sess-1" }));
|
||||
expect(res.headers.get("strict-transport-security")).toBe(
|
||||
"max-age=31536000; includeSubDomains; preload",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not ship HSTS in development", () => {
|
||||
setNodeEnv("development");
|
||||
const res = proxy(makeRequest("/", { healthlog_session: "sess-1" }));
|
||||
expect(res.headers.get("strict-transport-security")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("proxy.ts Withings CSP gating (F-5, 2026-05-16)", () => {
|
||||
it("does NOT allow wbsapi.withings.net on a non-Withings page", () => {
|
||||
const res = proxy(makeRequest("/", { healthlog_session: "sess-1" }));
|
||||
const csp = res.headers.get("content-security-policy") ?? "";
|
||||
expect(csp).not.toMatch(/wbsapi\.withings\.net/);
|
||||
});
|
||||
|
||||
it("allows wbsapi.withings.net under /settings/integrations/withings/*", () => {
|
||||
const res = proxy(
|
||||
makeRequest("/settings/integrations/withings", {
|
||||
healthlog_session: "sess-1",
|
||||
}),
|
||||
);
|
||||
const csp = res.headers.get("content-security-policy") ?? "";
|
||||
expect(csp).toMatch(/connect-src[^;]*https:\/\/wbsapi\.withings\.net/);
|
||||
});
|
||||
|
||||
it("allows wbsapi.withings.net under /api/withings/*", () => {
|
||||
const res = proxy(
|
||||
makeRequest("/api/withings/callback", {
|
||||
healthlog_session: "sess-1",
|
||||
}),
|
||||
);
|
||||
const csp = res.headers.get("content-security-policy") ?? "";
|
||||
expect(csp).toMatch(/connect-src[^;]*https:\/\/wbsapi\.withings\.net/);
|
||||
});
|
||||
|
||||
it("never ships the Withings host on the auth surface (DOM-XSS exfil channel)", () => {
|
||||
const res = proxy(makeRequest("/auth/login"));
|
||||
const csp = res.headers.get("content-security-policy") ?? "";
|
||||
expect(csp).not.toMatch(/wbsapi\.withings\.net/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
/**
|
||||
* F-7 (mobile security audit, 2026-05-16): three regressions to lock
|
||||
* in for `public/sw.js`.
|
||||
*
|
||||
* - `notificationclick` validates the destination resolves to the
|
||||
* same origin before calling `client.navigate()` — VAPID auth on
|
||||
* push payloads only proves authenticity, not that the URL is
|
||||
* safe to navigate to. A compromised admin issuing pushes could
|
||||
* otherwise drive a focused PWA at an off-origin URL.
|
||||
* - The offline fallback HTML is no longer hard-coded German body
|
||||
* copy. The replacement renders a language-neutral icon +
|
||||
* "Offline" wordmark.
|
||||
* - The manifest carries `scope`, `id`, and `display_override` so
|
||||
* PWA install identity and scope are explicit instead of
|
||||
* defaulting on the user-agent's behalf.
|
||||
*
|
||||
* The service worker depends on `self`, `caches`, `clients`, and other
|
||||
* worker-globals that aren't available in node, so this suite reads
|
||||
* `public/sw.js` as text and asserts on the source rather than
|
||||
* executing the worker. Cheap and stable across worker-runtime drift.
|
||||
*/
|
||||
|
||||
const SW_PATH = resolve(process.cwd(), "public/sw.js");
|
||||
const MANIFEST_PATH = resolve(process.cwd(), "public/manifest.json");
|
||||
|
||||
function readSw(): string {
|
||||
return readFileSync(SW_PATH, "utf-8");
|
||||
}
|
||||
|
||||
describe("public/sw.js notificationclick same-origin guard (F-7, 2026-05-16)", () => {
|
||||
it("validates the navigation URL resolves to the worker's own origin", () => {
|
||||
const sw = readSw();
|
||||
// The guard reads `data.url`, parses it with `new URL(rawUrl,
|
||||
// self.location.origin)`, and only navigates when
|
||||
// `resolved.origin === self.location.origin`. Each substring is
|
||||
// independently load-bearing.
|
||||
expect(sw).toMatch(/new URL\(rawUrl, self\.location\.origin\)/);
|
||||
expect(sw).toMatch(/resolved\.origin === self\.location\.origin/);
|
||||
});
|
||||
|
||||
it("falls back to `/` for off-origin or malformed payloads", () => {
|
||||
const sw = readSw();
|
||||
// Two fallback paths — the URL parse failure (try/catch) and the
|
||||
// origin mismatch branch. Both must end at `safeUrl = "/"`.
|
||||
const safeUrlAssignments = sw.match(/safeUrl\s*=\s*"\/"/g) ?? [];
|
||||
expect(safeUrlAssignments.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("never calls client.navigate with the raw payload URL", () => {
|
||||
const sw = readSw();
|
||||
// The previous code called `client.navigate(url)` directly. The
|
||||
// hardened version only ever navigates the sanitised `safeUrl`.
|
||||
expect(sw).not.toMatch(/client\.navigate\(url\)/);
|
||||
expect(sw).toMatch(/client\.navigate\(safeUrl\)/);
|
||||
expect(sw).toMatch(/self\.clients\.openWindow\(safeUrl\)/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("public/sw.js offline fallback locale neutrality (F-7, 2026-05-16)", () => {
|
||||
it("does not embed hard-coded German body copy in the offline page", () => {
|
||||
const sw = readSw();
|
||||
// Previous fallback shipped a German sentence. The replacement is
|
||||
// language-neutral — wordmark + "Offline" token + an icon. None
|
||||
// of the German body copy may reappear.
|
||||
expect(sw).not.toMatch(/Keine Internetverbindung/);
|
||||
expect(sw).not.toMatch(/Bitte versuche es später erneut/);
|
||||
});
|
||||
|
||||
it("still renders the wordmark and the universal `Offline` token", () => {
|
||||
const sw = readSw();
|
||||
// The minimal language-neutral fallback still names the app and
|
||||
// surfaces the universally understood status token.
|
||||
expect(sw).toMatch(/HealthLog/);
|
||||
expect(sw).toMatch(/<p>Offline<\/p>/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("public/manifest.json PWA identity hygiene (F-7, 2026-05-16)", () => {
|
||||
it("declares an explicit scope, id, and display_override", () => {
|
||||
const raw = readFileSync(MANIFEST_PATH, "utf-8");
|
||||
const manifest = JSON.parse(raw) as Record<string, unknown>;
|
||||
expect(manifest.scope).toBe("/");
|
||||
expect(manifest.id).toBe("/?source=pwa");
|
||||
expect(manifest.display_override).toEqual(["standalone"]);
|
||||
});
|
||||
|
||||
it("keeps the existing display/start_url contract", () => {
|
||||
const raw = readFileSync(MANIFEST_PATH, "utf-8");
|
||||
const manifest = JSON.parse(raw) as Record<string, unknown>;
|
||||
expect(manifest.display).toBe("standalone");
|
||||
expect(manifest.start_url).toBe("/");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ vi.mock("@/lib/db", () => ({
|
||||
update: vi.fn(),
|
||||
},
|
||||
},
|
||||
toJson: <T,>(v: T) => v,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
@@ -20,7 +20,7 @@ import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
|
||||
import { apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import {
|
||||
doctorReportPrefsSchema,
|
||||
parseDoctorReportPrefs,
|
||||
@@ -74,11 +74,8 @@ export const PUT = apiHandler(async (req: Request) => {
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
// `merged` is a typed object; Prisma's `InputJsonValue` insists on an
|
||||
// index signature so cast through `unknown` rather than widen the
|
||||
// typed return surface. Shape is identical to the validated input.
|
||||
data: {
|
||||
doctorReportPrefsJson: merged as unknown as Record<string, boolean>,
|
||||
doctorReportPrefsJson: toJson(merged),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import {
|
||||
CHART_OVERLAY_KEYS,
|
||||
resolveDashboardLayout,
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
type DashboardLayout,
|
||||
} from "@/lib/dashboard-layout";
|
||||
import { invalidateUserDashboardWidgets } from "@/lib/cache/invalidate";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { z } from "zod/v4";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
@@ -80,7 +79,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
await tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
dashboardWidgetsJson: normalized as unknown as Prisma.InputJsonValue,
|
||||
dashboardWidgetsJson: toJson(normalized),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import {
|
||||
resolveDashboardLayout,
|
||||
serializeDashboardLayout,
|
||||
@@ -153,7 +153,7 @@ export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
dashboardWidgetsJson: normalized as unknown as Prisma.InputJsonValue,
|
||||
dashboardWidgetsJson: toJson(normalized),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
user: { findUnique: vi.fn(), update: vi.fn() },
|
||||
},
|
||||
toJson: <T,>(v: T) => v,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
|
||||
|
||||
@@ -13,8 +13,7 @@ import { z } from "zod/v4";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, safeJson } from "@/lib/api-response";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { Prisma } from "@/generated/prisma/client";
|
||||
import { prisma, toJson } from "@/lib/db";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
export const directionEnum = z.enum([
|
||||
@@ -174,7 +173,7 @@ export const PATCH = apiHandler(async (request: NextRequest) => {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
healthKitConfigJson: updatedConfig as unknown as Prisma.InputJsonValue,
|
||||
healthKitConfigJson: toJson(updatedConfig),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+23
-4
@@ -49,17 +49,29 @@ export const metadata: Metadata = {
|
||||
openGraph: {
|
||||
title: "HealthLog",
|
||||
description:
|
||||
"Personal health tracking — weight, blood pressure, pulse, mood, medication compliance",
|
||||
"Self-hosted health tracker. Weight, blood pressure, glucose, mood, medications. Withings + Apple Health sync. AI Insights you own.",
|
||||
type: "website",
|
||||
locale: "en_US",
|
||||
alternateLocale: ["de_DE", "fr_FR", "es_ES", "it_IT", "pl_PL"],
|
||||
siteName: "HealthLog",
|
||||
// Drop-in OG asset. Replace with a 1200×630 dashboard-screenshot
|
||||
// capture when an official one ships; the logo render keeps the
|
||||
// unfurl from rendering a blank tile in the meantime.
|
||||
images: [
|
||||
{
|
||||
url: "/logo-readme.png",
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
alt: "HealthLog — your health data, your server",
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary",
|
||||
card: "summary_large_image",
|
||||
title: "HealthLog",
|
||||
description:
|
||||
"Personal health tracking — weight, blood pressure, pulse, mood, medication compliance",
|
||||
"Self-hosted health tracker. Weight, blood pressure, glucose, mood, medications. Withings + Apple Health sync. AI Insights you own.",
|
||||
images: ["/logo-readme.png"],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -67,7 +79,14 @@ export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
viewportFit: "cover",
|
||||
themeColor: "#282a36",
|
||||
// Match the browser chrome (Android URL bar, iOS PWA status bar) to
|
||||
// the active palette. The hex values are the resolved background of
|
||||
// `--background` from `app/globals.css` for each theme so the bar
|
||||
// edge never seams against the page on cold paint.
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#ffffff" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#282a36" },
|
||||
],
|
||||
};
|
||||
|
||||
// Inline script to apply theme before first paint (prevents FOUC)
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync, readdirSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
MEASUREMENT_FORM_TYPE_VALUES,
|
||||
ADD_TOKEN_ALIASES,
|
||||
resolveAddToken,
|
||||
} from "@/components/measurements/measurement-form";
|
||||
|
||||
/**
|
||||
* F-1 contract — every `?add=<token>` the Insights surfaces ship must
|
||||
* either land on a real `<MeasurementForm>` row or be an explicit
|
||||
* legacy alias mapped to one. Without this guard the empty-state CTA
|
||||
* silently drops the deep link and the dialog never opens — the
|
||||
* symptom that surfaced as "the Körpertemperatur CTA does nothing".
|
||||
*/
|
||||
describe("measurements ?add= deep link", () => {
|
||||
const repoRoot = join(__dirname, "..", "..", "..", "..");
|
||||
const insightsRoot = join(repoRoot, "src", "app", "insights");
|
||||
const insightsComponentsRoot = join(repoRoot, "src", "components", "insights");
|
||||
|
||||
// Captures both `href="/measurements?add=FOO"` literals and the
|
||||
// dynamic `?add=${prop}` template the shared HealthKit metric page
|
||||
// uses. The latter is covered by walking `emptyStateCtaType="…"` in
|
||||
// every `/insights/<slug>/page.tsx`.
|
||||
const literalAddPattern = /\/measurements\?add=([A-Z_]+)/g;
|
||||
const ctaTypePattern = /emptyStateCtaType=\{?["']([A-Z_]+)["']/g;
|
||||
|
||||
function collectFiles(dir: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const stat = statSync(full);
|
||||
if (stat.isDirectory()) {
|
||||
if (entry === "__tests__" || entry === "node_modules") continue;
|
||||
out.push(...collectFiles(full));
|
||||
} else if (entry.endsWith(".tsx") || entry.endsWith(".ts")) {
|
||||
out.push(full);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const files = [
|
||||
...collectFiles(insightsRoot),
|
||||
...collectFiles(insightsComponentsRoot),
|
||||
];
|
||||
|
||||
const emittedTokens = new Set<string>();
|
||||
for (const file of files) {
|
||||
const source = readFileSync(file, "utf8");
|
||||
for (const match of source.matchAll(literalAddPattern)) {
|
||||
emittedTokens.add(match[1]);
|
||||
}
|
||||
for (const match of source.matchAll(ctaTypePattern)) {
|
||||
emittedTokens.add(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
it("walks at least one `?add=` token under src/app/insights", () => {
|
||||
// Sanity guard so the regex collector doesn't silently zero out.
|
||||
expect(emittedTokens.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("every emitted `?add=` token resolves to a real form type", () => {
|
||||
const unresolved: string[] = [];
|
||||
for (const token of emittedTokens) {
|
||||
if (resolveAddToken(token) === null) unresolved.push(token);
|
||||
}
|
||||
expect(unresolved).toEqual([]);
|
||||
});
|
||||
|
||||
it("legacy aliases each map to a real form type", () => {
|
||||
for (const [legacy, canonical] of Object.entries(ADD_TOKEN_ALIASES)) {
|
||||
expect(MEASUREMENT_FORM_TYPE_VALUES).toContain(canonical);
|
||||
expect(resolveAddToken(legacy)).toBe(canonical);
|
||||
}
|
||||
});
|
||||
|
||||
it("resolves unknown tokens to null without throwing", () => {
|
||||
expect(resolveAddToken("NOT_A_TYPE")).toBeNull();
|
||||
expect(resolveAddToken(null)).toBeNull();
|
||||
expect(resolveAddToken(undefined)).toBeNull();
|
||||
expect(resolveAddToken("")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { MeasurementForm } from "@/components/measurements/measurement-form";
|
||||
import {
|
||||
MeasurementForm,
|
||||
resolveAddToken,
|
||||
} from "@/components/measurements/measurement-form";
|
||||
import { MeasurementList } from "@/components/measurements/measurement-list";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
@@ -10,23 +13,6 @@ import { Plus, Loader2 } from "lucide-react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
// v1.4.27 MB6 — the insights empty-state CTAs link here with a
|
||||
// `?add=<TYPE>` query param to auto-open the add-measurement dialog
|
||||
// with the right default type. Keep the allow-list aligned with
|
||||
// MEASUREMENT_TYPES inside `measurement-form.tsx`; an unknown value
|
||||
// is dropped silently so a stale link can never trap the user on a
|
||||
// broken form.
|
||||
const ALLOWED_ADD_TYPES = new Set([
|
||||
"BLOOD_PRESSURE",
|
||||
"WEIGHT",
|
||||
"PULSE",
|
||||
"BMI",
|
||||
"GLUCOSE",
|
||||
"TEMPERATURE",
|
||||
"BODY_FAT",
|
||||
"HEART_RATE",
|
||||
]);
|
||||
|
||||
export default function MeasurementsPage() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const router = useRouter();
|
||||
@@ -52,8 +38,13 @@ export default function MeasurementsPage() {
|
||||
|
||||
if (addParam && addParam !== consumedAddParam) {
|
||||
setConsumedAddParam(addParam);
|
||||
if (ALLOWED_ADD_TYPES.has(addParam)) {
|
||||
setDefaultType(addParam);
|
||||
// v1.4.34 IW-G — `resolveAddToken` is the single source of truth
|
||||
// for legacy aliases (`GLUCOSE`, `TEMPERATURE`, …) plus the
|
||||
// canonical form values. Unknown tokens still drop silently so a
|
||||
// stale link cannot trap the user on a broken form.
|
||||
const resolved = resolveAddToken(addParam);
|
||||
if (resolved) {
|
||||
setDefaultType(resolved);
|
||||
setDialogOpen(true);
|
||||
}
|
||||
// Drop the query string so the back-button leaves the user on
|
||||
@@ -82,7 +73,10 @@ export default function MeasurementsPage() {
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("measurements.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground hidden text-sm sm:block">
|
||||
{/* v1.4.34 IW-G — subtitle now stays visible on mobile so
|
||||
the H1 isn't an unframed label. `text-xs sm:text-sm`
|
||||
preserves the desktop hierarchy. */}
|
||||
<p className="text-muted-foreground text-xs sm:text-sm">
|
||||
{t("measurements.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -154,7 +154,9 @@ export default function MedicationsPage() {
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("medications.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground hidden text-sm sm:block">
|
||||
{/* v1.4.34 IW-G — subtitle stays visible on mobile so the
|
||||
H1 isn't an unframed label. */}
|
||||
<p className="text-muted-foreground text-xs sm:text-sm">
|
||||
{t("medications.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -42,7 +42,9 @@ export default function MoodPage() {
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
{t("mood.title")}
|
||||
</h1>
|
||||
<p className="text-muted-foreground hidden text-sm sm:block">
|
||||
{/* v1.4.34 IW-G — subtitle stays visible on mobile so the
|
||||
H1 isn't an unframed label. */}
|
||||
<p className="text-muted-foreground text-xs sm:text-sm">
|
||||
{t("mood.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -156,6 +156,26 @@ export function AdminShell({ active, children }: AdminShellProps) {
|
||||
const { t } = useTranslations();
|
||||
const activeSlug = deriveActiveSlug(pathname, active);
|
||||
|
||||
// v1.4.34 IW-G — mirror the settings-shell auto-scroll pattern so
|
||||
// the active chip stays in view when the user lands on a deeper
|
||||
// admin route. Without it the 16-chip strip stays scrolled to the
|
||||
// left and the active chip lives off-screen, reading as a broken nav.
|
||||
const mobileStripRef = React.useRef<HTMLElement | null>(null);
|
||||
React.useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const strip = mobileStripRef.current;
|
||||
if (!strip) return;
|
||||
const activeChip = strip.querySelector<HTMLElement>(
|
||||
'[aria-current="page"]',
|
||||
);
|
||||
if (!activeChip) return;
|
||||
activeChip.scrollIntoView({
|
||||
block: "nearest",
|
||||
inline: "center",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}, [activeSlug]);
|
||||
|
||||
// v1.4.25 W8 — AuthShell wraps the page in `px-4 py-6 md:px-6`
|
||||
// already, so this inner shell only carries the wider max-width.
|
||||
// Previously the duplicate `px-4 py-6 md:px-6 md:py-8` here was
|
||||
@@ -169,13 +189,19 @@ export function AdminShell({ active, children }: AdminShellProps) {
|
||||
horizontal swipe + keyboard arrow scrolling still work, the
|
||||
bar just doesn't draw. Without this the 13-section strip
|
||||
would render an always-on scrollbar that the maintainer kept
|
||||
mis-attributing to the api-tokens table below it. */}
|
||||
mis-attributing to the api-tokens table below it.
|
||||
|
||||
v1.4.34 IW-G — `snap-x snap-mandatory` plus per-chip
|
||||
`snap-start` lets a swipe-flick land on the next chip's
|
||||
leading edge instead of the in-between dead zone, matching
|
||||
the settings-shell polish. */}
|
||||
<nav
|
||||
ref={mobileStripRef}
|
||||
aria-label={t("admin.shell.sectionsNav")}
|
||||
className="no-scrollbar -mx-4 mb-4 overflow-x-auto px-4 md:hidden"
|
||||
className="no-scrollbar -mx-4 mb-4 snap-x snap-mandatory overflow-x-auto px-4 md:hidden"
|
||||
>
|
||||
<ul className="flex min-w-max gap-2">
|
||||
<li>
|
||||
<li className="snap-start">
|
||||
<Link
|
||||
href="/admin"
|
||||
aria-current={activeSlug === null ? "page" : undefined}
|
||||
@@ -197,7 +223,7 @@ export function AdminShell({ active, children }: AdminShellProps) {
|
||||
const isActive = section.slug === activeSlug;
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<li key={section.slug}>
|
||||
<li key={section.slug} className="snap-start">
|
||||
<Link
|
||||
href={`/admin/${section.slug}`}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
/**
|
||||
* v1.4.28 R3d (R1.2 H4) — layout-stable loading shell for every
|
||||
@@ -27,6 +28,7 @@ import { cn } from "@/lib/utils";
|
||||
* `motion-reduce:animate-none` modifier.
|
||||
*/
|
||||
export function ChartSkeleton({ className }: { className?: string }) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div
|
||||
data-slot="chart-skeleton"
|
||||
@@ -38,7 +40,9 @@ export function ChartSkeleton({ className }: { className?: string }) {
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="sr-only">Loading chart…</span>
|
||||
{/* v1.4.34 IW-G — sr-only announcement now reads the locale's
|
||||
`charts.loadingLabel` key instead of literal English. */}
|
||||
<span className="sr-only">{t("charts.loadingLabel")}</span>
|
||||
|
||||
{/* Header row — title + range tabs match the real chart's
|
||||
`mb-4 flex flex-col gap-2 sm:flex-row` chrome. */}
|
||||
|
||||
@@ -61,7 +61,13 @@ export function LayoutCoachFab() {
|
||||
aria-hidden={tooltipActive ? true : undefined}
|
||||
tabIndex={tooltipActive ? -1 : undefined}
|
||||
className={cn(
|
||||
"fixed right-4 bottom-20 z-40 h-12 rounded-full px-4 shadow-lg",
|
||||
// Sit above the 64 px bottom-nav + the iPhone home-indicator
|
||||
// safe-area inset. `calc(env(safe-area-inset-bottom,0)+5rem)`
|
||||
// keeps the FAB clear of the nav on notched iPhones (where the
|
||||
// inset is ~34 px) without forcing extra padding into the
|
||||
// layout grid.
|
||||
"fixed right-4 z-40 h-12 rounded-full px-4 shadow-lg",
|
||||
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]",
|
||||
"from-dracula-purple to-dracula-pink bg-gradient-to-br text-white",
|
||||
"hover:from-dracula-purple/90 hover:to-dracula-pink/90",
|
||||
"lg:hidden",
|
||||
|
||||
@@ -99,8 +99,13 @@ export function BottomNav() {
|
||||
// Touch target sized to WCAG 2.5.5 (44×44 CSS px). The
|
||||
// outer min-h-11 min-w-11 is the actual hit area; the icon
|
||||
// stays visually centered at 20px so the design doesn't shift.
|
||||
//
|
||||
// v1.4.34 IW-G — keyboard users get a visible ring on
|
||||
// focus-visible, matching the sidebar treatment from
|
||||
// v1.4.33. Without it the bottom-nav had no focus
|
||||
// indicator at all on Tab navigation.
|
||||
className={cn(
|
||||
"relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors",
|
||||
"relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2",
|
||||
isActive
|
||||
? "text-primary"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
@@ -121,7 +126,7 @@ export function BottomNav() {
|
||||
aria-current={overflowActive ? "page" : undefined}
|
||||
onClick={() => setMoreOpen(true)}
|
||||
className={cn(
|
||||
"relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors",
|
||||
"relative flex min-h-11 min-w-11 flex-1 items-center justify-center rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2",
|
||||
overflowActive
|
||||
? "text-primary"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
|
||||
@@ -44,7 +44,15 @@ export function TopBar() {
|
||||
);
|
||||
|
||||
return (
|
||||
<header className="bg-card/80 border-border sticky top-0 z-40 flex h-16 items-center justify-between border-b px-4 backdrop-blur-md md:px-6">
|
||||
<header
|
||||
className="bg-card/80 border-border sticky top-0 z-40 flex h-16 items-center justify-between border-b px-4 backdrop-blur-md md:px-6"
|
||||
// iOS PWA on notched iPhones overlays the status bar onto the
|
||||
// sticky header unless we reserve the safe-area inset. The
|
||||
// inline style adds `safe-area-inset-top` as padding-top on
|
||||
// devices that report one and is a no-op on every other
|
||||
// platform; the inner h-16 content area stays nominally 64 px.
|
||||
style={{ paddingTop: "env(safe-area-inset-top, 0px)" }}
|
||||
>
|
||||
{/* Mobile logo */}
|
||||
<Link href="/" className="flex items-center gap-2 md:hidden">
|
||||
<Logo className="text-primary" size={20} />
|
||||
@@ -64,7 +72,11 @@ export function TopBar() {
|
||||
aria-label={t("nav.userMenu")}
|
||||
// v1.4.25 W8 — hit the WCAG 2.5.5 44 px touch-target floor on
|
||||
// mobile. The text-only py-1.5 was previously ~30 px tall.
|
||||
className="text-muted-foreground hover:text-foreground flex min-h-11 min-w-11 items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors focus:outline-none"
|
||||
//
|
||||
// v1.4.34 IW-G — keyboard users get a visible ring on
|
||||
// focus-visible. The previous `focus:outline-none` killed
|
||||
// the focus indicator without replacing it.
|
||||
className="text-muted-foreground hover:text-foreground flex min-h-11 min-w-11 items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-offset-2"
|
||||
>
|
||||
<User className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">{user.username}</span>
|
||||
|
||||
@@ -87,8 +87,45 @@ const MEASUREMENT_TYPES = [
|
||||
unit: "%",
|
||||
placeholder: "98",
|
||||
},
|
||||
{
|
||||
value: "BODY_TEMPERATURE",
|
||||
labelKey: "measurements.typeBodyTemperature",
|
||||
unit: "°C",
|
||||
placeholder: "36.6",
|
||||
},
|
||||
] as const;
|
||||
|
||||
// v1.4.34 IW-G — single source of truth for the `/measurements?add=<TYPE>`
|
||||
// deep link the Insights empty-state CTAs ship. Derived from the form's
|
||||
// MEASUREMENT_TYPES so a new row in the form is immediately usable as
|
||||
// a deep-link target.
|
||||
export const MEASUREMENT_FORM_TYPE_VALUES = MEASUREMENT_TYPES.map(
|
||||
(t) => t.value,
|
||||
) as readonly string[];
|
||||
|
||||
// Legacy / Insights-internal tokens that predate the canonical enum.
|
||||
// Older empty-state CTAs and a handful of dashboard tiles still emit
|
||||
// these — translate them to the form's canonical value so the link
|
||||
// keeps working without forcing every caller to rename in lockstep.
|
||||
export const ADD_TOKEN_ALIASES: Readonly<Record<string, string>> = {
|
||||
GLUCOSE: "BLOOD_GLUCOSE",
|
||||
TEMPERATURE: "BODY_TEMPERATURE",
|
||||
HEART_RATE: "PULSE",
|
||||
BMI: "WEIGHT",
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a `?add=<token>` deep-link value to a real form type, or
|
||||
* `null` when the token has no canonical mapping. Centralised so the
|
||||
* page-level dispatcher and the F-1 contract test consume the same
|
||||
* resolver.
|
||||
*/
|
||||
export function resolveAddToken(token: string | null | undefined): string | null {
|
||||
if (!token) return null;
|
||||
const aliased = ADD_TOKEN_ALIASES[token] ?? token;
|
||||
return MEASUREMENT_FORM_TYPE_VALUES.includes(aliased) ? aliased : null;
|
||||
}
|
||||
|
||||
const GLUCOSE_CONTEXTS = [
|
||||
{ value: "FASTING", labelKey: "measurements.glucoseContextFasting" },
|
||||
{
|
||||
|
||||
@@ -46,7 +46,10 @@ export function NotificationsSection() {
|
||||
inbox at `/notifications`. Both surfaces used to be named plain
|
||||
"Notifications", so the crumb spells out where the user is and
|
||||
offers a one-tap jump to the inbox. */}
|
||||
<nav aria-label="Breadcrumb" className="text-muted-foreground text-xs">
|
||||
<nav
|
||||
aria-label={t("nav.breadcrumb")}
|
||||
className="text-muted-foreground text-xs"
|
||||
>
|
||||
<ol className="flex items-center gap-1">
|
||||
<li>{t("notifications.breadcrumbSettings")}</li>
|
||||
<li aria-hidden="true">
|
||||
|
||||
@@ -29,8 +29,11 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* even when iOS shrinks its placeholder, so the field looks
|
||||
* identical on both engines.
|
||||
*/
|
||||
// v1.4.34 IW-G — match the `<Input>` mobile floor (44 px) so date and
|
||||
// datetime fields don't sit below the WCAG 2.5.5 target-size minimum
|
||||
// on Pixel-5-class viewports. Desktop falls back to the 40 px norm.
|
||||
const DATE_INPUT_HEIGHT_CLASSES =
|
||||
"appearance-none min-h-10 h-10 [&::-webkit-date-and-time-value]:min-h-[1.5em]";
|
||||
"appearance-none min-h-11 h-11 sm:min-h-10 sm:h-10 [&::-webkit-date-and-time-value]:min-h-[1.5em]";
|
||||
|
||||
type InputProps = React.ComponentProps<typeof Input>;
|
||||
type DateInputProps = Omit<InputProps, "type" | "lang">;
|
||||
|
||||
@@ -6,6 +6,7 @@ import { Dialog as DialogPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
@@ -55,6 +56,7 @@ function DialogContent({
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
@@ -84,7 +86,9 @@ function DialogContent({
|
||||
className="ring-offset-background focus-visible:border-ring focus-visible:ring-ring/50 text-muted-foreground hover:bg-accent hover:text-foreground absolute top-3 right-4 inline-flex min-h-9 min-w-9 items-center justify-center rounded-md transition-colors focus-visible:ring-[3px] focus-visible:outline-none disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
{/* v1.4.34 IW-G — read `common.close` so screen readers
|
||||
hear the localised label instead of literal English. */}
|
||||
<span className="sr-only">{t("common.close")}</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
@@ -110,6 +114,7 @@ function DialogFooter({
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
@@ -122,7 +127,7 @@ function DialogFooter({
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
<Button variant="outline">{t("common.close")}</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -67,7 +67,9 @@ function Input({
|
||||
data-lpignore={skipAutofill ? "true" : undefined}
|
||||
data-1p-ignore={skipAutofill ? "true" : undefined}
|
||||
className={cn(
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
// v1.4.34 IW-G — floor at 44 px on mobile to clear WCAG 2.5.5;
|
||||
// shrink to 40 px on sm+ where pointer precision is higher.
|
||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-11 sm:h-10 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
className,
|
||||
|
||||
@@ -35,8 +35,10 @@ import { cn } from "@/lib/utils";
|
||||
* uniform with the rest of the form's `<Input>` + `<SelectTrigger>`
|
||||
* rows.
|
||||
*/
|
||||
// v1.4.34 IW-G — floor at 44 px on mobile to clear WCAG 2.5.5; shrink
|
||||
// to 40 px on sm+ where the pointer precision is higher.
|
||||
const NATIVE_SELECT_CLASS =
|
||||
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-10 w-full rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none";
|
||||
"border-input bg-background text-foreground ring-offset-background placeholder:text-muted-foreground focus-visible:ring-ring flex h-11 sm:h-10 w-full rounded-md border px-3 py-1 text-sm shadow-xs transition-[color,box-shadow] focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none";
|
||||
|
||||
export type NativeSelectProps = React.SelectHTMLAttributes<HTMLSelectElement>;
|
||||
|
||||
|
||||
@@ -37,7 +37,11 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-10 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
// v1.4.34 IW-G — `size=default` floors at 44 px on mobile to
|
||||
// clear WCAG 2.5.5 and shrinks to 40 px on sm+ to match the
|
||||
// desktop input rhythm. `size=sm` (filter chips) intentionally
|
||||
// keeps the 32 px footprint.
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-11 data-[size=default]:sm:h-10 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { XIcon } from "lucide-react";
|
||||
import { Dialog as SheetPrimitive } from "radix-ui";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
|
||||
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
@@ -54,6 +55,7 @@ function SheetContent({
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
@@ -86,7 +88,9 @@ function SheetContent({
|
||||
className="ring-offset-background focus-visible:border-ring focus-visible:ring-ring/50 text-muted-foreground hover:bg-accent hover:text-foreground absolute top-3 right-4 inline-flex min-h-9 min-w-9 items-center justify-center rounded-md transition-colors focus-visible:ring-[3px] focus-visible:outline-none disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
{/* v1.4.34 IW-G — read `common.close` so screen readers
|
||||
hear the localised label instead of literal English. */}
|
||||
<span className="sr-only">{t("common.close")}</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Content>
|
||||
|
||||
@@ -12,7 +12,11 @@ import { Toaster as Sonner, type ToasterProps } from "sonner";
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
return (
|
||||
<Sonner
|
||||
theme="dark"
|
||||
// `theme="system"` lets Sonner match the resolved theme on the
|
||||
// root element (set pre-paint by the inline theme script in
|
||||
// `app/layout.tsx`) instead of pinning dark — on light-mode the
|
||||
// hardcoded dark toaster contrasted badly against the page.
|
||||
theme="system"
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { toJson } from "../db";
|
||||
|
||||
// `toJson` is a typed no-op cast that re-types a value as
|
||||
// `Prisma.InputJsonValue`. The runtime behaviour is identity; the
|
||||
// guarantee being tested is that the exported helper exists, accepts
|
||||
// every JSON-serialisable shape we throw at it, and never mutates or
|
||||
// clones the value.
|
||||
describe("toJson", () => {
|
||||
it("is exported and callable", () => {
|
||||
expect(typeof toJson).toBe("function");
|
||||
});
|
||||
|
||||
it("returns the same reference for objects (identity cast)", () => {
|
||||
const value = { a: 1, nested: { b: "x" } };
|
||||
expect(toJson(value)).toBe(value);
|
||||
});
|
||||
|
||||
it("returns the same reference for arrays", () => {
|
||||
const value = [1, 2, 3];
|
||||
expect(toJson(value)).toBe(value);
|
||||
});
|
||||
|
||||
it("accepts primitives", () => {
|
||||
expect(toJson(42)).toBe(42);
|
||||
expect(toJson("hello")).toBe("hello");
|
||||
expect(toJson(true)).toBe(true);
|
||||
expect(toJson(null)).toBeNull();
|
||||
});
|
||||
|
||||
it("accepts typed application shapes (Record, interface-like)", () => {
|
||||
const prefs: Record<string, boolean> = { mood: true, weight: false };
|
||||
const round = toJson(prefs);
|
||||
expect(round).toBe(prefs);
|
||||
expect(round).toEqual({ mood: true, weight: false });
|
||||
});
|
||||
|
||||
it("accepts nested mixed shapes (the production payloads)", () => {
|
||||
const payload = {
|
||||
version: 1,
|
||||
widgets: [{ id: "weight", visible: true, order: 0 }],
|
||||
chartOverlayPrefs: {
|
||||
weight: {
|
||||
showTrendIndicator: true,
|
||||
showTrendArrow: false,
|
||||
showTargetRange: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(toJson(payload)).toBe(payload);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,15 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { getClientIp } from "../api-response";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import {
|
||||
getClientIp,
|
||||
getClientIpOrTrustWarning,
|
||||
_resetTrustViolationWarningForTests,
|
||||
} from "../api-response";
|
||||
|
||||
const ORIGINAL_ENV = process.env.TRUST_PROXY_HOPS;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.TRUST_PROXY_HOPS;
|
||||
_resetTrustViolationWarningForTests();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -97,3 +102,92 @@ describe("getClientIp trusted-proxy semantics (V3 audit)", () => {
|
||||
expect(ip).toBe("5.6.7.8");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* F-6 (mobile security audit, 2026-05-16): when `TRUST_PROXY_HOPS` and
|
||||
* the actual proxy chain don't agree, getClientIp returns null and every
|
||||
* caller falls back to a literal `"unknown"` rate-limit bucket. The
|
||||
* operator needs a single warning per process so the dashboards reflect
|
||||
* the misconfiguration, and a new `getClientIpOrTrustWarning` helper
|
||||
* lets future callers route the request to a tighter universal bucket.
|
||||
*/
|
||||
describe("getClientIp trust-violation warning (F-6, 2026-05-16)", () => {
|
||||
it("emits a console.warn once when the chain is shorter than configured hops", () => {
|
||||
process.env.TRUST_PROXY_HOPS = "2";
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
getClientIp(makeRequest({ "x-forwarded-for": "5.6.7.8" }));
|
||||
getClientIp(makeRequest({ "x-forwarded-for": "5.6.7.8" }));
|
||||
getClientIp(makeRequest({ "x-forwarded-for": "5.6.7.8" }));
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
expect(warn.mock.calls[0][0]).toMatch(/TRUST_PROXY_HOPS=2/);
|
||||
expect(warn.mock.calls[0][0]).toMatch(/1 entry/);
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not warn when the chain length matches", () => {
|
||||
process.env.TRUST_PROXY_HOPS = "1";
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
getClientIp(makeRequest({ "x-forwarded-for": "5.6.7.8" }));
|
||||
expect(warn).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("getClientIpOrTrustWarning shape (F-6, 2026-05-16)", () => {
|
||||
it("returns trustViolation=false on a well-formed chain", () => {
|
||||
const result = getClientIpOrTrustWarning(
|
||||
makeRequest({ "x-forwarded-for": "1.2.3.4, 5.6.7.8" }),
|
||||
);
|
||||
expect(result).toEqual({ ip: "5.6.7.8", trustViolation: false });
|
||||
});
|
||||
|
||||
it("returns trustViolation=true when the chain is shorter than hops", () => {
|
||||
process.env.TRUST_PROXY_HOPS = "2";
|
||||
const result = getClientIpOrTrustWarning(
|
||||
makeRequest({ "x-forwarded-for": "5.6.7.8" }),
|
||||
);
|
||||
expect(result.trustViolation).toBe(true);
|
||||
// ip falls back to x-real-ip (absent here) so it's null
|
||||
expect(result.ip).toBeNull();
|
||||
});
|
||||
|
||||
it("returns trustViolation=true and x-real-ip when chain is short but x-real-ip is set", () => {
|
||||
process.env.TRUST_PROXY_HOPS = "2";
|
||||
const result = getClientIpOrTrustWarning(
|
||||
makeRequest({
|
||||
"x-forwarded-for": "5.6.7.8",
|
||||
"x-real-ip": "9.9.9.9",
|
||||
}),
|
||||
);
|
||||
expect(result).toEqual({ ip: "9.9.9.9", trustViolation: true });
|
||||
});
|
||||
|
||||
it("returns trustViolation=false when XFF is absent (no chain to validate)", () => {
|
||||
const result = getClientIpOrTrustWarning(
|
||||
makeRequest({ "x-real-ip": "9.9.9.9" }),
|
||||
);
|
||||
expect(result).toEqual({ ip: "9.9.9.9", trustViolation: false });
|
||||
});
|
||||
|
||||
it("emits the same once-per-process warning when triggered via the tagged helper", () => {
|
||||
process.env.TRUST_PROXY_HOPS = "2";
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
try {
|
||||
getClientIpOrTrustWarning(
|
||||
makeRequest({ "x-forwarded-for": "5.6.7.8" }),
|
||||
);
|
||||
getClientIpOrTrustWarning(
|
||||
makeRequest({ "x-forwarded-for": "5.6.7.8" }),
|
||||
);
|
||||
expect(warn).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
warn.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -95,6 +95,34 @@ function parseTrustProxyHops(raw: string | undefined): number {
|
||||
return parseInt(trimmed, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Module-scope flag so the trust-violation warning fires at most once per
|
||||
* process. F-6 (mobile security audit, 2026-05-16): every IP-keyed
|
||||
* rate-limit caller falls back to a literal `"unknown"` bucket when
|
||||
* `getClientIp` returns null, which collapses anonymous traffic into a
|
||||
* single shared bucket. A persistent stderr line tells the operator the
|
||||
* proxy chain length and the configured `TRUST_PROXY_HOPS` value don't
|
||||
* match and the deployment is silently degrading rate-limit precision.
|
||||
*/
|
||||
let trustViolationWarned = false;
|
||||
|
||||
/**
|
||||
* Reset the once-per-process warning flag. Test-only; the production
|
||||
* code never calls this.
|
||||
*/
|
||||
export function _resetTrustViolationWarningForTests(): void {
|
||||
trustViolationWarned = false;
|
||||
}
|
||||
|
||||
function warnTrustViolationOnce(hops: number, chainLength: number): void {
|
||||
if (trustViolationWarned) return;
|
||||
trustViolationWarned = true;
|
||||
console.warn(
|
||||
`[getClientIp] TRUST_PROXY_HOPS=${hops} but X-Forwarded-For carried ${chainLength} entr${chainLength === 1 ? "y" : "ies"}; ` +
|
||||
`refusing to read XFF for this request. Every anonymous caller will now share the same "unknown" rate-limit bucket — fix TRUST_PROXY_HOPS or the proxy chain.`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getClientIp(request: Request): string | null {
|
||||
const hops = parseTrustProxyHops(process.env.TRUST_PROXY_HOPS);
|
||||
|
||||
@@ -113,8 +141,55 @@ export function getClientIp(request: Request): string | null {
|
||||
if (chain.length >= hops) {
|
||||
return chain[chain.length - hops];
|
||||
}
|
||||
// F-6 (mobile security audit, 2026-05-16): emit a one-shot
|
||||
// operator signal when the chain shape doesn't match the
|
||||
// configured trust. Without this warning the silent degrade was
|
||||
// invisible until rate-limits visibly misfired in production.
|
||||
warnTrustViolationOnce(hops, chain.length);
|
||||
}
|
||||
}
|
||||
const realIp = request.headers.get("x-real-ip");
|
||||
return realIp && looksLikeIp(realIp) ? realIp : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tagged return shape so a caller can apply a tighter universal
|
||||
* rate-limit when the trust chain is misconfigured. F-6 (mobile security
|
||||
* audit, 2026-05-16): callers today fall back to a literal `"unknown"`
|
||||
* string, collapsing every anonymous request into one bucket. New
|
||||
* callers should branch on `trustViolation === true` and route the
|
||||
* request to a tighter global rate-limit instead of the per-IP one.
|
||||
*
|
||||
* Existing callers using `getClientIp(request) ?? "unknown"` keep
|
||||
* working unchanged; this helper is additive.
|
||||
*/
|
||||
export function getClientIpOrTrustWarning(request: Request): {
|
||||
ip: string | null;
|
||||
trustViolation: boolean;
|
||||
} {
|
||||
const hops = parseTrustProxyHops(process.env.TRUST_PROXY_HOPS);
|
||||
|
||||
if (hops > 0) {
|
||||
const forwarded = request.headers.get("x-forwarded-for");
|
||||
if (forwarded) {
|
||||
const chain = forwarded
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(looksLikeIp);
|
||||
if (chain.length >= hops) {
|
||||
return { ip: chain[chain.length - hops], trustViolation: false };
|
||||
}
|
||||
warnTrustViolationOnce(hops, chain.length);
|
||||
const realIp = request.headers.get("x-real-ip");
|
||||
return {
|
||||
ip: realIp && looksLikeIp(realIp) ? realIp : null,
|
||||
trustViolation: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
const realIp = request.headers.get("x-real-ip");
|
||||
return {
|
||||
ip: realIp && looksLikeIp(realIp) ? realIp : null,
|
||||
trustViolation: false,
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,13 @@ vi.mock("@/lib/db", () => ({
|
||||
deleteMany: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
apiToken: {
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
refreshToken: {
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
$transaction: vi.fn(async (ops: Promise<unknown>[]) => Promise.all(ops)),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -36,7 +43,7 @@ vi.mock("next/headers", () => ({
|
||||
}));
|
||||
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSession } from "../session";
|
||||
import { getSession, destroyAllSessions } from "../session";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
@@ -63,3 +70,32 @@ describe("getSession", () => {
|
||||
expect(cookieState.delete).toHaveBeenCalledWith("hl_onboarding");
|
||||
});
|
||||
});
|
||||
|
||||
describe("destroyAllSessions", () => {
|
||||
it("revokes web sessions, API tokens, and refresh tokens for the user", async () => {
|
||||
vi.mocked(prisma.session.deleteMany).mockResolvedValue({
|
||||
count: 2,
|
||||
} as never);
|
||||
vi.mocked(prisma.apiToken.updateMany).mockResolvedValue({
|
||||
count: 1,
|
||||
} as never);
|
||||
vi.mocked(prisma.refreshToken.updateMany).mockResolvedValue({
|
||||
count: 3,
|
||||
} as never);
|
||||
|
||||
await destroyAllSessions("user-rotated");
|
||||
|
||||
expect(prisma.session.deleteMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-rotated" },
|
||||
});
|
||||
expect(prisma.apiToken.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-rotated", revoked: false },
|
||||
data: { revoked: true },
|
||||
});
|
||||
expect(prisma.refreshToken.updateMany).toHaveBeenCalledWith({
|
||||
where: { userId: "user-rotated", revokedAt: null },
|
||||
data: { revokedAt: expect.any(Date) },
|
||||
});
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+15
-7
@@ -3,6 +3,8 @@ import {
|
||||
verifyRegistrationResponse,
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
type AuthenticationResponseJSON,
|
||||
type RegistrationResponseJSON,
|
||||
type VerifiedRegistrationResponse,
|
||||
type VerifiedAuthenticationResponse,
|
||||
} from "@simplewebauthn/server";
|
||||
@@ -100,8 +102,7 @@ export async function createRegistrationOptions(
|
||||
|
||||
export async function verifyRegistration(
|
||||
challengeId: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
response: any,
|
||||
response: unknown,
|
||||
): Promise<VerifiedRegistrationResponse> {
|
||||
const challenge = await prisma.authChallenge.findUnique({
|
||||
where: { id: challengeId },
|
||||
@@ -112,8 +113,12 @@ export async function verifyRegistration(
|
||||
}
|
||||
|
||||
try {
|
||||
// The caller passes a parsed JSON body without prior shape-validation —
|
||||
// the SimpleWebAuthn verifier owns full schema validation and throws
|
||||
// on any mismatch. We narrow at the boundary so the rest of the file
|
||||
// sees the documented `RegistrationResponseJSON` shape.
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response,
|
||||
response: response as RegistrationResponseJSON,
|
||||
expectedChallenge: challenge.challenge,
|
||||
expectedOrigin: getExpectedOrigin(),
|
||||
expectedRPID: getRpId(),
|
||||
@@ -166,8 +171,7 @@ export async function createAuthenticationOptions(userId?: string) {
|
||||
|
||||
export async function verifyAuthentication(
|
||||
challengeId: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
response: any,
|
||||
response: unknown,
|
||||
): Promise<{
|
||||
verification: VerifiedAuthenticationResponse;
|
||||
passkey: { userId: string };
|
||||
@@ -180,8 +184,12 @@ export async function verifyAuthentication(
|
||||
throw new Error("Challenge expired or not found");
|
||||
}
|
||||
|
||||
// The caller passes a parsed JSON body — narrow at the boundary; the
|
||||
// SimpleWebAuthn verifier below performs full shape validation.
|
||||
const typedResponse = response as AuthenticationResponseJSON;
|
||||
|
||||
// Find the passkey by credential ID
|
||||
const credentialId = response.id;
|
||||
const credentialId = typedResponse.id;
|
||||
const passkey = await prisma.passkey.findUnique({
|
||||
where: { credentialId },
|
||||
});
|
||||
@@ -192,7 +200,7 @@ export async function verifyAuthentication(
|
||||
|
||||
try {
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response,
|
||||
response: typedResponse,
|
||||
expectedChallenge: challenge.challenge,
|
||||
expectedOrigin: getExpectedOrigin(),
|
||||
expectedRPID: getRpId(),
|
||||
|
||||
+26
-1
@@ -156,8 +156,33 @@ export async function destroySession(): Promise<void> {
|
||||
cookieStore.delete(ONBOARDING_COOKIE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke every authenticated surface a user owns: web sessions, API
|
||||
* tokens (long-lived Bearer credentials issued via `/settings/api-tokens`),
|
||||
* and native-client refresh tokens. Called from every credential-rotation
|
||||
* path (`/api/auth/password`, `/api/admin/users/[id]/reset-password`,
|
||||
* `DELETE /api/settings/account`) so a remediated user wipes a stolen
|
||||
* credential across every transport, not just the browser cookie.
|
||||
*
|
||||
* `ApiToken` flips `revoked=true` (the token-hash lookup at
|
||||
* `src/lib/auth/api-token.ts` filters on this column). `RefreshToken`
|
||||
* sets `revokedAt` (the rotation lookup at
|
||||
* `src/lib/auth/refresh-token.ts` short-circuits on a non-null value).
|
||||
* Both run as `updateMany` rather than `deleteMany` so the audit trail
|
||||
* survives — the rows linger as evidence of the rotation event.
|
||||
*/
|
||||
export async function destroyAllSessions(userId: string): Promise<void> {
|
||||
await prisma.session.deleteMany({ where: { userId } });
|
||||
await prisma.$transaction([
|
||||
prisma.session.deleteMany({ where: { userId } }),
|
||||
prisma.apiToken.updateMany({
|
||||
where: { userId, revoked: false },
|
||||
data: { revoked: true },
|
||||
}),
|
||||
prisma.refreshToken.updateMany({
|
||||
where: { userId, revokedAt: null },
|
||||
data: { revokedAt: new Date() },
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,21 @@
|
||||
/**
|
||||
* Schema-bootstrap path for fresh self-host containers.
|
||||
*
|
||||
* Production deploys run `prisma migrate deploy` from the entrypoint,
|
||||
* so the canonical migration history lives in `prisma/migrations/`.
|
||||
* That history is the source of truth — any new column or table must
|
||||
* land there first.
|
||||
*
|
||||
* This file is the belt-and-suspenders companion: it issues
|
||||
* idempotent `ALTER TABLE … ADD COLUMN IF NOT EXISTS` statements on
|
||||
* boot so a container that came up against a database which somehow
|
||||
* missed a migration (interrupted deploy, restored from an older
|
||||
* dump, etc.) self-heals before the app starts taking traffic.
|
||||
*
|
||||
* Treat every statement here as a mirror of an existing migration —
|
||||
* never invent schema in this file. If a column appears here without
|
||||
* a corresponding entry under `prisma/migrations/`, that is a bug.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
let dbCompatibilityPromise: Promise<void> | null = null;
|
||||
|
||||
+8
-1
@@ -1,4 +1,4 @@
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { Prisma, PrismaClient } from "@/generated/prisma/client";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
@@ -17,3 +17,10 @@ export const prisma = globalForPrisma.prisma ?? createPrismaClient();
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalForPrisma.prisma = prisma;
|
||||
}
|
||||
|
||||
// Prisma's `InputJsonValue` requires an explicit index signature that
|
||||
// typed application shapes (Zod-validated, hand-written interfaces)
|
||||
// don't carry. Every JSON-column write would otherwise repeat the same
|
||||
// `value as unknown as Prisma.InputJsonValue` escape hatch — this one
|
||||
// helper centralises the cast so the WHY stays in a single place.
|
||||
export const toJson = <T>(v: T) => v as unknown as Prisma.InputJsonValue;
|
||||
@@ -25,7 +25,8 @@
|
||||
* Locks per `.planning/research/v1434-r-1-xml-import.md` §5.1.
|
||||
*/
|
||||
import { unlinkSync } from "node:fs";
|
||||
import { PrismaClient, Prisma } from "@/generated/prisma/client";
|
||||
import { PrismaClient } from "@/generated/prisma/client";
|
||||
import { toJson } from "@/lib/db";
|
||||
import { PrismaPg } from "@prisma/adapter-pg";
|
||||
import type { Job } from "pg-boss";
|
||||
|
||||
@@ -82,7 +83,7 @@ async function writeProgress(
|
||||
where: { id: importJobId },
|
||||
data: {
|
||||
status,
|
||||
progress: progress as unknown as Prisma.InputJsonValue,
|
||||
progress: toJson(progress),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -173,7 +174,7 @@ export async function handleAppleHealthImport(
|
||||
where: { id: importJobId },
|
||||
data: {
|
||||
status: snapshot.currentPhase,
|
||||
progress: snapshot as unknown as Prisma.InputJsonValue,
|
||||
progress: toJson(snapshot),
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -185,14 +186,14 @@ export async function handleAppleHealthImport(
|
||||
data: {
|
||||
status: "done",
|
||||
completedAt: new Date(),
|
||||
progress: {
|
||||
progress: toJson({
|
||||
currentPhase: "upserting",
|
||||
recordsRead: result.totals.recordsRead,
|
||||
rowsUpserted: result.totals.rowsUpserted,
|
||||
percent: 100,
|
||||
elapsedMs: result.totals.durationMs,
|
||||
} as unknown as Prisma.InputJsonValue,
|
||||
result: result as unknown as Prisma.InputJsonValue,
|
||||
}),
|
||||
result: toJson(result),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
+37
-16
@@ -23,7 +23,6 @@ const PUBLIC_PATHS = [
|
||||
"/api/withings/webhook",
|
||||
"/api/telegram/webhook",
|
||||
"/api/integrations/moodlog/webhook",
|
||||
"/api/auth/codex/callback",
|
||||
"/api/ingest/",
|
||||
// v1.4.26 — `/privacy` is a public legal page. iOS App Store Connect
|
||||
// requires a publicly reachable Privacy-Policy URL during submission;
|
||||
@@ -33,19 +32,25 @@ const PUBLIC_PATHS = [
|
||||
// alongside the project credits. The CC licence requires the
|
||||
// attribution to be reachable without a sign-in.
|
||||
"/about",
|
||||
// v1.4.33 — `/.well-known/*` covers IETF-registered discovery
|
||||
// endpoints (RFC 8615). Apple reads
|
||||
// `/.well-known/apple-app-site-association` without credentials to
|
||||
// wire Web Credentials (passkey sharing) and Universal Links to the
|
||||
// iOS bundle, so the path must answer 200 with the bare JSON body
|
||||
// before any auth gate runs. The trailing slash future-proofs the
|
||||
// namespace for `/security.txt`, `/openid-configuration`, etc.
|
||||
"/.well-known/",
|
||||
// `/onboarding` itself + its subroutes are matched exactly via
|
||||
// `isPublicPath()` so we don't admit `/onboarding-export` etc.
|
||||
"/robots.txt",
|
||||
];
|
||||
|
||||
/**
|
||||
* Exact-match allowlist for IETF-registered discovery endpoints
|
||||
* (RFC 8615). Apple reads `/.well-known/apple-app-site-association`
|
||||
* without credentials to wire passkey sharing and Universal Links;
|
||||
* the bare JSON body has to answer 200 before any auth gate runs.
|
||||
*
|
||||
* Exact match (not prefix) — a future `/.well-known/openid-configuration`
|
||||
* or `/.well-known/security.txt` must be added here explicitly so a new
|
||||
* sub-path doesn't auto-inherit "no auth" status.
|
||||
*/
|
||||
const WELL_KNOWN_PUBLIC_PATHS = new Set<string>([
|
||||
"/.well-known/apple-app-site-association",
|
||||
]);
|
||||
|
||||
/**
|
||||
* v1.4.22 W5 reconcile (Sec-MED-2) — `/onboarding` matches exact
|
||||
* page + subroutes (`/onboarding`, `/onboarding/*`) only, NOT the
|
||||
@@ -59,6 +64,9 @@ function isPublicPath(pathname: string): boolean {
|
||||
if (pathname === "/onboarding" || pathname.startsWith("/onboarding/")) {
|
||||
return true;
|
||||
}
|
||||
if (WELL_KNOWN_PUBLIC_PATHS.has(pathname)) {
|
||||
return true;
|
||||
}
|
||||
return PUBLIC_PATHS.some((p) => pathname.startsWith(p));
|
||||
}
|
||||
|
||||
@@ -226,22 +234,35 @@ export function proxy(request: NextRequest) {
|
||||
"camera=(), microphone=(), geolocation=()",
|
||||
);
|
||||
|
||||
// CSP — permissive in dev, strict in production. AI provider hosts
|
||||
// (OpenAI / chatgpt.com) are gated to /settings/ai/** because that is
|
||||
// the only surface a browser fetch is needed (V3 audit: blanket
|
||||
// chatgpt.com on /auth/login is a DOM-XSS exfil channel).
|
||||
// CSP — permissive in dev, strict in production. Third-party hosts in
|
||||
// `connect-src` are gated to the surfaces that actually need them so a
|
||||
// DOM-XSS on an unrelated page can't exfiltrate to them (V3 audit:
|
||||
// blanket chatgpt.com on /auth/login was a DOM-XSS exfil channel).
|
||||
//
|
||||
// F-5 (mobile security audit, 2026-05-16): `wbsapi.withings.net` used
|
||||
// to live in the global `connect-src` and shipped on every page. The
|
||||
// Withings client lives server-side, so the browser never needs to
|
||||
// reach it from a non-Withings surface; mirror the AI gating shape.
|
||||
const isDev = process.env.NODE_ENV === "development";
|
||||
const cspReportEndpoint = "/api/monitoring/csp-report";
|
||||
const isAiSettingsRoute = pathname.startsWith("/settings/ai");
|
||||
const aiConnectSrc = isAiSettingsRoute
|
||||
? " https://api.openai.com https://chatgpt.com"
|
||||
: "";
|
||||
const isWithingsRoute =
|
||||
pathname.startsWith("/settings/integrations/withings") ||
|
||||
pathname.startsWith("/api/withings/");
|
||||
const withingsConnectSrc = isWithingsRoute
|
||||
? " https://wbsapi.withings.net"
|
||||
: "";
|
||||
const csp = isDev
|
||||
? `default-src 'self'; script-src 'self' 'unsafe-eval' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://www.gravatar.com; connect-src 'self'; font-src 'self';`
|
||||
: `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://www.gravatar.com; connect-src 'self'${aiConnectSrc} https://wbsapi.withings.net; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; worker-src 'self'; report-uri ${cspReportEndpoint}; report-to csp-endpoint;`;
|
||||
: `default-src 'self'; script-src 'self' 'nonce-${nonce}'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://www.gravatar.com; connect-src 'self'${aiConnectSrc}${withingsConnectSrc}; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; worker-src 'self'; report-uri ${cspReportEndpoint}; report-to csp-endpoint;`;
|
||||
response.headers.set("Content-Security-Policy", csp);
|
||||
|
||||
// Production-only headers
|
||||
// Production-only headers. HSTS carries `preload` so the domain stays
|
||||
// eligible for the Chromium preload list — closes the first-visit MITM
|
||||
// window on hostile networks (F-5, mobile security audit 2026-05-16).
|
||||
if (!isDev) {
|
||||
response.headers.set(
|
||||
"Reporting-Endpoints",
|
||||
@@ -249,7 +270,7 @@ export function proxy(request: NextRequest) {
|
||||
);
|
||||
response.headers.set(
|
||||
"Strict-Transport-Security",
|
||||
"max-age=31536000; includeSubDomains",
|
||||
"max-age=31536000; includeSubDomains; preload",
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user