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.
This commit is contained in:
+52
-30
@@ -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_<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.
|
||||
'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`.
|
||||
@@ -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).
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
+2
-2
@@ -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<typeof createRouter>
|
||||
['/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],
|
||||
|
||||
@@ -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'],
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user