mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 04:32:42 +00:00
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:
1 parent
8d665db2df
commit
4cec53be91
4 files changed
+109
-10
No files matched your search
@@ -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
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user