fix(ios): bind relay sends to registration origin

This commit is contained in:
Nimrod Gutman
2026-05-29 21:31:16 +03:00
parent e2986f827f
commit 75f939af5c
10 changed files with 189 additions and 65 deletions

View File

@@ -17,6 +17,7 @@ private struct RelayGatewayPushRegistrationPayload: Encodable {
var topic: String
var environment: String
var distribution: String
var relayOrigin: String
var tokenDebugSuffix: String?
}
@@ -107,6 +108,7 @@ actor PushRegistrationManager {
topic: topic,
environment: self.buildConfig.apnsEnvironment.rawValue,
distribution: self.buildConfig.distribution.rawValue,
relayOrigin: relayOrigin,
tokenDebugSuffix: stored.tokenDebugSuffix))
}
@@ -138,6 +140,7 @@ actor PushRegistrationManager {
topic: topic,
environment: self.buildConfig.apnsEnvironment.rawValue,
distribution: self.buildConfig.distribution.rawValue,
relayOrigin: relayOrigin,
tokenDebugSuffix: registrationState.tokenDebugSuffix))
}

View File

@@ -157,19 +157,30 @@ async function resolveDeliveryPlan(params: {
}
}
let relayConfig: ApnsRelayConfig | undefined;
const relayConfigByNodeId = new Map<string, ApnsRelayConfig>();
if (needsRelay) {
const relay = resolveApnsRelayConfigFromEnv(process.env, getRuntimeConfig().gateway);
if (relay.ok) {
relayConfig = relay.value;
} else {
params.log.warn?.(`exec approvals: iOS relay APNs config unavailable: ${relay.error}`);
for (const target of targets) {
if (target.registration.transport !== "relay") {
continue;
}
const relay = resolveApnsRelayConfigFromEnv(process.env, getRuntimeConfig().gateway, {
registrationRelayOrigin: target.registration.relayOrigin,
});
if (relay.ok) {
relayConfigByNodeId.set(target.nodeId, relay.value);
} else {
params.log.warn?.(`exec approvals: iOS relay APNs config unavailable: ${relay.error}`);
}
}
}
const relayConfig = relayConfigByNodeId.values().next().value;
return {
targets: targets.filter((target) =>
target.registration.transport === "direct" ? Boolean(directAuth) : Boolean(relayConfig),
target.registration.transport === "direct"
? Boolean(directAuth)
: relayConfigByNodeId.has(target.nodeId) &&
relayConfigByNodeId.get(target.nodeId)?.baseUrl === relayConfig?.baseUrl,
),
directAuth,
relayConfig,

View File

@@ -729,13 +729,17 @@ describe("node.invoke APNs wake path", () => {
apnsReason: "Unregistered",
apnsStatus: 410,
});
expect(mocks.resolveApnsRelayConfigFromEnv).toHaveBeenCalledWith(process.env, {
push: {
apns: {
relay: DEFAULT_RELAY_CONFIG,
expect(mocks.resolveApnsRelayConfigFromEnv).toHaveBeenCalledWith(
process.env,
{
push: {
apns: {
relay: DEFAULT_RELAY_CONFIG,
},
},
},
});
{ registrationRelayOrigin: undefined },
);
expect(mocks.shouldClearStoredApnsRegistration).toHaveBeenCalledWith({
registration,
result: {

View File

@@ -199,8 +199,16 @@ async function resolveDirectNodePushConfig() {
: { ok: false as const, error: auth.error };
}
function resolveRelayNodePushConfig(cfg: OpenClawConfig) {
const relay = resolveApnsRelayConfigFromEnv(process.env, cfg.gateway);
function resolveRelayNodePushConfig(
cfg: OpenClawConfig,
registration: Extract<
NonNullable<Awaited<ReturnType<typeof loadApnsRegistration>>>,
{ transport: "relay" }
>,
) {
const relay = resolveApnsRelayConfigFromEnv(process.env, cfg.gateway, {
registrationRelayOrigin: registration.relayOrigin,
});
return relay.ok
? { ok: true as const, relayConfig: relay.value }
: { ok: false as const, error: relay.error };
@@ -493,7 +501,7 @@ export async function maybeWakeNodeWithApns(
let wakeResult;
if (registration.transport === "relay") {
const relay = resolveRelayNodePushConfig(opts?.cfg ?? getRuntimeConfig());
const relay = resolveRelayNodePushConfig(opts?.cfg ?? getRuntimeConfig(), registration);
if (!relay.ok) {
return withDuration({
available: false,
@@ -595,7 +603,7 @@ export async function maybeSendNodeWakeNudge(
try {
let result;
if (registration.transport === "relay") {
const relay = resolveRelayNodePushConfig(opts?.cfg ?? getRuntimeConfig());
const relay = resolveRelayNodePushConfig(opts?.cfg ?? getRuntimeConfig(), registration);
if (!relay.ok) {
return withDuration({
sent: false,

View File

@@ -209,16 +209,20 @@ describe("push.test handler", () => {
expect(resolveApnsAuthConfigFromEnv).not.toHaveBeenCalled();
expect(resolveApnsRelayConfigFromEnv).toHaveBeenCalledTimes(1);
expect(resolveApnsRelayConfigFromEnv).toHaveBeenCalledWith(process.env, {
push: {
apns: {
relay: {
baseUrl: "https://relay.example.com",
timeoutMs: 1000,
expect(resolveApnsRelayConfigFromEnv).toHaveBeenCalledWith(
process.env,
{
push: {
apns: {
relay: {
baseUrl: "https://relay.example.com",
timeoutMs: 1000,
},
},
},
},
});
{ registrationRelayOrigin: undefined },
);
expect(sendApnsAlert).toHaveBeenCalledTimes(1);
const call = firstRespondCall(respond);
expect(call?.[0]).toBe(true);

View File

@@ -85,6 +85,7 @@ export const pushHandlers: GatewayRequestHandlers = {
const relay = resolveApnsRelayConfigFromEnv(
process.env,
context.getRuntimeConfig().gateway,
{ registrationRelayOrigin: registration.relayOrigin },
);
if (!relay.ok) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, relay.error));

View File

@@ -828,6 +828,7 @@ export const handleNodeEvent = async (
topic,
environment,
distribution: obj.distribution,
relayOrigin: obj.relayOrigin,
tokenDebugSuffix: obj.tokenDebugSuffix,
});
} else {

View File

@@ -64,11 +64,38 @@ function firstMockCall<T extends unknown[]>(mock: { mock: { calls: T[] } }): T |
describe("push-apns.relay", () => {
describe("resolveApnsRelayConfigFromEnv", () => {
it("defaults to the hosted relay when no relay base URL is configured", () => {
expectRelayConfig(resolveApnsRelayConfigFromEnv({} as NodeJS.ProcessEnv), {
baseUrl: DEFAULT_APNS_RELAY_BASE_URL,
timeoutMs: 10_000,
});
it("defaults to the hosted relay when the registration was minted by the hosted relay", () => {
expectRelayConfig(
resolveApnsRelayConfigFromEnv({} as NodeJS.ProcessEnv, undefined, {
registrationRelayOrigin: `${DEFAULT_APNS_RELAY_BASE_URL}/`,
}),
{
baseUrl: DEFAULT_APNS_RELAY_BASE_URL,
timeoutMs: 10_000,
},
);
});
it("fails closed when relay registration origin is unknown and no relay URL is configured", () => {
const resolved = resolveApnsRelayConfigFromEnv({} as NodeJS.ProcessEnv);
expect(resolved.ok).toBe(false);
if (!resolved.ok) {
expect(resolved.error).toContain("relay registrations without the hosted relay origin");
}
});
it("rejects config that does not match the registration relay origin", () => {
const resolved = resolveApnsRelayConfigFromEnv(
{} as NodeJS.ProcessEnv,
{ push: { apns: { relay: { baseUrl: DEFAULT_APNS_RELAY_BASE_URL } } } },
{ registrationRelayOrigin: "https://relay.example.com" },
);
expect(resolved.ok).toBe(false);
if (!resolved.ok) {
expect(resolved.error).toContain("origin mismatch");
}
});
it("lets env overrides win and clamps tiny timeout values", () => {

View File

@@ -23,6 +23,10 @@ type ApnsRelayConfigResolution =
| { ok: true; value: ApnsRelayConfig }
| { ok: false; error: string };
type ApnsRelayConfigResolutionOptions = {
registrationRelayOrigin?: string;
};
export type ApnsRelayPushResponse = {
ok: boolean;
status: number;
@@ -94,33 +98,10 @@ 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(
export function normalizeApnsRelayBaseUrl(
baseUrl: string,
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 ?? DEFAULT_APNS_RELAY_BASE_URL;
const baseUrlSource = envBaseUrl
? "OPENCLAW_APNS_RELAY_BASE_URL"
: configBaseUrl
? "gateway.push.apns.relay.baseUrl"
: "default APNs relay base URL";
): { ok: true; value: string } | { ok: false; error: string } {
try {
const parsed = new URL(baseUrl);
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
@@ -143,22 +124,87 @@ export function resolveApnsRelayConfigFromEnv(
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,
),
},
};
return { ok: true, value: parsed.toString().replace(/\/+$/, "") };
} catch (err) {
const message = formatErrorMessage(err);
return { ok: false, error: formatErrorMessage(err) };
}
}
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,
options: ApnsRelayConfigResolutionOptions = {},
): ApnsRelayConfigResolution {
const configuredRelay = gatewayConfig?.push?.apns?.relay;
const envBaseUrl = normalizeNonEmptyString(env.OPENCLAW_APNS_RELAY_BASE_URL);
const configBaseUrl = normalizeNonEmptyString(configuredRelay?.baseUrl);
const explicitBaseUrl = envBaseUrl ?? configBaseUrl;
const normalizedRegistrationOrigin = options.registrationRelayOrigin
? normalizeApnsRelayBaseUrl(options.registrationRelayOrigin, env)
: undefined;
if (normalizedRegistrationOrigin && !normalizedRegistrationOrigin.ok) {
return {
ok: false,
error: `invalid ${baseUrlSource} (${baseUrl}): ${message}`,
error: `invalid relay registration origin (${options.registrationRelayOrigin}): ${normalizedRegistrationOrigin.error}`,
};
}
const baseUrl =
explicitBaseUrl ??
(normalizedRegistrationOrigin?.value === DEFAULT_APNS_RELAY_BASE_URL
? DEFAULT_APNS_RELAY_BASE_URL
: undefined);
const baseUrlSource = envBaseUrl
? "OPENCLAW_APNS_RELAY_BASE_URL"
: configBaseUrl
? "gateway.push.apns.relay.baseUrl"
: "default APNs relay base URL";
if (!baseUrl) {
return {
ok: false,
error:
"APNs relay config missing: set gateway.push.apns.relay.baseUrl or OPENCLAW_APNS_RELAY_BASE_URL for relay registrations without the hosted relay origin",
};
}
const normalizedBaseUrl = normalizeApnsRelayBaseUrl(baseUrl, env);
if (!normalizedBaseUrl.ok) {
return {
ok: false,
error: `invalid ${baseUrlSource} (${baseUrl}): ${normalizedBaseUrl.error}`,
};
}
if (
normalizedRegistrationOrigin &&
normalizedRegistrationOrigin.value !== normalizedBaseUrl.value
) {
return {
ok: false,
error: `APNs relay config origin mismatch: registration uses ${normalizedRegistrationOrigin.value} but ${baseUrlSource} is ${normalizedBaseUrl.value}`,
};
}
return {
ok: true,
value: {
baseUrl: normalizedBaseUrl.value,
timeoutMs: normalizeTimeoutMs(
env.OPENCLAW_APNS_RELAY_TIMEOUT_MS ?? configuredRelay?.timeoutMs,
),
},
};
}
async function sendApnsRelayRequest(params: {

View File

@@ -14,6 +14,7 @@ import {
type ApnsRelayConfig,
type ApnsRelayPushResponse,
type ApnsRelayRequestSender,
normalizeApnsRelayBaseUrl,
resolveApnsRelayConfigFromEnv,
sendApnsRelayPush,
} from "./push-apns.relay.js";
@@ -40,6 +41,7 @@ type RelayApnsRegistration = {
environment: "production";
distribution: "official";
updatedAtMs: number;
relayOrigin?: string;
tokenDebugSuffix?: string;
};
@@ -109,6 +111,7 @@ type RegisterRelayApnsParams = {
topic: string;
environment?: unknown;
distribution?: unknown;
relayOrigin?: unknown;
tokenDebugSuffix?: unknown;
baseDir?: string;
};
@@ -263,6 +266,18 @@ function normalizeDistribution(value: unknown): "official" | null {
return normalized === "official" ? "official" : null;
}
function normalizeRelayOrigin(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = normalizeOptionalString(value);
if (!trimmed) {
return undefined;
}
const normalized = normalizeApnsRelayBaseUrl(trimmed, process.env);
return normalized.ok ? normalized.value : undefined;
}
function normalizeDirectRegistration(
record: Partial<DirectApnsRegistration> & { nodeId?: unknown; token?: unknown },
): DirectApnsRegistration | null {
@@ -312,6 +327,7 @@ function normalizeRelayRegistration(
const topic = normalizeTopic(typeof record.topic === "string" ? record.topic : "");
const environment = normalizeApnsEnvironment(record.environment);
const distribution = normalizeDistribution(record.distribution);
const relayOrigin = normalizeRelayOrigin(record.relayOrigin);
const updatedAtMs =
typeof record.updatedAtMs === "number" && Number.isFinite(record.updatedAtMs)
? Math.trunc(record.updatedAtMs)
@@ -337,6 +353,7 @@ function normalizeRelayRegistration(
environment,
distribution,
updatedAtMs,
...(relayOrigin ? { relayOrigin } : {}),
tokenDebugSuffix: normalizeTokenDebugSuffix(record.tokenDebugSuffix),
};
}
@@ -433,6 +450,7 @@ export async function registerApnsRegistration(
);
const environment = normalizeApnsEnvironment(params.environment);
const distribution = normalizeDistribution(params.distribution);
const relayOrigin = normalizeRelayOrigin(params.relayOrigin);
if (environment !== "production") {
throw new Error("relay registrations must use production environment");
}
@@ -449,6 +467,7 @@ export async function registerApnsRegistration(
environment,
distribution,
updatedAtMs,
...(relayOrigin ? { relayOrigin } : {}),
tokenDebugSuffix: normalizeTokenDebugSuffix(params.tokenDebugSuffix),
};
} else {