mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
feat(ios): default to hosted push relay (#88096)
Merged via squash.
Prepared head SHA: 75f939af5c
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Co-authored-by: ngutman <1540134+ngutman@users.noreply.github.com>
Reviewed-by: @ngutman
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -337,9 +337,9 @@ candidate contains redacted secret placeholders such as `***`.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Enable relay-backed push for official iOS builds">
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -552,9 +552,9 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"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":
|
||||
|
||||
@@ -87,7 +87,7 @@ const FIELD_PLACEHOLDERS: Record<string, string> = {
|
||||
"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",
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<T extends unknown[]>(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", () => {
|
||||
|
||||
@@ -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<ApnsRelayPushResponse>;
|
||||
|
||||
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: {
|
||||
|
||||
@@ -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