Files
platform/docs/push-notifications-app.md
T
pastilhas 98960d2827 app spec: the answers from the app repo, and alpha caught up
three questions answered from monorepo-mobile, and two of my assumptions were
wrong.

bundle ids exist — dev.officer.mobile and dev.officer.mobile.dev. two of them
needs nothing special: push_devices is unique on (token, bundleId), so the
variants are separate rows on one phone, and the dev variant is exactly the
sandbox/production distinction the doc warned about. concrete now rather than
derived from build config by guesswork.

scheme is officer:// and officer-dev://, both from app.config.ts.

expo prebuild, not bare — and a config plugin that creates an ios app
extension target ALREADY EXISTS there, withVaultAutofillTarget.js for the
autofill extension. the nse follows it. that removes what i had called the
longest pole in phase 3.

corrected: expo-notifications is not in apps/mobile at all, only apps/chat.

the real blocker is narrower than the doc implied and is not code. today's
signed build has no aps-environment entitlement, and adding one fails while
xcode has no account signed in — same failure as the network extension on
2026-08-05. one manual sign-in, team RGHC4NJC77, then automatic again.

alpha: /api/notify was 404 all day because that server predated the work.
pulled, db:push, gen:ecosystem, officer-notify started, officer restarted.
verified 401 unauthenticated and push_devices with its 10 columns.
2026-08-19 18:59:13 +00:00

220 lines
11 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.
- **Alpha is up to date as of 2026-08-19 19:xx.** `/api/notify/*` answered `404` there for most of the day
because the server predated this work; it now answers `401` unauthenticated, `push_devices` has its 10
columns, and `officer-notify` is running. If you get a `404`, that server needs `git pull` +
`pm2 restart officer`, and `officer-notify` needs starting — it is a new core process, so an existing
install will not have it until `bun gen:ecosystem` runs.
## 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` — **confirmed: `dev.officer.mobile`**, plus `dev.officer.mobile.dev`.
- `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.
Expo: `ios/` is generated by prebuild, so the NSE target comes from a config plugin. **One already exists
in this repo** — `packages/bw-vault/plugin/withVaultAutofillTarget.js` creates the AutoFill extension
target. Follow it rather than inventing one.
## 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. The scheme is **`officer://`** (`officer-dev://` in the dev variant), from
`app.config.ts`.
## 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.
## Answered, from the app repo (2026-08-19)
**Bundle ids exist.** `dev.officer.mobile`, and `dev.officer.mobile.dev` for the side-by-side dev variant.
Two bundle ids is fine and needs nothing special: `push_devices` is unique on `(token, bundleId)`, so the
two variants register as separate rows on the same phone. **Register the dev variant as
`environment: "sandbox"` and production as `"production"`** — that mapping is now concrete, so derive it
from the variant rather than the build config guesswork this document suggested earlier.
**The URL scheme is `officer://`, and `officer-dev://` in the dev variant.** Both come from
`app.config.ts`, and the share extension embeds the same string natively, so they cannot desync. So the
unknown-code fallback deep link is `officer://resync-notification-mapping` as written.
**Expo prebuild (CNG), not bare** — `ios/` is generated and gitignored; everything native comes from config
plugins. This is better news than the document assumed: **a config plugin that creates an iOS app-extension
target already exists in this repo**, `packages/bw-vault/plugin/withVaultAutofillTarget.js`, which builds
the AutoFill extension that prebuild cannot. **The NSE follows that pattern rather than being invented**,
which removes what this document called the longest pole in Phase 3.
**`expo-notifications` is NOT in `apps/mobile`** — only in `apps/chat`, for local turn alerts. Correcting
this document's assumption. Chat's `withoutPushEntitlement.js` still stays exactly as described above; the
hub needs the opposite.
## The one real blocker: the push entitlement
Today's signed build (`2026.08.19.02`) has **no `aps-environment` entitlement at all**. Nothing else in
Phase 1 can be tested until it does.
It has a known shape here. On 2026-08-05 adding a Network Extension failed for the same reason:
`-allowProvisioningUpdates` **cannot add a capability while Xcode has no account signed in**. The fix is a
one-time manual step by the owner — Xcode → Settings → Accounts, team `RGHC4NJC77` — after which it is
automatic again.
**Owner action, and it is the critical path.** Along with the APNs `.p8`, this is the whole of what stands
between here and a banner on a phone.