mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
chore: merge v1.4.26 into develop
# Conflicts: # CHANGELOG.md # messages/de.json # messages/en.json # messages/es.json # messages/fr.json # messages/it.json # messages/pl.json # package.json # src/lib/withings/__tests__/sync-activity.test.ts # src/lib/withings/__tests__/sync-sleep.test.ts # src/lib/withings/sync-activity.ts # src/lib/withings/sync-sleep.ts
This commit is contained in:
commit
ae244cab12
18 files changed
+1140
-16
No files matched your search
@@ -16,7 +16,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -45,7 +45,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
- run: pnpm db:migrate:deploy
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
|
||||
- name: Upload Playwright HTML report on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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<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 /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/);
|
||||
});
|
||||
});
|
||||
@@ -117,7 +117,7 @@ export default function LoginPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="flex w-full max-w-sm flex-col gap-4">
|
||||
<div className="border-border bg-card rounded-xl border p-8 shadow-lg shadow-black/20">
|
||||
<div className="flex flex-col items-center gap-3 text-center">
|
||||
<div className="bg-primary/10 flex h-12 w-12 items-center justify-center rounded-lg">
|
||||
@@ -241,6 +241,17 @@ export default function LoginPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<p className="text-muted-foreground text-center text-xs">
|
||||
<Link
|
||||
href="/privacy"
|
||||
className="hover:text-foreground underline underline-offset-2"
|
||||
>
|
||||
{t("auth.privacyPolicy")}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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(<PrivacyPage />);
|
||||
}
|
||||
|
||||
describe("<PrivacyPage>", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
* `<AuthShell>` 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 `<LoginPage>` 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 (
|
||||
<section id={id} className="space-y-3 scroll-mt-20">
|
||||
<h2 className="text-xl font-semibold tracking-tight md:text-2xl">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="text-muted-foreground space-y-3 text-sm leading-relaxed md:text-base">
|
||||
{children}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
interface SubProcessorProps {
|
||||
name: string;
|
||||
role: string;
|
||||
data: string;
|
||||
location: string;
|
||||
policyUrl: string;
|
||||
}
|
||||
|
||||
function SubProcessor({
|
||||
name,
|
||||
role,
|
||||
data,
|
||||
location,
|
||||
policyUrl,
|
||||
}: SubProcessorProps) {
|
||||
return (
|
||||
<li className="border-border bg-card/40 space-y-1 rounded-md border p-3">
|
||||
<p className="text-foreground font-medium">{name}</p>
|
||||
<p className="text-sm">
|
||||
<span className="text-foreground font-medium">Role.</span> {role}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
<span className="text-foreground font-medium">Data transferred.</span>{" "}
|
||||
{data}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
<span className="text-foreground font-medium">Storage.</span> {location}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
<a
|
||||
href={policyUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Privacy policy
|
||||
</a>
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PrivacyPage() {
|
||||
return (
|
||||
<div className="bg-background text-foreground min-h-dvh">
|
||||
{/* 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). */}
|
||||
<header className="border-border/60 bg-background/80 sticky top-0 z-10 border-b backdrop-blur">
|
||||
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3 md:px-6">
|
||||
<Link
|
||||
href="/"
|
||||
className="text-foreground hover:text-primary text-sm font-semibold tracking-tight"
|
||||
>
|
||||
HealthLog
|
||||
</Link>
|
||||
<Link
|
||||
href="/auth/login"
|
||||
className="text-muted-foreground hover:text-foreground text-sm"
|
||||
>
|
||||
Sign in
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main
|
||||
id="main-content"
|
||||
className="mx-auto max-w-3xl space-y-10 px-4 py-8 md:px-6 md:py-12"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<p className="text-muted-foreground text-xs tracking-wider uppercase">
|
||||
Policy version {POLICY_VERSION}
|
||||
</p>
|
||||
<h1 className="text-3xl font-bold tracking-tight md:text-4xl">
|
||||
Privacy Policy
|
||||
</h1>
|
||||
<p
|
||||
className="text-muted-foreground text-sm"
|
||||
data-slot="privacy-last-updated"
|
||||
>
|
||||
Last updated: {LAST_UPDATED}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Section id="intro" title="1. Overview">
|
||||
<p>
|
||||
HealthLog is an open-source, self-hostable personal-health-tracking
|
||||
application. Source code is published at{" "}
|
||||
<a
|
||||
href="https://github.com/MBombeck/HealthLog"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
github.com/MBombeck/HealthLog
|
||||
</a>{" "}
|
||||
under the GNU Affero General Public License v3.0. This policy
|
||||
describes how the hosted instance at{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 text-xs">
|
||||
healthlog.bombeck.io
|
||||
</code>{" "}
|
||||
and the companion iOS application{" "}
|
||||
<em>HealthLog for iOS</em> (bundle identifier{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 text-xs">
|
||||
io.bombeck.healthlog
|
||||
</code>
|
||||
) handle personal data.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
For questions or data-subject requests, open a public issue at{" "}
|
||||
<a
|
||||
href="https://github.com/MBombeck/HealthLog/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
github.com/MBombeck/HealthLog/issues
|
||||
</a>{" "}
|
||||
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.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section id="data-we-collect" title="2. Data we collect">
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h3 className="text-foreground pt-2 text-base font-semibold">
|
||||
2.1 Account and authentication
|
||||
</h3>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>Email address and chosen username.</li>
|
||||
<li>
|
||||
Password hash (Argon2id; the plain-text password is never stored
|
||||
or logged).
|
||||
</li>
|
||||
<li>
|
||||
Optional WebAuthn / passkey credentials (public key, credential
|
||||
ID, sign counter).
|
||||
</li>
|
||||
<li>
|
||||
Session identifiers (HTTP-only cookie on the web; opaque API
|
||||
tokens on iOS, stored in the device Keychain).
|
||||
</li>
|
||||
<li>
|
||||
Profile metadata: locale, timezone, date of birth (optional),
|
||||
biological sex (optional), height (optional). Used to compute
|
||||
age-adjusted target ranges and BMI.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-foreground pt-2 text-base font-semibold">
|
||||
2.2 Manually entered health data
|
||||
</h3>
|
||||
<p>
|
||||
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:
|
||||
</p>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
Body weight, body-fat percentage, body temperature, BMI (derived).
|
||||
</li>
|
||||
<li>
|
||||
Blood pressure (systolic, diastolic) and pulse rate, recorded as
|
||||
paired readings.
|
||||
</li>
|
||||
<li>
|
||||
Blood glucose with measurement context (fasting, postprandial,
|
||||
random, bedtime) and a configurable unit (mg/dL or mmol/L).
|
||||
</li>
|
||||
<li>
|
||||
Resting heart rate, heart-rate variability (SDNN), VO₂ max,
|
||||
oxygen saturation.
|
||||
</li>
|
||||
<li>
|
||||
Sleep duration including per-stage breakdown (Awake / REM / Core
|
||||
/ Deep).
|
||||
</li>
|
||||
<li>
|
||||
Step count, distance walked or run, active-energy burned, flights
|
||||
climbed.
|
||||
</li>
|
||||
<li>Environmental and headphone audio-exposure levels.</li>
|
||||
<li>Time in daylight.</li>
|
||||
<li>Mood (1-5 scale) with optional free-text note.</li>
|
||||
<li>
|
||||
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).
|
||||
</li>
|
||||
<li>
|
||||
Personal records derived automatically from the data above
|
||||
(metric type, value, achievement timestamp, optional workout
|
||||
link).
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-foreground pt-2 text-base font-semibold">
|
||||
2.3 Apple Health (iOS application)
|
||||
</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p className="font-medium">HKQuantityTypeIdentifier:</p>
|
||||
<ul
|
||||
className="grid list-disc grid-cols-1 gap-x-6 gap-y-1 pl-5 sm:grid-cols-2"
|
||||
data-slot="privacy-hk-quantity"
|
||||
>
|
||||
<li>
|
||||
<code className="text-xs">bodyMass</code>
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-xs">bodyFatPercentage</code>
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-xs">bodyTemperature</code>
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-xs">bloodPressureSystolic</code>
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-xs">bloodPressureDiastolic</code>
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-xs">bloodGlucose</code>
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-xs">oxygenSaturation</code>
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-xs">heartRate</code>
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-xs">restingHeartRate</code>
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-xs">heartRateVariabilitySDNN</code>
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-xs">vo2Max</code>
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-xs">stepCount</code>
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-xs">activeEnergyBurned</code>
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-xs">flightsClimbed</code>
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-xs">distanceWalkingRunning</code>
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-xs">environmentalAudioExposure</code>
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-xs">headphoneAudioExposure</code>
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-xs">timeInDaylight</code> (iOS 17 and later)
|
||||
</li>
|
||||
</ul>
|
||||
<p className="font-medium">HKCategoryTypeIdentifier:</p>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
<code className="text-xs">sleepAnalysis</code> — read with full
|
||||
per-stage granularity (Awake, REM, Core, Deep). Stages are stored
|
||||
server-side as separate rows so the chart can stack them.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h3 className="text-foreground pt-2 text-base font-semibold">
|
||||
2.4 Withings sync (optional)
|
||||
</h3>
|
||||
<p>
|
||||
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:
|
||||
</p>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>Body weight, fat ratio, fat-free mass, muscle mass.</li>
|
||||
<li>Bone mass, water mass, lean mass, hydration.</li>
|
||||
<li>Blood pressure (systolic, diastolic) and pulse.</li>
|
||||
<li>Blood glucose readings.</li>
|
||||
<li>Body temperature, basal temperature, skin temperature.</li>
|
||||
<li>Activity totals: steps, distance, active calories.</li>
|
||||
<li>
|
||||
Sleep sessions with per-stage segments (Awake / Light / Deep /
|
||||
REM).
|
||||
</li>
|
||||
<li>SpO₂ and heart-rate variability where available.</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-foreground pt-2 text-base font-semibold">
|
||||
2.5 Medications
|
||||
</h3>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
Active prescriptions: drug name, strength, route, schedule,
|
||||
treatment-class flag (standard or GLP-1).
|
||||
</li>
|
||||
<li>
|
||||
Intake events: scheduled time, actual time, status (taken,
|
||||
skipped, missed).
|
||||
</li>
|
||||
<li>
|
||||
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.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-foreground pt-2 text-base font-semibold">
|
||||
2.6 AI Coach and Insights
|
||||
</h3>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
|
||||
<h3 className="text-foreground pt-2 text-base font-semibold">
|
||||
2.7 Device and integration metadata
|
||||
</h3>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
A device identifier (random UUID generated client-side, stored
|
||||
in the iOS Keychain) sent as the{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 text-xs">
|
||||
X-Device-Id
|
||||
</code>{" "}
|
||||
request header. Used for multi-device sync and abuse prevention.
|
||||
</li>
|
||||
<li>
|
||||
Apple Push Notification service (APNs) device token and
|
||||
environment flag (sandbox / production), recorded only when the
|
||||
user enables push notifications.
|
||||
</li>
|
||||
<li>
|
||||
Telegram chat identifier — only when the user has explicitly
|
||||
connected the optional Telegram-bot notifier.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-foreground pt-2 text-base font-semibold">
|
||||
2.8 Security and audit
|
||||
</h3>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
Authentication events: login success / failure, passkey
|
||||
registration, password change, session revocation. Retained for
|
||||
security forensics.
|
||||
</li>
|
||||
<li>
|
||||
Server access logs: timestamp, request path, status code, user-
|
||||
agent, IP address. Retained for 14 days for abuse-rate-limiting
|
||||
and debugging.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3 className="text-foreground pt-2 text-base font-semibold">
|
||||
2.9 Data we do not collect
|
||||
</h3>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
No third-party advertising identifiers, fingerprints, or
|
||||
cross-app tracking.
|
||||
</li>
|
||||
<li>
|
||||
No payment information (the application has no paid tier).
|
||||
</li>
|
||||
<li>
|
||||
No precise background location (workouts only carry GPS when the
|
||||
user attaches a route).
|
||||
</li>
|
||||
<li>No social-network identifiers or contact-list scrapes.</li>
|
||||
<li>
|
||||
No third-party product analytics. The instance does not ship a
|
||||
client-side analytics SDK.
|
||||
</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section id="purpose" title="3. Why we collect each category">
|
||||
<ul className="list-disc space-y-2 pl-5">
|
||||
<li>
|
||||
<span className="text-foreground font-medium">
|
||||
Authentication data
|
||||
</span>{" "}
|
||||
— identifying the user and securing the session. Legal basis: GDPR
|
||||
Art. 6 (1) (b) performance of contract.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground font-medium">Health data</span>{" "}
|
||||
— 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.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground font-medium">
|
||||
Coach context bundles
|
||||
</span>{" "}
|
||||
— generating personalised written feedback. The bundle is
|
||||
transmitted to the configured AI provider for the duration of the
|
||||
request only.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground font-medium">Device data</span>{" "}
|
||||
— push notifications, abuse prevention, and multi-device session
|
||||
hygiene.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground font-medium">Audit logs</span>{" "}
|
||||
— security-event tracing and rate-limiting. Legal basis: GDPR
|
||||
Art. 6 (1) (f) legitimate interest in operating the service
|
||||
securely.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground font-medium">
|
||||
Product analytics
|
||||
</span>{" "}
|
||||
— none collected.
|
||||
</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section id="sub-processors" title="4. Third-party sub-processors">
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<ul className="grid gap-3">
|
||||
<SubProcessor
|
||||
name="Anthropic, PBC"
|
||||
role="AI Coach and Insights provider when the user selects Anthropic Claude in settings."
|
||||
data="Coach snapshot bundle (health-data context) for the duration of the request."
|
||||
location="United States. Anthropic states a 30-day retention window for abuse-monitoring."
|
||||
policyUrl="https://www.anthropic.com/legal/privacy"
|
||||
/>
|
||||
<SubProcessor
|
||||
name="OpenAI, L.L.C."
|
||||
role="Alternative AI Coach and Insights provider when the user selects an OpenAI model in settings."
|
||||
data="Coach snapshot bundle, same shape as the Anthropic variant."
|
||||
location="United States. Retention governed by OpenAI's enterprise policy applicable to the configured API key."
|
||||
policyUrl="https://openai.com/policies/privacy-policy"
|
||||
/>
|
||||
<SubProcessor
|
||||
name="Withings SAS"
|
||||
role="Wearable-device data sync when the user connects a Withings account."
|
||||
data="OAuth refresh and access tokens, Withings user identifier, webhook notifications about new measurements."
|
||||
location="France (European Union)."
|
||||
policyUrl="https://www.withings.com/de/de/legal/privacy-policy"
|
||||
/>
|
||||
<SubProcessor
|
||||
name="Apple, Inc."
|
||||
role="HealthKit (on-device store; samples never leave the user's device or iCloud unless the user grants read access to HealthLog) and Apple Push Notification service (APNs, when notifications are enabled)."
|
||||
data="HealthKit access is local to the device. APNs receives a device token, the application bundle identifier, and the notification payload."
|
||||
location="United States. Apple stores HealthKit data on the user's device and iCloud-encrypted backups under the user's Apple ID."
|
||||
policyUrl="https://www.apple.com/legal/privacy/"
|
||||
/>
|
||||
<SubProcessor
|
||||
name="Telegram FZ-LLC"
|
||||
role="Optional notification channel when the user enables the Telegram-bot integration."
|
||||
data="Telegram chat identifier and the notification payload."
|
||||
location="United Arab Emirates / Telegram global infrastructure."
|
||||
policyUrl="https://telegram.org/privacy"
|
||||
/>
|
||||
<SubProcessor
|
||||
name="GitHub, Inc."
|
||||
role="Hosting of the open-source repository and the issue tracker used as the support channel."
|
||||
data="Issue contents and any voluntary attachments. Avoid posting personal data in public issues; the maintainer offers a private channel after the first response."
|
||||
location="United States."
|
||||
policyUrl="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement"
|
||||
/>
|
||||
<SubProcessor
|
||||
name="Cloudflare, Inc."
|
||||
role="Authoritative DNS for the bombeck.io zones. The application itself is not behind the Cloudflare proxy; only the DNS resolution path is."
|
||||
data="Source IP address and user-agent at resolution time."
|
||||
location="United States; Cloudflare's standard global anycast network."
|
||||
policyUrl="https://www.cloudflare.com/privacypolicy/"
|
||||
/>
|
||||
<SubProcessor
|
||||
name="Hetzner Online GmbH"
|
||||
role="Hardware host for the application server and the PostgreSQL database."
|
||||
data="Disk and network traffic between the operator-controlled virtual machines and the public internet."
|
||||
location="Germany (European Union). All HealthLog application data lives on Hetzner-hosted infrastructure under German jurisdiction."
|
||||
policyUrl="https://www.hetzner.com/legal/privacy-policy"
|
||||
/>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section id="storage" title="5. Data storage and retention">
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
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.
|
||||
</li>
|
||||
<li>
|
||||
Backups: daily, encrypted, retained for 30 days on an
|
||||
S3-compatible object store with its own encryption-at-rest layer.
|
||||
</li>
|
||||
<li>
|
||||
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.
|
||||
</li>
|
||||
<li>
|
||||
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.
|
||||
</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section id="rights" title="6. Your rights (GDPR Art. 15-22, DSGVO)">
|
||||
<ul className="list-disc space-y-2 pl-5">
|
||||
<li>
|
||||
<span className="text-foreground font-medium">
|
||||
Right of access (Art. 15)
|
||||
</span>{" "}
|
||||
— every value the application has stored is reachable through the
|
||||
in-app history surfaces. A consolidated JSON export is offered
|
||||
under <em>Settings → Data & export</em>.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground font-medium">
|
||||
Right to rectification (Art. 16)
|
||||
</span>{" "}
|
||||
— every record carries an in-app edit and delete action.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground font-medium">
|
||||
Right to erasure (Art. 17)
|
||||
</span>{" "}
|
||||
— <em>Settings → Account → Delete account</em>. The action
|
||||
cascades immediately through user-scoped tables; backups age out
|
||||
within the 30-day window described in section 5.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground font-medium">
|
||||
Right to restriction of processing (Art. 18)
|
||||
</span>{" "}
|
||||
— request an administrative suspension by opening a GDPR issue
|
||||
(see section 9).
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground font-medium">
|
||||
Right to data portability (Art. 20)
|
||||
</span>{" "}
|
||||
— the JSON export under <em>Settings → Data & export</em>{" "}
|
||||
returns the full record set in a structured, machine-readable
|
||||
format.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground font-medium">
|
||||
Right to object to automated decision-making (Art. 22)
|
||||
</span>{" "}
|
||||
— 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.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground font-medium">
|
||||
Right to lodge a complaint
|
||||
</span>{" "}
|
||||
— 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.
|
||||
</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
id="medical-boundary"
|
||||
title="7. Medical-device boundary (EU MDR 2017/745, MDCG 2021-24)"
|
||||
>
|
||||
<p>
|
||||
HealthLog is <strong>not a medical device</strong> 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.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
id="apple-categories"
|
||||
title="8. Apple App Store privacy categories"
|
||||
>
|
||||
<p>
|
||||
The iOS application's Privacy Nutrition Labels in App Store
|
||||
Connect map to the following categories:
|
||||
</p>
|
||||
<ul className="list-disc space-y-1 pl-5">
|
||||
<li>
|
||||
<span className="text-foreground font-medium">
|
||||
Health & Fitness
|
||||
</span>{" "}
|
||||
— linked to the user; used for app functionality.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground font-medium">
|
||||
Sensitive Info
|
||||
</span>{" "}
|
||||
(blood pressure, glucose, medications) — linked to the user; used
|
||||
for app functionality.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground font-medium">Contact Info</span>{" "}
|
||||
(email address) — linked to the user; used for app functionality
|
||||
and account management.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground font-medium">User Content</span>{" "}
|
||||
(notes attached to measurements, mood notes, bug-report text) —
|
||||
linked to the user; used for app functionality.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground font-medium">Identifiers</span>{" "}
|
||||
(device identifier, user identifier) — linked to the user; used
|
||||
for app functionality.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground font-medium">Diagnostics</span>{" "}
|
||||
— not collected.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground font-medium">Usage Data</span>{" "}
|
||||
— not collected.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground font-medium">Location</span>{" "}
|
||||
— not collected, except for an optional GPS route the user
|
||||
attaches manually to a workout.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground font-medium">
|
||||
Browsing History
|
||||
</span>{" "}
|
||||
— not collected.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground font-medium">
|
||||
Search History
|
||||
</span>{" "}
|
||||
— not collected.
|
||||
</li>
|
||||
<li>
|
||||
<span className="text-foreground font-medium">Other Data</span>{" "}
|
||||
— not collected.
|
||||
</li>
|
||||
</ul>
|
||||
</Section>
|
||||
|
||||
<Section id="children" title="9. Children">
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section id="changes" title="10. Changes to this policy">
|
||||
<p>
|
||||
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.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<Section id="contact" title="11. Contact">
|
||||
<p>
|
||||
For privacy questions and data-subject requests, open a public
|
||||
issue at{" "}
|
||||
<a
|
||||
href="https://github.com/MBombeck/HealthLog/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
github.com/MBombeck/HealthLog/issues
|
||||
</a>{" "}
|
||||
titled <em>GDPR — <Access | Erasure | Portability> request</em>
|
||||
. Do not include personal data in the public issue. A private
|
||||
channel for the actual exchange will be provided in the first
|
||||
response.
|
||||
</p>
|
||||
</Section>
|
||||
|
||||
<footer
|
||||
className="border-border/60 mt-12 border-t pt-6 text-xs text-muted-foreground"
|
||||
data-slot="privacy-footer"
|
||||
>
|
||||
<p>
|
||||
HealthLog — open source under{" "}
|
||||
<a
|
||||
href="https://github.com/MBombeck/HealthLog/blob/main/LICENSE"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="hover:text-foreground hover:underline"
|
||||
>
|
||||
AGPL-3.0
|
||||
</a>
|
||||
. Policy version {POLICY_VERSION}. Last updated {LAST_UPDATED}.
|
||||
</p>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flex h-dvh flex-col">
|
||||
<MaintainershipBanner />
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user