diff --git a/web/app/api/device-tokens/route.ts b/web/app/api/device-tokens/route.ts index 5ab9c447a75c..70a20ec3bd2f 100644 --- a/web/app/api/device-tokens/route.ts +++ b/web/app/api/device-tokens/route.ts @@ -20,6 +20,7 @@ import { MAX_PUSH_REQUEST_BYTES, normalizeApnsBundle, readBoundedJsonObject, + registrationApnsBundle, } from "../../../services/apns/routePolicy"; import { AccountDeletionMutationBlockedError, @@ -321,7 +322,7 @@ function parseRegistrationInput( const installationId = typeof body.installationId === "string" ? body.installationId.trim() : ""; const pushKeyId = typeof body.pushKeyId === "string" ? body.pushKeyId.trim() : ""; const pushPublicKey = typeof body.pushPublicKey === "string" ? body.pushPublicKey.trim() : ""; - const bundle = normalizeApnsBundle(bundleId); + const bundle = registrationApnsBundle(normalizeApnsBundle(bundleId), body.environment); if (!HEX_TOKEN.test(deviceToken)) return { ok: false, response: jsonResponse({ error: "invalid_device_token" }, 400) }; if (!bundle) return { ok: false, response: jsonResponse({ error: "invalid_bundle_id" }, 400) }; if (!/^[A-Za-z0-9._:-]{1,255}$/.test(clientNamespace) || (clientNamespace !== "legacy" && clientNamespace !== bundle.bundleId)) { diff --git a/web/services/apns/routePolicy.ts b/web/services/apns/routePolicy.ts index 7cb10937f21e..27aa92d9032f 100644 --- a/web/services/apns/routePolicy.ts +++ b/web/services/apns/routePolicy.ts @@ -109,6 +109,23 @@ function boundedString(value: unknown, maxChars: number): string | null { return text; } +/** + * The APNs environment to store for one registration. A production bundle + * defaults to the production host, but a Simulator or development-signed + * install of that bundle only receives sandbox tokens and declares + * `environment: "sandbox"`; sending those to the production host fails with + * BadDeviceToken and prunes the row. Development bundles stay sandbox-only. + */ +export function registrationApnsBundle( + bundle: ApnsBundlePolicy | null, + requestedEnvironment: unknown, +): ApnsBundlePolicy | null { + if (bundle?.environment === "production" && requestedEnvironment === "sandbox") { + return { bundleId: bundle.bundleId, environment: "sandbox" }; + } + return bundle; +} + export function normalizeApnsBundle(bundleId: string): ApnsBundlePolicy | null { const normalized = bundleId.trim(); if (PROD_BUNDLE_IDS.has(normalized)) { diff --git a/web/tests/apns.test.ts b/web/tests/apns.test.ts index 5daa2db0bcf5..244ef306928f 100644 --- a/web/tests/apns.test.ts +++ b/web/tests/apns.test.ts @@ -35,6 +35,7 @@ import { normalizeApnsBundle, parsePushPayload, readBoundedJsonObject, + registrationApnsBundle, } from "../services/apns/routePolicy"; describe("apns payload", () => { @@ -495,6 +496,21 @@ describe("apns route policy", () => { expect(normalizeApnsBundle("dev.cmux.ios.-bad")).toBeNull(); }); + test("stores sandbox for a production bundle only when the install declares it", () => { + const official = normalizeApnsBundle("com.cmux.app")!; + // A Simulator or development-signed install only has sandbox tokens. + expect(registrationApnsBundle(official, "sandbox")).toEqual({ + bundleId: "com.cmux.app", + environment: "sandbox", + }); + expect(registrationApnsBundle(official, "production")).toEqual(official); + expect(registrationApnsBundle(official, undefined)).toEqual(official); + expect(registrationApnsBundle(official, "SANDBOX")).toEqual(official); + const dev = normalizeApnsBundle("dev.cmux.ios.push1")!; + expect(registrationApnsBundle(dev, "production")).toEqual(dev); + expect(registrationApnsBundle(null, "sandbox")).toBeNull(); + }); + test("allows the internal TestFlight bundle id as a production APNs topic", () => { // The scheduled internal TestFlight lane ships dev.cmux.app.internal // (.github/workflows/ios-testflight.yml); TestFlight uses the production