From 12e2c551712ccf23cf6cff99f38fc687f8e3ec08 Mon Sep 17 00:00:00 2001 From: natheihei Date: Thu, 23 Jul 2026 03:21:40 -0700 Subject: [PATCH] fix: default APNs delivery to production (#2381) Issue: TestFlight iOS clients register production APNs device tokens, but notifications from a default OpenChamber server are not delivered. Expected behavior: the released iOS app receives notifications without requiring users to configure an APNs environment, while development builds can explicitly select sandbox delivery. Root cause: both relay and direct APNs delivery defaulted to the sandbox environment, so production TestFlight tokens were sent to the wrong APNs endpoint. Fix: default both delivery modes to production, preserve OPENCHAMBER_APNS_ENVIRONMENT=sandbox as an explicit development override, and update tests and documentation. --- packages/web/server/lib/notifications/APNS.md | 2 +- .../server/lib/notifications/DOCUMENTATION.md | 2 +- .../server/lib/notifications/apns-runtime.js | 8 +++--- .../lib/notifications/apns-runtime.test.js | 25 +++++++++++++++++++ 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/packages/web/server/lib/notifications/APNS.md b/packages/web/server/lib/notifications/APNS.md index 61821a3e..efa615db 100644 --- a/packages/web/server/lib/notifications/APNS.md +++ b/packages/web/server/lib/notifications/APNS.md @@ -76,7 +76,7 @@ device token of a server sees the same badge. Server (`apns-runtime.js`): - `OPENCHAMBER_PUSH_RELAY_URL` (default the public relay), `OPENCHAMBER_APNS_ENVIRONMENT` - (`sandbox` default / `production`). The signing keypair is auto-generated — nothing to set. + (`production` default / `sandbox` for development builds). 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`. diff --git a/packages/web/server/lib/notifications/DOCUMENTATION.md b/packages/web/server/lib/notifications/DOCUMENTATION.md index 4e03861e..8dd53532 100644 --- a/packages/web/server/lib/notifications/DOCUMENTATION.md +++ b/packages/web/server/lib/notifications/DOCUMENTATION.md @@ -72,7 +72,7 @@ This module provides notification message preparation utilities for the web serv - `removeApnsTokenFromAllSessions(deviceToken)` - `sendApnsToAllUiSessions(payload)` — signs + sends to all registered tokens (no UI-visibility gate; iOS suppresses the foreground banner). No-ops with a single warning when APNs is unconfigured. Drops tokens on `410` / `BadDeviceToken` / `Unregistered`. - `resolveApnsConfig()` -- Configuration (env first, then `settings.apnsConfig`): `OPENCHAMBER_APNS_KEY_ID`, `OPENCHAMBER_APNS_TEAM_ID`, `OPENCHAMBER_APNS_P8` (PEM contents; literal `\n` accepted) or `OPENCHAMBER_APNS_P8_PATH`, `OPENCHAMBER_APNS_BUNDLE_ID` (default `com.openchamber.app`), `OPENCHAMBER_APNS_ENVIRONMENT` (`sandbox` default, or `production`). +- Configuration (env first, then `settings.apnsConfig`): `OPENCHAMBER_APNS_KEY_ID`, `OPENCHAMBER_APNS_TEAM_ID`, `OPENCHAMBER_APNS_P8` (PEM contents; literal `\n` accepted) or `OPENCHAMBER_APNS_P8_PATH`, `OPENCHAMBER_APNS_BUNDLE_ID` (default `com.openchamber.app`), `OPENCHAMBER_APNS_ENVIRONMENT` (`production` default, or `sandbox` for development builds). ### Emitter runtime API (emitter-runtime.js) - `createNotificationEmitterRuntime(dependencies)`: creates runtime for unified notification emission channels. diff --git a/packages/web/server/lib/notifications/apns-runtime.js b/packages/web/server/lib/notifications/apns-runtime.js index c9b475ef..0c0056d3 100644 --- a/packages/web/server/lib/notifications/apns-runtime.js +++ b/packages/web/server/lib/notifications/apns-runtime.js @@ -256,7 +256,7 @@ export const createApnsRuntime = (deps) => { teamId, p8, bundleId: bundleId || DEFAULT_BUNDLE_ID, - environment: environment === 'production' ? 'production' : 'sandbox', + environment: environment === 'sandbox' ? 'sandbox' : 'production', }; }; @@ -374,9 +374,9 @@ export const createApnsRuntime = (deps) => { url, registerUrl: url.replace(/\/send$/, '/register-token'), environment: - (trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || 'sandbox').toLowerCase() === 'production' - ? 'production' - : 'sandbox', + (trimmedEnv('OPENCHAMBER_APNS_ENVIRONMENT') || 'production').toLowerCase() === 'sandbox' + ? 'sandbox' + : 'production', }; }; diff --git a/packages/web/server/lib/notifications/apns-runtime.test.js b/packages/web/server/lib/notifications/apns-runtime.test.js index 5605ddc7..5c15d95b 100644 --- a/packages/web/server/lib/notifications/apns-runtime.test.js +++ b/packages/web/server/lib/notifications/apns-runtime.test.js @@ -69,6 +69,7 @@ afterEach(() => { vi.unstubAllGlobals(); delete process.env.OPENCHAMBER_PUSH_RELAY_URL; delete process.env.OPENCHAMBER_PUSH_RELAY_DISABLED; + delete process.env.OPENCHAMBER_APNS_ENVIRONMENT; }); describe('apns runtime relay mode (default)', () => { @@ -116,6 +117,7 @@ describe('apns runtime relay mode (default)', () => { expect(sent.title).toBe('Agent response is ready'); expect(sent.body).toBe('My session'); expect(sent.badge).toBe(3); + expect(sent.env).toBe('production'); expect(sent.data).toEqual({ sessionId: 'sess1' }); expect(sent.publicKeyJwk).toMatchObject({ kty: 'EC', crv: 'P-256' }); const sendMessage = `${sent.ts}.${[...sent.tokens].sort().join(',')}.${sent.title}`; @@ -144,6 +146,20 @@ describe('apns runtime relay mode (default)', () => { expect(deps.writeSettingsToDisk).toHaveBeenCalledTimes(1); }); + it('honors an explicit sandbox environment', async () => { + const fetchMock = vi.fn(async () => jsonResponse({ ok: true, results: [] })); + vi.stubGlobal('fetch', fetchMock); + process.env.OPENCHAMBER_PUSH_RELAY_URL = 'https://relay.test/v1/push/send'; + process.env.OPENCHAMBER_APNS_ENVIRONMENT = 'sandbox'; + + const runtime = createApnsRuntime(makeDeps()); + await runtime.addOrUpdateApnsToken('s1', 'tokenA'); + await runtime.sendApnsToAllUiSessions({ title: 't', body: 'b' }); + + const sent = JSON.parse(fetchMock.mock.calls.find(isSend)[1].body); + expect(sent.env).toBe('sandbox'); + }); + it('no-ops (no relay call) when no tokens are registered', async () => { const fetchMock = vi.fn(); vi.stubGlobal('fetch', fetchMock); @@ -154,6 +170,15 @@ describe('apns runtime relay mode (default)', () => { }); describe('apns runtime direct fallback (relay disabled)', () => { + it('defaults direct APNs configuration to production', async () => { + const { environment: _environment, ...configWithoutEnvironment } = APNS_CONFIG; + const runtime = createApnsRuntime( + makeDeps({ readSettingsFromDiskMigrated: vi.fn(async () => ({ apnsConfig: configWithoutEnvironment })) }), + ); + + await expect(runtime.resolveApnsConfig()).resolves.toMatchObject({ environment: 'production' }); + }); + it('signs an ES256 JWT and sends over http2 when relay is disabled', async () => { process.env.OPENCHAMBER_PUSH_RELAY_DISABLED = 'true'; const targeted = [];