Gateway: add APNs push test pipeline (#20307)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 6a1c442207
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com>
Reviewed-by: @mbelinky
This commit is contained in:
Mariano
2026-02-18 19:32:42 +00:00
committed by GitHub
parent 1f5cd65d60
commit 99d099aa84
14 changed files with 839 additions and 0 deletions

View File

@@ -0,0 +1,94 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
loadApnsRegistration,
normalizeApnsEnvironment,
registerApnsToken,
resolveApnsAuthConfigFromEnv,
} from "./push-apns.js";
const tempDirs: string[] = [];
async function makeTempDir(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-push-apns-test-"));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) {
await fs.rm(dir, { recursive: true, force: true });
}
}
});
describe("push APNs registration store", () => {
it("stores and reloads node APNs registration", async () => {
const baseDir = await makeTempDir();
const saved = await registerApnsToken({
nodeId: "ios-node-1",
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
topic: "ai.openclaw.ios",
environment: "sandbox",
baseDir,
});
const loaded = await loadApnsRegistration("ios-node-1", baseDir);
expect(loaded).not.toBeNull();
expect(loaded?.nodeId).toBe("ios-node-1");
expect(loaded?.token).toBe("abcd1234abcd1234abcd1234abcd1234");
expect(loaded?.topic).toBe("ai.openclaw.ios");
expect(loaded?.environment).toBe("sandbox");
expect(loaded?.updatedAtMs).toBe(saved.updatedAtMs);
});
it("rejects invalid APNs tokens", async () => {
const baseDir = await makeTempDir();
await expect(
registerApnsToken({
nodeId: "ios-node-1",
token: "not-a-token",
topic: "ai.openclaw.ios",
baseDir,
}),
).rejects.toThrow("invalid APNs token");
});
});
describe("push APNs env config", () => {
it("normalizes APNs environment values", () => {
expect(normalizeApnsEnvironment("sandbox")).toBe("sandbox");
expect(normalizeApnsEnvironment("PRODUCTION")).toBe("production");
expect(normalizeApnsEnvironment("staging")).toBeNull();
});
it("resolves inline private key and unescapes newlines", async () => {
const env = {
OPENCLAW_APNS_TEAM_ID: "TEAM123",
OPENCLAW_APNS_KEY_ID: "KEY123",
OPENCLAW_APNS_PRIVATE_KEY_P8:
"-----BEGIN PRIVATE KEY-----\\nline-a\\nline-b\\n-----END PRIVATE KEY-----",
} as NodeJS.ProcessEnv;
const resolved = await resolveApnsAuthConfigFromEnv(env);
expect(resolved.ok).toBe(true);
if (!resolved.ok) {
return;
}
expect(resolved.value.privateKey).toContain("\nline-a\n");
expect(resolved.value.teamId).toBe("TEAM123");
expect(resolved.value.keyId).toBe("KEY123");
});
it("returns an error when required APNs auth vars are missing", async () => {
const resolved = await resolveApnsAuthConfigFromEnv({} as NodeJS.ProcessEnv);
expect(resolved.ok).toBe(false);
if (resolved.ok) {
return;
}
expect(resolved.error).toContain("OPENCLAW_APNS_TEAM_ID");
});
});

409
src/infra/push-apns.ts Normal file
View File

