Files
platform/docs/push-notifications.md
T
pastilhas fd7c9becb6 notify is core again
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.
2026-08-19 18:05:33 +00:00

9.6 KiB
Raw Blame History

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).