import { URL } from "node:url" ;
import type { GatewayConfig } from "../config/types.gateway.js" ;
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "../shared/string-coerce.js" ;
import {
loadOrCreateDeviceIdentity,
signDevicePayload,
type DeviceIdentity,
} from "./device-identity.js" ;
import { formatErrorMessage } from "./errors.js" ;
import { normalizeHostname } from "./net/hostname.js" ;
export type ApnsRelayPushType = "alert" | "background" ;
export type ApnsRelayConfig = {
baseUrl: string;
timeoutMs: number;
};
export type ApnsRelayConfigResolution =
| { ok: true ; value: ApnsRelayConfig }
| { ok: false ; error: string };
export type ApnsRelayPushResponse = {
ok: boolean ;
status: number;
apnsId?: string;
reason?: string;
environment: "production" ;
tokenSuffix?: string;
};
export type ApnsRelayRequestSender = (params: {
relayConfig: ApnsRelayConfig;
sendGrant: string;
relayHandle: string;
gatewayDeviceId: string;
signature: string;
signedAtMs: number;
bodyJson: string;
pushType: ApnsRelayPushType;
priority: "10" | "5" ;
payload: object;
}) => Promise<ApnsRelayPushResponse>;
const DEFAULT_APNS_RELAY_TIMEOUT_MS = 10 _000 ;
const GATEWAY_DEVICE_ID_HEADER = "x-openclaw-gateway-device-id" ;
const GATEWAY_SIGNATURE_HEADER = "x-openclaw-gateway-signature" ;
const GATEWAY_SIGNED_AT_HEADER = "x-openclaw-gateway-signed-at-ms" ;
function normalizeNonEmptyString(value: string | undefined): string | null {
const trimmed = normalizeOptionalString(value) ?? "" ;
return trimmed.length > 0 ? trimmed : null ;
}
function normalizeTimeoutMs(value: string | number | undefined): number {
const raw =
typeof value === "number"
? value
: typeof value === "string"
? normalizeOptionalString(value)
: undefined;
if (raw === undefined || raw === "" ) {
return DEFAULT_APNS_RELAY_TIMEOUT_MS;
}
const parsed = Number(raw);
if (!Number.isFinite(parsed)) {
return DEFAULT_APNS_RELAY_TIMEOUT_MS;
}
return Math.max(1000 , Math.trunc(parsed));
}
function readAllowHttp(value: string | undefined): boolean {
const normalized = normalizeOptionalString(value)
? normalizeLowercaseStringOrEmpty(value)
: undefined;
return normalized === "1" || normalized === "true" || normalized === "yes" ;
}
function isLoopbackRelayHostname(hostname: string): boolean {
const normalized = normalizeHostname(hostname);
return (
normalized === "localhost" ||
normalized === "::1" ||
normalized === "[::1]" ||
/^127 (?:\.\d{1 ,3 }){3 }$/.test(normalized)
);
}
function parseReason(value: unknown): string | undefined {
return typeof value === "string" ? normalizeOptionalString(value) : undefined;
}
function buildRelayGatewaySignaturePayload(params: {
gatewayDeviceId: string;
signedAtMs: number;
bodyJson: string;
}): string {
return [
"openclaw-relay-send-v1" ,
params.gatewayDeviceId.trim(),
String(Math.trunc(params.signedAtMs)),
params.bodyJson,
].join("\n" );
}
export function resolveApnsRelayConfigFromEnv(
env: NodeJS.ProcessEnv = process.env,
gatewayConfig?: GatewayConfig,
): ApnsRelayConfigResolution {
const configuredRelay = gatewayConfig?.push?.apns?.relay;
const envBaseUrl = normalizeNonEmptyString(env.OPENCLAW_APNS_RELAY_BASE_URL);
const configBaseUrl = normalizeNonEmptyString(configuredRelay?.baseUrl);
const baseUrl = envBaseUrl ?? configBaseUrl;
const baseUrlSource = envBaseUrl
? "OPENCLAW_APNS_RELAY_BASE_URL"
: "gateway.push.apns.relay.baseUrl" ;
if (!baseUrl) {
return {
ok: false ,
error:
"APNs relay config missing: set gateway.push.apns.relay.baseUrl or OPENCLAW_APNS_RELAY_BASE_URL" ,
};
}
try {
const parsed = new URL(baseUrl);
if (parsed.protocol !== "https:" && parsed.protocol !== "http:" ) {
throw new Error("unsupported protocol" );
}
if (!parsed.hostname) {
throw new Error("host required" );
}
if (parsed.protocol === "http:" && !readAllowHttp(env.OPENCLAW_APNS_RELAY_ALLOW_HTTP)) {
throw new Error(
"http relay URLs require OPENCLAW_APNS_RELAY_ALLOW_HTTP=true (development only)" ,
);
}
if (parsed.protocol === "http:" && !isLoopbackRelayHostname(parsed.hostname)) {
throw new Error("http relay URLs are limited to loopback hosts" );
}
if (parsed.username || parsed.password) {
throw new Error("userinfo is not allowed" );
}
if (parsed.search || parsed.hash) {
throw new Error("query and fragment are not allowed" );
}
return {
ok: true ,
value: {
baseUrl: parsed.toString().replace(/\/+$/, "" ),
timeoutMs: normalizeTimeoutMs(
env.OPENCLAW_APNS_RELAY_TIMEOUT_MS ?? configuredRelay?.timeoutMs,
),
},
};
} catch (err) {
const message = formatErrorMessage(err);
return {
ok: false ,
error: `invalid ${baseUrlSource} (${baseUrl}): ${message}`,
};
}
}
async function sendApnsRelayRequest(params: {
relayConfig: ApnsRelayConfig;
sendGrant: string;
relayHandle: string;
gatewayDeviceId: string;
signature: string;
signedAtMs: number;
bodyJson: string;
pushType: ApnsRelayPushType;
priority: "10" | "5" ;
payload: object;
}): Promise<ApnsRelayPushResponse> {
const response = await fetch(`${params.relayConfig.baseUrl}/v1/push/send`, {
method: "POST" ,
redirect: "manual" ,
headers: {
authorization: `Bearer ${params.sendGrant}`,
"content-type" : "application/json" ,
[GATEWAY_DEVICE_ID_HEADER]: params.gatewayDeviceId,
[GATEWAY_SIGNATURE_HEADER]: params.signature,
[GATEWAY_SIGNED_AT_HEADER]: String(params.signedAtMs),
},
body: params.bodyJson,
signal: AbortSignal.timeout(params.relayConfig.timeoutMs),
});
if (response.status >= 300 && response.status < 400 ) {
return {
ok: false ,
status: response.status,
reason: "RelayRedirectNotAllowed" ,
environment: "production" ,
};
}
let json: unknown = null ;
try {
json = (await response.json()) as unknown;
} catch {
json = null ;
}
const body =
json && typeof json === "object" && !Array.isArray(json)
? (json as Record<string, unknown>)
: {};
const status =
typeof body.status === "number" && Number.isFinite(body.status)
? Math.trunc(body.status)
: response.status;
return {
ok: typeof body.ok === "boolean" ? body.ok : response.ok && status >= 200 && status < 300 ,
status,
apnsId: parseReason(body.apnsId),
reason: parseReason(body.reason),
environment: "production" ,
tokenSuffix: parseReason(body.tokenSuffix),
};
}
export async function sendApnsRelayPush(params: {
relayConfig: ApnsRelayConfig;
sendGrant: string;
relayHandle: string;
pushType: ApnsRelayPushType;
priority: "10" | "5" ;
payload: object;
gatewayIdentity?: Pick<DeviceIdentity, "deviceId" | "privateKeyPem" >;
requestSender?: ApnsRelayRequestSender;
}): Promise<ApnsRelayPushResponse> {
const sender = params.requestSender ?? sendApnsRelayRequest;
const gatewayIdentity = params.gatewayIdentity ?? loadOrCreateDeviceIdentity();
const signedAtMs = Date.now();
const bodyJson = JSON.stringify({
relayHandle: params.relayHandle,
pushType: params.pushType,
priority: Number(params.priority),
payload: params.payload,
});
const signature = signDevicePayload(
gatewayIdentity.privateKeyPem,
buildRelayGatewaySignaturePayload({
gatewayDeviceId: gatewayIdentity.deviceId,
signedAtMs,
bodyJson,
}),
);
return await sender({
relayConfig: params.relayConfig,
sendGrant: params.sendGrant,
relayHandle: params.relayHandle,
gatewayDeviceId: gatewayIdentity.deviceId,
signature,
signedAtMs,
bodyJson,
pushType: params.pushType,
priority: params.priority,
payload: params.payload,
});
}
Messung V0.5 in Prozent C=98 H=98 G=97
¤ Dauer der Verarbeitung: 0.15 Sekunden
(vorverarbeitet am 2026-06-10)
¤
*© Formatika GbR, Deutschland