From d98e85cd034f719a12499b202b776eb50b8d860e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 15 May 2026 07:35:05 +0200 Subject: [PATCH] =?UTF-8?q?chore(release):=20v1.4.26=20=E2=80=94=20privacy?= =?UTF-8?q?=20policy=20for=20App=20Store=20submission?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public privacy-policy page at /privacy enumerates every Apple HealthKit identifier the iOS app reads, names every third-party sub-processor, and restates the EU MDR + GDPR boundaries. Unblocks Apple BLOCKER 2 of the iOS App Store Connect submission by giving the registered Privacy URL a real page to point at. --- CHANGELOG.md | 37 + messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/pl.json | 3 +- package.json | 2 +- src/__tests__/proxy-privacy-public.test.ts | 60 ++ src/app/auth/login/page.tsx | 13 +- src/app/privacy/__tests__/page.test.tsx | 165 ++++ src/app/privacy/page.tsx | 831 +++++++++++++++++++++ src/components/layout/auth-shell.tsx | 12 +- src/proxy.ts | 4 + 14 files changed, 1133 insertions(+), 9 deletions(-) create mode 100644 src/__tests__/proxy-privacy-public.test.ts create mode 100644 src/app/privacy/__tests__/page.test.tsx create mode 100644 src/app/privacy/page.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e82187de..a8ebc266d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,42 @@ # Changelog +## [1.4.26] — 2026-05-15 + +Hotfix release. Adds a public, unauthenticated privacy-policy page at +`/privacy` so the iOS native application can register a reachable URL +in App Store Connect. The policy enumerates every Apple HealthKit +identifier the iOS app reads, lists every active third-party sub- +processor with its data-protection policy, restates the EU MDR +medical-device boundary that scopes the AI Coach surface, and walks +through the GDPR Art. 15-22 / DSGVO data-subject rights with concrete +in-app routes the user can hit. The page bypasses the standard +auth-shell so an App-Store reviewer or a first-time visitor sees the +full document immediately. The conservative-semver pattern still +applies: this could have been versioned `1.4.25.1` for symmetry with +the iOS hotfix track, but 4-part versions break the strict-semver +guard in `/api/version` so we incremented to the next clean patch +instead. + +### Added + +- Public privacy policy at `/privacy` with full HealthKit quantity-type + enumeration (18 identifiers plus `sleepAnalysis`), Withings + measurement-family list, sub-processor table (Anthropic, OpenAI, + Withings, Apple, Telegram, GitHub, Cloudflare, Hetzner), Apple + privacy-nutrition-label mapping, and a verbatim EU MDR 2017/745 + + MDCG 2021-24 medical-device-boundary statement. +- `auth.privacyPolicy` translation key in all six locales (English in + EN, German in DE, native translations for FR / ES / IT / PL). + +### Changed + +- The unauthenticated login page links out to `/privacy` below the + sign-in card so a first-time visitor can review the policy before + signing up, matching GDPR Art. 13 pre-signup expectations. +- The auth shell now treats `/privacy` as a standalone public page — + long-form legal content renders edge-to-edge instead of being squeezed + into the centered login-card layout. + ## [1.4.25] — 2026-05-14 Largest feature delta in the v1.4.x line. Insights expands from one diff --git a/messages/de.json b/messages/de.json index dc7bff436..f6818c157 100644 --- a/messages/de.json +++ b/messages/de.json @@ -202,7 +202,8 @@ "backToPasskey": "Zurück zu Passkey", "passkeySetupLater": "Passkeys kannst du jederzeit in den Einstellungen einrichten.", "passwordMinLength": "Min. 12 Zeichen", - "passwordTooShort": "Passwort muss mindestens {minLength} Zeichen lang sein." + "passwordTooShort": "Passwort muss mindestens {minLength} Zeichen lang sein.", + "privacyPolicy": "Datenschutzerklärung" }, "dashboard": { "title": "Dashboard", diff --git a/messages/en.json b/messages/en.json index 18ee87c63..8a9c62bfc 100644 --- a/messages/en.json +++ b/messages/en.json @@ -202,7 +202,8 @@ "backToPasskey": "Back to Passkey", "passkeySetupLater": "You can set up passkeys at any time in Settings.", "passwordMinLength": "Min. 12 characters", - "passwordTooShort": "Password must be at least {minLength} characters long." + "passwordTooShort": "Password must be at least {minLength} characters long.", + "privacyPolicy": "Privacy policy" }, "dashboard": { "title": "Dashboard", diff --git a/messages/es.json b/messages/es.json index 228bb3bf5..93bd7912c 100644 --- a/messages/es.json +++ b/messages/es.json @@ -202,7 +202,8 @@ "backToPasskey": "Back to Passkey", "passkeySetupLater": "You can set up passkeys at any time in Settings.", "passwordMinLength": "Min. 12 characters", - "passwordTooShort": "Password must be at least {minLength} characters long." + "passwordTooShort": "Password must be at least {minLength} characters long.", + "privacyPolicy": "Política de privacidad" }, "dashboard": { "title": "Panel", diff --git a/messages/fr.json b/messages/fr.json index 3a5d59645..c94acbd3a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -202,7 +202,8 @@ "backToPasskey": "Back to Passkey", "passkeySetupLater": "You can set up passkeys at any time in Settings.", "passwordMinLength": "Min. 12 characters", - "passwordTooShort": "Password must be at least {minLength} characters long." + "passwordTooShort": "Password must be at least {minLength} characters long.", + "privacyPolicy": "Politique de confidentialité" }, "dashboard": { "title": "Tableau de bord", diff --git a/messages/it.json b/messages/it.json index ef36b45f7..b1cff68c3 100644 --- a/messages/it.json +++ b/messages/it.json @@ -202,7 +202,8 @@ "backToPasskey": "Back to Passkey", "passkeySetupLater": "You can set up passkeys at any time in Settings.", "passwordMinLength": "Min. 12 characters", - "passwordTooShort": "Password must be at least {minLength} characters long." + "passwordTooShort": "Password must be at least {minLength} characters long.", + "privacyPolicy": "Informativa sulla privacy" }, "dashboard": { "title": "Cruscotto", diff --git a/messages/pl.json b/messages/pl.json index 36c637331..23f5aa1a1 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -202,7 +202,8 @@ "backToPasskey": "Back to Passkey", "passkeySetupLater": "You can set up passkeys at any time in Settings.", "passwordMinLength": "Min. 12 characters", - "passwordTooShort": "Password must be at least {minLength} characters long." + "passwordTooShort": "Password must be at least {minLength} characters long.", + "privacyPolicy": "Polityka prywatności" }, "dashboard": { "title": "Pulpit", diff --git a/package.json b/package.json index 1207d49c4..777254d5e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.4.25", + "version": "1.4.26", "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", diff --git a/src/__tests__/proxy-privacy-public.test.ts b/src/__tests__/proxy-privacy-public.test.ts new file mode 100644 index 000000000..a59909711 --- /dev/null +++ b/src/__tests__/proxy-privacy-public.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * v1.4.26 — `/privacy` is a public legal page. Apple's App Store + * Connect submission process requires the privacy-policy URL to be + * reachable without authentication. This guard locks the proxy + * allowlist in place so a future refactor of `src/proxy.ts` can't + * silently re-gate the page behind a session cookie. + */ + +vi.mock("@/lib/process-type", () => ({ + shouldRunWeb: () => true, +})); + +import { proxy } from "../proxy"; + +function makeRequest( + pathname: string, + cookies: Record = {}, +): 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 /privacy public-allowlist (v1.4.26)", () => { + it("does not 307 unauthenticated requests for /privacy to /auth/login", () => { + const res = proxy(makeRequest("/privacy")); + expect(res.status).toBe(200); + expect(res.headers.get("location")).toBeNull(); + }); + + it("does not 307 a pending-onboarding session away from /privacy", () => { + // A user whose onboarding has not completed must still be able to + // reach the legal page — GDPR Art. 13 expects pre-signup visibility, + // and any redirect would be a worse user experience for an App + // Store reviewer following the registered policy URL. + const res = proxy( + makeRequest("/privacy", { + healthlog_session: "sess-1", + hl_onboarding: "pending", + }), + ); + expect(res.status).toBe(200); + expect(res.headers.get("location")).toBeNull(); + }); + + it("still redirects unauthenticated requests for protected pages", () => { + // Negative check — confirms the test fixture would catch a real + // regression. `/insights` is not public; an unauthenticated visit + // must still 307 to /auth/login. + const res = proxy(makeRequest("/insights")); + expect([307, 308]).toContain(res.status); + expect(res.headers.get("location")).toMatch(/\/auth\/login/); + }); +}); diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index 48a4f9611..df6e6d955 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -117,7 +117,7 @@ export default function LoginPage() { } return ( -
+
@@ -241,6 +241,17 @@ export default function LoginPage() { )}
+ {/* v1.4.26 — discoverable privacy link below the login card. + App Store reviewers and first-time visitors need a one-click + path to the policy without signing in. */} +

