diff --git a/docs/push-notifications.md b/docs/push-notifications.md index 39f0096f..d556e827 100644 --- a/docs/push-notifications.md +++ b/docs/push-notifications.md @@ -1,7 +1,23 @@ # Push notifications — design -**Status:** agreed design, 2026-07-31. Implementation starting. Server side; the app side is a separate -document handed to the apps repo (`officer-suite/COMMS/PUSH_NOTIFICATIONS_APP.md`). +**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 @@ -11,8 +27,8 @@ earlier plan had these the other way round — believing iOS could be pushed to 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 +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 @@ -43,7 +59,7 @@ A new **`officer-notify` sidecar**, PM2 peer, its own loopback HTTP listener, an `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 +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 @@ -62,23 +78,26 @@ producer (queue / email / agent / platform) 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 | -| `APNS_ENV` | `production` or `sandbox` — different hosts AND different tokens | -| `FCM_SERVICE_ACCOUNT` | the service-account JSON | -| `DISCORD_WEBHOOK_URL` | existing, moves here | +| 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_ ?? `, 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. +'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//messages:send`. @@ -90,16 +109,16 @@ reachable from this host. 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 | +| 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. @@ -125,9 +144,12 @@ removes that entire mechanism — a genuine simplification, worth noting against ## Build order -1. `push_devices` table + `bun db:push`. -2. `officer-notify` sidecar shell: listener, `notify:server`, PM2 entry, `/api/notify` proxy. Discord - channel moved in — one real channel end-to-end before any push credential exists. -3. APNs channel + `POST /_officer/devices` registration. -4. FCM channel. -5. First producer wired. +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). diff --git a/scripts/setup/officer-setup/lib/services.sh b/scripts/setup/officer-setup/lib/services.sh index a379b8c6..a232d085 100644 --- a/scripts/setup/officer-setup/lib/services.sh +++ b/scripts/setup/officer-setup/lib/services.sh @@ -51,6 +51,15 @@ CORE_PROCESSES=( # server having a calendar is not an integration with something you already run; it is the baseline. # Radicale needs `python3` and the `radicale` package, both installed by machine-setup. "officer-caldav|bun|run src/servers/sidecar/caldav/index.ts" + # Push notifications, CORE since 2026-08-19. Built 2026-07-31, then switched off on 2026-08-13 with + # memos and photos when it was slated to become a plugin — the only one of the three never extracted, + # so it sat mounted nowhere with its schema commented out. + # + # 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. + # 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. + "officer-notify|bun|run src/servers/sidecar/notify/index.ts" ) write_ecosystem() { diff --git a/src/databases/officer_db/src/schema.ts b/src/databases/officer_db/src/schema.ts index 39b24433..c66373af 100644 --- a/src/databases/officer_db/src/schema.ts +++ b/src/databases/officer_db/src/schema.ts @@ -43,10 +43,10 @@ export * from './agent-panels/schema'; // agent_panels export * from './app-store/schema'; // sidecar_installs export * from './plugins/schema'; // plugin_installs — core: the plugin system is the platform's own export * from './service-connections/schema'; // service_connections +export * from './notify/schema'; // push_devices — core since 2026-08-19, see docs/push-notifications.md // ── Plugins — uncomment when the plugin is installed ───────────────────────────────────────────── -// export * from './notify/schema'; // push_devices officer-notify export * from './dav/schema'; // dav_app_passwords officer-caldav // ── Plugin tables are NOT here ─────────────────────────────────────────────────────────────────── diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 36e4c19c..45a52106 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -31,7 +31,7 @@ import { appStoreRouter } from './api/app-store/router'; import { davSyncRouter } from './api/dav/sync-router'; import { davRouter } from './api/dav/router'; import { claimIosProfile } from './api/dav/ios-profile'; -// import { notifyRouter } from './api/notify/router'; +import { notifyRouter } from './api/notify/router'; import { systemMonitorRouter } from './api/system-monitor/system-monitor'; import { activityRouter } from './api/activity/router'; import { dockRouter } from './api/dock/dock'; @@ -142,7 +142,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType ['/plugins', pluginsRouter], ['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI ['/dav', davRouter], // app-password management (the sync door is /dav, top-level) - // ['/notify', notifyRouter], // plugin — switched off 2026-08-13 + ['/notify', notifyRouter], // core since 2026-08-19 — the one outbound-notification surface // ['/photos', photosRouter], // plugin — switched off 2026-08-13 ['/system-monitor', systemMonitorRouter], ['/activity', activityRouter], diff --git a/src/servers/permissions/registry.ts b/src/servers/permissions/registry.ts index 0bf9ba13..312ef00d 100644 --- a/src/servers/permissions/registry.ts +++ b/src/servers/permissions/registry.ts @@ -167,8 +167,7 @@ const CORE_REGISTRY: Permission[] = [ label: 'Notifications', description: 'Push notifications to your devices', kind: 'app', - // api: ['/notify'], // plugin — switched off 2026-08-13 - api: [], + api: ['/notify'], // Device registration is the caller's own — a phone subscribing to its own push channel. personal: ['/devices', '/subscriptions'], }, diff --git a/src/servers/sidecar/notify/apns.ts b/src/servers/sidecar/notify/apns.ts index 6f64365f..bb98bb6e 100644 --- a/src/servers/sidecar/notify/apns.ts +++ b/src/servers/sidecar/notify/apns.ts @@ -22,7 +22,10 @@ const HOSTS = { /** Apple's reasons for "this token is dead" — the row goes immediately rather than after three strikes. */ const DEAD_TOKEN_REASONS = new Set(['BadDeviceToken', 'Unregistered', 'DeviceTokenNotForTopic']); -const { APNS_KEY_P8, APNS_KEY_ID, APNS_TEAM_ID } = process.env; +// `OFFICERDEV_X ?? X` — the house convention since the env prefixing, so an install that predates it +// keeps working. See src/servers/env-prefix.ts. +const env = (name: string) => process.env[`OFFICERDEV_${name}`] ?? process.env[name]; +const [APNS_KEY_P8, APNS_KEY_ID, APNS_TEAM_ID] = [env('APNS_KEY_P8'), env('APNS_KEY_ID'), env('APNS_TEAM_ID')]; const auth = APNS_KEY_P8 && APNS_KEY_ID && APNS_TEAM_ID diff --git a/src/servers/sidecar/notify/discord.ts b/src/servers/sidecar/notify/discord.ts index 69f778f3..d34bd234 100644 --- a/src/servers/sidecar/notify/discord.ts +++ b/src/servers/sidecar/notify/discord.ts @@ -15,7 +15,7 @@ import { renderTitle } from './text'; // and its history is searchable and permanent in a way a push banner is not — arguably it deserves the // rule more, not less. -const WEBHOOK_URL = process.env.DISCORD_WEBHOOK_URL; +const WEBHOOK_URL = process.env.OFFICERDEV_DISCORD_WEBHOOK_URL ?? process.env.DISCORD_WEBHOOK_URL; export const discordChannel: Channel = { name: 'discord', diff --git a/src/servers/sidecar/notify/fcm.ts b/src/servers/sidecar/notify/fcm.ts index 957cc673..2f381552 100644 --- a/src/servers/sidecar/notify/fcm.ts +++ b/src/servers/sidecar/notify/fcm.ts @@ -18,7 +18,7 @@ const ENDPOINT = (projectId: string) => `https://fcm.googleapis.com/v1/projects/ /** Google's ways of saying "this token is dead" — the row goes immediately. */ const DEAD_TOKEN_CODES = new Set(['UNREGISTERED', 'INVALID_ARGUMENT', 'NOT_FOUND']); -const raw = process.env.FCM_SERVICE_ACCOUNT; +const raw = process.env.OFFICERDEV_FCM_SERVICE_ACCOUNT ?? process.env.FCM_SERVICE_ACCOUNT; const auth = raw ? new FcmAccessToken(parseServiceAccount(raw)) : null; type FcmMessage = {