From 0167f0a6df180a7e64ec6386dff6c38d0c22420b Mon Sep 17 00:00:00 2001 From: Nimrod Gutman Date: Fri, 29 May 2026 22:05:25 +0300 Subject: [PATCH] feat(ios): default to hosted push relay (#88096) Merged via squash. Prepared head SHA: 75f939af5c2cdce7e55dffc30ccad1ecd37eaa2d Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com> Reviewed-by: @ngutman --- apps/ios/README.md | 7 +- .../Push/PushRegistrationManager.swift | 3 + docs/gateway/configuration.md | 9 +- docs/platforms/ios.md | 9 +- scripts/ios-beta-prepare.sh | 11 +- src/config/schema.help.ts | 4 +- src/config/schema.hints.ts | 2 +- src/gateway/exec-approval-ios-push.ts | 25 +++- .../server-methods/nodes.invoke-wake.test.ts | 14 +- src/gateway/server-methods/nodes.ts | 16 ++- src/gateway/server-methods/push.test.ts | 18 ++- src/gateway/server-methods/push.ts | 1 + src/gateway/server-node-events.ts | 1 + src/infra/push-apns.relay.test.ts | 44 +++++- src/infra/push-apns.relay.ts | 126 ++++++++++++------ src/infra/push-apns.ts | 19 +++ 16 files changed, 218 insertions(+), 91 deletions(-) diff --git a/apps/ios/README.md b/apps/ios/README.md index 7bf735c17f30..175bc78410ea 100644 --- a/apps/ios/README.md +++ b/apps/ios/README.md @@ -73,9 +73,10 @@ Release behavior: - Changing the root gateway version does not change the iOS app version until you explicitly pin from the gateway. - See `apps/ios/VERSIONING.md` for the full workflow. -Required env for beta builds: +Relay behavior for beta builds: -- `OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com` +- Beta builds default to `https://ios-push-relay.openclaw.ai`. +- Optional custom relay override: `OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com` This must be a plain `https://host[:port][/path]` base URL without whitespace, query params, fragments, or xcconfig metacharacters. Archive without upload: @@ -118,7 +119,7 @@ scripts/ios-asc-keychain-setup.sh \ This should create `apps/ios/fastlane/.env` with the non-secret ASC variables while the private key stays in Keychain. -3. Set the official/TestFlight relay URL for the build: +3. Optional: set a custom official/TestFlight relay URL for the build. If unset, the beta flow uses `https://ios-push-relay.openclaw.ai`. ```bash export OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com diff --git a/apps/ios/Sources/Push/PushRegistrationManager.swift b/apps/ios/Sources/Push/PushRegistrationManager.swift index d784ab89f92e..18bfd35e3484 100644 --- a/apps/ios/Sources/Push/PushRegistrationManager.swift +++ b/apps/ios/Sources/Push/PushRegistrationManager.swift @@ -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)) } diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index f000d4e74c1e..e971777551f4 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -337,9 +337,9 @@ candidate contains redacted secret placeholders such as `***`. - Relay-backed push is configured in `openclaw.json`. + Relay-backed push uses the hosted OpenClaw relay by default: `https://ios-push-relay.openclaw.ai`. - Set this in gateway config: + To use a custom relay, set this in gateway config: ```json5 { @@ -373,8 +373,8 @@ candidate contains redacted secret placeholders such as `***`. End-to-end flow: - 1. Install an official/TestFlight iOS build that was compiled with the same relay base URL. - 2. Configure `gateway.push.apns.relay.baseUrl` on the gateway. + 1. Install an official/TestFlight iOS build. + 2. Optional: configure `gateway.push.apns.relay.baseUrl` on the gateway only when using a custom relay deployment. 3. Pair the iOS app to the gateway and let both node and operator sessions connect. 4. The iOS app fetches the gateway identity, registers with the relay using App Attest plus the app receipt, and then publishes the relay-backed `push.apns.register` payload to the paired gateway. 5. The gateway stores the relay handle and send grant, then uses them for `push.test`, wake nudges, and reconnect wakes. @@ -387,6 +387,7 @@ candidate contains redacted secret placeholders such as `***`. Compatibility note: - `OPENCLAW_APNS_RELAY_BASE_URL` and `OPENCLAW_APNS_RELAY_TIMEOUT_MS` still work as temporary env overrides. + - Custom gateway relay URLs must match the relay base URL baked into the official/TestFlight iOS build. - `OPENCLAW_APNS_RELAY_ALLOW_HTTP=true` remains a loopback-only development escape hatch; do not persist HTTP relay URLs in config. See [iOS App](/platforms/ios#relay-backed-push-for-official-builds) for the end-to-end flow and [Authentication and trust flow](/platforms/ios#authentication-and-trust-flow) for the relay security model. diff --git a/docs/platforms/ios.md b/docs/platforms/ios.md index d1dadfb1ede3..39b777dd705a 100644 --- a/docs/platforms/ios.md +++ b/docs/platforms/ios.md @@ -75,7 +75,9 @@ openclaw gateway call node.list --params "{}" Official distributed iOS builds use the external push relay instead of publishing the raw APNs token to the gateway. -Gateway-side requirement: +By default, official/TestFlight builds and gateways use the hosted relay at `https://ios-push-relay.openclaw.ai`. + +Custom relay deployments can override the gateway relay URL: ```json5 { @@ -98,7 +100,7 @@ How the flow works: - The iOS app fetches the paired gateway identity and includes it in relay registration, so the relay-backed registration is delegated to that specific gateway. - The app forwards that relay-backed registration to the paired gateway with `push.apns.register`. - The gateway uses that stored relay handle for `push.test`, background wakes, and wake nudges. -- The gateway relay base URL must match the relay URL baked into the official/TestFlight iOS build. +- Custom gateway relay URLs must match the relay URL baked into the official/TestFlight iOS build. - If the app later connects to a different gateway or a build with a different relay base URL, it refreshes the relay registration instead of reusing the old binding. What the gateway does **not** need for this path: @@ -109,7 +111,7 @@ What the gateway does **not** need for this path: Expected operator flow: 1. Install the official/TestFlight iOS build. -2. Set `gateway.push.apns.relay.baseUrl` on the gateway. +2. Optional: set `gateway.push.apns.relay.baseUrl` on the gateway only when using a custom relay deployment. 3. Pair the app to the gateway and let it finish connecting. 4. The app publishes `push.apns.register` automatically after it has an APNs token, the operator session is connected, and relay registration succeeds. 5. After that, `push.test`, reconnect wakes, and wake nudges can use the stored relay-backed registration. @@ -128,6 +130,7 @@ compatible but does not count as a durable last-seen update. Compatibility note: - `OPENCLAW_APNS_RELAY_BASE_URL` still works as a temporary env override for the gateway. +- `OPENCLAW_PUSH_RELAY_BASE_URL` still works as a temporary env override for official/TestFlight iOS builds. ## Authentication and trust flow diff --git a/scripts/ios-beta-prepare.sh b/scripts/ios-beta-prepare.sh index 44cb531f8251..70706bfd96d2 100755 --- a/scripts/ios-beta-prepare.sh +++ b/scripts/ios-beta-prepare.sh @@ -4,6 +4,9 @@ set -euo pipefail usage() { cat <<'EOF' Usage: + scripts/ios-beta-prepare.sh --build-number 7 [--team-id TEAMID] + +Optional custom relay: OPENCLAW_PUSH_RELAY_BASE_URL=https://relay.example.com \ scripts/ios-beta-prepare.sh --build-number 7 [--team-id TEAMID] @@ -26,7 +29,8 @@ VERSION_SYNC_HELPER="${ROOT_DIR}/scripts/ios-sync-versioning.ts" BUILD_NUMBER="" TEAM_ID="${IOS_DEVELOPMENT_TEAM:-}" -PUSH_RELAY_BASE_URL="${OPENCLAW_PUSH_RELAY_BASE_URL:-${IOS_PUSH_RELAY_BASE_URL:-}}" +DEFAULT_IOS_PUSH_RELAY_BASE_URL="https://ios-push-relay.openclaw.ai" +PUSH_RELAY_BASE_URL="${OPENCLAW_PUSH_RELAY_BASE_URL:-${IOS_PUSH_RELAY_BASE_URL:-${DEFAULT_IOS_PUSH_RELAY_BASE_URL}}}" PUSH_RELAY_BASE_URL_XCCONFIG="" IOS_VERSION="" @@ -118,11 +122,6 @@ if [[ -z "${TEAM_ID}" ]]; then exit 1 fi -if [[ -z "${PUSH_RELAY_BASE_URL}" ]]; then - echo "Missing OPENCLAW_PUSH_RELAY_BASE_URL (or IOS_PUSH_RELAY_BASE_URL) for beta relay registration." >&2 - exit 1 -fi - validate_push_relay_base_url "${PUSH_RELAY_BASE_URL}" # `.xcconfig` treats `//` as a comment opener. Break the URL with a helper setting diff --git a/src/config/schema.help.ts b/src/config/schema.help.ts index d5e57477c725..b6b1fbf56c1a 100644 --- a/src/config/schema.help.ts +++ b/src/config/schema.help.ts @@ -552,9 +552,9 @@ export const FIELD_HELP: Record = { "gateway.push.apns": "APNs delivery settings for iOS devices paired to this gateway. Use relay settings for official/TestFlight builds that register through the external push relay.", "gateway.push.apns.relay": - "External relay settings for relay-backed APNs sends. The gateway uses this relay for push.test, wake nudges, and reconnect wakes after a paired official iOS build publishes a relay-backed registration.", + "External relay settings for relay-backed APNs sends. The gateway uses the hosted OpenClaw relay by default, or this custom relay for push.test, wake nudges, and reconnect wakes after a paired official iOS build publishes a relay-backed registration.", "gateway.push.apns.relay.baseUrl": - "Base HTTPS URL for the external APNs relay service used by official/TestFlight iOS builds. Keep this aligned with the relay URL baked into the iOS build so registration and send traffic hit the same deployment.", + "Optional custom base HTTPS URL for the external APNs relay service used by official/TestFlight iOS builds. Keep this aligned with the relay URL baked into the iOS build so registration and send traffic hit the same deployment.", "gateway.push.apns.relay.timeoutMs": "Timeout in milliseconds for relay send requests from the gateway to the APNs relay (default: 10000). Increase for slower relays or networks, or lower to fail wake attempts faster.", "gateway.http.endpoints.chatCompletions.enabled": diff --git a/src/config/schema.hints.ts b/src/config/schema.hints.ts index c21a5276550f..35afafeb09c5 100644 --- a/src/config/schema.hints.ts +++ b/src/config/schema.hints.ts @@ -87,7 +87,7 @@ const FIELD_PLACEHOLDERS: Record = { "gateway.controlUi.basePath": "/openclaw", "gateway.controlUi.root": "dist/control-ui", "gateway.controlUi.allowedOrigins": "https://control.example.com", - "gateway.push.apns.relay.baseUrl": "https://relay.example.com", + "gateway.push.apns.relay.baseUrl": "https://ios-push-relay.openclaw.ai", "channels.mattermost.baseUrl": "https://chat.example.com", "agents.list[].identity.avatar": "avatars/openclaw.png", }; diff --git a/src/gateway/exec-approval-ios-push.ts b/src/gateway/exec-approval-ios-push.ts index f0872b4f4b81..12ea05ae22f4 100644 --- a/src/gateway/exec-approval-ios-push.ts +++ b/src/gateway/exec-approval-ios-push.ts @@ -157,19 +157,30 @@ async function resolveDeliveryPlan(params: { } } - let relayConfig: ApnsRelayConfig | undefined; + const relayConfigByNodeId = new Map(); 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, diff --git a/src/gateway/server-methods/nodes.invoke-wake.test.ts b/src/gateway/server-methods/nodes.invoke-wake.test.ts index f6cbf9d0ee0f..117352cc7e11 100644 --- a/src/gateway/server-methods/nodes.invoke-wake.test.ts +++ b/src/gateway/server-methods/nodes.invoke-wake.test.ts @@ -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: { diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index 71f378e912ac..0f4741bd141f 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -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>>, + { 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, diff --git a/src/gateway/server-methods/push.test.ts b/src/gateway/server-methods/push.test.ts index 6ffaa8ad94ee..031eb0e6f9a9 100644 --- a/src/gateway/server-methods/push.test.ts +++ b/src/gateway/server-methods/push.test.ts @@ -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); diff --git a/src/gateway/server-methods/push.ts b/src/gateway/server-methods/push.ts index a61ad466d467..bbd78d4a71c9 100644 --- a/src/gateway/server-methods/push.ts +++ b/src/gateway/server-methods/push.ts @@ -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)); diff --git a/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts index 45c33b0a38a2..7c4aa3290d90 100644 --- a/src/gateway/server-node-events.ts +++ b/src/gateway/server-node-events.ts @@ -828,6 +828,7 @@ export const handleNodeEvent = async ( topic, environment, distribution: obj.distribution, + relayOrigin: obj.relayOrigin, tokenDebugSuffix: obj.tokenDebugSuffix, }); } else { diff --git a/src/infra/push-apns.relay.test.ts b/src/infra/push-apns.relay.test.ts index cb9ac4c4e105..763b05d07e69 100644 --- a/src/infra/push-apns.relay.test.ts +++ b/src/infra/push-apns.relay.test.ts @@ -5,7 +5,11 @@ import { publicKeyRawBase64UrlFromPem, verifyDeviceSignature, } from "./device-identity.js"; -import { resolveApnsRelayConfigFromEnv, sendApnsRelayPush } from "./push-apns.relay.js"; +import { + DEFAULT_APNS_RELAY_BASE_URL, + resolveApnsRelayConfigFromEnv, + sendApnsRelayPush, +} from "./push-apns.relay.js"; const relayGatewayIdentity = (() => { const { publicKey, privateKey } = generateKeyPairSync("ed25519"); @@ -60,12 +64,38 @@ function firstMockCall(mock: { mock: { calls: T[] } }): T | describe("push-apns.relay", () => { describe("resolveApnsRelayConfigFromEnv", () => { - it("returns a missing-config error when no relay base URL is configured", () => { - expect(resolveApnsRelayConfigFromEnv({} as NodeJS.ProcessEnv)).toEqual({ - ok: false, - error: - "APNs relay config missing: set gateway.push.apns.relay.baseUrl or OPENCLAW_APNS_RELAY_BASE_URL", - }); + 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", () => { diff --git a/src/infra/push-apns.relay.ts b/src/infra/push-apns.relay.ts index a87602239666..359c4cbe3c0a 100644 --- a/src/infra/push-apns.relay.ts +++ b/src/infra/push-apns.relay.ts @@ -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; @@ -45,6 +49,7 @@ export type ApnsRelayRequestSender = (params: { payload: object; }) => Promise; +export const DEFAULT_APNS_RELAY_BASE_URL = "https://ios-push-relay.openclaw.ai"; 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"; @@ -93,38 +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; - 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", - }; - } - +): { ok: true; value: string } | { ok: false; error: string } { try { const parsed = new URL(baseUrl); if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { @@ -147,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: { diff --git a/src/infra/push-apns.ts b/src/infra/push-apns.ts index d42bd6751834..772f2ded47b3 100644 --- a/src/infra/push-apns.ts +++ b/src/infra/push-apns.ts @@ -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 & { 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 {