+ + {t("auth.privacyPolicy")} + +

); } diff --git a/src/app/privacy/__tests__/page.test.tsx b/src/app/privacy/__tests__/page.test.tsx new file mode 100644 index 000000000..2110cdc05 --- /dev/null +++ b/src/app/privacy/__tests__/page.test.tsx @@ -0,0 +1,165 @@ +import { describe, it, expect } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +import PrivacyPage from "../page"; + +/** + * v1.4.26 — Public privacy-policy page contract. + * + * The page is the App-Store-submission blocker (Apple BLOCKER 2 of 5). + * These tests pin the structural commitments that any future redesign + * must preserve: + * + * 1. The H1 says "Privacy Policy". + * 2. The page advertises a "Last updated" date alongside a policy + * version stamp — App-Store reviewers and German DPA inspectors + * use both to gauge currency. + * 3. Every Apple-HealthKit type the iOS application reads is + * enumerated verbatim by identifier. Drift from the iOS source + * (`HealthKitWireConverter.swift` + `HealthKitService.swift`) + * would be a compliance hole. + * 4. The numbered section headings are all present, so the policy + * remains scannable without re-rendering with a specific locale. + */ + +function render() { + return renderToStaticMarkup(); +} + +describe("", () => { + it("renders without crashing and includes the H1", () => { + const html = render(); + expect(html).toContain("Privacy Policy"); + }); + + it("advertises the policy version + last-updated date", () => { + const html = render(); + expect(html).toContain("Policy version 1.4.26"); + expect(html).toContain("Last updated: 2026-05-15"); + expect(html).toContain('data-slot="privacy-last-updated"'); + }); + + it("renders the numbered section headings", () => { + const html = render(); + // 11 numbered sections — H2 headings render the leading numeral so + // the reader can navigate without an explicit table of contents. + const expectedHeadings = [ + "1. Overview", + "2. Data we collect", + "3. Why we collect each category", + "4. Third-party sub-processors", + "5. Data storage and retention", + "6. Your rights (GDPR Art. 15-22, DSGVO)", + "7. Medical-device boundary (EU MDR 2017/745, MDCG 2021-24)", + "8. Apple App Store privacy categories", + "9. Children", + "10. Changes to this policy", + "11. Contact", + ]; + for (const heading of expectedHeadings) { + expect(html, `missing H2 ${JSON.stringify(heading)}`).toContain(heading); + } + }); + + it("enumerates every HealthKit quantity type read by the iOS app", () => { + const html = render(); + // Source of truth: HealthKitService.defaultReadTypes + + // HealthKitWireConverter.preferredUnit in the iOS repo. Every + // identifier the iOS app reads must appear verbatim so an App + // Store reviewer can confirm coverage. The list must stay sorted + // by category in the page itself (vitals, cardio, activity, + // audio, daylight) — that's a UX commitment, not a test + // commitment; we just check membership here. + const requiredIdentifiers = [ + // Vitals (point metrics) + "bodyMass", + "bodyFatPercentage", + "bodyTemperature", + "bloodPressureSystolic", + "bloodPressureDiastolic", + "bloodGlucose", + "oxygenSaturation", + // Cardio + "heartRate", + "restingHeartRate", + "heartRateVariabilitySDNN", + "vo2Max", + // Activity + "stepCount", + "activeEnergyBurned", + "flightsClimbed", + "distanceWalkingRunning", + // Audio exposure + "environmentalAudioExposure", + "headphoneAudioExposure", + // Time in Daylight (iOS 17+) + "timeInDaylight", + ]; + expect( + requiredIdentifiers.length, + "at least 17 HealthKit quantity identifiers must be enumerated", + ).toBeGreaterThanOrEqual(17); + for (const identifier of requiredIdentifiers) { + expect( + html, + `missing HealthKit identifier ${JSON.stringify(identifier)}`, + ).toContain(identifier); + } + }); + + it("lists sleepAnalysis as the sole HKCategoryTypeIdentifier read", () => { + const html = render(); + expect(html).toContain("sleepAnalysis"); + // Per-stage breakdown is the differentiator vs. a single sleep + // duration — name the four stage labels so the disclosure stays + // explicit. + expect(html).toContain("Awake"); + expect(html).toContain("REM"); + expect(html).toContain("Core"); + expect(html).toContain("Deep"); + }); + + it("names every active sub-processor", () => { + const html = render(); + const expectedProviders = [ + "Anthropic", + "OpenAI", + "Withings", + "Apple, Inc.", + "Telegram", + "GitHub", + "Cloudflare", + "Hetzner", + ]; + for (const provider of expectedProviders) { + expect( + html, + `missing sub-processor ${JSON.stringify(provider)}`, + ).toContain(provider); + } + }); + + it("states the EU MDR medical-device boundary verbatim", () => { + const html = render(); + expect(html).toContain("not a medical device"); + expect(html).toContain("2017/745"); + expect(html).toContain("MDCG 2021-24"); + }); + + it("links to the GDPR-request contact channel without exposing PII", () => { + const html = render(); + expect(html).toContain("github.com/MBombeck/HealthLog/issues"); + // Marc-Voice rule: no personal name in user-facing artefacts. The + // policy must speak in terms of "the operator" / "the maintainer" + // not Marc's full name. + expect(html).not.toMatch(/Marc[- ]?Andr/i); + expect(html).not.toContain("Bombeck "); + }); + + it("renders a discoverable footer with the version and license", () => { + const html = render(); + expect(html).toContain('data-slot="privacy-footer"'); + expect(html).toContain("AGPL-3.0"); + expect(html).toContain("1.4.26"); + }); +}); diff --git a/src/app/privacy/page.tsx b/src/app/privacy/page.tsx new file mode 100644 index 000000000..7a9b1a10b --- /dev/null +++ b/src/app/privacy/page.tsx @@ -0,0 +1,831 @@ +import type { Metadata } from "next"; +import Link from "next/link"; + +/** + * v1.4.26 — Public privacy policy. + * + * Requirements driving this page: + * + * 1. **Apple App Store Connect** requires a publicly reachable Privacy- + * Policy URL before the iOS native app's first submission. The URL + * registered with ASC is `https://healthlog.bombeck.io/privacy`. + * 2. **GDPR Art. 13 / 14 + Art. 15-22** — data-subject rights need to + * be enumerated with concrete endpoints / routes the user can hit. + * 3. **EU MDR 2017/745 + MDCG 2021-24** — the boundary "HealthLog is + * not a medical device" is stated next to the AI-Coach explanation, + * because that boundary is what keeps the Coach legally outside + * MDR scope. Mirrors GROUND RULES 9 + 15 in the Coach system prompt. + * + * Public route — listed in `src/proxy.ts` PUBLIC_PATHS and the + * `` PUBLIC_PATHS so an unauthenticated visitor (or App-Store + * reviewer) lands directly on the content without an auth redirect. + * + * English-only body copy for this release. German + the four remaining + * locales follow in a subsequent patch — pattern matches the W14c + * native-Coach-prompts release where EN + DE shipped first. The + * `auth.privacyPolicy` label in `` IS translated into all + * six locales already so the discoverable link reads natively. + */ + +export const dynamic = "force-static"; +export const revalidate = false; + +const POLICY_VERSION = "1.4.26"; +const LAST_UPDATED = "2026-05-15"; + +export const metadata: Metadata = { + title: "Privacy Policy — HealthLog", + description: + "How HealthLog handles personal-health data, sub-processors, GDPR rights, and the EU MDR medical-device boundary.", + robots: { index: true, follow: true }, +}; + +interface SectionProps { + id: string; + title: string; + children: React.ReactNode; +} + +function Section({ id, title, children }: SectionProps) { + return ( +
+

+ {title} +

+
+ {children} +
+
+ ); +} + +interface SubProcessorProps { + name: string; + role: string; + data: string; + location: string; + policyUrl: string; +} + +function SubProcessor({ + name, + role, + data, + location, + policyUrl, +}: SubProcessorProps) { + return ( +
  • +

    {name}

    +

    + Role. {role} +

    +

    + Data transferred.{" "} + {data} +

    +

    + Storage. {location} +

    +

    + + Privacy policy + +

    +
  • + ); +} + +export default function PrivacyPage() { + return ( +
    + {/* Top bar — visible home-link so an App-Store reviewer can confirm + the policy belongs to the same brand as the iOS app. Public; no + nav menu (this page is reachable without a session). */} +
    +
    + + HealthLog + + + Sign in + +
    +
    + +
    +
    +

    + Policy version {POLICY_VERSION} +

    +

    + Privacy Policy +

    +

    + Last updated: {LAST_UPDATED} +

    +
    + +
    +

    + HealthLog is an open-source, self-hostable personal-health-tracking + application. Source code is published at{" "} + + github.com/MBombeck/HealthLog + {" "} + under the GNU Affero General Public License v3.0. This policy + describes how the hosted instance at{" "} + + healthlog.bombeck.io + {" "} + and the companion iOS application{" "} + HealthLog for iOS (bundle identifier{" "} + + io.bombeck.healthlog + + ) handle personal data. +

    +

    + This document covers the web application version {POLICY_VERSION}{" "} + and the iOS application version 0.3 and later. Self-hosted + deployments controlled by a different operator are governed by the + operator's own privacy policy; this document applies only to + the instance reachable at the hostname above. +

    +

    + For questions or data-subject requests, open a public issue at{" "} + + github.com/MBombeck/HealthLog/issues + {" "} + titled "GDPR — <Access | Erasure | Portability> + request" without personal information in the title; a private + channel for the actual exchange will be provided in the first + response. +

    +
    + +
    +

    + HealthLog is designed to record observations the user enters + themselves and to consume health signals the user has explicitly + connected through a third-party integration. The categories below + are exhaustive for the released feature set. +

    + +

    + 2.1 Account and authentication +

    +
      +
    • Email address and chosen username.
    • +
    • + Password hash (Argon2id; the plain-text password is never stored + or logged). +
    • +
    • + Optional WebAuthn / passkey credentials (public key, credential + ID, sign counter). +
    • +
    • + Session identifiers (HTTP-only cookie on the web; opaque API + tokens on iOS, stored in the device Keychain). +
    • +
    • + Profile metadata: locale, timezone, date of birth (optional), + biological sex (optional), height (optional). Used to compute + age-adjusted target ranges and BMI. +
    • +
    + +

    + 2.2 Manually entered health data +

    +

    + Any value entered through the in-app forms, including timestamp, + optional note, and the metric type. The full enumeration of + supported metrics is published in the OpenAPI schema; the + user-relevant subset includes: +

    +
      +
    • + Body weight, body-fat percentage, body temperature, BMI (derived). +
    • +
    • + Blood pressure (systolic, diastolic) and pulse rate, recorded as + paired readings. +
    • +
    • + Blood glucose with measurement context (fasting, postprandial, + random, bedtime) and a configurable unit (mg/dL or mmol/L). +
    • +
    • + Resting heart rate, heart-rate variability (SDNN), VO₂ max, + oxygen saturation. +
    • +
    • + Sleep duration including per-stage breakdown (Awake / REM / Core + / Deep). +
    • +
    • + Step count, distance walked or run, active-energy burned, flights + climbed. +
    • +
    • Environmental and headphone audio-exposure levels.
    • +
    • Time in daylight.
    • +
    • Mood (1-5 scale) with optional free-text note.
    • +
    • + Workout sessions: activity type, duration, distance, and an + optional GPS route (only uploaded when the user attaches a route + explicitly; HealthLog does not run continuous location services). +
    • +
    • + Personal records derived automatically from the data above + (metric type, value, achievement timestamp, optional workout + link). +
    • +
    + +

    + 2.3 Apple Health (iOS application) +

    +

    + When the user grants the iOS application read access to Apple + HealthKit, HealthLog reads samples for the identifiers listed + below. HealthKit data remains on the user's device and the + user's iCloud-backed Health store; the iOS application copies + relevant samples to the user's HealthLog account so that the + web surface can render the same trends as the iPhone. +

    +

    HKQuantityTypeIdentifier:

    +
      +
    • + bodyMass +
    • +
    • + bodyFatPercentage +
    • +
    • + bodyTemperature +
    • +
    • + bloodPressureSystolic +
    • +
    • + bloodPressureDiastolic +
    • +
    • + bloodGlucose +
    • +
    • + oxygenSaturation +
    • +
    • + heartRate +
    • +
    • + restingHeartRate +
    • +
    • + heartRateVariabilitySDNN +
    • +
    • + vo2Max +
    • +
    • + stepCount +
    • +
    • + activeEnergyBurned +
    • +
    • + flightsClimbed +
    • +
    • + distanceWalkingRunning +
    • +
    • + environmentalAudioExposure +
    • +
    • + headphoneAudioExposure +
    • +
    • + timeInDaylight (iOS 17 and later) +
    • +
    +

    HKCategoryTypeIdentifier:

    +
      +
    • + sleepAnalysis — read with full + per-stage granularity (Awake, REM, Core, Deep). Stages are stored + server-side as separate rows so the chart can stack them. +
    • +
    +

    + Write access is requested for a subset (body mass, blood-pressure + systolic / diastolic, blood glucose) so that manual entries made + inside the iOS app can flow back into HealthKit. The user controls + both directions in the iOS Health app's permission surface and + may revoke at any time. +

    + +

    + 2.4 Withings sync (optional) +

    +

    + When the user connects a Withings account, HealthLog stores the + OAuth refresh and access tokens (encrypted at the column level) and + the user's Withings identifier. Subsequent webhook-driven + syncs pull the following measurement families: +

    +
      +
    • Body weight, fat ratio, fat-free mass, muscle mass.
    • +
    • Bone mass, water mass, lean mass, hydration.
    • +
    • Blood pressure (systolic, diastolic) and pulse.
    • +
    • Blood glucose readings.
    • +
    • Body temperature, basal temperature, skin temperature.
    • +
    • Activity totals: steps, distance, active calories.
    • +
    • + Sleep sessions with per-stage segments (Awake / Light / Deep / + REM). +
    • +
    • SpO₂ and heart-rate variability where available.
    • +
    + +

    + 2.5 Medications +

    +
      +
    • + Active prescriptions: drug name, strength, route, schedule, + treatment-class flag (standard or GLP-1). +
    • +
    • + Intake events: scheduled time, actual time, status (taken, + skipped, missed). +
    • +
    • + For GLP-1 treatments: dose-change history, injection events with + optional injection site and pen identifier, side-effect logs + against a fixed taxonomy, pen-and-vial inventory with a 30-day + in-use clock. +
    • +
    + +

    + 2.6 AI Coach and Insights +

    +

    + When the user enables the AI Coach surface and configures a + language-model provider, HealthLog sends a snapshot bundle to that + provider on demand. The bundle contains health context derived + from the data above (aggregates, recent observations, target + ranges, optional medication context). The bundle is generated per + request; HealthLog does not retain long-term conversation state + beyond the most recent thread. See section 4 for what each + provider does with the data. +

    + +

    + 2.7 Device and integration metadata +

    +
      +
    • + A device identifier (random UUID generated client-side, stored + in the iOS Keychain) sent as the{" "} + + X-Device-Id + {" "} + request header. Used for multi-device sync and abuse prevention. +
    • +
    • + Apple Push Notification service (APNs) device token and + environment flag (sandbox / production), recorded only when the + user enables push notifications. +
    • +
    • + Telegram chat identifier — only when the user has explicitly + connected the optional Telegram-bot notifier. +
    • +
    + +

    + 2.8 Security and audit +

    +
      +
    • + Authentication events: login success / failure, passkey + registration, password change, session revocation. Retained for + security forensics. +
    • +
    • + Server access logs: timestamp, request path, status code, user- + agent, IP address. Retained for 14 days for abuse-rate-limiting + and debugging. +
    • +
    + +

    + 2.9 Data we do not collect +

    +
      +
    • + No third-party advertising identifiers, fingerprints, or + cross-app tracking. +
    • +
    • + No payment information (the application has no paid tier). +
    • +
    • + No precise background location (workouts only carry GPS when the + user attaches a route). +
    • +
    • No social-network identifiers or contact-list scrapes.
    • +
    • + No third-party product analytics. The instance does not ship a + client-side analytics SDK. +
    • +
    +
    + +
    +
      +
    • + + Authentication data + {" "} + — identifying the user and securing the session. Legal basis: GDPR + Art. 6 (1) (b) performance of contract. +
    • +
    • + Health data{" "} + — displaying trends, computing target adherence, generating the + Coach context bundle when the user invokes it. Legal basis: GDPR + Art. 9 (2) (a) explicit consent for special-category data. +
    • +
    • + + Coach context bundles + {" "} + — generating personalised written feedback. The bundle is + transmitted to the configured AI provider for the duration of the + request only. +
    • +
    • + Device data{" "} + — push notifications, abuse prevention, and multi-device session + hygiene. +
    • +
    • + Audit logs{" "} + — security-event tracing and rate-limiting. Legal basis: GDPR + Art. 6 (1) (f) legitimate interest in operating the service + securely. +
    • +
    • + + Product analytics + {" "} + — none collected. +
    • +
    +
    + +
    +

    + The following providers may process personal data on behalf of the + HealthLog operator. The list is exhaustive for the released + feature set; sub-processors are only engaged for the features the + user has explicitly enabled. +

    +
      + + + + + + + + +
    +
    + +
    +
      +
    • + Primary data store: PostgreSQL on a Hetzner-hosted server in + Germany. Disk encrypted at rest. Sensitive columns + (authentication tokens, integration secrets) are individually + encrypted with a key separate from the database key. +
    • +
    • + Backups: daily, encrypted, retained for 30 days on an + S3-compatible object store with its own encryption-at-rest layer. +
    • +
    • + Retention: account data is retained until the user requests + erasure (see section 6) or the account is administratively + closed. Audit logs are retained for 90 days; server access logs + for 14 days. +
    • +
    • + Deletion: the account-deletion endpoint cascades through every + user-scoped table — health observations, sessions, audit log, + integration tokens, notification subscriptions, achievements, + uploaded files. The deletion is immediate; backups age out under + the 30-day window above. +
    • +
    +
    + +
    +
      +
    • + + Right of access (Art. 15) + {" "} + — every value the application has stored is reachable through the + in-app history surfaces. A consolidated JSON export is offered + under Settings → Data & export. +
    • +
    • + + Right to rectification (Art. 16) + {" "} + — every record carries an in-app edit and delete action. +
    • +
    • + + Right to erasure (Art. 17) + {" "} + — Settings → Account → Delete account. The action + cascades immediately through user-scoped tables; backups age out + within the 30-day window described in section 5. +
    • +
    • + + Right to restriction of processing (Art. 18) + {" "} + — request an administrative suspension by opening a GDPR issue + (see section 9). +
    • +
    • + + Right to data portability (Art. 20) + {" "} + — the JSON export under Settings → Data & export{" "} + returns the full record set in a structured, machine-readable + format. +
    • +
    • + + Right to object to automated decision-making (Art. 22) + {" "} + — the AI Coach does not make automated decisions in the Art. 22 + sense. It generates written suggestions for the user to read; no + action is taken on the user's behalf without explicit + confirmation. See section 7 for the medical-device boundary. +
    • +
    • + + Right to lodge a complaint + {" "} + — with the Federal Commissioner for Data Protection and Freedom + of Information (BfDI) for the German federal level, or with the + data-protection authority of the user's habitual residence. +
    • +
    +
    + +
    +

    + HealthLog is not a medical device within the + meaning of EU Regulation 2017/745 (MDR). The application records + and displays observations the user has entered; it does not + diagnose, treat, prescribe, or monitor a specific medical + condition. +

    +

    + The AI Coach surface is constrained by two explicit ground rules + in its system prompt: it does not recommend GLP-1 doses (GROUND + RULE 9), and it does not produce drug-level estimates (GROUND + RULE 15). The GLP-1 Research Mode chart is a display-only + visualisation gated behind a versioned acknowledgment that cites + the MDR boundary to the user before unlocking. None of these + surfaces issue clinical recommendations. +

    +

    + For medical advice the user must consult a licensed clinician. + HealthLog is intended to support that conversation by surfacing + self-tracked data — not to replace it. +

    +
    + +
    +

    + The iOS application's Privacy Nutrition Labels in App Store + Connect map to the following categories: +

    +
      +
    • + + Health & Fitness + {" "} + — linked to the user; used for app functionality. +
    • +
    • + + Sensitive Info + {" "} + (blood pressure, glucose, medications) — linked to the user; used + for app functionality. +
    • +
    • + Contact Info{" "} + (email address) — linked to the user; used for app functionality + and account management. +
    • +
    • + User Content{" "} + (notes attached to measurements, mood notes, bug-report text) — + linked to the user; used for app functionality. +
    • +
    • + Identifiers{" "} + (device identifier, user identifier) — linked to the user; used + for app functionality. +
    • +
    • + Diagnostics{" "} + — not collected. +
    • +
    • + Usage Data{" "} + — not collected. +
    • +
    • + Location{" "} + — not collected, except for an optional GPS route the user + attaches manually to a workout. +
    • +
    • + + Browsing History + {" "} + — not collected. +
    • +
    • + + Search History + {" "} + — not collected. +
    • +
    • + Other Data{" "} + — not collected. +
    • +
    +
    + +
    +

    + HealthLog is not directed at children under the age of 16. The + application should not be used by anyone under 16 without + verifiable parental supervision. The operator does not knowingly + collect personal data from children under 16; if such data is + discovered, it will be deleted on detection. +

    +
    + +
    +

    + This document is version-stamped at the top. Material changes will + be summarised in the in-app release notes and on the open-source + changelog. The published policy version is bound to the + application release version that introduced it. +

    +
    + +
    +

    + For privacy questions and data-subject requests, open a public + issue at{" "} + + github.com/MBombeck/HealthLog/issues + {" "} + titled GDPR — <Access | Erasure | Portability> request + . Do not include personal data in the public issue. A private + channel for the actual exchange will be provided in the first + response. +

    +
    + +
    +

    + HealthLog — open source under{" "} + + AGPL-3.0 + + . Policy version {POLICY_VERSION}. Last updated {LAST_UPDATED}. +

    +
    +
    +
    + ); +} diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index 5ee09fc70..e92d5aaaf 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -11,7 +11,7 @@ import { BottomNav } from "./bottom-nav"; import { SidebarNav } from "./sidebar-nav"; import { TopBar } from "./top-bar"; -const PUBLIC_PATHS = ["/auth/login", "/auth/register"]; +const PUBLIC_PATHS = ["/auth/login", "/auth/register", "/privacy"]; export function AuthShell({ children }: { children: React.ReactNode }) { const { user, isAuthenticated, isLoading } = useAuth(); @@ -20,6 +20,12 @@ export function AuthShell({ children }: { children: React.ReactNode }) { const router = useRouter(); const isPublicPage = PUBLIC_PATHS.some((p) => pathname.startsWith(p)); + // v1.4.26 — `/privacy` is a long-form legal page that brings its own + // header + footer. Centering it inside the public-page wrapper would + // squash a 3000-word policy into a "login card" layout, so we let it + // render edge-to-edge while still resolving as a public page for the + // auth gate above. + const isStandalonePublicPage = pathname.startsWith("/privacy"); const isAdminPage = pathname.startsWith("/admin"); const isOnboardingPage = pathname === "/onboarding"; const showUnlockNotifier = isAuthenticated && !isPublicPage && !!user?.id; @@ -61,6 +67,10 @@ export function AuthShell({ children }: { children: React.ReactNode }) { // Public pages (login, register) — render without any nav if (isPublicPage) { + // Long-form legal pages render edge-to-edge with their own chrome. + if (isStandalonePublicPage) { + return <>{children}; + } return (
    diff --git a/src/proxy.ts b/src/proxy.ts index e8c04aed3..5539c6a55 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -25,6 +25,10 @@ const PUBLIC_PATHS = [ "/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; + // GDPR Art. 13 expects the same for any visitor before they sign up. + "/privacy", // `/onboarding` itself + its subroutes are matched exactly via // `isPublicPath()` so we don't admit `/onboarding-export` etc. "/robots.txt",