mattermost-push-proxy-router/spec.md
Felipe M. 3c6fea8a46
All checks were successful
Release / release (push) Successful in 2m26s
CI / goreleaser-lint (push) Successful in 6s
CI / format (push) Successful in 28s
CI / test (push) Successful in 1m20s
CI / lint (push) Successful in 2m36s
CI / build (push) Successful in 34s
feat: add Mattermost push proxy prefix router
Implement a stateless Go service that routes /api/v1/send_push and /api/v1/ack
by platform prefix to configured backends, enabling mixed official and custom
mobile clients on a single PushNotificationServer URL.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-26 10:03:43 +02:00

269 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Push Notifications — Design & Research Spec
Status: **research complete, no code yet** (2026-07-17). This document consolidates the
findings needed to implement push notifications for Bubbles across future sessions.
Nothing here has been built; treat file/line anchors as "as of this writing".
---
## 1. Goal & decisions taken
- Add push notifications so users are alerted to messages while the app is backgrounded
(the live WebSocket in `Bubbles/Sync/` is **foreground-only** — iOS can't hold it
suspended, so there is currently no background delivery path at all).
- **Content mode: id-only** (`PushNotificationContents = id_loaded`). The push carries only
ids; the device fetches the message and builds the notification locally. Chosen for
privacy (message bodies never transit Apple or the proxy) and because it fits the
local-first architecture (the fetched post is written to the DB, the single source of truth).
- **Multi-server**, with the option for others to self-deploy — not a single hosted service
everyone must use.
---
## 2. How Mattermost push works
Pipeline (four hops):
```
Mattermost server ──▶ Push proxy (MPNS / self-hosted / plugin) ──▶ APNs (HTTP/2, .p8) ──▶ device
```
- The **server** decides a user should be notified (offline/away, mention, DM, …) and POSTs
a payload to whatever URL is set in **System Console → Environment → Push Notification
Server** (`EmailSettings.PushNotificationServer`). The server never talks to APNs directly.
- The **proxy** holds the APNs credentials and relays to Apple. It routes using the
`device_id` **platform prefix** (see §4).
- Gating (confirmed in server source `server/channels/app/notification_push.go`): the whole
path requires `EmailSettings.SendPushNotifications = true` **and**
`EmailSettings.PushNotificationServer` set. Delivery uses
`rawSendToPushProxy()` → `POST ‹server›/api/v1/send_push`; acks use
`SendAckToPushProxy()` → `POST ‹server›/api/v1/ack` (a `model.PushNotificationAck`).
### id-only vs full
| Mode (`PushNotificationContents`) | Body carries | Client must… |
| --------------------------------- | ------------------------------------------- | -------------------------------------- |
| `full` | message text, sender, channel | just display |
| `id_loaded` (**chosen**) | ids only, `is_id_loaded: true`, **no text** | fetch the post, build the notification |
### id-only payload fields
`ack_id`, `platform`, `server_id`, `device_id`, `post_id`, `root_id`, `category`,
`channel_id`, `channel_name`, `team_id`, `sender_id`, `sender_name`, `type`, `version`,
`badge`, `is_id_loaded`.
- `server_id` is the server's **DiagnosticId**, **not** Bubbles' internal server UUID → a
mapping is required (§7).
- `ack_id` → optional `POST /api/v1/notifications/ack` (delivery metrics / email-fallback
suppression). Not required for v1; **not exposed by the SDK**.
Sources: [service docs](https://developers.mattermost.com/contribute/more-info/mobile/push-notifications/service/),
[iOS docs](https://developers.mattermost.com/contribute/more-info/mobile/push-notifications/ios/),
[5.18 id-only release](https://mattermost.com/blog/mattermost-5-18-id-only-option-for-push-notifications-one-click-plugin-updates-mark-posts-unread-and-more/),
[notification_push.go](https://raw.githubusercontent.com/mattermost/mattermost/master/server/channels/app/notification_push.go).
---
## 3. The hard constraint: APNs key ⇄ bundle id
APNs authorizes by **topic = bundle id (**`com.fmartingr.Bubbles`**)**. Only an APNs key (`.p8`)
issued by **the Bubbles Apple Developer team** can push to that topic. Consequences:
- Mattermost's hosted **HPNS** (`push.mattermost.com`) is bound to the *official* app's
bundle id/keys and **cannot** deliver to Bubbles. A custom client always needs its own
key in its own proxy.
- Whatever sends Bubbles pushes must hold **your** `.p8`. This is true no matter how many
proxies exist, so **do not ship the key inside a publicly distributed plugin/proxy** — it
would leak to every operator. Ship keyless; configure the key per deployment.
- "Full sovereignty" for a third party = they rebuild Bubbles under **their own** bundle id
- own key (standard cost of custom Mattermost mobile builds).
---
## 4. `PushNotificationServer` is one server-wide setting → coexistence problem
Confirmed: `EmailSettings.PushNotificationServer` is a **single, server-wide** value applied
to **all** mobile clients/users; it **cannot** be scoped per app. Options in System Console:
disabled / HPNS (US or DE) / TPNS (test) / **manually enter** a custom URL.
The proxy chooses the APNs cert/key by matching the `device_id` **prefix** against `Type`
entries in `ApplePushSettings` / `AndroidPushSettings`:
- Official iOS/Android apps register as `apple_rn` / `android_rn` (also `apple`, `android`).
- Stock proxy is a **terminal sender**: an unmatched `Type` is dropped; there is **no
"forward upstream" option**.
**Therefore:** if a server has official-app users and you repoint its single
`PushNotificationServer` at a Bubbles-only proxy, **official push breaks** (their
`apple_rn`/`android_rn` traffic has no valid Mattermost key on your proxy).
Scope of the risk:
- **Other servers:** unaffected — the setting is per-server.
- **Same server, Bubbles-only:** nothing to break; point straight at a stock proxy with your key.
- **Same server, mixed:** breaks unless you **relay** (below).
Sources: [push server config](https://docs.mattermost.com/administration-guide/configure/push-notification-server-configuration-settings.html),
[push-proxy sample config](https://github.com/mattermost/mattermost-push-proxy),
[server.go](https://github.com/mattermost/mattermost-push-proxy/blob/master/server/server.go).
---
## 5. Recommended server-side design: a prefix router (no fork)
Route by `device_id` prefix in front of **unmodified** backends:
```
Mattermost server
└─ PushNotificationServer ─▶ [prefix router]
├─ apple_bubbles:* ─▶ your APNs sender (.p8, topic=com.fmartingr.Bubbles)
└─ apple_rn:* / android_rn:* ─▶ HPNS (push.mattermost.com), relay /send_push + /ack
```
- **Bubbles sending needs no fork:** stock `mattermost-push-proxy` already supports multiple
`ApplePushSettings` entries; add one whose `Type` matches your client's device prefix
(e.g. `apple_bubbles`), topic = your bundle id, with your `.p8`.
- **Relay is the only new logic.** It's a small, stateless shim (tens of lines of Go, or
nginx + a body-inspecting route). It forwards the identical `send_push`/`ack` JSON to HPNS
for official prefixes — the same call the server makes today, just one hop relayed.
- One proxy/router is **stateless across Mattermost servers** (payload `server_id`/`device_id`
disambiguate), so **one central router serves many servers — per-server proxies buy nothing**
and multiply key handling. ("Per server" is only sensible in the plugin form, §6.3.)
### Form factors (same routing logic)
1. **Fork the proxy** — patch stock proxy to forward unmatched types. Works, but you carry a
fork of a moving codebase. Least appealing.
2. **Shim + stock proxy (recommended for a central deploy)** — router is its own tiny service;
Bubbles → stock proxy behind it; official → HPNS. No fork.
3. **Plugin (**`ServeHTTP`**)** — the plugin *is* the router, running in-server; admins upload it
(System Console) instead of deploying anything. Best DX for self-hosters. Ship keyless.
Confirmed viable: plugins serve at `/plugins/<id>/…`; set `PushNotificationServer` to that
path. Uses the documented `/api/v1/send_push` + `/api/v1/ack` protocol, so it's
version-stable. (Prefer this over the `NotificationWillBePushed` hook: that hook fires in
`sendPushNotificationToAllSessions()` **before** `SetDeviceIdAndPlatform`, so it does **not**
have the device id — unsuitable as the sender.)
### Deployment models
- **Model A — central router you host** (holds your `.p8` + HPNS relay). Any cooperating
server points its `PushNotificationServer` at it. Simplest for users; you bear cost/trust.
Note: you can see notification *metadata* (channel/post ids) but not message bodies (id-only).
- **Model B — self-host** via the plugin/shim. For operators who want no dependence on you;
still needs a valid key (yours, or their own via a rebuilt app under their bundle id).
Either way an **admin action is irreducible**: someone with System Console access must set the
one global `PushNotificationServer` (and the router must relay if the server has official users).
---
## 6. Client-side work (iOS app)
Current state (grep confirms **zero** notification code): single app target, no entitlements
file, no App Group, no background modes, no extension target. `BubblesApp.swift` is a pure
SwiftUI `App` with **no AppDelegate**. DB lives in `.applicationSupportDirectory`
(`Bubbles/Database/AppDatabase.swift:167`), keyed Keychain token has no access group
(`Bubbles/Network/TokenStore.swift`).
### 6.1 Already provided by the SDK (fork `fmartingr/MattermostSwift`, branch `feat/crt-unread-counters`)
- `MattermostClient.attachMobileDevice(deviceID:)` → `PUT /users/sessions/device`
(`MattermostClient+Users.swift:238`).
- `detachMobileDevice(deviceID:)` → `DELETE /users/sessions/device` (`:247`).
- `MattermostClient.login(…, deviceID:)` — attach at login (`MattermostClient.swift:69,85`).
- `post(id:)` — single-post GET for the id-only fetch (`MattermostClient+Posts.swift:83`),
returns `MattermostPost { id, createAt, userId, channelId, message, … }`.
### 6.2 To build
| Area | Work | Anchor |
| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
| Capability | `aps-environment` (Push Notifications), `UIBackgroundModes: [remote-notification]` | `project.yml` app target + generated `Support/Info.plist` |
| App entry | `@UIApplicationDelegateAdaptor` AppDelegate: `didRegisterForRemoteNotificationsWithDeviceToken`, `didFailToRegister`; `UNUserNotificationCenterDelegate` for foreground + taps | `Bubbles/App/BubblesApp.swift` |
| Permission + registration | request auth (after first login, not cold launch), `registerForRemoteNotifications()`, format token as `apple_bubbles:<hex>`, `attachMobileDevice` **per server** (iterate `AppState.servers`, `MattermostClientFactory.makeClient(for:)`) | new + `Bubbles/Services/AuthService.swift` |
| Notification Service Extension | new `app-extension` target; reads `server_id`→server, reads token from **shared Keychain**, `post(id:)`, writes the post to the shared DB (mirror `LiveEventApplier.applyPosted` — counter bumps + `SyncService.post(from:).save(db)`), rewrites `bestAttemptContent` | new target; logic mirrors `Bubbles/Sync/LiveEventApplier.swift:102` |
| App Group | `group.com.fmartingr.Bubbles`; move SQLite to `FileManager.containerURL(forSecurityApplicationGroupIdentifier:)` + one-time migration of the existing file; GRDB multi-process/WAL coordination | `Bubbles/Database/AppDatabase.swift:167` |
| Shared Keychain | add `kSecAttrAccessGroup` to all three queries so the NSE can read the token | `Bubbles/Network/TokenStore.swift` |
| server_id mapping | persist each server's **DiagnosticId**; map push `server_id` → Bubbles server UUID. **SDK gap:** `MattermostClientConfig` does not expose `DiagnosticId` — extend the fork or raw-fetch `/config/client?format=old` | new `Server` column + migration (`Bubbles/Database/Records/Server.swift`, `AppDatabase.swift` migrator) |
| Deep-link nav | tap → map `server_id`→serverID, `AppState.select(serverID:teamID:)`, push `Channel` onto the stack. `MainView`'s `NavigationStack` currently has **no bound** `path` — add one + an `AppState` pending-deep-link intake | `Bubbles/Features/Main/MainView.swift:23,92`, `Bubbles/App/AppState.swift` |
| Lifecycle | attach on login; **detach on server removal** before the token is deleted (removal today cascades DB + deletes token — must also `detachMobileDevice` first) and on logout | `Bubbles/Features/Main/ServerEditModel.swift` / `ServerEditView.swift` |
| Foreground de-dupe | when active, the live socket already delivers the post; suppress or in-app-banner the push, don't double-write | `Bubbles/Features/Main/MainView.swift` scenePhase handling |
| Tests (required) | unit: token-format helper, `server_id`→server mapping, NSE post-write vs `AppDatabase.inMemory()`; XCUITest: permission-gated UI + deep-link intake. (NSE internals aren't XCUITest-drivable — keep them thin, test the pure functions.) | `BubblesTests/`, `BubblesUITests/` |
---
## 7. Dev environment & testing
The iOS **Simulator cannot receive real APNs** (its device token isn't a real APNs token).
Split testing in two:
- **Client loop on the Simulator (no proxy/APNs):**
`xcrun simctl push <udid> com.fmartingr.Bubbles bubbles-id-only.apns` with a Mattermost-shaped
id-only aps payload. This triggers the real NSE → fetches the post from the local `dev/`
server → writes to the shared DB, and exercises the tap/deep-link path. Covers ~90% of client
work without any proxy. `attachMobileDevice` against the dev server also validates
registration + payloads.
- **Full chain (physical device required):** add to `dev/`
`MM_EMAILSETTINGS_SENDPUSHNOTIFICATIONS=true`,
`MM_EMAILSETTINGS_PUSHNOTIFICATIONCONTENTS=id_loaded`,
`MM_EMAILSETTINGS_PUSHNOTIFICATIONSERVER=<router/plugin/proxy url>`; a real `.p8` for
`com.fmartingr.Bubbles` in the sender; a physical iPhone reachable to the dev server (LAN IP,
not `localhost`).
---
## 8. Open decisions
1. **Form factor:** shim + stock proxy (Model A, central) vs plugin (Model B, self-host). Both
are "your router in front of your sender + HPNS".
2. **Distribution:** central router you operate vs plugin others install; and the documented
BYO-bundle-id path for full sovereignty.
3. **Ownership:** willing to maintain a Go server component (shim/plugin, `sideshow/apns2`)
alongside the Swift app + SDK fork.
4. **DiagnosticId gap:** extend the SDK fork's config model vs raw config fetch.
---
## 9. Source index
- Push notification service — [https://developers.mattermost.com/contribute/more-info/mobile/push-notifications/service/](https://developers.mattermost.com/contribute/more-info/mobile/push-notifications/service/)
- iOS push notifications — [https://developers.mattermost.com/contribute/more-info/mobile/push-notifications/ios/](https://developers.mattermost.com/contribute/more-info/mobile/push-notifications/ios/)
- Set up push (custom app must self-host) — [https://developers.mattermost.com/contribute/more-info/mobile/push-notifications/](https://developers.mattermost.com/contribute/more-info/mobile/push-notifications/)
- Host your own push proxy — [https://docs.mattermost.com/deployment-guide/mobile/host-your-own-push-proxy-service.html](https://docs.mattermost.com/deployment-guide/mobile/host-your-own-push-proxy-service.html)
- Push server config (single setting, HPNS/TPNS/manual) — [https://docs.mattermost.com/administration-guide/configure/push-notification-server-configuration-settings.html](https://docs.mattermost.com/administration-guide/configure/push-notification-server-configuration-settings.html)
- id-only feature — [https://mattermost.com/blog/mattermost-5-18-id-only-option-for-push-notifications-one-click-plugin-updates-mark-posts-unread-and-more/](https://mattermost.com/blog/mattermost-5-18-id-only-option-for-push-notifications-one-click-plugin-updates-mark-posts-unread-and-more/) , [https://mattermost.com/blog/id-only-push-notifications/](https://mattermost.com/blog/id-only-push-notifications/)
- Push proxy repo + server.go — [https://github.com/mattermost/mattermost-push-proxy](https://github.com/mattermost/mattermost-push-proxy) , [https://github.com/mattermost/mattermost-push-proxy/blob/master/server/server.go](https://github.com/mattermost/mattermost-push-proxy/blob/master/server/server.go)
- Server send path — [https://raw.githubusercontent.com/mattermost/mattermost/master/server/channels/app/notification_push.go](https://raw.githubusercontent.com/mattermost/mattermost/master/server/channels/app/notification_push.go)
- Device attach endpoint (`PUT /api/v4/users/sessions/device`) — mattermost-api-reference `v4/source/users.yaml`
- Plugin hooks (`ServeHTTP`, `NotificationWillBePushed`) — [https://pkg.go.dev/github.com/mattermost/mattermost/server/public/plugin](https://pkg.go.dev/github.com/mattermost/mattermost/server/public/plugin)