Issue: after defaulting APNs delivery to production (#2381), development builds installed from Xcode stopped receiving notifications entirely: their sandbox device tokens were sent to the production APNs endpoint, rejected as BadDeviceToken, and dropped as dead. Fix: the iOS shell reads the aps-environment entitlement from the embedded provisioning profile and exposes it to the web layer as a document-start user script (added in capacitorDidLoad, since Capacitor replaces the userContentController after webViewConfiguration(for:)). Token registration reports the environment to the server, which stores it per token and groups delivery by environment for both relay and direct APNs sends. OPENCHAMBER_APNS_ENVIRONMENT remains as an explicit override forcing every send to one environment. TestFlight/App Store builds and older clients without the field default to production, preserving released behavior; the relay already accepts env per send request.
136 lines
8.7 KiB
Markdown
136 lines
8.7 KiB
Markdown
# 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<tag>` (`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-<id>`, `question-<id>`, `permission-<requestKey>` 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).
|