mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Every visible delivery now carries a url. A managed-record delivery to a guardian and a discreet one open "/", which names no event and no record; only the silent intake sync push stays without one. The tap-target test walks every event type across own, guardian and discreet delivery and the channels that carry the absolute url. Document the forward-auth exemption for the quick-answer route.
601 lines
33 KiB
Markdown
601 lines
33 KiB
Markdown
# 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.** Five of the six channels are
|
||
free, work in the browser or in an app you already have, and cover every
|
||
platform a self-hoster runs. APNs is only for the native iOS app, and
|
||
even that you can build yourself (see the last section).
|
||
|
||
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 the generic webhook, then email, then Web Push as the universal
|
||
fallback (`src/lib/notifications/dispatcher.ts`). Each channel is
|
||
best-effort and independent: a user can enable one or several, and a
|
||
failure on one never blocks the others.
|
||
|
||
An APNs-less instance degrades gracefully. Nothing about push depends on
|
||
APNs being configured. Urgent events still surface everywhere it matters:
|
||
ntfy escalates them to max priority (`5`), Web Push raises urgency and
|
||
keeps the notification on screen until the user acts, and the webhook
|
||
carries the urgent flag for your own routing. APNs only adds the
|
||
Apple-platform exclusives the web cannot offer.
|
||
|
||
## 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 |
|
||
| **Webhook** | A URL you own (and optionally one custom header) | Free | Anywhere — you route the POST yourself |
|
||
| **Email** | An SMTP transport you operate or rent | Free–cheap | Any mailbox |
|
||
| **APNs** | An Apple Developer account + a `.p8` push key + your own signed iOS build | Apple Developer Program (paid) | A native iOS app you build and sign |
|
||
|
||
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 and store a VAPID keypair
|
||
|
||
VAPID is the signing scheme that lets a push service trust your server.
|
||
You also choose a **subject** — a `mailto:` address the push service can
|
||
contact about your sender, e.g. `mailto:you@example.com`.
|
||
|
||
The server loads VAPID config from the database first and falls back to
|
||
environment variables (`src/lib/notifications/vapid-config.ts`). The admin
|
||
panel is the easiest path because it survives without touching `.env`.
|
||
|
||
**Admin panel — Generate keys (easiest).** Sign in as the admin user,
|
||
open `/admin`, and find the **Web Push VAPID** card
|
||
(`src/components/admin/web-push-vapid-section.tsx`). Click **Generate
|
||
keys**: the server mints a fresh keypair, stores the private key encrypted
|
||
at rest with your `ENCRYPTION_KEY`, seeds a placeholder subject, and fills
|
||
in the public key. Edit the subject to your real `mailto:` address and
|
||
save. The card shows a "configured" badge once all three fields are set —
|
||
no shell, no copy-paste.
|
||
|
||
> If a keypair already exists, **Generate keys** asks you to confirm before
|
||
> replacing it. Regenerating invalidates every existing browser
|
||
> subscription, so each device has to re-subscribe afterwards. Only
|
||
> regenerate when you mean to.
|
||
|
||
**Admin panel — paste an existing pair.** If you already have a keypair
|
||
(e.g. copied from another deployment), paste the public key, the private
|
||
key, and the subject into the same card and save instead of generating.
|
||
|
||
**CLI — generate a pair yourself.** You can also mint one with the bundled
|
||
`web-push` CLI and paste the result into the card or your `.env`:
|
||
|
||
```bash
|
||
npx web-push generate-vapid-keys
|
||
```
|
||
|
||
That prints a **public key** and a **private key** (both Base64URL).
|
||
|
||
### 2. Store the keys via 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. These three names are the
|
||
only ones the loader reads.
|
||
|
||
### 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.
|
||
|
||
## The PWA path — install to the Home Screen
|
||
|
||
Installing HealthLog as a PWA and enabling Web Push is the recommended
|
||
default for phones. It needs nothing from Apple and gives you, in
|
||
v1.18.4:
|
||
|
||
- **Reminders.** Medication doses, mood check-ins, and any other event
|
||
the user has enabled, delivered to the installed app even when it is
|
||
closed.
|
||
- **Taken → clears the reminder.** When you log a dose, the server sends
|
||
a silent `type:"clear"` push that closes the still-pending dose-due
|
||
reminder for that slot (matched on its stable tag) without showing a
|
||
new notification (`public/sw.js`). This is the PWA equivalent of ending
|
||
an iOS Live Activity.
|
||
- **An app badge for outstanding doses.** The icon on the Home Screen
|
||
carries a server-authoritative count of doses still due; logging a dose
|
||
decrements it, and a count of zero clears the badge. The count is
|
||
computed on the server and reflected by the service worker via the
|
||
Badging API (`navigator.setAppBadge`), feature-detected — engines
|
||
without it (most desktop Firefox, older Safari) silently no-op.
|
||
|
||
### iPhone — 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.
|
||
|
||
### The inherent limit
|
||
|
||
The installed PWA gets reminders, the taken-clears behaviour, and the app
|
||
badge — but it **cannot** show a native iOS lock-screen Live Activity
|
||
widget. That single feature rides Apple Push Notification service and is
|
||
only available through a native iOS build with APNs. Reminders themselves
|
||
arrive on both paths; only the Live Activity is APNs-exclusive.
|
||
|
||
## 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/<topic>`.
|
||
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.
|
||
|
||
HealthLog publishes each message as JSON to the server's root URL (the topic
|
||
travels in the body, see ntfy's
|
||
[publish as JSON](https://docs.ntfy.sh/publish/#publish-as-json)), so titles
|
||
and button labels in any language arrive as written. A reverse proxy in front
|
||
of your own ntfy has to pass `POST /` through, not only `POST /<topic>`.
|
||
|
||
If your ntfy server requires auth, an access token can be attached; the
|
||
server URL is validated at save time and dialled through the egress guard
|
||
(`src/lib/notifications/senders/ntfy.ts`). A self-hosted ntfy on your own
|
||
network needs the operator grant described under [Notification targets on
|
||
a private network](#notification-targets-on-a-private-network). Urgent
|
||
events are sent at ntfy's top priority (`5` / max) so they bypass batching
|
||
and surface on the lock screen.
|
||
|
||
## Webhook
|
||
|
||
The generic webhook POSTs every enabled event as JSON to a URL you own —
|
||
wire it into your own automation (Home Assistant, n8n, a Discord/Slack
|
||
relay, whatever routes the payload). You supply the URL and,
|
||
optionally, one custom header (e.g. an auth token).
|
||
|
||
1. In HealthLog open `/settings/notifications`, fill the Webhook card's
|
||
**URL** and, if your endpoint expects one, a single **header name** +
|
||
**header value**.
|
||
2. Save. A Test button fires a one-off POST so you can confirm routing.
|
||
|
||
The URL is validated at save time and dialled through `safeFetch` with
|
||
the connect-time resolver pin — the SSRF guard refuses a target that
|
||
resolves to a private or loopback address because the target is
|
||
user-supplied (`src/lib/notifications/senders/webhook.ts`). A Gotify or
|
||
Home Assistant on your own network needs the operator grant described in
|
||
the next section. The webhook carries the event's urgency flag so you can
|
||
route urgent events differently.
|
||
|
||
### What the webhook sends
|
||
|
||
Every event is one `POST` with `Content-Type: application/json` and the
|
||
one header you configured. It goes to the URL exactly as you saved it:
|
||
nothing is added to the path or the query. The body depends on the
|
||
**Payload format** you choose on the card. The examples below are what the
|
||
Test button sends.
|
||
|
||
**Generic JSON** is the default, and what every webhook saved before the
|
||
choice existed keeps sending:
|
||
|
||
```json
|
||
{
|
||
"title": "HealthLog Test",
|
||
"message": "HealthLog: Connection successful! Webhook notifications are active.",
|
||
"eventType": "SYSTEM_ALERT",
|
||
"priority": "default"
|
||
}
|
||
```
|
||
|
||
- `title` and `message` are plain text. HTML tags are stripped, and routine
|
||
reminders lose their decorative emoji.
|
||
- `eventType` is the event name, for routing rules. With discreet cycle
|
||
notifications on, a cycle event arrives as `reminder`, and its title and
|
||
message are masked as they are on every other channel.
|
||
- `priority` is a word: `urgent` for an event flagged urgent, `high` for
|
||
medication reminders, `default` for everything else.
|
||
- `url`, when `APP_URL` is set, is the page the notification leads to (see
|
||
[Tap targets](#what-tapping-a-notification-opens)). Absent otherwise.
|
||
|
||
**Gotify** sends the body Gotify's `POST /message` endpoint expects:
|
||
|
||
```json
|
||
{
|
||
"title": "HealthLog Test",
|
||
"message": "HealthLog: Connection successful! Webhook notifications are active.",
|
||
"priority": 5,
|
||
"extras": {
|
||
"client::display": { "contentType": "text/plain" },
|
||
"healthlog::event": { "type": "SYSTEM_ALERT" }
|
||
}
|
||
}
|
||
```
|
||
|
||
- `priority` is an integer: `5` for most events, `8` for medication
|
||
reminders, `10` for urgent events. The Gotify Android app plays a sound
|
||
from 4 and shows a heads-up banner from 8, so routine events are audible
|
||
and reminders pop up on screen.
|
||
- `extras` tells Gotify clients to show the message as plain text and
|
||
carries the event name, which is `reminder` for a discreet cycle event.
|
||
|
||
The generic body does not work with Gotify. Gotify reads `priority` as a
|
||
number, so the word `default` makes it answer `400 Bad Request`.
|
||
|
||
### Sending to Gotify
|
||
|
||
1. In Gotify, create an application and copy its token.
|
||
2. On the Webhook card, set **URL** to your Gotify address ending in
|
||
`/message`, for example `https://gotify.example.com/message`.
|
||
3. Set **Header name** to `X-Gotify-Key` and **Header value** to the
|
||
application token. Gotify also accepts the token as `?token=` on the URL,
|
||
but in a header it stays out of proxy access logs.
|
||
4. Set **Payload format** to **Gotify**, save, and press Test.
|
||
|
||
If the test fails, the card shows the status Gotify answered and, when it is
|
||
short, Gotify's own error text. A `401` means the token is wrong. A `400`
|
||
means Gotify could not read the body; check that the format is set to
|
||
Gotify. A Gotify on your own network also needs the operator grant below.
|
||
|
||
The detail reaches the card inside the server's `502` response. A reverse
|
||
proxy that replaces error pages for 5xx responses, such as Traefik's
|
||
`errors` middleware or nginx with `proxy_intercept_errors on`, swaps that
|
||
response for its own page, and the card can then only say "Test failed".
|
||
Exclude the `/api/` paths from the error-page rule to keep the detail. This
|
||
applies to the ntfy and email tests too.
|
||
|
||
## Notification targets on a private network
|
||
|
||
Every webhook and ntfy target is resolved at send time and refused when
|
||
the answer is a private, loopback, link-local or CGNAT address. That is
|
||
the DNS-rebinding defence, and it applies to a public-looking name with a
|
||
LAN A record (a Gotify behind Nginx Proxy Manager with a DNS-01
|
||
certificate) exactly as it applies to `http://192.168.1.20`. The test
|
||
button and the scheduled delivery take the same decision, so a test that
|
||
fails with "Private network target" means nothing would have arrived
|
||
either.
|
||
|
||
The operator opens a target by listing its exact origin in the server
|
||
environment:
|
||
|
||
```env
|
||
NOTIFICATION_PRIVATE_ORIGINS="https://gotify.example.com,http://ntfy.lan:8080"
|
||
```
|
||
|
||
Each entry is one `scheme://host[:port]`, the same grammar as
|
||
`NIGHTSCOUT_PRIVATE_ORIGINS`:
|
||
|
||
- only `http` and `https`; scheme, host and effective port must all match
|
||
what the user saves (`https://gotify.example.com:8443` and
|
||
`https://gotify.example.com` are two different grants);
|
||
- no path, query, credentials, wildcard (`*.lan`) or CIDR (`10.0.0.0/8`) —
|
||
a range grant would let a DNS rebinding to any address in the range pass
|
||
the pin, so an operator with several relays lists several origins;
|
||
- listing a host does not list its sub-hosts or sibling ports;
|
||
- the unspecified address (`0.0.0.0`, `::`), link-local and the
|
||
cloud-metadata range (`169.254.0.0/16`, `fe80::/10`) cannot be granted: a
|
||
literal entry is logged once and ignored, and a listed name that resolves
|
||
there is refused at dial time;
|
||
- RFC1918, IPv6 unique-local, CGNAT (a Tailscale address or MagicDNS name)
|
||
and, on a host-network deployment, loopback (`http://127.0.0.1:8080`,
|
||
`http://localhost:8080`, `::1`) are what the list is for — an exact
|
||
loopback origin with its port is as deliberate a decision as a LAN one;
|
||
- a malformed entry is logged once at first use and grants nothing; the
|
||
valid entries beside it keep working.
|
||
|
||
A listed origin is still dialled through the pinned resolver with
|
||
redirects forbidden, so a relay that answers with a redirect to another
|
||
host is refused. The variable has to be on the compose `environment:`
|
||
whitelist to reach the container; the published `docker-compose.yml`
|
||
lists it. Restart the app after changing it.
|
||
|
||
What is refused where, so you know which lever to pull:
|
||
|
||
| Target | Without the grant | With the exact origin listed |
|
||
| ----------------------------------------------------------- | ----------------- | ---------------------------------------------- |
|
||
| Public name resolving to a public address | works | works, public pin |
|
||
| Public name with a private A record (LAN reverse proxy) | refused at send | works |
|
||
| `gotify.lan`, `gotify.home.arpa`, a Docker service name | refused at send | works |
|
||
| `gotify.local` (mDNS) | refused at save | allowed, if the container resolves mDNS |
|
||
| Literal `192.168.x.x`, `10.x.x.x`, `100.64.x.x`, `fd00::/8` | refused at save | works |
|
||
| `localhost`, `127.0.0.1`, `::1` (host networking) | refused at save | works |
|
||
| `0.0.0.0`, `169.254.169.254`, `fe80::…` | refused at save | still refused (`private_origin_not_grantable`) |
|
||
|
||
A private target that is not listed is refused when the card is saved
|
||
(`422`, `meta.errorCode` = `private_origin_not_approved`, the message
|
||
names the variable; a link-local, metadata or unspecified target answers
|
||
`private_origin_not_grantable` instead, because no grant can open it) or, for a DNS name the save cannot classify, when the
|
||
test button or a reminder dials it. The resolved address is never shown
|
||
to the user. Telegram, email and Web Push are not affected: their hosts
|
||
are not user-supplied.
|
||
|
||
## Email (SMTP)
|
||
|
||
The email channel sends reminders to a mailbox over an SMTP transport you
|
||
operate or rent. It is configured **instance-wide by the operator** (one
|
||
transport serves every user); each user then enables the channel and sets
|
||
their destination address from `/settings/notifications`.
|
||
|
||
Set the SMTP transport in `.env` / the compose `environment:` block:
|
||
|
||
```env
|
||
SMTP_HOST="smtp.example.com"
|
||
SMTP_PORT="587"
|
||
SMTP_FROM="HealthLog <noreply@example.com>"
|
||
SMTP_USER="" # optional — omit for an unauthenticated relay
|
||
SMTP_PASS="" # optional
|
||
SMTP_SECURE="false" # true = implicit TLS (port 465); false = STARTTLS
|
||
```
|
||
|
||
`SMTP_HOST` + `SMTP_PORT` + `SMTP_FROM` together enable the channel;
|
||
`SMTP_USER` / `SMTP_PASS` are optional (omit them for an unauthenticated
|
||
relay). `SMTP_SECURE=true` uses implicit TLS on port 465; leave it `false`
|
||
for STARTTLS on 587. As with every var in `docker-compose.yml`, these must
|
||
be on the compose `environment:` whitelist to reach the container.
|
||
|
||
## APNs — native iOS, and why it needs your own Apple account
|
||
|
||
Apple Push Notification service is used by **only** a native SwiftUI iOS
|
||
app. PWA, browser, Telegram, ntfy, webhook, and email users never touch
|
||
it. It is the one channel a self-hoster cannot simply switch on, and the
|
||
reason is worth stating plainly.
|
||
|
||
### Why your server can't push to the published app
|
||
|
||
APNs binds a push to a specific **Apple Developer team + app bundle ID +
|
||
signing key**. The HealthLog iOS app published on TestFlight / the App
|
||
Store is signed under **the maintainer's Apple team and bundle ID**. Your
|
||
self-hosted server has neither that team's `.p8` key nor the authority to
|
||
mint one, so it **cannot** send APNs pushes to the published app. There is
|
||
no way around this — it is how Apple's trust model works, by design.
|
||
|
||
To get native iOS push **and** the lock-screen Live Activity widget on
|
||
**your own** infrastructure, you have to publish your own copy of the app
|
||
under your own Apple identity. Everything below is that path.
|
||
|
||
### Step by step — your own native iOS build
|
||
|
||
1. **Get an Apple Developer account** (~$99/yr). Required to sign an iOS
|
||
app for a real device and to create a push key.
|
||
2. **Build and sign your own copy of the iOS app.** The client is open in
|
||
a separate repository (`healthlog-iOS`). Open it in Xcode and set your
|
||
**own bundle identifier** and **your team** as the signing team, then
|
||
build to your device (or distribute via your own TestFlight).
|
||
3. **Create your own APNs `.p8` auth key** in the Apple Developer Portal,
|
||
under **Keys → Create a Key → Apple Push Notifications service (APNs)**.
|
||
Note the **Key ID**, and your **Team ID** (top-right of the portal).
|
||
Scope the key **Sandbox & Production** (see the gotcha below).
|
||
4. **Point your bundle ID at your server.** Build the iOS app against your
|
||
instance so the device registers its APNs token with your server.
|
||
5. **Set the APNs manifest on the server.** Add the env vars below to
|
||
`.env` / the compose `environment:` block.
|
||
|
||
The APNs env vars (`src/lib/notifications/senders/apns.ts`):
|
||
|
||
| Variable | Purpose |
|
||
| --------------------------- | ----------------------------------------------------------------------------------- |
|
||
| `APNS_KEY_ID` | The `.p8` key's Key ID |
|
||
| `APNS_TEAM_ID` | Your Apple Developer Team ID |
|
||
| `APNS_BUNDLE_ID` | Your 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_CRITICAL_ENTITLEMENT` | Optional — set `true` only if Apple grants your app the Critical Alerts entitlement |
|
||
|
||
APNs is **all-or-none**: either set the three IDs plus exactly 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`.
|
||
|
||
`APNS_CRITICAL_ENTITLEMENT=true` is honoured only if Apple has actually
|
||
granted your app the Critical Alerts entitlement (a separate request to
|
||
Apple). Without that entitlement, leave it unset — the server keeps urgent
|
||
events at normal priority rather than minting a `critical` payload your
|
||
app cannot send (`src/lib/notifications/types.ts`).
|
||
|
||
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.
|
||
|
||
> **`clientManaged` is iOS-app-only.** A native iOS app can opt to run its
|
||
> own local medication reminders and ask the server to stop sending the
|
||
> duplicate `MEDICATION_REMINDER` push (the `clientManaged` flag on
|
||
> `PATCH /api/auth/me/notification-prefs`). This is a native-app contract:
|
||
> a web/PWA-only self-hoster never needs it and there is no toggle for it
|
||
> in the web UI. If you only run the PWA, ignore it entirely — your
|
||
> medication reminders come from the server cron over the channels above.
|
||
|
||
## Tap targets, content and quick answers
|
||
|
||
### What tapping a notification opens
|
||
|
||
Every notification leads somewhere in the app. The server decides where,
|
||
once per event type, and every channel carries the same target:
|
||
|
||
| Notification | Opens |
|
||
| -------------------------------------------- | ---------------------------------------------------------------------------------- |
|
||
| Mood reminder | `/mood`, with the mood entry sheet already open |
|
||
| Morning briefing | the dashboard |
|
||
| Medication reminder, low supply, shared dose | `/medications` (low supply opens the medication) |
|
||
| Check-up and measurement reminders | `/checkups` |
|
||
| Proactive Coach nudge | the Coach conversation it started |
|
||
| Cycle reminders | `/cycle` (the dashboard with discreet notifications on) |
|
||
| New sign-in | Settings, Security |
|
||
| Integration sync failing | Settings, Integrations (an admin alert about another account opens the admin page) |
|
||
| Illness red flag | the illness episode |
|
||
| Other system alerts | the dashboard, or the admin status page for operator alerts |
|
||
|
||
How each channel carries it:
|
||
|
||
- **ntfy**: the message's `click` field, so tapping the notification opens
|
||
the page.
|
||
- **Telegram**: an **Open in HealthLog** button under the message, below any
|
||
action buttons. Telegram only accepts `https` button links on a public
|
||
host, so a plain-HTTP instance gets a text link instead, and an instance
|
||
on `localhost` gets no link.
|
||
- **Web Push**: the service worker opens the page, or focuses an open
|
||
HealthLog tab and navigates it there.
|
||
- **APNs**: the path rides the payload as `url`.
|
||
- **Webhook**: the generic body gains a `url` field; the Gotify body gains
|
||
`extras["client::notification"].click.url`, which the Gotify app opens.
|
||
- **Email**: a link line under the message.
|
||
|
||
ntfy, Telegram, the webhook and email need an absolute link, built from
|
||
`APP_URL` (or `NEXT_PUBLIC_APP_URL`). Without one those channels deliver as
|
||
before, just without a link. A notification about a profile you look after
|
||
as a Guardian opens the dashboard rather than the profile's page, because
|
||
your session opens on your own record and the record switcher lives there.
|
||
A discreet notification opens the dashboard as well, so its link names
|
||
nothing. The silent background sync push for medication intake is the only
|
||
notification without a link.
|
||
|
||
### Health figures in notifications
|
||
|
||
Settings, Notifications, **Health figures in notifications** decides what a
|
||
notification may say on a lock screen:
|
||
|
||
- **Off** (the default): a notification says what it is about and shows no
|
||
health figure. The morning briefing reads "Your morning briefing".
|
||
- **On**: notifications add their figures. The morning briefing becomes
|
||
"Morning score 78" with the day's one-line summary underneath.
|
||
|
||
Tap targets and quick answers work the same either way. The setting is
|
||
stored as `content.detail` (`generic` or `detailed`) in the notification
|
||
preferences (`PATCH /api/auth/me/notification-prefs`) and travels with the
|
||
account backup.
|
||
|
||
### Quick answers on the mood reminder
|
||
|
||
On **ntfy** the mood reminder carries three buttons, **Good**, **Okay** and
|
||
**Bad**. Tapping one logs a mood entry straight from the notification,
|
||
without opening the app, and clears the notification. They map onto the
|
||
five-point mood scale as Good = 4, Okay = 3, Bad = 2; the outer points
|
||
stay in the app. The entry is marked with the source **Notification**.
|
||
|
||
Each button calls `POST <APP_URL>/api/notifications/action/<token>`. The
|
||
token is the only credential, so it is built to be harmless on its own:
|
||
|
||
- it is signed with `API_TOKEN_HMAC_KEY` and names one account and one
|
||
answer, so it cannot be moved to another account or another answer;
|
||
- it expires six hours after the reminder was sent;
|
||
- the three answers of one reminder are one use between them: the first
|
||
tap logs, every later tap on that reminder is refused, and that stays
|
||
true when the logged entry is deleted and purged from the trash before
|
||
the six hours are up;
|
||
- the route is rate-limited before it reads the token, refuses when the
|
||
mood module has been turned off, and the token is scrubbed from logs.
|
||
|
||
The buttons need `APP_URL`, because the ntfy app calls the URL from your
|
||
phone. Your phone has to reach the instance at that address.
|
||
|
||
### Quick answers behind forward auth
|
||
|
||
If the instance sits behind a forward-auth gateway (Authelia, Authentik,
|
||
oauth2-proxy, Cloudflare Access and the like), the gateway answers the
|
||
button's request with its login page and the answer is never logged: the
|
||
ntfy app has no session with the gateway. Exempt the one path from the
|
||
gateway:
|
||
|
||
```
|
||
/api/notifications/action/*
|
||
```
|
||
|
||
The route does not rely on the gateway. The signed token is its only
|
||
credential, it is rate-limited before the token is read, and it can log one
|
||
mood answer for one account and nothing else. Every other path stays behind
|
||
the gateway.
|
||
|
||
What the token cannot protect against is the topic itself. The button URLs,
|
||
token included, travel through your ntfy server inside the message, and the
|
||
message is delivered to everyone subscribed to the topic. For the six hours
|
||
a token is valid, anyone who can read the topic can log that one answer on
|
||
your behalf. On the public `ntfy.sh` the topic name is the only access
|
||
control, so:
|
||
|
||
- use a long, random topic name and do not share it;
|
||
- better, run your own ntfy server with access control and give HealthLog
|
||
an access token that may only publish to your topic;
|
||
- to invalidate every quick-answer token at once, rotate
|
||
`API_TOKEN_HMAC_KEY`. The same key also hashes API tokens and session
|
||
cookies, so rotating it invalidates every issued API token and signs out
|
||
every browser session and the iOS app as well (see
|
||
[getting started](./getting-started.md)).
|
||
|
||
**Telegram** keeps its own keyboard on the mood reminder: the full 1 to 5
|
||
scale, a note and a "later" button.
|
||
|
||
**Web Push** offers no buttons. Most browsers show at most two actions,
|
||
which cannot carry a three-way answer, and Safari on iPhone shows none.
|
||
Tapping the notification opens the mood entry sheet instead.
|
||
|
||
**APNs**: the iOS app draws its own buttons from the notification category
|
||
it registers for the mood reminder.
|
||
|
||
## 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, taken-clears, and the app badge all work; the lock-screen Live Activity needs a native build. |
|
||
| You want the native iOS lock-screen Live Activity | Build and sign **your own** iOS app under your own Apple account and wire up **APNs** (paid), with 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. |
|
||
| You already run an automation hub | **Webhook** — POST every event to your own URL and route it however you like. |
|
||
| You want reminders in your inbox | **Email** — set the SMTP transport once as the operator; users add their address. |
|
||
|
||
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`.
|