mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
chore: scrub instance details and personal data from committed artefacts
Blood-pressure fixtures in the in-target integration suite were copied from live readings; they are now synthetic with the same in/out split so every assertion holds. Hard-coded operator hostnames move behind env vars (DEPLOY_LOGS_URL, HEALTHLOG_PROD_URL), the codex client identifies via the public repository URL, the OpenAPI servers entry points at a neutral example host, and remaining workflow, docs and comment references to the operator's instance or working notes are generalised. The compose whitelist also gains DEPLOY_WEBHOOK_SECRET, which was missing and made the deploy webhook unusable on compose installs.
This commit is contained in:
1 parent
b8c2b2e8c5
commit
76012bf8e2
17 files changed
+145
-105
No files matched your search
@@ -65,6 +65,9 @@ API_TOKEN_HMAC_KEY="<openssl rand -hex 32>"
|
||||
# Deploy webhook -- Coolify -> HealthLog auto-deploy feedback
|
||||
# -----------------------------------------------------------------------------
|
||||
# DEPLOY_WEBHOOK_SECRET=""
|
||||
# Optional: deployment-dashboard URL (e.g. the Coolify UI) linked in
|
||||
# deploy-failure notifications. When unset the notification omits the link.
|
||||
# DEPLOY_LOGS_URL=""
|
||||
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
@@ -313,15 +313,15 @@ jobs:
|
||||
run: |
|
||||
docker buildx imagetools inspect '${{ env.IMAGE_REF }}:${{ steps.meta.outputs.version }}'
|
||||
|
||||
# Phase C2 (v1.4.15): trigger Coolify deploy automatically once GHCR has
|
||||
# the new image. Removes the manual SSH + force-pull recipe documented
|
||||
# in `.planning/phase-8-report.md`.
|
||||
# v1.4.15: trigger the Coolify deploy automatically once GHCR has
|
||||
# the new image. Replaces the former manual SSH + force-pull recipe
|
||||
# on the deployment host.
|
||||
#
|
||||
# Runs only on the publish path (push to `main` or to a `v*` tag), never
|
||||
# on PRs. `continue-on-error: true` because the image is *already*
|
||||
# published to GHCR by the time we reach this step — a Coolify-side
|
||||
# outage must not retroactively fail the build. Marc still has the
|
||||
# manual recipe as fallback.
|
||||
# outage must not retroactively fail the build. The maintainer still
|
||||
# has the manual recipe as fallback.
|
||||
#
|
||||
# Required GitHub repository secrets (set by the maintainer once):
|
||||
# COOLIFY_WEBHOOK = full deploy URL incl. ?uuid=...&force=false
|
||||
@@ -377,8 +377,8 @@ jobs:
|
||||
# new digest. The webhook fires inside that window, so
|
||||
# Coolify pulls "latest" while the edge still serves the
|
||||
# prior digest, gets "no change", and returns success.
|
||||
# See `.planning/round-coolify-auto-deploy-fix-2026-05-16.md`
|
||||
# for the full root-cause + remediation note.
|
||||
# Waiting out the propagation window before firing the
|
||||
# webhook closes that gap.
|
||||
echo "Waiting 90 s for GHCR cache propagation before triggering Coolify (image: $IMAGE_VERSION)…"
|
||||
sleep 90
|
||||
# v1.4.22 C3 — force=true tells Coolify to skip its image-cache
|
||||
@@ -405,8 +405,8 @@ jobs:
|
||||
echo "Coolify response (HTTP $status):"
|
||||
echo "$body"
|
||||
if [ "$status" -lt 200 ] || [ "$status" -ge 300 ]; then
|
||||
echo "::error::Coolify deploy webhook returned HTTP $status — image is published, but auto-deploy did not fire. Marc: deploy manually via SSH recipe in .planning/phase-8-report.md."
|
||||
echo "::error::Coolify deploy webhook returned HTTP $status — image is published, but auto-deploy did not fire. Maintainer: deploy manually on the deployment host (SSH force-pull recipe, see docs/ops/deploy.md)."
|
||||
exit 1
|
||||
fi
|
||||
echo "::notice::Coolify auto-deploy webhook fired at $(date -u +%FT%TZ) for ref ${{ github.ref_name }} (sha ${GITHUB_SHA::7}). If /api/version doesn't reach the new tag within ~60s, see .planning/coolify-auto-deploy-howto.md."
|
||||
echo "::notice::Coolify auto-deploy webhook fired at $(date -u +%FT%TZ) for ref ${{ github.ref_name }} (sha ${GITHUB_SHA::7}). If /api/version doesn't reach the new tag within ~60s, re-trigger the deploy from the Coolify UI or deploy manually on the deployment host (docs/ops/deploy.md)."
|
||||
echo "Coolify deploy queued — awaiting outgoing notification webhook to /api/internal/deploy-webhook for final status."
|
||||
@@ -65,6 +65,13 @@ services:
|
||||
# container (the `environment:` block is a whitelist).
|
||||
WHOOP_WEBHOOK_SECRET: "${WHOOP_WEBHOOK_SECRET:-}"
|
||||
WHOOP_REDIRECT_URI: "${WHOOP_REDIRECT_URI:-}"
|
||||
# Deploy-result webhook (optional). DEPLOY_WEBHOOK_SECRET authenticates
|
||||
# POST /api/internal/deploy-webhook; DEPLOY_LOGS_URL is the
|
||||
# deployment-dashboard URL (e.g. the Coolify UI) linked in
|
||||
# deploy-failure notifications. Listed here so operator values in
|
||||
# .env reach the container (the `environment:` block is a whitelist).
|
||||
DEPLOY_WEBHOOK_SECRET: "${DEPLOY_WEBHOOK_SECRET:-}"
|
||||
DEPLOY_LOGS_URL: "${DEPLOY_LOGS_URL:-}"
|
||||
# TLS leaf SPKI-change alarm baseline (optional). The native client
|
||||
# pins the served TLS leaf; this is the known-good pin set the monitor
|
||||
# alarms against on a rotation. Comma-separated for the dual-pin
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
file: docs/apple-store-connect-checklist.md
|
||||
purpose: Step-by-step ASC submission checklist for HealthLog iOS — covering medical-device declaration, privacy nutrition labels, and ASC metadata
|
||||
audience: Marc (the maintainer) before submitting to App Store
|
||||
audience: the maintainer, before submitting to App Store
|
||||
estimated_read_time: 15 min
|
||||
last_updated: 2026-05-15
|
||||
---
|
||||
@@ -10,7 +10,7 @@ last_updated: 2026-05-15
|
||||
|
||||
## TL;DR
|
||||
|
||||
Three submission gates that need Marc's hand at ASC:
|
||||
Three submission gates that need the maintainer's hand at ASC:
|
||||
1. Regulated-Medical-Device declaration (Guideline 1.4 + 5.1.1(ix))
|
||||
2. Privacy Nutrition Labels matching the iOS Privacy Manifest (Guideline 5.1.2)
|
||||
3. Standard metadata (name, description, screenshots, categories, age rating)
|
||||
@@ -29,7 +29,7 @@ Plus pre-submit final checks.
|
||||
**What to check**:
|
||||
- [ ] Check "No" on the medical-device toggle
|
||||
- [ ] Add the description above to the medical-device-justification field if Apple shows one
|
||||
- [ ] Cross-reference the in-app `/privacy` page (https://healthlog.bombeck.io/privacy) §7 MDR-boundary
|
||||
- [ ] Cross-reference the in-app `/privacy` page (https://<your-instance>/privacy) §7 MDR-boundary
|
||||
|
||||
**Why this matters**: Apple's first-submission rejection rate for health apps is ~40%. The most common reason is unclear medical-device classification. The above wording mirrors the in-app disclaimer + the per-feature gating; consistency between the form and the app is what Apple looks for.
|
||||
|
||||
@@ -37,7 +37,7 @@ Plus pre-submit final checks.
|
||||
|
||||
**Where**: App Store Connect → App Privacy → "Edit" the privacy choices.
|
||||
|
||||
**Cross-reference**: every data category here MUST match what the iOS Privacy Manifest (in the iOS repo) declares. iOS-Claude's v0.3.0 release extended the manifest to: Email + OtherUserContent + SensitiveInfo + DeviceID + UserID + AccountManagement-purpose.
|
||||
**Cross-reference**: every data category here MUST match what the iOS Privacy Manifest (in the iOS repo) declares. The iOS client's v0.3.0 release extended the manifest to: Email + OtherUserContent + SensitiveInfo + DeviceID + UserID + AccountManagement-purpose.
|
||||
|
||||
### Data categories to declare
|
||||
|
||||
@@ -86,7 +86,7 @@ For each category below: **Linked to user, used for App Functionality + Account
|
||||
- Location (Precise or Coarse) — workout GPS routes are opt-in per-workout; if the iOS app implements them, declare; otherwise omit
|
||||
- Purchases
|
||||
- Financial Info
|
||||
- Diagnostics, Crash Data, Performance Data, Other Diagnostic Data — if iOS uses any analytics SDK, declare; otherwise omit. Verify with iOS-Claude.
|
||||
- Diagnostics, Crash Data, Performance Data, Other Diagnostic Data — if iOS uses any analytics SDK, declare; otherwise omit. Verify against the iOS client.
|
||||
- Advertising Data
|
||||
- Other Usage Data
|
||||
|
||||
@@ -101,7 +101,7 @@ For each category below: **Linked to user, used for App Functionality + Account
|
||||
- [ ] Subtitle: 30-char tagline (suggestion: "Personal health-data log + Coach")
|
||||
- [ ] Primary category: Health & Fitness
|
||||
- [ ] Secondary category: Medical (if Apple accepts non-medical-device classification for it)
|
||||
- [ ] Content rights: confirm Marc owns the app rights
|
||||
- [ ] Content rights: confirm the maintainer owns the app rights
|
||||
- [ ] Age rating: 17+ (because Sensitive Health Info content)
|
||||
|
||||
### Description
|
||||
@@ -114,36 +114,36 @@ For each category below: **Linked to user, used for App Functionality + Account
|
||||
- [ ] iPad screenshots if supporting iPad.
|
||||
|
||||
### URLs
|
||||
- [ ] Marketing URL: https://healthlog.bombeck.io (or marketing site once landing repo's v1.4.25 deployed)
|
||||
- [ ] Privacy Policy URL: **https://healthlog.bombeck.io/privacy** (ships with v1.4.25.1 hotfix)
|
||||
- [ ] Marketing URL: https://<your-instance> (or the marketing site once the landing repo's v1.4.25 is deployed)
|
||||
- [ ] Privacy Policy URL: **https://<your-instance>/privacy** (ships with v1.4.25.1 hotfix)
|
||||
- [ ] Support URL: https://github.com/MBombeck/HealthLog/issues
|
||||
|
||||
### App Review Information
|
||||
- [ ] Demo account credentials: Marc creates a read-only demo account on `demo.healthlog.dev` and provides credentials in the demo account fields
|
||||
- [ ] Demo account credentials: the maintainer creates a read-only demo account on `demo.healthlog.dev` and provides credentials in the demo account fields
|
||||
- [ ] Review notes: explain MDR boundary + AI Coach safety contract + Research Mode gating; reference `/privacy` §7 MDR-boundary. Apple reviewers DO read this and DO check the in-app behavior matches the claim.
|
||||
- [ ] Sign-in required: yes (provide demo credentials)
|
||||
|
||||
### Pricing + Availability
|
||||
- [ ] Free
|
||||
- [ ] Availability: select countries (Marc-decision — recommend EU + US + Canada + Australia + Japan for first launch)
|
||||
- [ ] Availability: select countries (maintainer decision — recommend EU + US + Canada + Australia + Japan for first launch)
|
||||
|
||||
## Section 4 — Pre-Submit Final Check
|
||||
|
||||
Run through this list:
|
||||
|
||||
- [ ] Privacy Policy URL responds 200 (curl https://healthlog.bombeck.io/privacy)
|
||||
- [ ] Privacy Policy URL responds 200 (curl https://<your-instance>/privacy)
|
||||
- [ ] iOS Privacy Manifest categories match the App Privacy declarations in §2
|
||||
- [ ] Demo account works on the demo server
|
||||
- [ ] iOS app's TestFlight build is on the latest commit
|
||||
- [ ] Marc has the APNs .p8 backed up to 1Password or similar (see `.planning/v15-apns-key-handling.md`)
|
||||
- [ ] Marc's Apple Developer Program subscription is active
|
||||
- [ ] The maintainer has the APNs .p8 backed up to a password manager
|
||||
- [ ] The maintainer's Apple Developer Program subscription is active
|
||||
|
||||
## Section 5 — Common First-Submission Rejection Reasons (Apple Health-App-Specific)
|
||||
|
||||
- Guideline 1.4.1: "Medical claims" — wording in app or screenshots implies diagnosis/treatment → fix: tone down wording, lean on the MDR-boundary disclaimer
|
||||
- Guideline 5.1.1(v): "Account Deletion missing" — iOS-Claude added this in v0.3.0
|
||||
- Guideline 5.1.2(i): "Third-Party AI consent" — DEFERRED to v0.3.1 per iOS-Claude release notes; resubmission may flag
|
||||
- Guideline 5.1.2: "Privacy Manifest mismatch" — see §2; iOS-Claude needs to ensure the manifest matches
|
||||
- Guideline 5.1.1(v): "Account Deletion missing" — the iOS client added this in v0.3.0
|
||||
- Guideline 5.1.2(i): "Third-Party AI consent" — DEFERRED to v0.3.1 per the iOS release notes; resubmission may flag
|
||||
- Guideline 5.1.2: "Privacy Manifest mismatch" — see §2; the iOS release process needs to ensure the manifest matches
|
||||
|
||||
## Section 6 — Post-Submit / If Rejected
|
||||
|
||||
|
||||
+6
-6
@@ -13,8 +13,8 @@ const eslintConfig = defineConfig([
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
// v1.4.41 — ignore Claude Code session worktrees so a mid-marathon
|
||||
// worktree on an older commit can't poison `pnpm lint` locally.
|
||||
// v1.4.41 — ignore local tool worktrees so a worktree on an older
|
||||
// commit can't poison `pnpm lint` locally.
|
||||
// CI never sees this path; the rule is for the dev environment.
|
||||
".claude/**",
|
||||
// The project-local ESLint rule plugin is CommonJS (require/module
|
||||
@@ -22,11 +22,11 @@ const eslintConfig = defineConfig([
|
||||
// is not linted by the app's TypeScript ruleset.
|
||||
"eslint-plugins/**",
|
||||
]),
|
||||
// v1.4.41 W-PROCESS-DOCS — custom rule that flags any bare-array
|
||||
// v1.4.41 — custom rule that flags any bare-array
|
||||
// `queryKey: [ … ]` / `mutationKey: [ … ]` declaration inside the
|
||||
// files the queryKeys factory currently guards. Promotes the
|
||||
// test-guard substitute from v1.4.40 W-RSC to a real ESLint rule
|
||||
// so IDE + CI both fail fast on a factory bypass. See
|
||||
// files the queryKeys factory currently guards. Promotes the earlier
|
||||
// v1.4.40 test-guard substitute to a real ESLint rule so IDE + CI
|
||||
// both fail fast on a factory bypass. See
|
||||
// `eslint-plugins/healthlog/queryKey-factory.js` for the
|
||||
// whitelist + rationale.
|
||||
{
|
||||
|
||||
@@ -13,7 +13,11 @@
|
||||
* Run it after every deploy — the queued-deploy status is not the source
|
||||
* of truth, the served version is:
|
||||
*
|
||||
* pnpm dlx tsx scripts/assert-deploy.ts <expected-version>
|
||||
* HEALTHLOG_PROD_URL=https://healthlog.example.com \
|
||||
* pnpm dlx tsx scripts/assert-deploy.ts <expected-version>
|
||||
*
|
||||
* The prod target's base URL comes from HEALTHLOG_PROD_URL (required
|
||||
* unless --only=demo).
|
||||
*
|
||||
* Examples:
|
||||
*
|
||||
@@ -36,8 +40,15 @@ interface Target {
|
||||
url: string;
|
||||
}
|
||||
|
||||
const TARGETS: Record<string, Target> = {
|
||||
prod: { name: "prod", url: "https://healthlog.bombeck.io/api/version" },
|
||||
// The production base URL is instance-specific — there is no meaningful
|
||||
// default for a self-hosted app. Set HEALTHLOG_PROD_URL (e.g.
|
||||
// `https://healthlog.example.com`) before asserting the prod target.
|
||||
const PROD_BASE_URL = process.env.HEALTHLOG_PROD_URL?.replace(/\/+$/, "");
|
||||
|
||||
const TARGETS: Record<string, Target | undefined> = {
|
||||
prod: PROD_BASE_URL
|
||||
? { name: "prod", url: `${PROD_BASE_URL}/api/version` }
|
||||
: undefined,
|
||||
demo: { name: "demo", url: "https://demo.healthlog.dev/api/version" },
|
||||
};
|
||||
|
||||
@@ -140,14 +151,26 @@ async function main(): Promise<void> {
|
||||
|
||||
let selected: Target[];
|
||||
if (only) {
|
||||
if (!(only in TARGETS)) {
|
||||
console.error(`unknown --only target: ${only} (use prod or demo)`);
|
||||
process.exit(2);
|
||||
}
|
||||
const picked = TARGETS[only];
|
||||
if (!picked) {
|
||||
console.error(`unknown --only target: ${only} (use prod or demo)`);
|
||||
console.error(
|
||||
`target "${only}" needs HEALTHLOG_PROD_URL set to the instance base URL (e.g. https://healthlog.example.com)`,
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
selected = [picked];
|
||||
} else {
|
||||
selected = Object.values(TARGETS);
|
||||
if (!TARGETS.prod) {
|
||||
console.error(
|
||||
"HEALTHLOG_PROD_URL is not set — set it to the instance base URL (e.g. https://healthlog.example.com) or use --only=demo",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
selected = Object.values(TARGETS).filter((t): t is Target => Boolean(t));
|
||||
}
|
||||
|
||||
console.log(
|
||||
|
||||
@@ -87,6 +87,10 @@
|
||||
{
|
||||
"name": "DEPLOY_WEBHOOK_SECRET",
|
||||
"purpose": "Shared secret for the X-Deploy-Webhook-Secret header on POST /api/internal/deploy-webhook. 64 hex chars."
|
||||
},
|
||||
{
|
||||
"name": "DEPLOY_LOGS_URL",
|
||||
"purpose": "URL of the deployment dashboard (e.g. the Coolify UI) linked in deploy-failure notifications. Optional; when unset the notification omits the link."
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@ import { NextRequest } from "next/server";
|
||||
* 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
|
||||
* - HSTS now carries `; preload` so the served host is
|
||||
* eligible for the Chromium HSTS preload list.
|
||||
* - The `wbsapi.withings.net` `connect-src` entry only ships on
|
||||
* the Withings surfaces (`/settings/integrations/withings/*`
|
||||
|
||||
@@ -21,8 +21,8 @@ import { NextResponse } from "next/server";
|
||||
* it to devices, so the origin only needs to respond reliably; Apple's
|
||||
* one-hour CDN TTL pairs with the `Cache-Control` directive below.
|
||||
*
|
||||
* Both `healthlog.bombeck.io` (maintainer prod) and `demo.healthlog.dev`
|
||||
* (demo) share this handler — the response is host-independent.
|
||||
* Every host serving this app (an operator's instance, the public demo)
|
||||
* shares this handler — the response is host-independent.
|
||||
*/
|
||||
const AASA_APP_ID = "S8WDX4W5KX.dev.healthlog.app";
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-loca
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Deploy-status webhook (phase C2 / v1.4.15).
|
||||
* Deploy-status webhook (v1.4.15).
|
||||
*
|
||||
* Coolify (Settings → Notifications → Webhook) calls this endpoint after
|
||||
* every deploy attempt. We verify the request via a shared secret in the
|
||||
@@ -135,7 +135,10 @@ async function notifyAdminsOfFailure(event: NormalizedEvent): Promise<void> {
|
||||
application: event.applicationName,
|
||||
error: event.error ?? "",
|
||||
deployment: event.deploymentUuid ?? "",
|
||||
logsUrl: "https://apps-01.bombeck.io",
|
||||
// Optional: operators can point this at their deployment
|
||||
// dashboard (e.g. the Coolify UI). Falls back to an empty
|
||||
// string so the body template still composes cleanly.
|
||||
logsUrl: process.env.DEPLOY_LOGS_URL ?? "",
|
||||
},
|
||||
metadata: {
|
||||
source: "deploy-webhook",
|
||||
|
||||
+14
-19
@@ -8,7 +8,7 @@ import Link from "next/link";
|
||||
*
|
||||
* 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`.
|
||||
* registered with ASC is this route on the maintainer's instance.
|
||||
* 2. **GDPR / DSGVO Art. 13 / 14 + Art. 15-22** — data-subject rights
|
||||
* enumerated with concrete in-app routes the user can hit.
|
||||
* 3. **EU MDR 2017/745 + MDCG 2021-24** — the boundary "HealthLog is
|
||||
@@ -342,14 +342,12 @@ export default function PrivacyPage() {
|
||||
>
|
||||
github.com/MBombeck/HealthLog
|
||||
</a>{" "}
|
||||
veröffentlicht. Diese Erklärung beschreibt, wie die unter{" "}
|
||||
veröffentlicht. Diese Erklärung beschreibt, wie die
|
||||
Instanz, über die Sie diese Seite abrufen, und die
|
||||
zugehörige iOS-Anwendung <em>HealthLog for iOS</em>{" "}
|
||||
(Bundle-ID{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 text-xs">
|
||||
healthlog.bombeck.io
|
||||
</code>{" "}
|
||||
betriebene Instanz und die zugehörige iOS-Anwendung{" "}
|
||||
<em>HealthLog for iOS</em> (Bundle-ID{" "}
|
||||
<code className="bg-muted rounded px-1 py-0.5 text-xs">
|
||||
io.bombeck.healthlog
|
||||
dev.healthlog.app
|
||||
</code>
|
||||
) personenbezogene Daten verarbeiten.
|
||||
</p>
|
||||
@@ -359,7 +357,7 @@ export default function PrivacyPage() {
|
||||
Deutschland). Eigenständig betriebene, selbst-gehostete
|
||||
Instanzen unterliegen der Datenschutzerklärung ihres
|
||||
jeweiligen Betreibers; dieses Dokument gilt ausschließlich
|
||||
für die genannte Domain.
|
||||
für diese Instanz.
|
||||
</p>
|
||||
<p>
|
||||
Für Fragen oder Datenschutz-Anfragen siehe Abschnitt 11
|
||||
@@ -381,14 +379,11 @@ export default function PrivacyPage() {
|
||||
github.com/MBombeck/HealthLog
|
||||
</a>{" "}
|
||||
under the GNU Affero General Public License v3.0. This
|
||||
policy describes how the hosted instance at{" "}
|
||||
policy describes how the instance serving this page and
|
||||
the companion iOS application <em>HealthLog for iOS</em>{" "}
|
||||
(bundle identifier{" "}
|
||||
<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
|
||||
dev.healthlog.app
|
||||
</code>
|
||||
) handle personal data.
|
||||
</p>
|
||||
@@ -397,7 +392,7 @@ export default function PrivacyPage() {
|
||||
this instance (an individual based in Germany). Self-
|
||||
hosted deployments controlled by a different operator are
|
||||
governed by that operator's own privacy policy; this
|
||||
document applies only to the hostname above.
|
||||
document applies only to this instance.
|
||||
</p>
|
||||
<p>
|
||||
For questions or data-subject requests, see section 11
|
||||
@@ -1211,8 +1206,8 @@ export default function PrivacyPage() {
|
||||
/>
|
||||
<SubProcessor
|
||||
name="Cloudflare, Inc."
|
||||
roleDe="Autoritative DNS-Auflösung für die bombeck.io-Zonen. Die Anwendung selbst steht nicht hinter dem Cloudflare-Proxy."
|
||||
roleEn="Authoritative DNS for the bombeck.io zones. The application itself is not behind the Cloudflare proxy; only the DNS resolution path is."
|
||||
roleDe="Autoritative DNS-Auflösung für die Domain dieser Instanz. Die Anwendung selbst steht nicht hinter dem Cloudflare-Proxy."
|
||||
roleEn="Authoritative DNS for this instance's domain. The application itself is not behind the Cloudflare proxy; only the DNS resolution path is."
|
||||
dataDe="Quell-IP und User-Agent zum Zeitpunkt der Auflösung."
|
||||
dataEn="Source IP address and user-agent at resolution time."
|
||||
locationDe="USA; globales Anycast-Netz von Cloudflare."
|
||||
|
||||
@@ -58,8 +58,8 @@ describe("<AdminShell>", () => {
|
||||
// The 13-section strip is ~1700 CSS px wide. Without
|
||||
// `no-scrollbar` (defined in `globals.css`) Chromium/WebKit paint
|
||||
// an always-on horizontal scrollbar at the top of every admin
|
||||
// page. Production probe at https://healthlog.bombeck.io/admin/
|
||||
// api-tokens at Pixel-5 viewport pins this as the *only* element
|
||||
// page. A live probe of /admin/api-tokens at a Pixel-5 viewport
|
||||
// pins this as the *only* element
|
||||
// with `overflow-x:auto AND scrollWidth > clientWidth`, so the
|
||||
// class is the load-bearing fix.
|
||||
const html = renderShell({ active: "api-tokens" });
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useTranslations } from "@/lib/i18n/context";
|
||||
* v1.4.25 W9e — notice strip for AI-initial locales.
|
||||
*
|
||||
* HealthLog ships German + English as maintainer-curated translations
|
||||
* (the maintainer, Marc, owns both bodies). French, Spanish, Italian
|
||||
* (the maintainer owns both bodies). French, Spanish, Italian
|
||||
* and Polish ship as AI-initial translations — they pass the
|
||||
* locale-integrity test (full key parity, no empty values, no TODOs)
|
||||
* but the prose has not been hand-reviewed for register, regional
|
||||
|
||||
@@ -92,7 +92,7 @@ function isSlugRejection(status: number, bodyExcerpt: string): boolean {
|
||||
}
|
||||
|
||||
const ORIGINATOR = "healthlog";
|
||||
const USER_AGENT = "HealthLog/1.0 (+https://healthlog.bombeck.io)";
|
||||
const USER_AGENT = "HealthLog/1.0 (+https://github.com/MBombeck/HealthLog)";
|
||||
|
||||
interface CodexClientConfig {
|
||||
accessToken: string;
|
||||
|
||||
@@ -52,7 +52,10 @@ const openApiBase: Pick<
|
||||
contact: { name: "HealthLog", url: "https://healthlog.dev" },
|
||||
},
|
||||
servers: [
|
||||
{ url: "https://healthlog.bombeck.io", description: "Production" },
|
||||
{
|
||||
url: "https://healthlog.example.com",
|
||||
description: "Your self-hosted instance",
|
||||
},
|
||||
{ url: "http://localhost:3000", description: "Local dev" },
|
||||
],
|
||||
tags: [
|
||||
|
||||
@@ -65,8 +65,9 @@ const BERLIN_DAY_FORMATTER = new Intl.DateTimeFormat("en-US", {
|
||||
* Format a Date as `YYYY-MM-DD` in `Europe/Berlin`. The seven Insight
|
||||
* status helpers (`bmi`, `blood-pressure`, `weight`, `pulse`, `mood`,
|
||||
* `medication-compliance`, `general`) all key their per-day cache on
|
||||
* this — Marc's primary tenant runs in Berlin and the cache action
|
||||
* row reads identically across helpers when the formatter lives here.
|
||||
* this — the maintainer's primary tenant runs in Berlin and the cache
|
||||
* action row reads identically across helpers when the formatter lives
|
||||
* here.
|
||||
*
|
||||
* For per-user surfaces, prefer `userDayKey(date, tz)` from `./format`.
|
||||
* This helper exists for the cache-key path where the day-bucket is
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Integration regression for the "BD-Zielbereich" / BP-in-target tile.
|
||||
*
|
||||
* Marc reported (v1.4.16 marathon) that the dashboard tile rendered
|
||||
* 0 % despite multiple BP readings clearly inside the well-controlled
|
||||
* A v1.4.16 bug report showed the dashboard tile rendering 0 %
|
||||
* despite multiple BP readings clearly inside the well-controlled
|
||||
* range. v1.4.15 A4 had restructured the calculation but kept the
|
||||
* ESH 2023 narrow-band semantics (`sysLow <= sys <= sysHigh AND diaLow
|
||||
* <= dia <= diaHigh`) — which collapses to 0 % for any user whose
|
||||
@@ -73,15 +73,15 @@ describe("BP-in-target % — production data shape", () => {
|
||||
it("computes a non-zero % for a normotensive user born under 65", async () => {
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
// Marc's actual production readings (anonymised but timestamps + values
|
||||
// verbatim so a future reviewer can re-derive the expected count by
|
||||
// hand from the test fixture). DOB: 1985-07-09 — under 65, so target
|
||||
// is sysHigh = 129, diaHigh = 79.
|
||||
// Synthetic but representative normotensive readings for a user
|
||||
// under 65, annotated IN/OUT so a future reviewer can re-derive the
|
||||
// expected count by hand from the test fixture. DOB 1980-01-15 —
|
||||
// under 65, so target is sysHigh = 129, diaHigh = 79.
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username: "bp-in-target-fixture",
|
||||
email: "bp-in-target@example.test",
|
||||
dateOfBirth: new Date("1985-07-09"),
|
||||
dateOfBirth: new Date("1980-01-15"),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -90,16 +90,16 @@ describe("BP-in-target % — production data shape", () => {
|
||||
dia: number;
|
||||
measuredAt: string;
|
||||
}> = [
|
||||
{ sys: 117, dia: 79, measuredAt: "2026-05-08T07:38:22Z" }, // IN
|
||||
{ sys: 122, dia: 86, measuredAt: "2026-05-03T21:22:02Z" }, // OUT (dia)
|
||||
{ sys: 108, dia: 76, measuredAt: "2026-05-03T05:51:45Z" }, // IN
|
||||
{ sys: 106, dia: 73, measuredAt: "2026-05-03T05:50:55Z" }, // IN
|
||||
{ sys: 127, dia: 86, measuredAt: "2026-04-20T05:57:42Z" }, // OUT (dia)
|
||||
{ sys: 115, dia: 78, measuredAt: "2026-04-18T06:59:29Z" }, // IN
|
||||
{ sys: 108, dia: 75, measuredAt: "2026-04-16T05:24:51Z" }, // IN
|
||||
{ sys: 124, dia: 82, measuredAt: "2026-04-15T05:34:35Z" }, // OUT (dia)
|
||||
{ sys: 126, dia: 80, measuredAt: "2026-04-15T05:33:44Z" }, // OUT (dia)
|
||||
{ sys: 133, dia: 95, measuredAt: "2026-04-15T20:52:26Z" }, // OUT (both)
|
||||
{ sys: 118, dia: 78, measuredAt: "2026-05-08T07:40:00Z" }, // IN
|
||||
{ sys: 124, dia: 85, measuredAt: "2026-05-03T21:20:00Z" }, // OUT (dia)
|
||||
{ sys: 110, dia: 75, measuredAt: "2026-05-03T06:00:00Z" }, // IN
|
||||
{ sys: 104, dia: 72, measuredAt: "2026-05-03T05:50:00Z" }, // IN
|
||||
{ sys: 126, dia: 84, measuredAt: "2026-04-20T06:00:00Z" }, // OUT (dia)
|
||||
{ sys: 116, dia: 77, measuredAt: "2026-04-18T07:00:00Z" }, // IN
|
||||
{ sys: 109, dia: 74, measuredAt: "2026-04-16T05:25:00Z" }, // IN
|
||||
{ sys: 123, dia: 83, measuredAt: "2026-04-15T05:35:00Z" }, // OUT (dia)
|
||||
{ sys: 127, dia: 81, measuredAt: "2026-04-15T05:34:00Z" }, // OUT (dia)
|
||||
{ sys: 134, dia: 94, measuredAt: "2026-04-15T20:50:00Z" }, // OUT (both)
|
||||
];
|
||||
|
||||
// Seed both sys and dia rows at the same `measuredAt` so the helper's
|
||||
@@ -154,7 +154,7 @@ describe("BP-in-target % — production data shape", () => {
|
||||
data: {
|
||||
username: "bp-empty-fixture",
|
||||
email: "bp-empty@example.test",
|
||||
dateOfBirth: new Date("1985-07-09"),
|
||||
dateOfBirth: new Date("1980-01-15"),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -184,7 +184,7 @@ describe("BP-in-target % — production data shape", () => {
|
||||
data: {
|
||||
username: "bp-sys-only-fixture",
|
||||
email: "bp-sys-only@example.test",
|
||||
dateOfBirth: new Date("1985-07-09"),
|
||||
dateOfBirth: new Date("1980-01-15"),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -224,7 +224,7 @@ describe("BP-in-target % — production data shape", () => {
|
||||
data: {
|
||||
username: "bp-hypo-fixture",
|
||||
email: "bp-hypo@example.test",
|
||||
dateOfBirth: new Date("1985-07-09"),
|
||||
dateOfBirth: new Date("1980-01-15"),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -272,9 +272,9 @@ describe("BP-in-target % — production data shape", () => {
|
||||
|
||||
describe("BP-in-target % — windowed (7-day + 30-day) — production data shape", () => {
|
||||
/**
|
||||
* v1.4.18 A1 regression — Marc's BD-Zielbereich tile rendered the
|
||||
* v1.4.18 A1 regression — the BD-Zielbereich tile rendered the
|
||||
* 30-day headline (50 %) but `7T: —` and `30T: —` placeholders even
|
||||
* though he had paired BP readings in both windows. Root cause was
|
||||
* though the user had paired BP readings in both windows. Root cause was
|
||||
* that the API only returned a single `bpInTargetPct`; the tile got
|
||||
* `avg7={null}, avg30={null}` and rendered the dash fallback. The fix
|
||||
* surfaces both windows from `computeBpInTargetWindows()` against the
|
||||
@@ -290,7 +290,7 @@ describe("BP-in-target % — windowed (7-day + 30-day) — production data shape
|
||||
data: {
|
||||
username: "bp-windows-fixture",
|
||||
email: "bp-windows@example.test",
|
||||
dateOfBirth: new Date("1985-07-09"),
|
||||
dateOfBirth: new Date("1980-01-15"),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -309,12 +309,12 @@ describe("BP-in-target % — windowed (7-day + 30-day) — production data shape
|
||||
// last30Days: 15/29 = 52 % (rounded).
|
||||
const seed: Array<{ daysAgo: number; sys: number; dia: number }> = [
|
||||
// 7-day window (4 IN, 3 OUT)
|
||||
{ daysAgo: 0.5, sys: 117, dia: 79 }, // IN
|
||||
{ daysAgo: 1.5, sys: 122, dia: 86 }, // OUT (dia)
|
||||
{ daysAgo: 2.5, sys: 108, dia: 76 }, // IN
|
||||
{ daysAgo: 0.5, sys: 118, dia: 78 }, // IN
|
||||
{ daysAgo: 1.5, sys: 124, dia: 85 }, // OUT (dia)
|
||||
{ daysAgo: 2.5, sys: 110, dia: 75 }, // IN
|
||||
{ daysAgo: 3.5, sys: 145, dia: 95 }, // OUT (both)
|
||||
{ daysAgo: 4.5, sys: 115, dia: 78 }, // IN
|
||||
{ daysAgo: 5.5, sys: 124, dia: 82 }, // OUT (dia)
|
||||
{ daysAgo: 4.5, sys: 116, dia: 77 }, // IN
|
||||
{ daysAgo: 5.5, sys: 123, dia: 83 }, // OUT (dia)
|
||||
{ daysAgo: 6.5, sys: 125, dia: 75 }, // IN
|
||||
// older 7-30-day window (alternating, 11 IN / 11 OUT)
|
||||
...Array.from({ length: 22 }, (_, i) => {
|
||||
@@ -377,10 +377,11 @@ describe("BP-in-target % — windowed (7-day + 30-day) — production data shape
|
||||
});
|
||||
|
||||
/**
|
||||
* v1.4.19 A1 regression — Marc reported the tile shows EXACTLY 50 %
|
||||
* on 7T, 30T, AND the headline ("total"). With his real production
|
||||
* shape (572 paired readings since 2022, recent 30d ≈ 50 %, all-time
|
||||
* ≈ 11 %) the headline cannot legitimately be 50 %. Root cause: the
|
||||
* v1.4.19 A1 regression — a bug report showed the tile rendering
|
||||
* EXACTLY 50 % on 7T, 30T, AND the headline ("total"). With a long
|
||||
* reading history (hundreds of paired readings over several years,
|
||||
* recent 30d ≈ 50 %, all-time far lower) the headline cannot
|
||||
* legitimately be 50 %. Root cause: the
|
||||
* analytics route routed `bpInTargetPct = windows.last30Days?.pct`,
|
||||
* making the headline a literal copy of `30T`. The fix returns a
|
||||
* third `allTime` window that the route now uses for the headline.
|
||||
@@ -397,14 +398,14 @@ describe("BP-in-target % — windowed (7-day + 30-day) — production data shape
|
||||
data: {
|
||||
username: "bp-three-windows-fixture",
|
||||
email: "bp-three-windows@example.test",
|
||||
dateOfBirth: new Date("1985-07-09"),
|
||||
dateOfBirth: new Date("1980-01-15"),
|
||||
},
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const seed: Array<{ daysAgo: number; sys: number; dia: number }> = [
|
||||
// Last 7 days: 1 IN, 1 OUT.
|
||||
{ daysAgo: 1.5, sys: 117, dia: 79 }, // IN
|
||||
{ daysAgo: 1.5, sys: 118, dia: 78 }, // IN
|
||||
{ daysAgo: 5.5, sys: 145, dia: 95 }, // OUT
|
||||
// 7-30 days ago: 4 IN, 4 OUT (alternating).
|
||||
{ daysAgo: 8, sys: 122, dia: 75 }, // IN
|
||||
@@ -466,7 +467,7 @@ describe("BP-in-target % — windowed (7-day + 30-day) — production data shape
|
||||
expect(windows.allTime!.pct).toBe(Math.round((5 / 40) * 100));
|
||||
|
||||
// The smoking gun: even when 7d AND 30d are 50 %, all-time is NOT
|
||||
// 50 % once older history is present. Marc's prod tile pinned to
|
||||
// 50 % once older history is present. The reported tile pinned to
|
||||
// 50/50/50 because the route routed the headline through `last30Days`
|
||||
// — the algorithmic pin the brief warned about.
|
||||
expect(windows.allTime!.pct).not.toBe(windows.last30Days!.pct);
|
||||
@@ -485,7 +486,7 @@ describe("BP-in-target % — windowed (7-day + 30-day) — production data shape
|
||||
data: {
|
||||
username: "bp-no-recent-fixture",
|
||||
email: "bp-no-recent@example.test",
|
||||
dateOfBirth: new Date("1985-07-09"),
|
||||
dateOfBirth: new Date("1980-01-15"),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -552,7 +553,7 @@ describe("GET /api/analytics — BP-in-target headline (v1.4.22 A1)", () => {
|
||||
username,
|
||||
email: `${username}@example.test`,
|
||||
role: "USER",
|
||||
dateOfBirth: new Date("1985-07-09"),
|
||||
dateOfBirth: new Date("1980-01-15"),
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
@@ -576,7 +577,7 @@ describe("GET /api/analytics — BP-in-target headline (v1.4.22 A1)", () => {
|
||||
// 30d both ~50 %, all-time ~13 %.
|
||||
const seed: Array<{ daysAgo: number; sys: number; dia: number }> = [
|
||||
// Last 7 days: 1 IN, 1 OUT.
|
||||
{ daysAgo: 1.5, sys: 117, dia: 79 },
|
||||
{ daysAgo: 1.5, sys: 118, dia: 78 },
|
||||
{ daysAgo: 5.5, sys: 145, dia: 95 },
|
||||
// 7-30 days ago: 4 IN, 4 OUT.
|
||||
{ daysAgo: 8, sys: 122, dia: 75 },
|
||||
|
||||
Reference in new issue
Block a user