Files
platform/docs/push-notifications.md
T
pastilhas 16369f3570 secret store holds foreign secrets, and notify reads from it
putSecret/getSecret, `bun run secret:set`, and notify taking its credentials
from the store with env as the fallback. this was described two messages ago
as a later thing; it is about fifteen lines and it was blocking the honest
answer to "where does the .p8 go", so it is done.

getKey was the wrong door for anything supplied from outside — it MINTS a
random 32-byte value when the purpose is absent, which for a foreign
credential means silently authenticating to apple with a fake. getSecret
returns null instead, which is what lets the env fallback work.

putSecret retires the previous row rather than deleting it: a mistyped paste
over a working key should be recoverable, and retiredKeys already exists for
that shape. one transaction, because the partial unique index means the retire
has to land before the insert.

the CLI reads stdin and has deliberately no --value flag: a secret in argv is
in shell history, in ps output, and in any audit log that records command
lines. offering the flag would make the convenient path the leaky one. it
prints a length and the last four characters, never the value.

also `bun run notify:test`, which fires officer/core:test at a registered
device. the sidecar's port is ephemeral, so it now writes its address to
data/notify-server.url on boot and removes it on clean shutdown; the CLI
treats an unreachable address as "not running" rather than trusting the file,
because a crash leaves one behind.

the script says what actually happened, because the day-waster here is that a
send with nothing configured returns success and delivers nothing — it refuses
up front when channels is empty, and calls out sent:0/failed:0 as "no device
registered" rather than letting it read as success.

verified end to end with a p-256 key generated locally and then deleted from
the store: zero APNS vars in .env, sidecar reported channels: apns, and the
send reached the channel. store is back to jwt and offscale only.
2026-08-19 19:57:51 +00:00

18 KiB

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:

{
  "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/notifications/publish.ts, which deliberately mirrors affordances/publish.ts): a notifications/ directory of YAML in the plugin repository, copied to capabilities/notifications/<publisher>/<appName>/ on install and removed on uninstall.

The namespace is derived from where the file lands, never from what it declares — the same rule as mountPrefix. A plugin therefore cannot claim officer/core: or another publisher's codes by writing them into its own catalogue; it would have to install itself somewhere it cannot. This is pinned by test. 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 they differ — so the mapping normally catches up before an unknown code can arrive.

version is a content hash, not a counter, so the test is mv !== version rather than mv > version. A counter would need somewhere to live and would then be 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.

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 { code, 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.

The secret store is where these live now — env is only the fallback.

cat AuthKey_ABCD123456.p8 | bun run secret:set apns-p8
echo -n ABCD123456        | bun run secret:set apns-key-id
echo -n RGHC4NJC77        | bun run secret:set apns-team-id
pm2 restart officer-notify        # _health should then report channels: ["apns"]

Purposes: apns-p8, apns-key-id, apns-team-id, fcm-service-account, discord-webhook. Read with getSecret and NOT getKey — getKey mints a random value for an absent purpose, which for a credential supplied from outside would mean silently authenticating to Apple with a fake.

Env is still read when the store has nothing, so an install that predates this keeps working. But it is a fallback: bun auto-loads .env into every PM2 process, so a key there is readable from /proc/<pid>/environ of twenty of them, and officer-music has no business being able to read a credential that can push to your apps forever.

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. Done. The producer contract is now POST /_officer/notify { code, userId, n?, id? }, validated on the SHAPE of the code (<publisher>/<appName>:<key>) and deliberately not against the catalogue — a producer firing a code the catalogue has not caught up with is sending a real event, and the device already has a defined fallback. ok is gone: a failed job is its own code (officer/core:job-failed), which is what made the enum rigid in the first place.
  • text.ts moves to the device. Done — replaced by render.ts, which renders server-side only for Discord (no app to resolve a code) and for the alert fallback string.

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.