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.
This commit is contained in:
natheihei
2026-07-23 13:21:40 +03:00
committed by GitHub
parent a1784b8697
commit 12e2c55171
4 changed files with 31 additions and 6 deletions
@@ -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`.
@@ -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.
@@ -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',
};
};
@@ -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 = [];