built 2026-07-31, switched off 2026-08-13 when it was slated to become a plugin alongside memos and photos. those two were extracted; this one never was, so it spent six days mounted nowhere with its schema commented out and no pm2 entry — complete code that nothing could reach. core rather than a plugin because the producers are the platform's own: the job queue, an agent turn ending, mail arriving. a notifier the owner can uninstall is one those producers cannot rely on. - hono.ts mounts /api/notify again; verified 401 unauthenticated, so the permission gate covers it and assertPermissionTotality accepts the mount - push_devices exported from the core schema and pushed: 10 columns, uq_push_devices_token_bundle, idx_push_devices_user - officer-notify added to CORE_PROCESSES, so gen:ecosystem writes it - credentials read OFFICERDEV_X ?? X, the convention it predates safe as core because it boots with nothing configured: every channel reports isConfigured() false and dispatch resolves empty. verified by importing it with the env stripped — "channels: none configured", no throw. an install with no apns key runs it as a no-op rather than crash-looping. APNS_ENV is dropped from the doc's credential table. it was never read: the host comes from each device's `environment` column, so one server pushes to a testflight build and a release build at once. doc status corrected. the app side is not started — there is no officer-suite repo, no PUSH_NOTIFICATIONS_APP.md, and apps/chat actively strips the ios push entitlement. recorded there rather than here.
156 lines
9.6 KiB
Markdown
156 lines
9.6 KiB
Markdown
# Push notifications — design
|
||
|
||
**Status (2026-08-19):** the server side is **built and core**. The app side is **not started**, and the
|
||
handoff document this file promised was never written.
|
||
|
||
- **Server: done and live.** `officer-notify` runs as a core PM2 process, `/api/notify` is mounted and
|
||
permission-gated, and `push_devices` is in the schema. Steps 1–4 of the build order below are complete;
|
||
step 5 (a first producer) is not.
|
||
- **Switched off and back on.** On 2026-08-13 this was slated to become a plugin alongside memos and
|
||
photos. Those two were extracted; this never was, so it spent six days mounted nowhere with its schema
|
||
commented out. Restored to core on 2026-08-19 — core rather than a plugin because the producers are
|
||
the platform's own (the queue, an agent turn ending, mail arriving), and a notifier the owner can
|
||
uninstall is one those producers cannot rely on.
|
||
- **App side: nothing.** `officer-suite` does not exist; the apps repo is `officerdev/monorepo-mobile`.
|
||
It has no `COMMS/` directory, no `PUSH_NOTIFICATIONS_APP.md`, no APNs/FCM token handling and no call to
|
||
the device-registration endpoint. `apps/chat` ships a config plugin that deliberately STRIPS the iOS
|
||
push entitlement (it uses local notifications only), so today's iOS build cannot receive a remote push
|
||
at all. The only app-side note, `apps/mobile/PUSH_NOTIFICATIONS.md` (2026-07-24), predates this design
|
||
and CONTRADICTS it — it rejects FCM outright because the Pixel runs GrapheneOS without Play Services.
|
||
**That contradiction is unresolved and is the first thing to settle**, because it decides Android.
|
||
|
||
## What was decided, and what was rejected
|
||
|
||
**Apple and Google are unavoidable and we accept them.** There is no direct server→phone push on either
|
||
platform: iOS suspends apps, so only APNs can wake one, and Android only accepts pushes from FCM. An
|
||
earlier plan had these the other way round — believing iOS could be pushed to directly and Android needed
|
||
a bridge. It is the reverse.
|
||
|
||
**Expo is rejected.** Its push service is a relay in front of APNs and FCM, and it does not remove either
|
||
credential — a standalone Android build still needs an FCM service account, which you would upload _to
|
||
Expo_. All it adds is fan-out convenience, in exchange for our payloads and our APNs key living on a
|
||
fourth party's servers. We talk to Apple and Google ourselves.
|
||
|
||
**The Android foreground-service alternative is rejected for now.** A persistent socket plus a local
|
||
notification genuinely avoids Google, and the tailnet makes the connection easy. It costs a permanent
|
||
"Officer is running" entry in the notification shade and is unreliable against OEM battery managers
|
||
(Samsung, Xiaomi). Recorded here because it remains the fallback if FCM ever becomes unacceptable.
|
||
|
||
## Payload policy — the notification is a doorbell, not a message
|
||
|
||
**This is the load-bearing decision, and it is not optional.**
|
||
|
||
Both services see metadata regardless: which device, how often, at what times. What they must not see is
|
||
content. So a push carries a category and an id, never the substance:
|
||
|
||
```
|
||
✅ { type: 'mail', count: 3 } ❌ { title: 'Re: invoice', from: 'x@y.com' }
|
||
✅ { type: 'job', id: 412, ok: false } ❌ { error: 'ENOENT /home/pastilhas/…' }
|
||
```
|
||
|
||
The app already has a tailnet connection. On tap it fetches the real content itself. The visible text is
|
||
generic ("3 new emails"), assembled on the device from the category, not sent through Apple or Google.
|
||
|
||
Cost of both services: **free**. No per-message charge on either.
|
||
|
||
## Where it lives
|
||
|
||
A new **`officer-notify` sidecar**, PM2 peer, its own loopback HTTP listener, announced as
|
||
`notify:server` and proxied at `/api/notify` by `createSidecarProxy`.
|
||
|
||
Not the platform, because the producers are spread out — the queue, the email sidecar, the agent sidecar —
|
||
and a platform-owned notifier would force every sidecar to call _back_ into the platform. That is the
|
||
inversion just removed from email. As a sidecar, anything POSTs to it over loopback.
|
||
|
||
It also absorbs `src/servers/notify/discord.ts`, so there is one outbound-notification surface with
|
||
channels behind it rather than a Discord path and a push path that do not know about each other.
|
||
|
||
```
|
||
producer (queue / email / agent / platform)
|
||
│ POST /_officer/notify { type, ... }
|
||
▼
|
||
officer-notify ─┬─ apns → api.push.apple.com (HTTP/2, ES256 JWT)
|
||
├─ fcm → fcm.googleapis.com/v1 (OAuth2 bearer)
|
||
└─ discord→ webhook (existing)
|
||
```
|
||
|
||
## Credentials
|
||
|
||
Env only, on the sidecar, never in the platform process and never in the database.
|
||
|
||
| var | what |
|
||
| --------------------- | ------------------------------------------------ |
|
||
| `APNS_KEY_P8` | contents of the `.p8` auth key (PKCS#8 EC P-256) |
|
||
| `APNS_KEY_ID` | the key's 10-char Key ID |
|
||
| `APNS_TEAM_ID` | Apple Developer Team ID |
|
||
| `FCM_SERVICE_ACCOUNT` | the service-account JSON |
|
||
| `DISCORD_WEBHOOK_URL` | existing, moves here |
|
||
|
||
The `.p8` does not expire and can push to your apps forever. Treat it as spending-grade.
|
||
|
||
`APNS_ENV` was in this table and is **not** implemented, deliberately: the host is chosen per device from
|
||
the `environment` column, not per process, so one server can push to a TestFlight build and a release
|
||
build at the same time. All three read `OFFICERDEV_<NAME> ?? <NAME>`, the house env convention.
|
||
|
||
## Both protocols, verified in Bun before designing around them
|
||
|
||
- **APNs**: HTTP/2 only. `node:http2` works in Bun 1.3.10 (checked against a live server). Auth is an
|
||
ES256 JWT signed with the `.p8`, valid ≤1h, refreshed no more often than every 20 min or Apple rejects
|
||
it. **The signature must be raw `r||s` (64 bytes)** — `createSign(...).sign({ key, dsaEncoding:
|
||
'ieee-p1363' })`. Node's default DER encoding is silently rejected.
|
||
- **FCM v1**: ordinary HTTPS. Sign an RS256 JWT with the service-account key, exchange it at
|
||
`oauth2.googleapis.com/token` for a 1-hour access token, cache that, then POST to
|
||
`fcm.googleapis.com/v1/projects/<id>/messages:send`.
|
||
|
||
No push library is needed for either — `node:crypto` and `node:http2` cover it. All three endpoints are
|
||
reachable from this host.
|
||
|
||
## Device registry
|
||
|
||
New table `push_devices`:
|
||
|
||
| column | note |
|
||
| --------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
||
| `id` | serial |
|
||
| `user_id` | fk users |
|
||
| `token` | the native token — APNs device token or FCM registration token |
|
||
| `platform` | `ios` \| `android` |
|
||
| `environment` | `production` \| `sandbox` — an iOS dev-build token fails against prod with a silent `BadDeviceToken` |
|
||
| `bundle_id` | `apns-topic`; also distinguishes the three apps |
|
||
| `app_slug` | `mobile` \| `music` \| `read-aloud` |
|
||
| `last_seen_at`, `failure_count`, `created_at` | pruning |
|
||
|
||
Unique on `(token, bundle_id)`. Registration is idempotent: the app re-registers on every launch, since
|
||
tokens rotate.
|
||
|
||
## Failure handling — the part that is usually skipped
|
||
|
||
**APNs** answers inline: `410 Unregistered` or `400 BadDeviceToken` means delete the row immediately.
|
||
|
||
**FCM** answers inline too: `UNREGISTERED` / `INVALID_ARGUMENT` means delete.
|
||
|
||
Everything else increments `failure_count`; three strikes and the row goes. Without this the registry
|
||
fills with dead tokens and delivery quietly degrades.
|
||
|
||
(If Expo were used, this would instead require a deferred receipt poll ~15 min after send. Going direct
|
||
removes that entire mechanism — a genuine simplification, worth noting against the fan-out we gave up.)
|
||
|
||
## Open, deliberately deferred
|
||
|
||
- **Which events notify.** Out of scope by instruction: build the pipe first. Candidates when we get
|
||
there: job finished, new mail, long agent turn done, download complete.
|
||
- **Quiet hours and batching.** New mail on an 18k-mail account is unusable at one push per message.
|
||
- **`apps/mobile` EAS/bundle id** — only `apps/music` is confirmed to have one (`dev.officer.music`).
|
||
|
||
## Build order
|
||
|
||
1. ~~`push_devices` table + `bun db:push`.~~ done
|
||
2. ~~`officer-notify` sidecar shell: listener, PM2 entry, `/api/notify` proxy, Discord channel moved in.~~ done
|
||
3. ~~APNs channel + `POST /_officer/devices` registration.~~ done (27 tests)
|
||
4. ~~FCM channel.~~ done (27 tests)
|
||
5. First producer wired. **← next, and nothing has been notified by anything yet.**
|
||
|
||
Ahead of 5, two things are unresolved and neither is code: the Android/GrapheneOS contradiction above,
|
||
and whether a member can register a device (the `notify` permission is `kind: 'app'`, so it is grantable
|
||
per role and denied by default — a member with no grant cannot subscribe their own phone).
|