@@ -0,0 +1,409 @@
import { createHash, createPrivateKey, sign as signJwt } from "node:crypto";
import fs from "node:fs/promises";
import http2 from "node:http2";
import path from "node:path";
import { resolveStateDir } from "../config/paths.js";
import { createAsyncLock, readJsonFile, writeJsonAtomic } from "./json-files.js";
export type ApnsEnvironment = "sandbox" | "production";
export type ApnsRegistration = {
nodeId: string;
token: string;
topic: string;
environment: ApnsEnvironment;
updatedAtMs: number;
};
export type ApnsAuthConfig = {
teamId: string;
keyId: string;
privateKey: string;
};
export type ApnsAuthConfigResolution =
| { ok: true; value: ApnsAuthConfig }
| { ok: false; error: string };
export type ApnsPushAlertResult = {
ok: boolean;
status: number;
apnsId?: string;
reason?: string;
tokenSuffix: string;
topic: string;
environment: ApnsEnvironment;
};
type ApnsRegistrationState = {
registrationsByNodeId: Record<string, ApnsRegistration>;
};
const APNS_STATE_FILENAME = "push/apns-registrations.json";
const APNS_JWT_TTL_MS = 50 * 60 * 1000;
const DEFAULT_APNS_TIMEOUT_MS = 10_000;
const withLock = createAsyncLock();
let cachedJwt: { cacheKey: string; token: string; expiresAtMs: number } | null = null;
function resolveApnsRegistrationPath(baseDir?: string): string {
const root = baseDir ?? resolveStateDir();
return path.join(root, APNS_STATE_FILENAME);
}
function normalizeNodeId(value: string): string {
return value.trim();
}
function normalizeApnsToken(value: string): string {
return value
.trim()
.replace(/[<>\s]/g, "")
.toLowerCase();
}
function normalizeTopic(value: string): string {
return value.trim();
}
function isLikelyApnsToken(value: string): boolean {
return /^[0-9a-f]{32,}$/i.test(value);
}
function parseReason(body: string): string | undefined {
const trimmed = body.trim();
if (!trimmed) {
return undefined;
}
try {
const parsed = JSON.parse(trimmed) as { reason?: unknown };
return typeof parsed.reason === "string" && parsed.reason.trim().length > 0
? parsed.reason.trim()
: trimmed.slice(0, 200);
} catch {
return trimmed.slice(0, 200);
}
}
function toBase64UrlBytes(value: Uint8Array): string {
return Buffer.from(value)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
function toBase64UrlJson(value: object): string {
return toBase64UrlBytes(Buffer.from(JSON.stringify(value)));
}
function getJwtCacheKey(auth: ApnsAuthConfig): string {
const keyHash = createHash("sha256").update(auth.privateKey).digest("hex");
return `${auth.teamId}:${auth.keyId}:${keyHash}`;
}
function getApnsBearerToken(auth: ApnsAuthConfig, nowMs: number = Date.now()): string {
const cacheKey = getJwtCacheKey(auth);
if (cachedJwt && cachedJwt.cacheKey === cacheKey && nowMs < cachedJwt.expiresAtMs) {
return cachedJwt.token;
}
const iat = Math.floor(nowMs / 1000);
const header = toBase64UrlJson({ alg: "ES256", kid: auth.keyId, typ: "JWT" });
const payload = toBase64UrlJson({ iss: auth.teamId, iat });
const signingInput = `${header}.${payload}`;
const signature = signJwt("sha256", Buffer.from(signingInput, "utf8"), {
key: createPrivateKey(auth.privateKey),
dsaEncoding: "ieee-p1363",
});
const token = `${signingInput}.${toBase64UrlBytes(signature)}`;
cachedJwt = {
cacheKey,
token,
expiresAtMs: nowMs + APNS_JWT_TTL_MS,
};
return token;
}
function normalizePrivateKey(value: string): string {
return value.trim().replace(/\\n/g, "\n");
}
function normalizeNonEmptyString(value: string | undefined): string | null {
const trimmed = value?.trim() ?? "";
return trimmed.length > 0 ? trimmed : null;
}
async function loadRegistrationsState(baseDir?: string): Promise<ApnsRegistrationState> {
const filePath = resolveApnsRegistrationPath(baseDir);
const existing = await readJsonFile<ApnsRegistrationState>(filePath);
if (!existing || typeof existing !== "object") {
return { registrationsByNodeId: {} };
}
const registrations =
existing.registrationsByNodeId &&
typeof existing.registrationsByNodeId === "object" &&
!Array.isArray(existing.registrationsByNodeId)
? existing.registrationsByNodeId
: {};
return { registrationsByNodeId: registrations };
}
async function persistRegistrationsState(
state: ApnsRegistrationState,
baseDir?: string,
): Promise<void> {
const filePath = resolveApnsRegistrationPath(baseDir);
await writeJsonAtomic(filePath, state);
}
export function normalizeApnsEnvironment(value: unknown): ApnsEnvironment | null {
if (typeof value !== "string") {
return null;
}
const normalized = value.trim().toLowerCase();
if (normalized === "sandbox" || normalized === "production") {
return normalized;
}
return null;
}
export async function registerApnsToken(params: {
nodeId: string;
token: string;
topic: string;
environment?: unknown;
baseDir?: string;
}): Promise<ApnsRegistration> {
const nodeId = normalizeNodeId(params.nodeId);
const token = normalizeApnsToken(params.token);
const topic = normalizeTopic(params.topic);
const environment = normalizeApnsEnvironment(params.environment) ?? "sandbox";
if (!nodeId) {
throw new Error("nodeId required");
}
if (!topic) {
throw new Error("topic required");
}
if (!isLikelyApnsToken(token)) {
throw new Error("invalid APNs token");
}
return await withLock(async () => {
const state = await loadRegistrationsState(params.baseDir);
const next: ApnsRegistration = {
nodeId,
token,
topic,
environment,
updatedAtMs: Date.now(),
};
state.registrationsByNodeId[nodeId] = next;
await persistRegistrationsState(state, params.baseDir);
return next;
});
}
export async function loadApnsRegistration(
nodeId: string,
baseDir?: string,
): Promise<ApnsRegistration | null> {
const normalizedNodeId = normalizeNodeId(nodeId);
if (!normalizedNodeId) {
return null;
}
const state = await loadRegistrationsState(baseDir);
return state.registrationsByNodeId[normalizedNodeId] ?? null;
}
export async function resolveApnsAuthConfigFromEnv(
env: NodeJS.ProcessEnv = process.env,
): Promise<ApnsAuthConfigResolution> {
const teamId = normalizeNonEmptyString(env.OPENCLAW_APNS_TEAM_ID);
const keyId = normalizeNonEmptyString(env.OPENCLAW_APNS_KEY_ID);
if (!teamId || !keyId) {
return {
ok: false,
error: "APNs auth missing: set OPENCLAW_APNS_TEAM_ID and OPENCLAW_APNS_KEY_ID",
};
}
const inlineKeyRaw =
normalizeNonEmptyString(env.OPENCLAW_APNS_PRIVATE_KEY_P8) ??
normalizeNonEmptyString(env.OPENCLAW_APNS_PRIVATE_KEY);
if (inlineKeyRaw) {
return {
ok: true,
value: {
teamId,
keyId,
privateKey: normalizePrivateKey(inlineKeyRaw),
},
};
}
const keyPath = normalizeNonEmptyString(env.OPENCLAW_APNS_PRIVATE_KEY_PATH);
if (!keyPath) {
return {
ok: false,
error:
"APNs private key missing: set OPENCLAW_APNS_PRIVATE_KEY_P8 or OPENCLAW_APNS_PRIVATE_KEY_PATH",
};
}
try {
const privateKey = normalizePrivateKey(await fs.readFile(keyPath, "utf8"));
return {
ok: true,
value: {
teamId,
keyId,
privateKey,
},
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
ok: false,
error: `failed reading OPENCLAW_APNS_PRIVATE_KEY_PATH (${keyPath}): ${message}`,
};
}
}
async function sendApnsRequest(params: {
token: string;
topic: string;
environment: ApnsEnvironment;
bearerToken: string;
payload: object;
timeoutMs: number;
}): Promise<{ status: number; apnsId?: string; body: string }> {
const authority =
params.environment === "production"
? "https://api.push.apple.com"
: "https://api.sandbox.push.apple.com";
const body = JSON.stringify(params.payload);
const requestPath = `/3/device/${params.token}`;
return await new Promise((resolve, reject) => {
const client = http2.connect(authority);
let settled = false;
const fail = (err: unknown) => {
if (settled) {
return;
}
settled = true;
client.destroy();
reject(err);
};
const finish = (result: { status: number; apnsId?: string; body: string }) => {
if (settled) {
return;
}
settled = true;
client.close();
resolve(result);
};
client.once("error", (err) => fail(err));
const req = client.request({
":method": "POST",
":path": requestPath,
authorization: `bearer ${params.bearerToken}`,
"apns-topic": params.topic,
"apns-push-type": "alert",
"apns-priority": "10",
"apns-expiration": "0",
"content-type": "application/json",
"content-length": Buffer.byteLength(body).toString(),
});
let statusCode = 0;
let apnsId: string | undefined;
let responseBody = "";
req.setEncoding("utf8");
req.setTimeout(params.timeoutMs, () => {
req.close(http2.constants.NGHTTP2_CANCEL);
fail(new Error(`APNs request timed out after ${params.timeoutMs}ms`));
});
req.on("response", (headers) => {
const statusHeader = headers[":status"];
statusCode = typeof statusHeader === "number" ? statusHeader : Number(statusHeader ?? 0);
const idHeader = headers["apns-id"];
if (typeof idHeader === "string" && idHeader.trim().length > 0) {
apnsId = idHeader.trim();
}
});
req.on("data", (chunk) => {
if (typeof chunk === "string") {
responseBody += chunk;
}
});
req.on("end", () => {
finish({ status: statusCode, apnsId, body: responseBody });
});
req.on("error", (err) => fail(err));
req.end(body);
});
}
export async function sendApnsAlert(params: {
auth: ApnsAuthConfig;
registration: ApnsRegistration;
nodeId: string;
title: string;
body: string;
timeoutMs?: number;
}): Promise<ApnsPushAlertResult> {
const token = normalizeApnsToken(params.registration.token);
if (!isLikelyApnsToken(token)) {
throw new Error("invalid APNs token");
}
const topic = normalizeTopic(params.registration.topic);
if (!topic) {
throw new Error("topic required");
}
const environment = params.registration.environment;
const bearerToken = getApnsBearerToken(params.auth);
const payload = {
aps: {
alert: {
title: params.title,
body: params.body,
},
sound: "default",
},
openclaw: {
kind: "push.test",
nodeId: params.nodeId,
ts: Date.now(),
},
};
const response = await sendApnsRequest({
token,
topic,
environment,
bearerToken,
payload,
timeoutMs:
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
? Math.max(1000, Math.trunc(params.timeoutMs))
: DEFAULT_APNS_TIMEOUT_MS,
});
return {
ok: response.status === 200,
status: response.status,
apnsId: response.apnsId,
reason: parseReason(response.body),
tokenSuffix: token.slice(-8),
topic,
environment,
};
}