fix(apns): retry opposite gateway on BadEnvironmentKeyInToken (v1.4.47.5)

On BadEnvironmentKeyInToken, retry once with the opposite gateway. On success, persist Device.apnsEnvironment correction. Two new unit tests cover retry+persist and no-retry-on-BadDeviceToken.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-22 14:38:29 +02:00
1 parent 4e1e2bed32
commit 94c08b36eb
4 files changed
+128 -9

No files matched your search

+19
View File
@@ -1,5 +1,24 @@
# Changelog
## [1.4.47.5] — 2026-05-22 — APNs gateway auto-detect on `BadEnvironmentKeyInToken`
v1.4.47.4 + the Coolify `APNS_PRODUCTION` env-var deletion got JWT signing past Apple's gate, but the next push still failed with `BadEnvironmentKeyInToken` — the server's per-device gateway routing didn't match the actual token environment. The iOS client picks the gateway env when it registers, but in practice the client's reported env can mismatch the token's true environment (e.g. a DEBUG build that registered itself as "production" by accident, or a TestFlight-then-Debug installation chain).
This release adds gateway auto-detect: on `BadEnvironmentKeyInToken`, retry exactly once with the opposite gateway. If the retry succeeds, persist the correction to `Device.apnsEnvironment` so subsequent sends go straight to the right gateway.
### Changed
- `src/lib/notifications/senders/apns.ts:sendViaApns` — per-device send-loop now walks an env-sequence (`[initial, opposite]`). Breaks on success OR on any non-`BadEnvironmentKeyInToken` failure. On retry-success, updates `Device.apnsEnvironment` in DB and emits a wide-event warning so the operator sees the correction.
### Effect
- Future pushes hit the right gateway for each device, even if the iOS app misreported the env at registration time.
- The first delivery after deploy may take 2 RTT-to-Apple round-trips for any device that needs correction; subsequent ones go through directly.
### Risk
Low. The retry only fires on the specific `BadEnvironmentKeyInToken` reason. Other failures (`BadDeviceToken`, `Unregistered`, network) take the existing single-attempt path. Two new unit tests cover the retry-and-correct path + the no-retry-on-BadDeviceToken path.
## [1.4.47.4] — 2026-05-22 — APNs key escape-free env var (APNS_KEY_B64)
The v1.4.47.2 / .3 chain established that the production `APNS_KEY` env-var is mangled along the `docker-compose env_file` → `process.env` pipeline. Defensive normalisation cannot recover it because the base64 body itself is corrupted on arrival.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.4.47.4",
"version": "1.4.47.5",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "AGPL-3.0-only",
"homepage": "https://healthlog.dev",
@@ -55,6 +55,7 @@ vi.mock("@/lib/db", () => ({
device: {
findMany: vi.fn(),
deleteMany: vi.fn(),
update: vi.fn().mockResolvedValue({}),
},
},
}));
@@ -489,6 +490,61 @@ describe("sendViaApns — dispatcher fan-out", () => {
expect(prisma.device.deleteMany).not.toHaveBeenCalled();
});
it("retries the opposite gateway on BadEnvironmentKeyInToken and corrects Device.apnsEnvironment", async () => {
// v1.4.47.5 — when Apple returns BadEnvironmentKeyInToken, the
// server's gateway choice (driven by Device.apnsEnvironment)
// doesn't match the token's actual environment. Try the other
// gateway exactly once; if it succeeds, persist the correction.
vi.mocked(prisma.device.findMany).mockResolvedValueOnce([
{ id: "d1", apnsToken: "tok-a", apnsEnvironment: "production" },
] as never);
// First send (production gateway): rejected.
sendMock.mockResolvedValueOnce({
sent: [],
failed: [{ device: "tok-a", status: 403, response: { reason: "BadEnvironmentKeyInToken" } }],
});
// Second send (sandbox gateway): success.
sendMock.mockResolvedValueOnce({
sent: [{ device: "tok-a" }],
failed: [],
});
const r = await sendViaApns("u-1", {
title: "t",
message: "m",
eventType: "MEDICATION_REMINDER",
});
expect(r.ok).toBe(true);
expect(sendMock).toHaveBeenCalledTimes(2);
expect(prisma.device.update).toHaveBeenCalledWith({
where: { id: "d1" },
data: { apnsEnvironment: "sandbox" },
});
});
it("does NOT retry the opposite gateway when reason is BadDeviceToken", async () => {
vi.mocked(prisma.device.findMany).mockResolvedValueOnce([
{ id: "d1", apnsToken: "tok-a", apnsEnvironment: "production" },
] as never);
sendMock.mockResolvedValueOnce({
sent: [],
failed: [{ device: "tok-a", status: 410, response: { reason: "BadDeviceToken" } }],
});
const r = await sendViaApns("u-1", {
title: "t",
message: "m",
eventType: "MEDICATION_REMINDER",
});
expect(r.ok).toBe(false);
expect(r.hardReject).toBe(true);
// Only one send attempt — no retry on BadDeviceToken.
expect(sendMock).toHaveBeenCalledTimes(1);
expect(prisma.device.update).not.toHaveBeenCalled();
});
it("strips HTML from the alert body before handing to APNs", async () => {
vi.mocked(prisma.device.findMany).mockResolvedValueOnce([
{ id: "d1", apnsToken: "tok-a", apnsEnvironment: "sandbox" },
+52 -8
View File
@@ -562,9 +562,22 @@ export async function sendViaApns(
for (const device of devices) {
if (!device.apnsToken) continue;
const env =
const initialEnv: "production" | "sandbox" =
device.apnsEnvironment === "production" ? "production" : "sandbox";
// v1.4.47.5 — gateway auto-detect on BadEnvironmentKeyInToken.
// The iOS client picks the gateway env when it registers, but in
// practice the client's reported env can mismatch the token's
// actual environment (e.g. DEBUG build that registered itself as
// "production" by accident). Apple returns `BadEnvironmentKeyInToken`
// when the provider gateway env doesn't match the token env. Try
// the opposite gateway exactly once; if it succeeds, persist the
// correction so subsequent sends go straight to the right env.
const envSequence: ReadonlyArray<"production" | "sandbox"> =
initialEnv === "production"
? (["production", "sandbox"] as const)
: (["sandbox", "production"] as const);
// SB-5 v1.4.40 — only MEDICATION_REMINDER opts in to Focus-bypass.
// The user explicitly asked the server to nudge them about a dose;
// a missed dose is the textbook "time-sensitive" use case Apple
@@ -573,9 +586,12 @@ export async function sendViaApns(
// Focus modes — including Sleep — silence them as the user expects.
const isTimeSensitive = payload.eventType === "MEDICATION_REMINDER";
const result = await sendApnsPush({
let result: Awaited<ReturnType<typeof sendApnsPush>> | null = null;
let workingEnv: "production" | "sandbox" | null = null;
for (const attemptEnv of envSequence) {
result = await sendApnsPush({
deviceToken: device.apnsToken,
environment: env,
environment: attemptEnv,
payload: {
alert: { title: payload.title, body: stripHtml(payload.message) },
data: {
@@ -602,18 +618,46 @@ export async function sendViaApns(
: {}),
},
collapseId: payload.eventType,
});
});
if (result.ok) {
if (result.ok) {
workingEnv = attemptEnv;
break;
}
// Only retry the OTHER gateway on the specific environment-mismatch
// reason. Other failure reasons (BadDeviceToken, expired, network
// blips) wouldn't be solved by switching gateways.
if (result.reason !== "BadEnvironmentKeyInToken") {
break;
}
}
if (result?.ok) {
anySuccess = true;
if (workingEnv && workingEnv !== initialEnv) {
// Persist the correction so subsequent sends go straight to the
// right gateway. Log a wide-event warning so the operator sees
// the iOS client's reported env was wrong.
await prisma.device
.update({
where: { id: device.id },
data: { apnsEnvironment: workingEnv },
})
.catch(() => {
/* persist is best-effort; the send already succeeded */
});
getEvent()?.addWarning(
`APNs device.apnsEnvironment corrected ${initialEnv} → ${workingEnv} after BadEnvironmentKeyInToken`,
);
}
continue;
}
if (result.shouldDisable) {
if (result?.shouldDisable) {
deadDeviceIds.push(device.id);
}
lastFailureReason = result.reason;
lastFailureStatus = result.status;
lastFailureReason = result?.reason;
lastFailureStatus = result?.status;
}
if (deadDeviceIds.length > 0) {