mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-03 19:34:07 +00:00
fix(ios): bind relay sends to registration origin
This commit is contained in:
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -828,6 +828,7 @@ export const handleNodeEvent = async (
|
||||
topic,
|
||||
environment,
|
||||
distribution: obj.distribution,
|
||||
relayOrigin: obj.relayOrigin,
|
||||
tokenDebugSuffix: obj.tokenDebugSuffix,
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user