# APNs remote push — signed relay mode Native iOS background push (notifications even when the app is **suspended or killed**) is delivered via APNs through a **central relay**, so no user configures an Apple key. Each server signs its relay requests with an auto-generated keypair, and tokens are bound to the server that registered them — so a leaked device token alone can't be used to push. ## How it works 1. The app registers its APNs device token with **its own server** (`POST /api/push/apns-token`, `useNativePushRegistration`). PWA/desktop never register — only the native Capacitor app. 2. The server **binds the token on the relay**: it POSTs `{ token, publicKeyJwk, ts, sig }` to `POST /v1/push/register-token`, signed with its auto-generated ECDSA P-256 key (`getOrCreateRelayKeypair`, persisted in settings like the VAPID keys). The relay records `token → serverId` where `serverId = SHA-256(publicKey)`. 3. On a trigger (ready/error/question/permission), the server composes **generic, content-free** text — a fixed scenario title ("Agent response is ready" / "Agent needs your input" / "Agent needs permission" / "Agent hit an error") + the **session name** as the body, no model/project/ message content — plus a **`badge`** count (see below) — and POSTs `{ tokens, title, body, badge, env, data:{sessionId}, publicKeyJwk, ts, sig }` to `POST /v1/push/send` (`apns-runtime.js` → `sendViaRelay`). It does **not** gate on UI visibility (see below). 4. The **relay** (`openchamber-website/apps/api`, Cloudflare Worker) verifies the signature + `ts` freshness, derives `serverId`, and only delivers to tokens bound to that server. It holds the single project APNs `.p8` key, signs an ES256 JWT with `crypto.subtle`, and sends each token to APNs over HTTP/2, returning per-token results; the server drops tokens flagged `drop` (410 / BadDeviceToken). The relay stores no secret — only `token → serverId` hashes. 5. Tapping a push deep-links to its session via the forwarded `sessionId`. ## Foreground suppression APNs is **not** gated on UI visibility. A backgrounded WKWebView can't reliably report "hidden" before iOS suspends it, so a server-side visibility gate dropped background push for short responses. Instead the server always sends, and **iOS** suppresses the foreground banner (`PushNotifications.presentationOptions: []` in `capacitor.config`) — so there is no notification while the app is active, with no race. APNs is the native app's **only** channel; local notifications were removed (a WKWebView can't tell foreground from background — `document.hasFocus()` is unreliable — so they leaked while the app was open). Cloudflare is touched only when a native app with notifications on has a registered token and a trigger fires. ## App-icon badge Each push carries an **absolute** `aps.badge` = the number of **distinct collapse-ids (`tag`) pushed since the app was last foregrounded**. It mirrors the lock-screen banner stack. The count is a `Set` (`pendingPushTags`) in the trigger runtime (`runtime.js`): `toApnsGenericPayload` adds the push `tag` and returns the set size as the badge. We key by **`tag`, not sessionId**, because the tag *is* the banner identity — iOS uses it as `apns-collapse-id`, so same-tag pushes replace one banner while different tags are distinct banners. One session can raise several banners (`ready-`, `question-`, `permission-` are different tags), so counting sessionIds both over- and under-counts the stack; counting tags matches it. It is deliberately **not** derived from the live attention snapshot (`needsAttention`/`isViewed`): that machinery drives in-app indicators on *connected* clients, where a backgrounded client stays "viewing" and `needsAttention` is set by a separate `session.status` event that races the push trigger. The set self-clears via `clearPendingPushBadge` on any signal that the user is engaging with the app: the visibility beacon (`updateUiVisibility` wrapper, `visible:true`), **plus** opening a session (`POST /api/sessions/:id/view`) and sending a message (`POST /api/sessions/:id/ message-sent`). The latter two need no auth and fire reliably on the native app when it foregrounds, so they are the dependable reset — the visibility beacon alone proved unreliable in WKWebView. This mirrors the device zeroing its icon badge on `sceneDidBecomeActive` (`AppDelegate.swift`), keeping server and device in sync. The value flows `runtime.js` (`toApnsGenericPayload`) → `apns-runtime.js` (`sendViaRelay` body / direct-mode `aps.badge`) → relay (`pushSendSchema.badge` → `aps.badge`). It is **not** signed (like `body`/`data`); the relay still only delivers to bound tokens. The set is server-global, so every device token of a server sees the same badge. ## Modes - **Relay (default):** server has no Apple key; `OPENCHAMBER_PUSH_RELAY_URL` defaults to `https://api.openchamber.dev/v1/push/send` (register URL is derived as `…/register-token`). - **Direct (fallback):** set `OPENCHAMBER_PUSH_RELAY_DISABLED=true` + `OPENCHAMBER_APNS_KEY_ID/ TEAM_ID/P8` to sign+send from the server itself (HTTP/2 + ES256 JWT); no relay binding needed. ## Config Server (`apns-runtime.js`): - `OPENCHAMBER_PUSH_RELAY_URL` (default the public relay), `OPENCHAMBER_APNS_ENVIRONMENT` (optional override forcing every send to `sandbox` or `production`; normally unset — each token is delivered to the environment it registered with: the iOS shell reads the `aps-environment` entitlement from the embedded provisioning profile and reports it at registration, so Xcode dev builds go to sandbox and TestFlight/App Store to production). The signing keypair is auto-generated — nothing to set. - Direct fallback: `OPENCHAMBER_APNS_KEY_ID`, `OPENCHAMBER_APNS_TEAM_ID`, `OPENCHAMBER_APNS_P8` (or `_P8_PATH`), `OPENCHAMBER_APNS_BUNDLE_ID`, `OPENCHAMBER_PUSH_RELAY_DISABLED=true`. Relay (Cloudflare Worker secrets via `wrangler secret put` / GitHub Actions): `APNS_P8`, `APNS_KEY_ID`, `APNS_TEAM_ID`, optional `APNS_BUNDLE_ID` / `APNS_DEFAULT_ENV`. The `push_tokens` binding table is created by `migrations/0002_push_tokens.sql` (applied on deploy). ## Apple setup (one-time) 1. Apple **Keys** (not Certificates) → create an **APNs Auth Key** (`.p8`) → Key ID + Team ID; enable **Push Notifications** on App ID `com.openchamber.app`. 2. In the **openchamber-website** repo → Actions secrets: `APNS_P8` (PEM), `APNS_KEY_ID`, `APNS_TEAM_ID`. Push to `main` → relay deploys, secrets sync, D1 migrations apply. 3. Xcode: confirm the Push Notifications capability; Clean Build Folder; run on device. ## Security posture - The device token is a per-install secret, but no longer the *only* defence: every relay request is signed by the server's private key, and the relay only delivers to a token from its bound `serverId`. A leaked token alone is useless — an attacker has neither the private key nor a matching binding. - `serverId` self-certifies (`SHA-256(publicKey)`), so the relay holds no secret; a D1 leak exposes only `token → serverId` hashes. The signed `ts` (±5 min window) blocks replay. - Residual: trust-on-first-bind (whoever registers a token first owns it) — acceptable, since registering already requires possessing the token. Cloudflare rate limiting is defence-in-depth. ## Data confidentiality (what the relay / Apple can see) The push payload is **not** application-encrypted, so there is no decryption step. The text is sent in plaintext, protected only by **TLS in transit** (HTTPS to the relay, TLS from the relay to APNs). The request **signature is authentication, not encryption** — the relay *verifies* it (valid / invalid), it does not hide anything. Who can read the alert text: - **Network hops:** nothing (TLS). - **The relay (Cloudflare):** the generic title + body (session name), the device token, and `sessionId`. It stores only `token → serverId` hashes (no text, no payload). - **Apple APNs:** the alert text too — APNs always reads the alert payload of an `alert` push. - **The device:** displays it. This is acceptable **because the text is deliberately content-free**: a fixed scenario title + the session name only — no model, project, or message content (`runtime.js` → `toApnsGenericPayload`). The session name is the single semi-personal field that crosses the relay/Apple. To hide even that from Apple would require an end-to-end **encrypted payload** (`mutable-content` + a Notification Service Extension that decrypts on-device with a key never sent to the relay) — not implemented, and unnecessary for generic text. ## Android (FCM) note The Android equivalent is **FCM** (not implemented): the same relay would forward to FCM with a server key, and the client would register an FCM token (same store/routes + signing).