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.
295 lines
17 KiB
Markdown
295 lines
17 KiB
Markdown
# Push notifications — design
|
|
|
|
**Status (2026-08-19):** server side **built and core**; app side **not started**. This is the second
|
|
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
|
|
**hub**: the payload it gets is a **code** plus a few numbers, and the app turns that into the notification
|
|
you actually see, deep-linked to whichever app and event it concerns. The code's meaning lives in a
|
|
**mapping owned by the server** and synced to the device ahead of time, so adding a new kind of
|
|
notification is a server change and never an app build. Plugins declare their own codes the same way they
|
|
declare affordances.
|
|
|
|
## What changed and why
|
|
|
|
The first design was a **doorbell**: a category and an id, never content, with the visible text composed
|
|
server-side from the category alone. It called that "the load-bearing decision, and it is not optional".
|
|
|
|
Three things moved.
|
|
|
|
**1. Privacy relaxed, but not abandoned.** The original weighting — nothing readable may reach Apple or
|
|
Google — was reconsidered on 2026-08-19 as stricter than the owner actually wanted. What replaced it is
|
|
not "send plaintext": it is **codes**, which keeps content off Apple's and Google's servers while removing
|
|
the reason the doorbell was painful (a rigid category enum). The privacy property survives almost intact;
|
|
what was given up is the claim that it was inviolable.
|
|
|
|
**2. One receiver, not many.** The first design had each app register its own token and `appSlug` select
|
|
which app a notification went to. Now a single hub app receives everything and `appSlug` names the
|
|
**deep-link destination** instead. Same field, opposite direction — see "Migration notes".
|
|
|
|
**3. Extensibility became the priority.** The binding constraint is now "a new notification kind must not
|
|
require an app store build". That single requirement is what forces the server-owned mapping, and it is
|
|
worth more than the category purity the first design optimised for.
|
|
|
|
**What did not change:** content still does not travel. Apple and Google see a code and some integers.
|
|
|
|
## Why a hub app
|
|
|
|
- **One entitlement, one topic, one sender.** A single bundle id means one `aps-environment`, one APNs
|
|
topic and one FCM sender, instead of credentials multiplying per app.
|
|
- **It resolves an existing contradiction.** `apps/chat` ships `withoutPushEntitlement.js`, a config plugin
|
|
that deliberately STRIPS `aps-environment` because chat uses local notifications only. Under the first
|
|
design that was a blocker. Under this one it is correct and stays — chat is not a push receiver, the hub
|
|
is.
|
|
- **It concentrates the Android problem** rather than multiplying it (see "Android and GrapheneOS").
|
|
|
|
## The payload
|
|
|
|
An ordinary **alert** push — not a silent one — carrying a code and numbers:
|
|
|
|
```jsonc
|
|
{
|
|
"aps": {
|
|
"alert": { "title": "Officer" }, // fallback only; see "What the OS shows"
|
|
"mutable-content": 1,
|
|
"sound": "default",
|
|
"thread-id": "officerdev/music",
|
|
"badge": 3,
|
|
},
|
|
"officer": {
|
|
"c": "officerdev/music:12.3", // the code — namespaced, see below
|
|
"n": 3, // variable numbers, never content
|
|
"id": 412, // what to fetch/open, never a human-readable string
|
|
"mv": 47, // mapping version the server rendered against
|
|
},
|
|
}
|
|
```
|
|
|
|
**Codes are namespaced, for the same reason affordances are.** `<publisher>/<appName>:<code>`. Plugins from
|
|
different publishers will otherwise collide on a bare integer, and a code in a log should say whose it is
|
|
without a lookup. `core` occupies the appName slot for the platform's own.
|
|
|
|
**Variable data rides beside the code, never inside it.** The mapping holds a template — `"{n} new emails"` —
|
|
and the push supplies `n`. Numbers and ids are not content, so the privacy property survives templating.
|
|
|
|
**What does NOT go in:** subject lines, senders, filenames, error strings, agent output, paths. If a value
|
|
would embarrass you in a Google log, it belongs behind `id` and gets fetched over the tailnet on tap.
|
|
|
|
## What the OS shows, and why `mutable-content` is required
|
|
|
|
A normal alert push is rendered **by the operating system, not by the app**. Send `alert.title = "12.3"`
|
|
and the user sees `12.3` on the lock screen — the app never gets a chance to touch it.
|
|
|
|
So the hub needs a **Notification Service Extension**, enabled per-push by `mutable-content: 1`. The
|
|
extension runs before display, resolves the code, and rewrites the banner.
|
|
|
|
**Crucially, the extension does a LOCAL lookup, not a network fetch.** The mapping is synced in advance by
|
|
the app into a shared App Group container; at notification time it is a dictionary read. This is what makes
|
|
the design work where a fetch-on-arrival design would not:
|
|
|
|
- no dependence on the tailnet being connected when the push lands
|
|
- no 30-second budget to overrun
|
|
- no partial failure mode on cellular with the VPN off
|
|
|
|
**This costs exactly one app build, ever — never one per notification kind.** An extension that hardcoded a
|
|
`switch` on type would need rebuilding for every new kind; this one is a generic resolver and does not.
|
|
|
|
**The alert text you send is the degraded case.** If the extension does not run — low memory, a crash, an
|
|
older OS — the user sees exactly what was in `alert`. So it is a graceful generic ("Officer"), deliberately
|
|
**not** plausible-looking decoy text: a convincing fake sentence would mislead the one person who most needs
|
|
to trust it.
|
|
|
|
**Silent pushes were considered and rejected.** `content-available: 1` with no alert would let the app
|
|
compose everything and leak least, but Apple throttles silent pushes and explicitly does not guarantee
|
|
delivery. For notifications that must arrive, that is the wrong tool.
|
|
|
|
**Android needs no extension.** An FCM **data-only message at high priority** wakes the app, which composes
|
|
the notification itself. Same codes, same mapping.
|
|
|
|
## The mapping
|
|
|
|
The server owns it; the device caches it.
|
|
|
|
```
|
|
GET /api/notify/mapping → { version: 47, entries: { "officerdev/music:12.3": {...} } }
|
|
```
|
|
|
|
An entry says how to render and where to go:
|
|
|
|
| field | note |
|
|
| -------- | -------------------------------------------------------------- |
|
|
| `title` | template, `{n}`-style placeholders |
|
|
| `body` | template, optional |
|
|
| `target` | which app the deep link opens |
|
|
| `route` | the deep link, with `{id}` interpolated |
|
|
| `thread` | grouping key, so like-notifications collapse rather than stack |
|
|
|
|
**Plugins declare their own**, on the affordances pattern (`src/servers/affordances/publish.ts`): a
|
|
directory in the plugin repository, copied to a platform-managed store on install, removed on uninstall,
|
|
namespaced `<publisher>/<appName>/`. That mechanism already exists and is understood; a second one for
|
|
notifications would drift from it. The platform aggregates every installed plugin's catalogue plus its own
|
|
into the response above, and `version` increments whenever that aggregate changes.
|
|
|
|
### Staying in sync, and the fallback
|
|
|
|
Every push carries `mv`, the mapping version it was rendered against. The app compares it to its cached
|
|
version and **resyncs in the background when it is behind** — so the mapping normally catches up before an
|
|
unknown code can arrive.
|
|
|
|
When a push still outruns the sync, the fallback renders:
|
|
|
|
> **Unknown notification**
|
|
> code `officerdev/music:12.3` — tap to sync
|
|
|
|
deep-linking to `officer://resync-notification-mapping`. The code goes **in the body on purpose**: when this
|
|
fires on the owner's own phone, the first useful question is which code the server sent that the app did not
|
|
have, and without it that is debugged blind.
|
|
|
|
**The resync screen must be able to give up.** If a plugin is uninstalled its codes leave the catalogue, so
|
|
a queued notification for one will resync, still not resolve, and otherwise loop forever. That path ends
|
|
with "this came from something no longer installed", not another retry.
|
|
|
|
## What this still leaks
|
|
|
|
Apple and Google see **which code, how often, at what time, to which device**. Over months that is a
|
|
fingerprint of the owner's category vocabulary even though every code is opaque. That is much less than
|
|
plaintext and is judged acceptable — but it is not nothing, and this document does not claim otherwise.
|
|
|
|
## Where it lives
|
|
|
|
`officer-notify`, a **core** PM2 process since 2026-08-19 (built 2026-07-31; spent 2026-08-13 to 08-19
|
|
switched off after being slated for plugin extraction and never extracted). Core rather than a plugin
|
|
because the producers are the platform's own — the job queue, an agent turn ending, mail arriving — and a
|
|
notifier the owner can uninstall is one those producers cannot rely on.
|
|
|
|
```
|
|
producer (queue / email / agent / platform / plugin)
|
|
│ POST /_officer/notify { c, userId, n?, id? }
|
|
▼
|
|
officer-notify ─┬─ apns → api.push.apple.com (HTTP/2, ES256 JWT)
|
|
├─ fcm → fcm.googleapis.com/v1 (OAuth2 bearer)
|
|
└─ discord→ webhook
|
|
```
|
|
|
|
It boots with **no credentials at all** — every channel reports `isConfigured() === false` and `dispatch`
|
|
resolves empty — so an install that never sets an APNs key runs it as a no-op rather than crash-looping.
|
|
That is what makes it safe as core.
|
|
|
|
## Credentials
|
|
|
|
Env only, on the sidecar, never in the platform process and never in the database. All read
|
|
`OFFICERDEV_<NAME> ?? <NAME>`.
|
|
|
|
| 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 |
|
|
|
|
The `.p8` does not expire and can push to your apps forever. Treat it as spending-grade.
|
|
|
|
`APNS_ENV` appeared in the first design's table and is deliberately **not** implemented: the host is chosen
|
|
per device from the `environment` column, not per process, so one server pushes to a TestFlight build and a
|
|
release build at the same time.
|
|
|
|
## Both protocols, verified in Bun before designing around them
|
|
|
|
- **APNs**: HTTP/2 only. `node:http2` works in Bun. 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 it, then POST to
|
|
`fcm.googleapis.com/v1/projects/<id>/messages:send`.
|
|
|
|
No push library is needed for either.
|
|
|
|
## Device registry
|
|
|
|
`push_devices` — live in the schema as of 2026-08-19.
|
|
|
|
| column | note |
|
|
| --------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
| `id` | serial |
|
|
| `user_id` | fk users |
|
|
| `token` | APNs device token or FCM registration token |
|
|
| `platform` | `ios` \| `android` |
|
|
| `environment` | `production` \| `sandbox` — a dev-build token fails against prod with a silent `BadDeviceToken` |
|
|
| `bundle_id` | `apns-topic` |
|
|
| `app_slug` | see migration note |
|
|
| `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.
|
|
|
|
### Migration notes from the first design
|
|
|
|
- **`appSlug` on a NOTIFICATION inverts.** It meant "deliver only to this app". It now means "the deep-link
|
|
destination". Nothing consumes it yet, so this is a rename-in-place, not a data migration.
|
|
- **`bundle_id` / `app_slug` on a DEVICE go near-constant.** The columns exist to let several apps register
|
|
separately; with a hub there is one bundle id per device. Keep them — a second receiver later (a watch, a
|
|
desktop build) costs nothing this way — but "which apps has this user got" stops being a delivery question.
|
|
- **`NotifyType` is replaced by codes.** The `'job' | 'mail' | 'agent' | 'download' | 'test'` enum was the
|
|
rigidity this design exists to remove. `test` should survive as a reserved code for first light.
|
|
- **`text.ts` moves to the device.** Server-side `renderTitle`/`renderBody` becomes the mapping. Keep a
|
|
minimal server-side generic for the `alert` fallback.
|
|
|
|
## Failure handling
|
|
|
|
**APNs** answers inline: `410 Unregistered` or `400 BadDeviceToken` means delete the row immediately.
|
|
**FCM** likewise: `UNREGISTERED` / `INVALID_ARGUMENT` / `NOT_FOUND` 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.
|
|
|
|
## Android and GrapheneOS — UNRESOLVED, and it decides Android
|
|
|
|
`apps/mobile/PUSH_NOTIFICATIONS.md` (2026-07-24) rejects FCM outright: _"requires Google Play Services; the
|
|
Pixel runs GrapheneOS without it."_ The 2026-07-31 platform design chose FCM for Android a week later and
|
|
does not mention GrapheneOS. **Nobody reconciled the two, and this design does not either.**
|
|
|
|
It matters because FCM cannot reach a device with no Play Services, so on such a phone the entire Android
|
|
path is inert regardless of anything above.
|
|
|
|
The recorded fallback is a **foreground service holding a tailnet socket**, with notifications raised
|
|
locally. It was rejected in the first design for a permanent "Officer is running" entry in the shade and
|
|
unreliability against OEM battery managers. **The hub changes that calculus**: one socket and one shade
|
|
entry for the whole platform, not one per app. The battery-manager objection is also weaker on GrapheneOS
|
|
than on Samsung or Xiaomi.
|
|
|
|
This needs an owner decision before Android work starts. iOS is unaffected and can proceed independently.
|
|
|
|
## Build order
|
|
|
|
1. ~~`push_devices` table.~~ done
|
|
2. ~~`officer-notify` sidecar, PM2 entry, `/api/notify` proxy, Discord channel.~~ done
|
|
3. ~~APNs channel + device registration endpoint.~~ done, 27 tests
|
|
4. ~~FCM channel.~~ done, 27 tests
|
|
5. **APNs credentials.** Owner action; nothing can be tested end-to-end without them.
|
|
6. **Hub app registers its token** against `POST /api/notify/_officer/devices`. Small.
|
|
7. **First light**: a `test` push rendered by the OS with plain text, no codes, no extension. Proves
|
|
credential → APNs → device → banner as one thing, before any of the mapping exists.
|
|
8. **Codes and the mapping**: `NotifyType` → codes, `GET /api/notify/mapping`, plugin catalogues on the
|
|
affordances pattern.
|
|
9. **The Notification Service Extension**, local lookup, App Group container.
|
|
10. **First real producer** — job finished is the obvious one.
|
|
|
|
Steps 7 and 8 are deliberately separable: the transport can be proven working before the rendering exists,
|
|
and if it is not, no amount of mapping work will help.
|
|
|
|
## Still open
|
|
|
|
- **Which events notify**, beyond the first producer. Quiet hours and batching — new mail on an 18k-mail
|
|
account is unusable at one push per message.
|
|
- **Whether members can register devices.** 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.
|
|
- **Android**, per above.
|