Files
Bohdan Triapitsyn 9f1bd0dfa0 fix: route APNs delivery per-token by registered environment
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.
2026-07-25 01:18:41 +03:00

8.7 KiB

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.jssendViaRelay). 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.badgeaps.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.jstoApnsGenericPayload). 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).