fix(apns): add APNS_KEY_B64 env-var for escape-free PEM transport (v1.4.47.4)

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.
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-05-22 12:20:45 +02:00
1 parent 8d665db2df
commit 4cec53be91
4 files changed
+109 -10

No files matched your search

+24
View File
@@ -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_<KEY_ID>.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.
+1 -1
View File
@@ -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",
@@ -102,6 +102,7 @@ function setEnv(over: Record<string, string | undefined> = {}): 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);
+54 -9
View File
@@ -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.