From 4e1e2bed32f89adaeeda3169b087d78145ec01b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 22 May 2026 12:20:45 +0200 Subject: [PATCH] fix(apns): add APNS_KEY_B64 env-var for escape-free PEM transport (v1.4.47.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v1.4.47.2/.3 confirmed the production APNS_KEY env-var is corrupted along the docker-compose env_file pipeline. Add APNS_KEY_B64 — operator base64-encodes the .p8 file and stores that ASCII-safe blob; app decodes back to PEM. No \n escape gymnastics. Precedence: B64 > KEY > FILE. --- CHANGELOG.md | 24 +++++++ package.json | 2 +- .../senders/__tests__/apns.test.ts | 30 +++++++++ src/lib/notifications/senders/apns.ts | 63 ++++++++++++++++--- 4 files changed, 109 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 545a56635..b3447f7ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog +## [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. + +This release adds an escape-free alternative: `APNS_KEY_B64`. The operator base64-encodes the raw `.p8` file (real newlines intact) and stores that single ASCII-safe blob; the app decodes it back to a PEM string. No `\n` escape gymnastics, no character class transformations along the way — base64 chars survive every env-var pipeline. + +### Changed + +- `src/lib/notifications/senders/apns.ts:loadApnsConfig` — read `APNS_KEY_B64` first; if present, decode via `Buffer.from(value, "base64").toString("utf-8")` and pass directly to `crypto.createPrivateKey` for verification. Existing `APNS_KEY` and `APNS_KEY_FILE` paths kept as fallbacks. Precedence: `APNS_KEY_B64 > APNS_KEY > APNS_KEY_FILE`. + +### Operator action + +To enable on Coolify (or any other env-var-block): + +```bash +base64 -i AuthKey_.p8 | tr -d '\n' +``` + +Paste the output as `APNS_KEY_B64`. The legacy `APNS_KEY` can stay set or be removed; the B64 variant overrides it when present. + +### Risk + +Zero. Additive — the new env-var has its own try/catch + parse-verification with one-warning-per-process disable semantics. Existing operators keep their setup unchanged. + ## [1.4.47.3] — 2026-05-22 — APNs PEM diagnostic dump on parse failure v1.4.47.2 added defensive PEM normalisation but the production env-var was still rejected at `crypto.createPrivateKey` with `error:1E08010C:DECODER routines::unsupported`. The base64 body itself appears to be corrupted somewhere along the Coolify → docker-compose → `process.env` path; normalising the wrapping cannot recover it. diff --git a/package.json b/package.json index 7ae03ebdf..a23f03d29 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.4.47.3", + "version": "1.4.47.4", "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", diff --git a/src/lib/notifications/senders/__tests__/apns.test.ts b/src/lib/notifications/senders/__tests__/apns.test.ts index acee7ee49..a803b7075 100644 --- a/src/lib/notifications/senders/__tests__/apns.test.ts +++ b/src/lib/notifications/senders/__tests__/apns.test.ts @@ -102,6 +102,7 @@ function setEnv(over: Record = {}): void { "APNS_TEAM_ID", "APNS_BUNDLE_ID", "APNS_KEY", + "APNS_KEY_B64", "APNS_KEY_FILE", "APNS_PRODUCTION", ]) { @@ -210,6 +211,35 @@ describe("loadApnsConfig — all-or-none env-var guard", () => { expect(config).toBeNull(); }); + it("APNS_KEY_B64 base64-decodes to a valid PEM and is preferred over APNS_KEY", () => { + // v1.4.47.4 — APNS_KEY_B64 sidesteps the `\n`-escape mangling that + // some docker-compose env_file round-trips do to inline PEMs. + const canonicalPem = TEST_EC_PEM_LINES.join("\n"); + const b64 = Buffer.from(canonicalPem, "utf-8").toString("base64"); + setEnv({ + APNS_KEY_ID: VALID_ENV.APNS_KEY_ID, + APNS_TEAM_ID: VALID_ENV.APNS_TEAM_ID, + APNS_BUNDLE_ID: VALID_ENV.APNS_BUNDLE_ID, + // Both set on purpose — B64 takes precedence. + APNS_KEY: "-----BEGIN PRIVATE KEY-----\\nBROKEN\\n-----END PRIVATE KEY-----", + APNS_KEY_B64: b64, + }); + const config = loadApnsConfig(); + expect(config).toBeTruthy(); + expect(config?.signingKey).toBe(canonicalPem); + }); + + it("returns null + warns when APNS_KEY_B64 is not valid base64-encoded PEM", () => { + setEnv({ + APNS_KEY_ID: VALID_ENV.APNS_KEY_ID, + APNS_TEAM_ID: VALID_ENV.APNS_TEAM_ID, + APNS_BUNDLE_ID: VALID_ENV.APNS_BUNDLE_ID, + APNS_KEY_B64: Buffer.from("not a pem at all").toString("base64"), + }); + const config = loadApnsConfig(); + expect(config).toBeNull(); + }); + it("APNS_PRODUCTION=true sets forceProduction", () => { setEnv({ ...VALID_ENV, APNS_PRODUCTION: "true" }); expect(loadApnsConfig()?.forceProduction).toBe(true); diff --git a/src/lib/notifications/senders/apns.ts b/src/lib/notifications/senders/apns.ts index 45e1cbad3..c6df47b3f 100644 --- a/src/lib/notifications/senders/apns.ts +++ b/src/lib/notifications/senders/apns.ts @@ -146,10 +146,14 @@ const providers = new Map<"sandbox" | "production", apn.Provider>(); * rather than throwing on every dispatch. * * Validation rule: APNS_KEY_ID, APNS_TEAM_ID, APNS_BUNDLE_ID, and ONE OF - * (APNS_KEY | APNS_KEY_FILE) must all be set together. Setting some but - * not others is treated as "intentionally disabled" and emits a warning - * the first time a send is attempted, so a half-finished Coolify env - * surfaces in the logs the moment it matters. + * (APNS_KEY_B64 | APNS_KEY | APNS_KEY_FILE) must all be set together. + * Setting some but not others is treated as "intentionally disabled" and + * emits a warning the first time a send is attempted, so a half-finished + * Coolify env surfaces in the logs the moment it matters. + * + * Key-source precedence: APNS_KEY_B64 > APNS_KEY > APNS_KEY_FILE. The B64 + * variant lets operators sidestep `\n`-escape gymnastics that some + * docker-compose `env_file` round-trips mangle (v1.4.47.4). */ export function loadApnsConfig(): ApnsConfig | null { if (cachedConfig !== undefined) return cachedConfig; @@ -158,6 +162,12 @@ export function loadApnsConfig(): ApnsConfig | null { const teamId = process.env.APNS_TEAM_ID?.trim() || ""; const bundleId = process.env.APNS_BUNDLE_ID?.trim() || ""; const inlineKey = process.env.APNS_KEY?.trim() || ""; + // v1.4.47.4 — `APNS_KEY_B64` is an escape-free alternative when the + // `\n`-escaped inline form is mangled by a `docker-compose env_file` + // round-trip. Operator base64-encodes the .p8 PEM file as-is and + // stores the result; we decode it back to a real PEM string here. + // Takes precedence over `APNS_KEY` and `APNS_KEY_FILE` when set. + const inlineKeyB64 = process.env.APNS_KEY_B64?.trim() || ""; const keyFile = process.env.APNS_KEY_FILE?.trim() || ""; const forceProduction = process.env.APNS_PRODUCTION === "true"; @@ -165,8 +175,12 @@ export function loadApnsConfig(): ApnsConfig | null { // simply disabled — no warning. If some are set, the operator started // configuring and stopped — surface that as a warning so the // half-finished state is visible in logs. - const anySet = Boolean(keyId || teamId || bundleId || inlineKey || keyFile); - const allSet = Boolean(keyId && teamId && bundleId && (inlineKey || keyFile)); + const anySet = Boolean( + keyId || teamId || bundleId || inlineKey || inlineKeyB64 || keyFile, + ); + const allSet = Boolean( + keyId && teamId && bundleId && (inlineKey || inlineKeyB64 || keyFile), + ); if (!anySet) { cachedConfig = null; @@ -176,15 +190,46 @@ export function loadApnsConfig(): ApnsConfig | null { if (!allSet) { getEvent()?.addWarning( "APNs config incomplete — set APNS_KEY_ID, APNS_TEAM_ID, " + - "APNS_BUNDLE_ID, and one of APNS_KEY / APNS_KEY_FILE together. " + - "APNs sender is disabled until all four are present.", + "APNS_BUNDLE_ID, and one of APNS_KEY / APNS_KEY_B64 / APNS_KEY_FILE " + + "together. APNs sender is disabled until all four are present.", ); cachedConfig = null; return null; } let signingKey: string; - if (inlineKey) { + if (inlineKeyB64) { + // v1.4.47.4 — base64-encoded PEM. The operator base64s the raw .p8 + // file contents (with real newlines intact) and stores that result + // in `APNS_KEY_B64`. We decode back to UTF-8 PEM string here. This + // sidesteps every `\n`-escape gymnastics needed for the legacy + // `APNS_KEY` inline path — base64 chars are env-var-pipeline safe. + try { + signingKey = Buffer.from(inlineKeyB64, "base64").toString("utf-8"); + } catch (err) { + const message = err instanceof Error ? err.message : "decode_failed"; + getEvent()?.addWarning(`APNS_KEY_B64 did not decode: ${message}`); + cachedConfig = null; + return null; + } + try { + const keyObject = createPrivateKey({ key: signingKey, format: "pem" }); + if (keyObject.asymmetricKeyType !== "ec") { + getEvent()?.addWarning( + `APNS_KEY_B64 parsed as ${keyObject.asymmetricKeyType ?? "unknown"}; expected "ec" for ES256`, + ); + cachedConfig = null; + return null; + } + } catch (err) { + const message = err instanceof Error ? err.message : "parse_failed"; + getEvent()?.addWarning( + `APNS_KEY_B64 did not parse as PEM after base64 decode: ${message}`, + ); + cachedConfig = null; + return null; + } + } else if (inlineKey) { // Multi-line PEM with literal `\n` escapes is the 12-factor norm // when the env block is a single line. Convert them back here so // node-apn sees a real PEM. No-op for already-multiline values.