push notifications: the app-side spec
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.
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
# 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.
|
||||
@@ -4,6 +4,9 @@
|
||||
design. It supersedes the 2026-07-31 one, which is summarised under "What changed and why" so the
|
||||
reversals are legible rather than silently overwritten.
|
||||
|
||||
The app-side contract and build order is **`docs/push-notifications-app.md`** — hand that to whoever builds
|
||||
the client. This file is the design and the reasoning.
|
||||
|
||||
## The shape, in one paragraph
|
||||
|
||||
One app on the device — the main Officer app — receives every push and is the only one that can. It is a
|
||||
|
||||
Reference in New Issue
Block a user