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.
220 lines
11 KiB
Markdown
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.
|