From e57d6cc1cc8447c266e52228aa82403db34a015e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Wed, 19 Aug 2026 18:49:15 +0000 Subject: [PATCH] push notifications: the app-side spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/push-notifications-app.md | 184 +++++++++++++++++++++++++++++++++ docs/push-notifications.md | 3 + 2 files changed, 187 insertions(+) create mode 100644 docs/push-notifications-app.md diff --git a/docs/push-notifications-app.md b/docs/push-notifications-app.md new file mode 100644 index 00000000..f5f42714 --- /dev/null +++ b/docs/push-notifications-app.md @@ -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 +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":}`. + +**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/ → { 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: { "": { 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 `/:`, 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://` 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. diff --git a/docs/push-notifications.md b/docs/push-notifications.md index 2c0b789b..cb57dbbc 100644 --- a/docs/push-notifications.md +++ b/docs/push-notifications.md @@ -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