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.
8.6 KiB
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-notifyruns as a core PM2 process and is reachable at/api/notify/*.push_devicesexists 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/_healthreports{"ok":true,"channels":[]}and every send is a silent no-op that returns success. A green response does not mean a push was sent — checkchannelsfirst, 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(onlydev.officer.musicwas ever confirmed to exist — check). aps-environmententitlement. 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:
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
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
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:
{
"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/notifymeans "queued", not "delivered". With no credentials configured every channel is skipped and the call still succeeds. X-Officer-Useris injected by the platform proxy. You never set it; if you see a401mentioning 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
personalpaths did not match the real route. Fixed. If you test with a member account on an older server, that is why. appSlugmeans 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
- Does
apps/mobilehave a bundle id and a provisioning profile with push enabled? - What URL scheme does
apps/mobileregister for deep links? - Is the hub in Expo managed or bare? It decides how the NSE gets built.