the 2026-07-31 design promised this document to `officer-suite/COMMS/`, a repo path that has never existed. writing it where the work is instead, to be handed to whoever builds the client. exact contracts rather than prose: the three device routes with real request and response shapes read off devices.ts, what is required versus defaulted, and that the server takes the user from the session and ignores a userId in the body. most of it is the failure modes that cost a day each and are invisible from the client — a send with no credentials configured returns success and delivers nothing; a sandbox token registered as production fails at apple with no user-visible symptom; the notification extension must not touch the network or it will pass every test at a desk and fail on cellular with the vpn off. leaves three questions for the owner: whether apps/mobile has a bundle id and a provisioning profile at all, its url scheme, and expo managed versus bare — that last one decides how the extension gets built and is probably the longest pole in phase 3.
185 lines
8.6 KiB
Markdown
185 lines
8.6 KiB
Markdown
# Push notifications — the app side
|
|
|
|
**Audience:** whoever builds this in `officerdev/monorepo-mobile`. **Platform status:** the server is built,
|
|
running and core; nothing on the device exists yet. **Target: iOS first, Android soon after.**
|
|
|
|
Read `docs/push-notifications.md` first — it is the design and the _why_. This document is the contract and
|
|
the build order, and it does not repeat the reasoning.
|
|
|
|
## What the server already does
|
|
|
|
- `officer-notify` runs as a core PM2 process and is reachable at `/api/notify/*`.
|
|
- `push_devices` exists in Postgres. Registration is idempotent.
|
|
- APNs and FCM channels are implemented and unit-tested; neither has ever sent a real push.
|
|
- **Missing: credentials.** Until the owner installs an APNs `.p8`, `/api/notify/_health` reports
|
|
`{"ok":true,"channels":[]}` and every send is a silent no-op that returns success. **A green response
|
|
does not mean a push was sent** — check `channels` first, or you will debug the app for a day over a
|
|
server with no key.
|
|
|
|
## Which app
|
|
|
|
**Only the main Officer app (`apps/mobile`) receives pushes.** It is the hub: everything arrives there and
|
|
it deep-links onward to music, chat, photos and the rest.
|
|
|
|
`apps/chat` keeps `withoutPushEntitlement.js` — that plugin is **correct** and should not be removed. Chat
|
|
uses local notifications and is not a push receiver. Same for every other app in the monorepo.
|
|
|
|
## Phase 1 — first light
|
|
|
|
The goal is one plain banner on a real device. **No codes, no mapping, no extension.** If this does not
|
|
work, nothing later will, and everything later is harder to debug.
|
|
|
|
### 1. iOS project setup
|
|
|
|
- A bundle id for `apps/mobile` (only `dev.officer.music` was ever confirmed to exist — check).
|
|
- `aps-environment` entitlement. Do **not** copy chat's plugin here; the hub needs the opposite.
|
|
- Push capability on the App ID in the developer portal, and a provisioning profile that includes it.
|
|
|
|
### 2. Get the token and register it
|
|
|
|
Ask for permission, get the **native APNs device token** (not an Expo push token — the server talks to
|
|
Apple directly and an Expo token will be rejected as malformed), then:
|
|
|
|
```http
|
|
POST /api/notify/_officer/devices
|
|
Authorization: Bearer <the app's normal Officer session token>
|
|
Content-Type: application/json
|
|
|
|
{
|
|
"token": "<64-hex APNs device token>",
|
|
"platform": "ios",
|
|
"environment": "production",
|
|
"bundleId": "dev.officer.mobile",
|
|
"appSlug": "mobile"
|
|
}
|
|
```
|
|
|
|
`token`, `platform`, `bundleId` and `appSlug` are **required**; a missing one is a `400` with
|
|
`{"error":"token, platform, bundleId and appSlug are required"}`. `environment` defaults to `production`.
|
|
|
|
Response: `{"ok":true,"id":<row id>}`.
|
|
|
|
**Never send a `userId`.** The server takes the user from the authenticated session and ignores the body —
|
|
that is deliberate access control, not an oversight. Sending one is harmless but will not do what it looks
|
|
like it does.
|
|
|
|
**`environment` is the classic silent failure.** An Xcode/debug build gets a **sandbox** token; TestFlight
|
|
and App Store builds get **production** ones. A sandbox token registered as `production` fails at Apple
|
|
with `BadDeviceToken` and **no user-visible symptom** — no error on the device, nothing in the app. Derive
|
|
it from the build configuration; do not hardcode it.
|
|
|
|
**Call this on every launch.** APNs tokens rotate. Registration is an upsert keyed on
|
|
`(token, bundleId)`, so re-registering is free and expected.
|
|
|
|
### 3. The other two routes
|
|
|
|
```http
|
|
GET /api/notify/_officer/devices → { devices: [ … ] }
|
|
DELETE /api/notify/_officer/devices/<token> → { ok: true }
|
|
```
|
|
|
|
`GET` returns `{ id, platform, environment, bundleId, appSlug, tokenSuffix, lastSeenAt }` per row —
|
|
**tokens are never echoed in full**, only the last 8 characters, so build any "your devices" UI against
|
|
`tokenSuffix` and `id`. Call `DELETE` on sign-out.
|
|
|
|
### 4. Ask for a test push
|
|
|
|
The owner triggers one server-side. You should get an ordinary banner. That closes the loop:
|
|
credential → APNs → device → banner.
|
|
|
|
## Phase 2 — the mapping
|
|
|
|
```http
|
|
GET /api/notify/mapping → { version: 47, entries: { "<code>": { title, body?, target, route, thread } } }
|
|
```
|
|
|
|
_(Not built yet — I'm writing it now. The shape is fixed; assume it and I'll match it.)_
|
|
|
|
Codes are namespaced `<publisher>/<appName>:<code>`, e.g. `officerdev/music:12.3`.
|
|
|
|
The app **fetches this and caches it into a shared App Group container** — shared because the notification
|
|
extension in Phase 3 must read it without the app running. Sync on launch, and whenever a push arrives
|
|
carrying an `mv` higher than the cached `version`.
|
|
|
|
Entries are templates: `title: "{n} new emails"` rendered against the push's `n`. `route` carries the deep
|
|
link with `{id}` interpolated.
|
|
|
|
## Phase 3 — the Notification Service Extension
|
|
|
|
A push arrives looking like this:
|
|
|
|
```jsonc
|
|
{
|
|
"aps": {
|
|
"alert": { "title": "Officer" }, // the fallback the OS shows if the extension does not run
|
|
"mutable-content": 1,
|
|
"sound": "default",
|
|
"thread-id": "officerdev/music",
|
|
"badge": 3,
|
|
},
|
|
"officer": { "c": "officerdev/music:12.3", "n": 3, "id": 412, "mv": 47 },
|
|
}
|
|
```
|
|
|
|
The extension resolves `c` against the **cached** mapping and rewrites the banner.
|
|
|
|
**It must not make a network request.** The lookup is a local dictionary read against the App Group cache.
|
|
This is the single most important constraint in the whole design: it is what makes notifications work when
|
|
the tailnet is down, on cellular, with the VPN off. An extension that fetches on arrival will appear to
|
|
work in every test you run at your desk and fail in the field.
|
|
|
|
**Unknown code** — the push outran the sync, or came from an uninstalled plugin:
|
|
|
|
> **Unknown notification**
|
|
> code `officerdev/music:12.3` — tap to sync
|
|
|
|
deep-linking to `officer://resync-notification-mapping`. Keep the code in the body; when this fires on the
|
|
owner's phone, _which_ code is the only useful thing to know.
|
|
|
|
That screen must be able to **give up**. An uninstalled plugin's codes are gone from the catalogue forever,
|
|
so "resync, still missing, resync" would loop. After one failed resync, say it came from something no
|
|
longer installed and stop.
|
|
|
|
**One build, ever.** The extension is a generic resolver — it must never `switch` on known codes, or every
|
|
new notification kind becomes an app store release, which is the entire thing this design exists to avoid.
|
|
|
|
Note for Expo: a custom NSE needs a config plugin (`expo-notifications` alone does not give you one). Worth
|
|
scoping early — it may be the longest pole in Phase 3.
|
|
|
|
## Phase 4 — deep linking
|
|
|
|
`route` from the mapping is the destination. Tapping a notification opens the hub, which routes onward to
|
|
the target app or screen. Confirm the scheme with the owner — the platform uses `officer://<hex>` origins
|
|
for app surfaces, and I have not verified what `apps/mobile` registers today.
|
|
|
|
## Android — later, but design for it now
|
|
|
|
No extension needed. Send **FCM data-only messages at high priority** and the app composes the notification
|
|
itself; same codes, same mapping, same cache. Register with `platform: "android"` and the FCM registration
|
|
token.
|
|
|
|
**Do not start Android before the owner settles one thing.** `apps/mobile/PUSH_NOTIFICATIONS.md`
|
|
(2026-07-24) rejects FCM because the Pixel runs GrapheneOS with no Play Services; the platform design chose
|
|
FCM a week later without addressing it. If the target device has no Play Services, FCM cannot reach it at
|
|
all and the fallback is a foreground service holding a tailnet socket. That decision changes the whole
|
|
Android build and is not yours to absorb.
|
|
|
|
## Things that will waste your time if nobody tells you
|
|
|
|
- **A successful `POST /_officer/notify` means "queued", not "delivered".** With no credentials configured
|
|
every channel is skipped and the call still succeeds.
|
|
- **`X-Officer-User` is injected by the platform proxy.** You never set it; if you see a `401` mentioning
|
|
it, the request bypassed the proxy.
|
|
- **Tokens are addresses, not secrets**, but the server still refuses to echo them back in full. Don't
|
|
build anything that needs the whole token back.
|
|
- **Until 2026-08-19 a non-owner could not register a device at all** — the permission's `personal` paths
|
|
did not match the real route. Fixed. If you test with a member account on an older server, that is why.
|
|
- **`appSlug` means the deep-link destination**, not "which app receives this". The name is a leftover from
|
|
the first design, where each app had its own token.
|
|
|
|
## Open questions for the owner
|
|
|
|
1. Does `apps/mobile` have a bundle id and a provisioning profile with push enabled?
|
|
2. What URL scheme does `apps/mobile` register for deep links?
|
|
3. Is the hub in Expo managed or bare? It decides how the NSE gets built.
|