mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
4e1e2bed32
commit
5803612e4d
4 files changed
+128
-9
No files matched your search
@@ -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
@@ -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" },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user