diff --git a/docs/self-hosting/getting-started.md b/docs/self-hosting/getting-started.md index e2bcdfb4c..ca8abe9a6 100644 --- a/docs/self-hosting/getting-started.md +++ b/docs/self-hosting/getting-started.md @@ -147,6 +147,7 @@ NEXT_PUBLIC_DASHBOARD_SNAPSHOT=false pnpm build | Next step | File | | --------- | ---- | | TLS + reverse-proxy configuration | `docs/self-hosting/reverse-proxy.md` | +| Push notifications — Web Push, Telegram, ntfy, APNs | `docs/self-hosting/notifications.md` | | Web/worker process split for horizontal scale | `docs/self-hosting/scaling.md` | | Off-host encrypted backups to S3/R2/B2 | `docs/ops/backup-restore.md` | | Encryption-key rotation procedure | `docs/ops/encryption-key-rotation.md` | diff --git a/docs/self-hosting/notifications.md b/docs/self-hosting/notifications.md new file mode 100644 index 000000000..affe53abe --- /dev/null +++ b/docs/self-hosting/notifications.md @@ -0,0 +1,193 @@ +# Notifications — push channels for a self-host + +HealthLog delivers reminders (medication doses, mood check-ins, personal +records) and a few operational alerts through a small cascade of push +channels. The single message to take away: **you do not need an Apple +Developer account or APNs to get push.** Three of the four channels are +free, work in the browser or in a messaging app you already have, and +cover every platform a self-hoster runs. APNs is only for the optional +native iOS app. + +When an event fires, the dispatcher fans it out to every channel a user +has enabled, in a fixed order — APNs first, then Telegram, then ntfy, +then Web Push as the universal fallback +(`src/lib/notifications/dispatcher.ts`). Each channel is best-effort and +independent: a user can enable one, two, or all four, and a failure on +one never blocks the others. + +## TL;DR — which channel needs what + +| Channel | What it needs | Cost | Where it works | +| --- | --- | --- | --- | +| **Web Push** | A VAPID keypair (one command, or paste into the admin panel) | Free | Any modern desktop/Android browser; the installed PWA on iPhone (iOS 16.4+, after *Add to Home Screen*) | +| **Telegram** | A bot token from @BotFather + each user's chat ID | Free | Anywhere the Telegram app runs | +| **ntfy** | A topic on `ntfy.sh` or your own ntfy server | Free | The ntfy app (iOS/Android) or any browser | +| **APNs** | An Apple Developer account + a `.p8` push key | Apple Developer Program (paid) | The native iOS app only (TestFlight / App Store) | + +Web Push is the recommended default — it needs no third-party account, +just a keypair you generate yourself once. + +## Web Push (recommended default) + +Web Push delivers notifications straight to a browser — desktop or +mobile — even when the HealthLog tab is closed, as long as the browser +or installed PWA is alive. It is the broadest-reach channel and the one +to set up first. + +### 1. Generate a VAPID keypair + +VAPID is the signing scheme that lets a push service trust your server. +Generate a keypair once with the bundled `web-push` CLI: + +```bash +npx web-push generate-vapid-keys +``` + +That prints a **public key** and a **private key** (both Base64URL). You +also choose a **subject** — a `mailto:` address the push service can +contact about your sender, e.g. `mailto:you@example.com`. + +### 2. Store the keys + +The server loads VAPID config from the database first and falls back to +environment variables (`src/lib/notifications/vapid-config.ts`). Either +path works; the admin panel is the easier one because it survives without +touching `.env`. + +**Admin panel (preferred).** Sign in as the admin user, open +`/admin`, and find the **Web Push VAPID** card +(`src/components/admin/web-push-vapid-section.tsx`). Paste the public key, +the private key, and the subject, then save. The private key is encrypted +at rest with your `ENCRYPTION_KEY`, exactly like the other secrets in the +admin panel. The card shows a "configured" badge once all three fields +are set. + +**Environment variables (alternative).** If you would rather keep +secrets out of the database, set them in `.env` / the compose +`environment:` block instead: + +```env +VAPID_PUBLIC_KEY="BElx...(public key)" +VAPID_PRIVATE_KEY="...(private key)" +VAPID_SUBJECT="mailto:you@example.com" +``` + +The database values win when both are present. The loader also accepts a +few aliases (`WEB_PUSH_VAPID_PUBLIC_KEY`, `WEB_PUSH_PUBLIC_KEY`, and the +private/subject equivalents) so a config copied from another deployment +keeps working. + +### 3. Subscribe a device + +Each user enables Web Push for their own browser from +`/settings/notifications` — the **Browser Push** card has a Subscribe +button, and a Test button to fire a one-off push once subscribed. The +browser prompts for notification permission on first subscribe; once +granted, that browser receives every enabled event. + +### iPhone caveat — Add to Home Screen first + +Safari on iPhone supports Web Push only from **iOS 16.4 or newer**, and +only for a site that has been **installed to the Home Screen as a PWA**. +A regular Safari tab cannot subscribe. So on iPhone: + +1. Open your HealthLog instance in Safari. +2. Tap the Share button, then **Add to Home Screen**. +3. Launch HealthLog from the new Home Screen icon (not from the Safari + tab). +4. Open `/settings/notifications` inside that installed app and tap + Subscribe; accept the permission prompt. + +Android and desktop browsers have no such step — Subscribe and grant the +permission, and push works. + +## Telegram + +Telegram delivers reminders as bot messages, with inline action buttons +on medication reminders (Taken / snooze / skip). It is a good fit when +you already live in Telegram or want push without running a browser. + +1. In Telegram, open **@BotFather**, create a bot, and copy the **bot + token** (it looks like `123456:ABC-DEF...`). +2. Send `/start` to your new bot so it can message you. +3. Find your **chat ID** — send a message to **@userinfobot**, or read it + from the Bot API. +4. In HealthLog open `/settings/notifications`, fill the Telegram card's + **Bot Token** and **Chat ID**, toggle it on, and save. + +One bot can serve every user on the instance; each user pairs their own +chat ID. The token is encrypted at rest. + +## ntfy + +[ntfy](https://ntfy.sh) is the most self-hoster-native option: a tiny +pub/sub push relay you can use hosted (`ntfy.sh`) or run yourself. You +subscribe to a **topic** in the ntfy app (iOS/Android) or a browser, and +HealthLog publishes to that topic. + +1. Pick a hard-to-guess topic name (the topic *is* the access control on + public `ntfy.sh` — anyone who knows the string can read it, so treat + it like a secret). +2. Subscribe to that topic in the ntfy app or at `https://ntfy.sh/`. +3. In HealthLog open `/settings/notifications`, set the ntfy card's + **Server URL** (defaults to `https://ntfy.sh`; point it at your own + server if you self-host ntfy) and **Topic**, then save. + +If your ntfy server requires auth, an access token can be attached; the +server URL is validated as a public host and dialled through the egress +guard (`src/lib/notifications/senders/ntfy.ts`). + +## APNs — native iOS app only + +Apple Push Notification service is used by **only** the native SwiftUI +iOS app (TestFlight / App Store build). PWA and browser users never touch +it. Setting it up requires a paid **Apple Developer Program** membership. + +You do **not** need APNs to give iPhone users push: the installed PWA +gets every reminder over Web Push (see the iPhone caveat above). APNs adds +one Apple-platform exclusive the PWA cannot offer — the lock-screen Live +Activity — plus the tighter native delivery path. Reminders themselves +arrive on both. + +If you do run the native app and want server-driven push, set the APNs +manifest (`src/lib/notifications/senders/apns.ts`) — three identifiers +plus exactly one key source: + +| Variable | Purpose | +| --- | --- | +| `APNS_KEY_ID` | The `.p8` key's Key ID | +| `APNS_TEAM_ID` | Your Apple Developer Team ID | +| `APNS_BUNDLE_ID` | The app's bundle identifier | +| `APNS_KEY_B64` | The `.p8` PEM, base64-encoded (recommended key source) | +| `APNS_KEY` | OR the raw PEM body inline | +| `APNS_KEY_FILE` | OR a path to the `.p8` file | + +APNs is **all-or-none**: either set the three IDs plus one key source, or +leave the whole block unset. A partial config is rejected so the channel +never silently half-works (`scripts/check-env.ts`). When more than one key +source is present, the precedence is `APNS_KEY_B64` > `APNS_KEY` > +`APNS_KEY_FILE`. + +Two gotchas worth knowing before you blame a stale token: + +- **Base64 the key.** Pasting a raw `.p8` PEM through some env-file + pipelines mangles its line breaks. `APNS_KEY_B64` (the PEM body, + base64-encoded) sidesteps that entirely and is the recommended source. +- **Scope the key "Sandbox & Production".** In the Apple Developer + Portal, a push key defaults to Sandbox-only. A Sandbox-only key fails + on production (TestFlight / App Store) device tokens with + `BadEnvironmentKeyInToken`. Confirm the key shows **Sandbox & + Production**; a wrongly scoped key cannot be reconfigured — re-issue it. + +## Which channel should I pick? + +| Your setup | Recommended channel | +| --- | --- | +| PWA on Android or desktop | **Web Push** — generate a VAPID keypair, subscribe, done. | +| PWA on an iPhone | **Web Push**, but install the PWA via *Add to Home Screen* first (iOS 16.4+). Reminders work; the lock-screen Live Activity needs the native app. | +| You want the native iOS app's full experience | **APNs** (paid Apple Developer account) *plus* Web Push as a fallback for any non-iOS device. | +| Headless / no browser, or you live in chat | **Telegram** or **ntfy** — neither needs a browser, both are free. ntfy is the most self-host-native; Telegram adds inline reminder action buttons. | + +Mixing channels is fine and common — e.g. Web Push on the laptop and +ntfy on the phone. Each user configures their own set from +`/settings/notifications`.