Files
platform/docs/push-notifications-app.md
pastilhas 55d6eba511 notification catalogue: GET /api/notify/mapping
the piece that makes a new notification kind a server change rather than an
app store release. a push carries a code and integers; this is what the code
means, synced to the device instead of compiled into it.

deliberately the same shape as affordances rather than a second mechanism: a
`notifications/` directory of yaml in the plugin repository, copied to
capabilities/notifications/<publisher>/<appName>/ on install, removed on
uninstall. publish.ts mirrors affordances/publish.ts closely enough to diff.

the namespace is DERIVED from where the file lands, never from what it
declares — same rule as mountPrefix. a plugin writing `officer/core:test` as a
key gets `<publisher>/<plugin>:officer/core:test` and the real core entry is
untouched. that is the security property of the whole scheme and it is pinned
by test.

version is a content hash, not a counter. a counter needs somewhere to live
and is then wrong after a restore, a rollback, or two servers built from the
same install; a hash needs no storage and is right in all three. so the client
compares mv !== version, and both docs now say so — i had written "higher".

core entries ship in code rather than on disk, because capabilities/ is
installed rather than committed and `test` has to exist on a machine that has
installed nothing — it is what first light is proved with.

read from disk per request, not memoised: installing a plugin has to change
the answer without a restart, or it presents as "the new plugin's
notifications don't work", which is the hardest kind of bug to place.

verified live: 401 through the platform, plugin catalogue appearing and
disappearing with no restart, version returning to its prior value when the
plugin is removed. 10 catalogue tests, 100% coverage of catalogue.ts.
2026-08-19 19:07:49 +00:00

12 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-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:

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 } } }

Built and live as of 2026-08-19. Cache-Control: no-store — the client decides by version, so a cached copy would defeat the sync it drives. The catalogue is re-read from disk per request, so installing a plugin changes the response with no restart.

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 whose mv differs from the cached version.

Compare with !==, not >. version is a content hash, not a counter, so it does not increase monotonically — removing a plugin returns it to the value it had before that plugin was installed. That is deliberate: a counter would need persistent storage and would be wrong after a restore or a rollback.

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.

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.