mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-07 22:09:57 +00:00
feat(push): add iOS APNs relay gateway (#43369)
* feat(push): add ios apns relay gateway * fix(shared): avoid oslog string concatenation # Conflicts: # apps/shared/OpenClawKit/Sources/OpenClawKit/GatewayChannel.swift * fix(push): harden relay validation and invalidation * fix(push): persist app attest state before relay registration * fix(push): harden relay invalidation and url handling * feat(push): use scoped relay send grants * feat(push): configure ios relay through gateway config * feat(push): bind relay registration to gateway identity * fix(push): tighten ios relay trust flow * fix(push): bound APNs registration fields (#43369) (thanks @ngutman)
This commit is contained in:
@@ -386,6 +386,16 @@ export const FIELD_HELP: Record<string, string> = {
|
||||
"Loosens strict browser auth checks for Control UI when you must run a non-standard setup. Keep this off unless you trust your network and proxy path, because impersonation risk is higher.",
|
||||
"gateway.controlUi.dangerouslyDisableDeviceAuth":
|
||||
"Disables Control UI device identity checks and relies on token/password only. Use only for short-lived debugging on trusted networks, then turn it off immediately.",
|
||||
"gateway.push":
|
||||
"Push-delivery settings used by the gateway when it needs to wake or notify paired devices. Configure relay-backed APNs here for official iOS builds; direct APNs auth remains env-based for local/manual builds.",
|
||||
"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.",
|
||||
"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.",
|
||||
"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":
|
||||
"Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).",
|
||||
"gateway.http.endpoints.chatCompletions.maxBodyBytes":
|
||||
|
||||
@@ -75,6 +75,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",
|
||||
"channels.mattermost.baseUrl": "https://chat.example.com",
|
||||
"agents.list[].identity.avatar": "avatars/openclaw.png",
|
||||
};
|
||||
|
||||
@@ -41,6 +41,7 @@ const TAG_PRIORITY: Record<ConfigTag, number> = {
|
||||
const TAG_OVERRIDES: Record<string, ConfigTag[]> = {
|
||||
"gateway.auth.token": ["security", "auth", "access", "network"],
|
||||
"gateway.auth.password": ["security", "auth", "access", "network"],
|
||||
"gateway.push.apns.relay.baseUrl": ["network", "advanced"],
|
||||
"gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback": [
|
||||
"security",
|
||||
"access",
|
||||
|
||||
@@ -347,6 +347,21 @@ export type GatewayHttpConfig = {
|
||||
securityHeaders?: GatewayHttpSecurityHeadersConfig;
|
||||
};
|
||||
|
||||
export type GatewayPushApnsRelayConfig = {
|
||||
/** Base HTTPS URL for the external iOS APNs relay service. */
|
||||
baseUrl?: string;
|
||||
/** Timeout in milliseconds for relay send requests (default: 10000). */
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
export type GatewayPushApnsConfig = {
|
||||
relay?: GatewayPushApnsRelayConfig;
|
||||
};
|
||||
|
||||
export type GatewayPushConfig = {
|
||||
apns?: GatewayPushApnsConfig;
|
||||
};
|
||||
|
||||
export type GatewayNodesConfig = {
|
||||
/** Browser routing policy for node-hosted browser proxies. */
|
||||
browser?: {
|
||||
@@ -395,6 +410,7 @@ export type GatewayConfig = {
|
||||
reload?: GatewayReloadConfig;
|
||||
tls?: GatewayTlsConfig;
|
||||
http?: GatewayHttpConfig;
|
||||
push?: GatewayPushConfig;
|
||||
nodes?: GatewayNodesConfig;
|
||||
/**
|
||||
* IPs of trusted reverse proxies (e.g. Traefik, nginx). When a connection
|
||||
|
||||
@@ -789,6 +789,23 @@ export const OpenClawSchema = z
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
push: z
|
||||
.object({
|
||||
apns: z
|
||||
.object({
|
||||
relay: z
|
||||
.object({
|
||||
baseUrl: z.string().optional(),
|
||||
timeoutMs: z.number().int().positive().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
nodes: z
|
||||
.object({
|
||||
browser: z
|
||||
|
||||
@@ -75,6 +75,7 @@ const METHOD_SCOPE_GROUPS: Record<OperatorScope, readonly string[]> = {
|
||||
"cron.list",
|
||||
"cron.status",
|
||||
"cron.runs",
|
||||
"gateway.identity.get",
|
||||
"system-presence",
|
||||
"last-heartbeat",
|
||||
"node.list",
|
||||
|
||||
22
src/gateway/protocol/push.test.ts
Normal file
22
src/gateway/protocol/push.test.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import AjvPkg from "ajv";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PushTestResultSchema } from "./schema/push.js";
|
||||
|
||||
describe("gateway protocol push schema", () => {
|
||||
const Ajv = AjvPkg as unknown as new (opts?: object) => import("ajv").default;
|
||||
const ajv = new Ajv({ allErrors: true, strict: false });
|
||||
const validatePushTestResult = ajv.compile(PushTestResultSchema);
|
||||
|
||||
it("accepts push.test results with a transport", () => {
|
||||
expect(
|
||||
validatePushTestResult({
|
||||
ok: true,
|
||||
status: 200,
|
||||
tokenSuffix: "abcd1234",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
transport: "relay",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,7 @@ export const PushTestResultSchema = Type.Object(
|
||||
tokenSuffix: Type.String(),
|
||||
topic: Type.String(),
|
||||
environment: ApnsEnvironmentSchema,
|
||||
transport: Type.String({ enum: ["direct", "relay"] }),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
@@ -91,6 +91,7 @@ const BASE_METHODS = [
|
||||
"cron.remove",
|
||||
"cron.run",
|
||||
"cron.runs",
|
||||
"gateway.identity.get",
|
||||
"system-presence",
|
||||
"system-event",
|
||||
"send",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ErrorCodes } from "../protocol/index.js";
|
||||
import { nodeHandlers } from "./nodes.js";
|
||||
import { maybeWakeNodeWithApns, nodeHandlers } from "./nodes.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
loadConfig: vi.fn(() => ({})),
|
||||
@@ -10,10 +10,13 @@ const mocks = vi.hoisted(() => ({
|
||||
ok: true,
|
||||
params: rawParams,
|
||||
})),
|
||||
clearApnsRegistrationIfCurrent: vi.fn(),
|
||||
loadApnsRegistration: vi.fn(),
|
||||
resolveApnsAuthConfigFromEnv: vi.fn(),
|
||||
resolveApnsRelayConfigFromEnv: vi.fn(),
|
||||
sendApnsBackgroundWake: vi.fn(),
|
||||
sendApnsAlert: vi.fn(),
|
||||
shouldClearStoredApnsRegistration: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
@@ -30,10 +33,13 @@ vi.mock("../node-invoke-sanitize.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/push-apns.js", () => ({
|
||||
clearApnsRegistrationIfCurrent: mocks.clearApnsRegistrationIfCurrent,
|
||||
loadApnsRegistration: mocks.loadApnsRegistration,
|
||||
resolveApnsAuthConfigFromEnv: mocks.resolveApnsAuthConfigFromEnv,
|
||||
resolveApnsRelayConfigFromEnv: mocks.resolveApnsRelayConfigFromEnv,
|
||||
sendApnsBackgroundWake: mocks.sendApnsBackgroundWake,
|
||||
sendApnsAlert: mocks.sendApnsAlert,
|
||||
shouldClearStoredApnsRegistration: mocks.shouldClearStoredApnsRegistration,
|
||||
}));
|
||||
|
||||
type RespondCall = [
|
||||
@@ -154,6 +160,7 @@ async function ackPending(nodeId: string, ids: string[]) {
|
||||
function mockSuccessfulWakeConfig(nodeId: string) {
|
||||
mocks.loadApnsRegistration.mockResolvedValue({
|
||||
nodeId,
|
||||
transport: "direct",
|
||||
token: "abcd1234abcd1234abcd1234abcd1234",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "sandbox",
|
||||
@@ -173,6 +180,7 @@ function mockSuccessfulWakeConfig(nodeId: string) {
|
||||
tokenSuffix: "1234abcd",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "sandbox",
|
||||
transport: "direct",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -189,9 +197,12 @@ describe("node.invoke APNs wake path", () => {
|
||||
({ rawParams }: { rawParams: unknown }) => ({ ok: true, params: rawParams }),
|
||||
);
|
||||
mocks.loadApnsRegistration.mockClear();
|
||||
mocks.clearApnsRegistrationIfCurrent.mockClear();
|
||||
mocks.resolveApnsAuthConfigFromEnv.mockClear();
|
||||
mocks.resolveApnsRelayConfigFromEnv.mockClear();
|
||||
mocks.sendApnsBackgroundWake.mockClear();
|
||||
mocks.sendApnsAlert.mockClear();
|
||||
mocks.shouldClearStoredApnsRegistration.mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -215,6 +226,43 @@ describe("node.invoke APNs wake path", () => {
|
||||
expect(nodeRegistry.invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not throttle repeated relay wake attempts when relay config is missing", async () => {
|
||||
mocks.loadApnsRegistration.mockResolvedValue({
|
||||
nodeId: "ios-node-relay-no-auth",
|
||||
transport: "relay",
|
||||
relayHandle: "relay-handle-123",
|
||||
sendGrant: "send-grant-123",
|
||||
installationId: "install-123",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
distribution: "official",
|
||||
updatedAtMs: 1,
|
||||
tokenDebugSuffix: "abcd1234",
|
||||
});
|
||||
mocks.resolveApnsRelayConfigFromEnv.mockReturnValue({
|
||||
ok: false,
|
||||
error: "relay config missing",
|
||||
});
|
||||
|
||||
const first = await maybeWakeNodeWithApns("ios-node-relay-no-auth");
|
||||
const second = await maybeWakeNodeWithApns("ios-node-relay-no-auth");
|
||||
|
||||
expect(first).toMatchObject({
|
||||
available: false,
|
||||
throttled: false,
|
||||
path: "no-auth",
|
||||
apnsReason: "relay config missing",
|
||||
});
|
||||
expect(second).toMatchObject({
|
||||
available: false,
|
||||
throttled: false,
|
||||
path: "no-auth",
|
||||
apnsReason: "relay config missing",
|
||||
});
|
||||
expect(mocks.resolveApnsRelayConfigFromEnv).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.sendApnsBackgroundWake).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("wakes and retries invoke after the node reconnects", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockSuccessfulWakeConfig("ios-node-reconnect");
|
||||
@@ -259,6 +307,152 @@ describe("node.invoke APNs wake path", () => {
|
||||
expect(call?.[1]).toMatchObject({ ok: true, nodeId: "ios-node-reconnect" });
|
||||
});
|
||||
|
||||
it("clears stale registrations after an invalid device token wake failure", async () => {
|
||||
mocks.loadApnsRegistration.mockResolvedValue({
|
||||
nodeId: "ios-node-stale",
|
||||
transport: "direct",
|
||||
token: "abcd1234abcd1234abcd1234abcd1234",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "sandbox",
|
||||
updatedAtMs: 1,
|
||||
});
|
||||
mocks.resolveApnsAuthConfigFromEnv.mockResolvedValue({
|
||||
ok: true,
|
||||
value: {
|
||||
teamId: "TEAM123",
|
||||
keyId: "KEY123",
|
||||
privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", // pragma: allowlist secret
|
||||
},
|
||||
});
|
||||
mocks.sendApnsBackgroundWake.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
reason: "BadDeviceToken",
|
||||
tokenSuffix: "1234abcd",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "sandbox",
|
||||
transport: "direct",
|
||||
});
|
||||
mocks.shouldClearStoredApnsRegistration.mockReturnValue(true);
|
||||
|
||||
const nodeRegistry = {
|
||||
get: vi.fn(() => undefined),
|
||||
invoke: vi.fn().mockResolvedValue({ ok: true }),
|
||||
};
|
||||
|
||||
const respond = await invokeNode({
|
||||
nodeRegistry,
|
||||
requestParams: { nodeId: "ios-node-stale", idempotencyKey: "idem-stale" },
|
||||
});
|
||||
|
||||
const call = respond.mock.calls[0] as RespondCall | undefined;
|
||||
expect(call?.[0]).toBe(false);
|
||||
expect(call?.[2]?.message).toBe("node not connected");
|
||||
expect(mocks.clearApnsRegistrationIfCurrent).toHaveBeenCalledWith({
|
||||
nodeId: "ios-node-stale",
|
||||
registration: {
|
||||
nodeId: "ios-node-stale",
|
||||
transport: "direct",
|
||||
token: "abcd1234abcd1234abcd1234abcd1234",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "sandbox",
|
||||
updatedAtMs: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not clear relay registrations from wake failures", async () => {
|
||||
mocks.loadConfig.mockReturnValue({
|
||||
gateway: {
|
||||
push: {
|
||||
apns: {
|
||||
relay: {
|
||||
baseUrl: "https://relay.example.com",
|
||||
timeoutMs: 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
mocks.loadApnsRegistration.mockResolvedValue({
|
||||
nodeId: "ios-node-relay",
|
||||
transport: "relay",
|
||||
relayHandle: "relay-handle-123",
|
||||
sendGrant: "send-grant-123",
|
||||
installationId: "install-123",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
distribution: "official",
|
||||
updatedAtMs: 1,
|
||||
tokenDebugSuffix: "abcd1234",
|
||||
});
|
||||
mocks.resolveApnsRelayConfigFromEnv.mockReturnValue({
|
||||
ok: true,
|
||||
value: {
|
||||
baseUrl: "https://relay.example.com",
|
||||
timeoutMs: 1000,
|
||||
},
|
||||
});
|
||||
mocks.sendApnsBackgroundWake.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 410,
|
||||
reason: "Unregistered",
|
||||
tokenSuffix: "abcd1234",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
transport: "relay",
|
||||
});
|
||||
mocks.shouldClearStoredApnsRegistration.mockReturnValue(false);
|
||||
|
||||
const nodeRegistry = {
|
||||
get: vi.fn(() => undefined),
|
||||
invoke: vi.fn().mockResolvedValue({ ok: true }),
|
||||
};
|
||||
|
||||
const respond = await invokeNode({
|
||||
nodeRegistry,
|
||||
requestParams: { nodeId: "ios-node-relay", idempotencyKey: "idem-relay" },
|
||||
});
|
||||
|
||||
const call = respond.mock.calls[0] as RespondCall | undefined;
|
||||
expect(call?.[0]).toBe(false);
|
||||
expect(call?.[2]?.message).toBe("node not connected");
|
||||
expect(mocks.resolveApnsRelayConfigFromEnv).toHaveBeenCalledWith(process.env, {
|
||||
push: {
|
||||
apns: {
|
||||
relay: {
|
||||
baseUrl: "https://relay.example.com",
|
||||
timeoutMs: 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(mocks.shouldClearStoredApnsRegistration).toHaveBeenCalledWith({
|
||||
registration: {
|
||||
nodeId: "ios-node-relay",
|
||||
transport: "relay",
|
||||
relayHandle: "relay-handle-123",
|
||||
sendGrant: "send-grant-123",
|
||||
installationId: "install-123",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
distribution: "official",
|
||||
updatedAtMs: 1,
|
||||
tokenDebugSuffix: "abcd1234",
|
||||
},
|
||||
result: {
|
||||
ok: false,
|
||||
status: 410,
|
||||
reason: "Unregistered",
|
||||
tokenSuffix: "abcd1234",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
transport: "relay",
|
||||
},
|
||||
});
|
||||
expect(mocks.clearApnsRegistrationIfCurrent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forces one retry wake when the first wake still fails to reconnect", async () => {
|
||||
vi.useFakeTimers();
|
||||
mockSuccessfulWakeConfig("ios-node-throttle");
|
||||
|
||||
@@ -10,10 +10,13 @@ import {
|
||||
verifyNodeToken,
|
||||
} from "../../infra/node-pairing.js";
|
||||
import {
|
||||
clearApnsRegistrationIfCurrent,
|
||||
loadApnsRegistration,
|
||||
resolveApnsAuthConfigFromEnv,
|
||||
sendApnsAlert,
|
||||
sendApnsBackgroundWake,
|
||||
shouldClearStoredApnsRegistration,
|
||||
resolveApnsAuthConfigFromEnv,
|
||||
resolveApnsRelayConfigFromEnv,
|
||||
} from "../../infra/push-apns.js";
|
||||
import {
|
||||
buildCanvasScopedHostUrl,
|
||||
@@ -92,6 +95,39 @@ type PendingNodeAction = {
|
||||
|
||||
const pendingNodeActionsById = new Map<string, PendingNodeAction[]>();
|
||||
|
||||
async function resolveDirectNodePushConfig() {
|
||||
const auth = await resolveApnsAuthConfigFromEnv(process.env);
|
||||
return auth.ok
|
||||
? { ok: true as const, auth: auth.value }
|
||||
: { ok: false as const, error: auth.error };
|
||||
}
|
||||
|
||||
function resolveRelayNodePushConfig() {
|
||||
const relay = resolveApnsRelayConfigFromEnv(process.env, loadConfig().gateway);
|
||||
return relay.ok
|
||||
? { ok: true as const, relayConfig: relay.value }
|
||||
: { ok: false as const, error: relay.error };
|
||||
}
|
||||
|
||||
async function clearStaleApnsRegistrationIfNeeded(
|
||||
registration: NonNullable<Awaited<ReturnType<typeof loadApnsRegistration>>>,
|
||||
nodeId: string,
|
||||
params: { status: number; reason?: string },
|
||||
) {
|
||||
if (
|
||||
!shouldClearStoredApnsRegistration({
|
||||
registration,
|
||||
result: params,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await clearApnsRegistrationIfCurrent({
|
||||
nodeId,
|
||||
registration,
|
||||
});
|
||||
}
|
||||
|
||||
function isNodeEntry(entry: { role?: string; roles?: string[] }) {
|
||||
if (entry.role === "node") {
|
||||
return true;
|
||||
@@ -238,23 +274,43 @@ export async function maybeWakeNodeWithApns(
|
||||
return withDuration({ available: false, throttled: false, path: "no-registration" });
|
||||
}
|
||||
|
||||
const auth = await resolveApnsAuthConfigFromEnv(process.env);
|
||||
if (!auth.ok) {
|
||||
return withDuration({
|
||||
available: false,
|
||||
throttled: false,
|
||||
path: "no-auth",
|
||||
apnsReason: auth.error,
|
||||
let wakeResult;
|
||||
if (registration.transport === "relay") {
|
||||
const relay = resolveRelayNodePushConfig();
|
||||
if (!relay.ok) {
|
||||
return withDuration({
|
||||
available: false,
|
||||
throttled: false,
|
||||
path: "no-auth",
|
||||
apnsReason: relay.error,
|
||||
});
|
||||
}
|
||||
state.lastWakeAtMs = Date.now();
|
||||
wakeResult = await sendApnsBackgroundWake({
|
||||
registration,
|
||||
nodeId,
|
||||
wakeReason: opts?.wakeReason ?? "node.invoke",
|
||||
relayConfig: relay.relayConfig,
|
||||
});
|
||||
} else {
|
||||
const auth = await resolveDirectNodePushConfig();
|
||||
if (!auth.ok) {
|
||||
return withDuration({
|
||||
available: false,
|
||||
throttled: false,
|
||||
path: "no-auth",
|
||||
apnsReason: auth.error,
|
||||
});
|
||||
}
|
||||
state.lastWakeAtMs = Date.now();
|
||||
wakeResult = await sendApnsBackgroundWake({
|
||||
registration,
|
||||
nodeId,
|
||||
wakeReason: opts?.wakeReason ?? "node.invoke",
|
||||
auth: auth.auth,
|
||||
});
|
||||
}
|
||||
|
||||
state.lastWakeAtMs = Date.now();
|
||||
const wakeResult = await sendApnsBackgroundWake({
|
||||
auth: auth.value,
|
||||
registration,
|
||||
nodeId,
|
||||
wakeReason: opts?.wakeReason ?? "node.invoke",
|
||||
});
|
||||
await clearStaleApnsRegistrationIfNeeded(registration, nodeId, wakeResult);
|
||||
if (!wakeResult.ok) {
|
||||
return withDuration({
|
||||
available: true,
|
||||
@@ -316,24 +372,44 @@ export async function maybeSendNodeWakeNudge(nodeId: string): Promise<NodeWakeNu
|
||||
if (!registration) {
|
||||
return withDuration({ sent: false, throttled: false, reason: "no-registration" });
|
||||
}
|
||||
const auth = await resolveApnsAuthConfigFromEnv(process.env);
|
||||
if (!auth.ok) {
|
||||
return withDuration({
|
||||
sent: false,
|
||||
throttled: false,
|
||||
reason: "no-auth",
|
||||
apnsReason: auth.error,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sendApnsAlert({
|
||||
auth: auth.value,
|
||||
registration,
|
||||
nodeId,
|
||||
title: "OpenClaw needs a quick reopen",
|
||||
body: "Tap to reopen OpenClaw and restore the node connection.",
|
||||
});
|
||||
let result;
|
||||
if (registration.transport === "relay") {
|
||||
const relay = resolveRelayNodePushConfig();
|
||||
if (!relay.ok) {
|
||||
return withDuration({
|
||||
sent: false,
|
||||
throttled: false,
|
||||
reason: "no-auth",
|
||||
apnsReason: relay.error,
|
||||
});
|
||||
}
|
||||
result = await sendApnsAlert({
|
||||
registration,
|
||||
nodeId,
|
||||
title: "OpenClaw needs a quick reopen",
|
||||
body: "Tap to reopen OpenClaw and restore the node connection.",
|
||||
relayConfig: relay.relayConfig,
|
||||
});
|
||||
} else {
|
||||
const auth = await resolveDirectNodePushConfig();
|
||||
if (!auth.ok) {
|
||||
return withDuration({
|
||||
sent: false,
|
||||
throttled: false,
|
||||
reason: "no-auth",
|
||||
apnsReason: auth.error,
|
||||
});
|
||||
}
|
||||
result = await sendApnsAlert({
|
||||
registration,
|
||||
nodeId,
|
||||
title: "OpenClaw needs a quick reopen",
|
||||
body: "Tap to reopen OpenClaw and restore the node connection.",
|
||||
auth: auth.auth,
|
||||
});
|
||||
}
|
||||
await clearStaleApnsRegistrationIfNeeded(registration, nodeId, result);
|
||||
if (!result.ok) {
|
||||
return withDuration({
|
||||
sent: false,
|
||||
|
||||
@@ -2,18 +2,32 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ErrorCodes } from "../protocol/index.js";
|
||||
import { pushHandlers } from "./push.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
loadConfig: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("../../config/config.js", () => ({
|
||||
loadConfig: mocks.loadConfig,
|
||||
}));
|
||||
|
||||
vi.mock("../../infra/push-apns.js", () => ({
|
||||
clearApnsRegistrationIfCurrent: vi.fn(),
|
||||
loadApnsRegistration: vi.fn(),
|
||||
normalizeApnsEnvironment: vi.fn(),
|
||||
resolveApnsAuthConfigFromEnv: vi.fn(),
|
||||
resolveApnsRelayConfigFromEnv: vi.fn(),
|
||||
sendApnsAlert: vi.fn(),
|
||||
shouldClearStoredApnsRegistration: vi.fn(),
|
||||
}));
|
||||
|
||||
import {
|
||||
clearApnsRegistrationIfCurrent,
|
||||
loadApnsRegistration,
|
||||
normalizeApnsEnvironment,
|
||||
resolveApnsAuthConfigFromEnv,
|
||||
resolveApnsRelayConfigFromEnv,
|
||||
sendApnsAlert,
|
||||
shouldClearStoredApnsRegistration,
|
||||
} from "../../infra/push-apns.js";
|
||||
|
||||
type RespondCall = [boolean, unknown?, { code: number; message: string }?];
|
||||
@@ -46,10 +60,15 @@ function expectInvalidRequestResponse(
|
||||
|
||||
describe("push.test handler", () => {
|
||||
beforeEach(() => {
|
||||
mocks.loadConfig.mockClear();
|
||||
mocks.loadConfig.mockReturnValue({});
|
||||
vi.mocked(loadApnsRegistration).mockClear();
|
||||
vi.mocked(normalizeApnsEnvironment).mockClear();
|
||||
vi.mocked(resolveApnsAuthConfigFromEnv).mockClear();
|
||||
vi.mocked(resolveApnsRelayConfigFromEnv).mockClear();
|
||||
vi.mocked(sendApnsAlert).mockClear();
|
||||
vi.mocked(clearApnsRegistrationIfCurrent).mockClear();
|
||||
vi.mocked(shouldClearStoredApnsRegistration).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("rejects invalid params", async () => {
|
||||
@@ -68,6 +87,7 @@ describe("push.test handler", () => {
|
||||
it("sends push test when registration and auth are available", async () => {
|
||||
vi.mocked(loadApnsRegistration).mockResolvedValue({
|
||||
nodeId: "ios-node-1",
|
||||
transport: "direct",
|
||||
token: "abcd",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "sandbox",
|
||||
@@ -88,6 +108,7 @@ describe("push.test handler", () => {
|
||||
tokenSuffix: "1234abcd",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "sandbox",
|
||||
transport: "direct",
|
||||
});
|
||||
|
||||
const { respond, invoke } = createInvokeParams({
|
||||
@@ -102,4 +123,246 @@ describe("push.test handler", () => {
|
||||
expect(call?.[0]).toBe(true);
|
||||
expect(call?.[1]).toMatchObject({ ok: true, status: 200 });
|
||||
});
|
||||
|
||||
it("sends push test through relay registrations", async () => {
|
||||
mocks.loadConfig.mockReturnValue({
|
||||
gateway: {
|
||||
push: {
|
||||
apns: {
|
||||
relay: {
|
||||
baseUrl: "https://relay.example.com",
|
||||
timeoutMs: 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
vi.mocked(loadApnsRegistration).mockResolvedValue({
|
||||
nodeId: "ios-node-1",
|
||||
transport: "relay",
|
||||
relayHandle: "relay-handle-123",
|
||||
sendGrant: "send-grant-123",
|
||||
installationId: "install-1",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
distribution: "official",
|
||||
updatedAtMs: 1,
|
||||
tokenDebugSuffix: "abcd1234",
|
||||
});
|
||||
vi.mocked(resolveApnsRelayConfigFromEnv).mockReturnValue({
|
||||
ok: true,
|
||||
value: {
|
||||
baseUrl: "https://relay.example.com",
|
||||
timeoutMs: 1000,
|
||||
},
|
||||
});
|
||||
vi.mocked(normalizeApnsEnvironment).mockReturnValue(null);
|
||||
vi.mocked(sendApnsAlert).mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
tokenSuffix: "abcd1234",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
transport: "relay",
|
||||
});
|
||||
|
||||
const { respond, invoke } = createInvokeParams({
|
||||
nodeId: "ios-node-1",
|
||||
title: "Wake",
|
||||
body: "Ping",
|
||||
});
|
||||
await invoke();
|
||||
|
||||
expect(resolveApnsAuthConfigFromEnv).not.toHaveBeenCalled();
|
||||
expect(resolveApnsRelayConfigFromEnv).toHaveBeenCalledTimes(1);
|
||||
expect(resolveApnsRelayConfigFromEnv).toHaveBeenCalledWith(process.env, {
|
||||
push: {
|
||||
apns: {
|
||||
relay: {
|
||||
baseUrl: "https://relay.example.com",
|
||||
timeoutMs: 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(sendApnsAlert).toHaveBeenCalledTimes(1);
|
||||
const call = respond.mock.calls[0] as RespondCall | undefined;
|
||||
expect(call?.[0]).toBe(true);
|
||||
expect(call?.[1]).toMatchObject({ ok: true, status: 200, transport: "relay" });
|
||||
});
|
||||
|
||||
it("clears stale registrations after invalid token push-test failures", async () => {
|
||||
vi.mocked(loadApnsRegistration).mockResolvedValue({
|
||||
nodeId: "ios-node-1",
|
||||
transport: "direct",
|
||||
token: "abcd",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "sandbox",
|
||||
updatedAtMs: 1,
|
||||
});
|
||||
vi.mocked(resolveApnsAuthConfigFromEnv).mockResolvedValue({
|
||||
ok: true,
|
||||
value: {
|
||||
teamId: "TEAM123",
|
||||
keyId: "KEY123",
|
||||
privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", // pragma: allowlist secret
|
||||
},
|
||||
});
|
||||
vi.mocked(normalizeApnsEnvironment).mockReturnValue(null);
|
||||
vi.mocked(sendApnsAlert).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
reason: "BadDeviceToken",
|
||||
tokenSuffix: "1234abcd",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "sandbox",
|
||||
transport: "direct",
|
||||
});
|
||||
vi.mocked(shouldClearStoredApnsRegistration).mockReturnValue(true);
|
||||
|
||||
const { invoke } = createInvokeParams({
|
||||
nodeId: "ios-node-1",
|
||||
title: "Wake",
|
||||
body: "Ping",
|
||||
});
|
||||
await invoke();
|
||||
|
||||
expect(clearApnsRegistrationIfCurrent).toHaveBeenCalledWith({
|
||||
nodeId: "ios-node-1",
|
||||
registration: {
|
||||
nodeId: "ios-node-1",
|
||||
transport: "direct",
|
||||
token: "abcd",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "sandbox",
|
||||
updatedAtMs: 1,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not clear relay registrations after invalidation-shaped failures", async () => {
|
||||
vi.mocked(loadApnsRegistration).mockResolvedValue({
|
||||
nodeId: "ios-node-1",
|
||||
transport: "relay",
|
||||
relayHandle: "relay-handle-123",
|
||||
sendGrant: "send-grant-123",
|
||||
installationId: "install-123",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
distribution: "official",
|
||||
updatedAtMs: 1,
|
||||
tokenDebugSuffix: "abcd1234",
|
||||
});
|
||||
vi.mocked(resolveApnsRelayConfigFromEnv).mockReturnValue({
|
||||
ok: true,
|
||||
value: {
|
||||
baseUrl: "https://relay.example.com",
|
||||
timeoutMs: 1000,
|
||||
},
|
||||
});
|
||||
vi.mocked(normalizeApnsEnvironment).mockReturnValue(null);
|
||||
vi.mocked(sendApnsAlert).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 410,
|
||||
reason: "Unregistered",
|
||||
tokenSuffix: "abcd1234",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
transport: "relay",
|
||||
});
|
||||
vi.mocked(shouldClearStoredApnsRegistration).mockReturnValue(false);
|
||||
|
||||
const { invoke } = createInvokeParams({
|
||||
nodeId: "ios-node-1",
|
||||
title: "Wake",
|
||||
body: "Ping",
|
||||
});
|
||||
await invoke();
|
||||
|
||||
expect(shouldClearStoredApnsRegistration).toHaveBeenCalledWith({
|
||||
registration: {
|
||||
nodeId: "ios-node-1",
|
||||
transport: "relay",
|
||||
relayHandle: "relay-handle-123",
|
||||
sendGrant: "send-grant-123",
|
||||
installationId: "install-123",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
distribution: "official",
|
||||
updatedAtMs: 1,
|
||||
tokenDebugSuffix: "abcd1234",
|
||||
},
|
||||
result: {
|
||||
ok: false,
|
||||
status: 410,
|
||||
reason: "Unregistered",
|
||||
tokenSuffix: "abcd1234",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
transport: "relay",
|
||||
},
|
||||
overrideEnvironment: null,
|
||||
});
|
||||
expect(clearApnsRegistrationIfCurrent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not clear direct registrations when push.test overrides the environment", async () => {
|
||||
vi.mocked(loadApnsRegistration).mockResolvedValue({
|
||||
nodeId: "ios-node-1",
|
||||
transport: "direct",
|
||||
token: "abcd",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "sandbox",
|
||||
updatedAtMs: 1,
|
||||
});
|
||||
vi.mocked(resolveApnsAuthConfigFromEnv).mockResolvedValue({
|
||||
ok: true,
|
||||
value: {
|
||||
teamId: "TEAM123",
|
||||
keyId: "KEY123",
|
||||
privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----", // pragma: allowlist secret
|
||||
},
|
||||
});
|
||||
vi.mocked(normalizeApnsEnvironment).mockReturnValue("production");
|
||||
vi.mocked(sendApnsAlert).mockResolvedValue({
|
||||
ok: false,
|
||||
status: 400,
|
||||
reason: "BadDeviceToken",
|
||||
tokenSuffix: "1234abcd",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
transport: "direct",
|
||||
});
|
||||
vi.mocked(shouldClearStoredApnsRegistration).mockReturnValue(false);
|
||||
|
||||
const { invoke } = createInvokeParams({
|
||||
nodeId: "ios-node-1",
|
||||
title: "Wake",
|
||||
body: "Ping",
|
||||
environment: "production",
|
||||
});
|
||||
await invoke();
|
||||
|
||||
expect(shouldClearStoredApnsRegistration).toHaveBeenCalledWith({
|
||||
registration: {
|
||||
nodeId: "ios-node-1",
|
||||
transport: "direct",
|
||||
token: "abcd",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "sandbox",
|
||||
updatedAtMs: 1,
|
||||
},
|
||||
result: {
|
||||
ok: false,
|
||||
status: 400,
|
||||
reason: "BadDeviceToken",
|
||||
tokenSuffix: "1234abcd",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
transport: "direct",
|
||||
},
|
||||
overrideEnvironment: "production",
|
||||
});
|
||||
expect(clearApnsRegistrationIfCurrent).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import {
|
||||
clearApnsRegistrationIfCurrent,
|
||||
loadApnsRegistration,
|
||||
normalizeApnsEnvironment,
|
||||
resolveApnsAuthConfigFromEnv,
|
||||
resolveApnsRelayConfigFromEnv,
|
||||
sendApnsAlert,
|
||||
shouldClearStoredApnsRegistration,
|
||||
} from "../../infra/push-apns.js";
|
||||
import { ErrorCodes, errorShape, validatePushTestParams } from "../protocol/index.js";
|
||||
import { respondInvalidParams, respondUnavailableOnThrow } from "./nodes.helpers.js";
|
||||
@@ -50,23 +54,55 @@ export const pushHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
|
||||
const auth = await resolveApnsAuthConfigFromEnv(process.env);
|
||||
if (!auth.ok) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, auth.error));
|
||||
const overrideEnvironment = normalizeApnsEnvironment(params.environment);
|
||||
const result =
|
||||
registration.transport === "direct"
|
||||
? await (async () => {
|
||||
const auth = await resolveApnsAuthConfigFromEnv(process.env);
|
||||
if (!auth.ok) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, auth.error));
|
||||
return null;
|
||||
}
|
||||
return await sendApnsAlert({
|
||||
registration: {
|
||||
...registration,
|
||||
environment: overrideEnvironment ?? registration.environment,
|
||||
},
|
||||
nodeId,
|
||||
title,
|
||||
body,
|
||||
auth: auth.value,
|
||||
});
|
||||
})()
|
||||
: await (async () => {
|
||||
const relay = resolveApnsRelayConfigFromEnv(process.env, loadConfig().gateway);
|
||||
if (!relay.ok) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, relay.error));
|
||||
return null;
|
||||
}
|
||||
return await sendApnsAlert({
|
||||
registration,
|
||||
nodeId,
|
||||
title,
|
||||
body,
|
||||
relayConfig: relay.value,
|
||||
});
|
||||
})();
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const overrideEnvironment = normalizeApnsEnvironment(params.environment);
|
||||
const result = await sendApnsAlert({
|
||||
auth: auth.value,
|
||||
registration: {
|
||||
...registration,
|
||||
environment: overrideEnvironment ?? registration.environment,
|
||||
},
|
||||
nodeId,
|
||||
title,
|
||||
body,
|
||||
});
|
||||
if (
|
||||
shouldClearStoredApnsRegistration({
|
||||
registration,
|
||||
result,
|
||||
overrideEnvironment,
|
||||
})
|
||||
) {
|
||||
await clearApnsRegistrationIfCurrent({
|
||||
nodeId,
|
||||
registration,
|
||||
});
|
||||
}
|
||||
respond(true, result, undefined);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { resolveMainSessionKeyFromConfig } from "../../config/sessions.js";
|
||||
import {
|
||||
loadOrCreateDeviceIdentity,
|
||||
publicKeyRawBase64UrlFromPem,
|
||||
} from "../../infra/device-identity.js";
|
||||
import { getLastHeartbeatEvent } from "../../infra/heartbeat-events.js";
|
||||
import { setHeartbeatsEnabled } from "../../infra/heartbeat-runner.js";
|
||||
import { enqueueSystemEvent, isSystemEventContextChanged } from "../../infra/system-events.js";
|
||||
@@ -8,6 +12,17 @@ import { broadcastPresenceSnapshot } from "../server/presence-events.js";
|
||||
import type { GatewayRequestHandlers } from "./types.js";
|
||||
|
||||
export const systemHandlers: GatewayRequestHandlers = {
|
||||
"gateway.identity.get": ({ respond }) => {
|
||||
const identity = loadOrCreateDeviceIdentity();
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
deviceId: identity.deviceId,
|
||||
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
},
|
||||
"last-heartbeat": ({ respond }) => {
|
||||
respond(true, getLastHeartbeatEvent(), undefined);
|
||||
},
|
||||
|
||||
@@ -25,6 +25,14 @@ const buildSessionLookup = (
|
||||
});
|
||||
|
||||
const ingressAgentCommandMock = vi.hoisted(() => vi.fn().mockResolvedValue(undefined));
|
||||
const registerApnsRegistrationMock = vi.hoisted(() => vi.fn());
|
||||
const loadOrCreateDeviceIdentityMock = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
deviceId: "gateway-device-1",
|
||||
publicKeyPem: "public",
|
||||
privateKeyPem: "private",
|
||||
})),
|
||||
);
|
||||
|
||||
vi.mock("../infra/system-events.js", () => ({
|
||||
enqueueSystemEvent: vi.fn(),
|
||||
@@ -43,6 +51,12 @@ vi.mock("../config/config.js", () => ({
|
||||
vi.mock("../config/sessions.js", () => ({
|
||||
updateSessionStore: vi.fn(),
|
||||
}));
|
||||
vi.mock("../infra/push-apns.js", () => ({
|
||||
registerApnsRegistration: registerApnsRegistrationMock,
|
||||
}));
|
||||
vi.mock("../infra/device-identity.js", () => ({
|
||||
loadOrCreateDeviceIdentity: loadOrCreateDeviceIdentityMock,
|
||||
}));
|
||||
vi.mock("./session-utils.js", () => ({
|
||||
loadSessionEntry: vi.fn((sessionKey: string) => buildSessionLookup(sessionKey)),
|
||||
pruneLegacyStoreKeys: vi.fn(),
|
||||
@@ -58,6 +72,7 @@ import type { HealthSummary } from "../commands/health.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { updateSessionStore } from "../config/sessions.js";
|
||||
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||
import { registerApnsRegistration } from "../infra/push-apns.js";
|
||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
import type { NodeEventContext } from "./server-node-events-types.js";
|
||||
import { handleNodeEvent } from "./server-node-events.js";
|
||||
@@ -69,6 +84,7 @@ const loadConfigMock = vi.mocked(loadConfig);
|
||||
const agentCommandMock = vi.mocked(agentCommand);
|
||||
const updateSessionStoreMock = vi.mocked(updateSessionStore);
|
||||
const loadSessionEntryMock = vi.mocked(loadSessionEntry);
|
||||
const registerApnsRegistrationVi = vi.mocked(registerApnsRegistration);
|
||||
|
||||
function buildCtx(): NodeEventContext {
|
||||
return {
|
||||
@@ -97,6 +113,8 @@ describe("node exec events", () => {
|
||||
beforeEach(() => {
|
||||
enqueueSystemEventMock.mockClear();
|
||||
requestHeartbeatNowMock.mockClear();
|
||||
registerApnsRegistrationVi.mockClear();
|
||||
loadOrCreateDeviceIdentityMock.mockClear();
|
||||
});
|
||||
|
||||
it("enqueues exec.started events", async () => {
|
||||
@@ -255,6 +273,75 @@ describe("node exec events", () => {
|
||||
expect(enqueueSystemEventMock).not.toHaveBeenCalled();
|
||||
expect(requestHeartbeatNowMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores direct APNs registrations from node events", async () => {
|
||||
const ctx = buildCtx();
|
||||
await handleNodeEvent(ctx, "node-direct", {
|
||||
event: "push.apns.register",
|
||||
payloadJSON: JSON.stringify({
|
||||
token: "abcd1234abcd1234abcd1234abcd1234",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "sandbox",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(registerApnsRegistrationVi).toHaveBeenCalledWith({
|
||||
nodeId: "node-direct",
|
||||
transport: "direct",
|
||||
token: "abcd1234abcd1234abcd1234abcd1234",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "sandbox",
|
||||
});
|
||||
});
|
||||
|
||||
it("stores relay APNs registrations from node events", async () => {
|
||||
const ctx = buildCtx();
|
||||
await handleNodeEvent(ctx, "node-relay", {
|
||||
event: "push.apns.register",
|
||||
payloadJSON: JSON.stringify({
|
||||
transport: "relay",
|
||||
relayHandle: "relay-handle-123",
|
||||
sendGrant: "send-grant-123",
|
||||
gatewayDeviceId: "gateway-device-1",
|
||||
installationId: "install-123",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
distribution: "official",
|
||||
tokenDebugSuffix: "abcd1234",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(registerApnsRegistrationVi).toHaveBeenCalledWith({
|
||||
nodeId: "node-relay",
|
||||
transport: "relay",
|
||||
relayHandle: "relay-handle-123",
|
||||
sendGrant: "send-grant-123",
|
||||
installationId: "install-123",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
distribution: "official",
|
||||
tokenDebugSuffix: "abcd1234",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects relay registrations bound to a different gateway identity", async () => {
|
||||
const ctx = buildCtx();
|
||||
await handleNodeEvent(ctx, "node-relay", {
|
||||
event: "push.apns.register",
|
||||
payloadJSON: JSON.stringify({
|
||||
transport: "relay",
|
||||
relayHandle: "relay-handle-123",
|
||||
sendGrant: "send-grant-123",
|
||||
gatewayDeviceId: "gateway-device-other",
|
||||
installationId: "install-123",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
distribution: "official",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(registerApnsRegistrationVi).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("voice transcript events", () => {
|
||||
|
||||
@@ -4,11 +4,12 @@ import { createOutboundSendDeps } from "../cli/outbound-send-deps.js";
|
||||
import { agentCommandFromIngress } from "../commands/agent.js";
|
||||
import { loadConfig } from "../config/config.js";
|
||||
import { updateSessionStore } from "../config/sessions.js";
|
||||
import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js";
|
||||
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||
import { deliverOutboundPayloads } from "../infra/outbound/deliver.js";
|
||||
import { buildOutboundSessionContext } from "../infra/outbound/session-context.js";
|
||||
import { resolveOutboundTarget } from "../infra/outbound/targets.js";
|
||||
import { registerApnsToken } from "../infra/push-apns.js";
|
||||
import { registerApnsRegistration } from "../infra/push-apns.js";
|
||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
import { normalizeMainKey, scopedHeartbeatWakeOptions } from "../routing/session-key.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
@@ -588,16 +589,41 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
|
||||
if (!obj) {
|
||||
return;
|
||||
}
|
||||
const token = typeof obj.token === "string" ? obj.token : "";
|
||||
const transport =
|
||||
typeof obj.transport === "string" ? obj.transport.trim().toLowerCase() : "direct";
|
||||
const topic = typeof obj.topic === "string" ? obj.topic : "";
|
||||
const environment = obj.environment;
|
||||
try {
|
||||
await registerApnsToken({
|
||||
nodeId,
|
||||
token,
|
||||
topic,
|
||||
environment,
|
||||
});
|
||||
if (transport === "relay") {
|
||||
const gatewayDeviceId =
|
||||
typeof obj.gatewayDeviceId === "string" ? obj.gatewayDeviceId.trim() : "";
|
||||
const currentGatewayDeviceId = loadOrCreateDeviceIdentity().deviceId;
|
||||
if (!gatewayDeviceId || gatewayDeviceId !== currentGatewayDeviceId) {
|
||||
ctx.logGateway.warn(
|
||||
`push relay register rejected node=${nodeId}: gateway identity mismatch`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await registerApnsRegistration({
|
||||
nodeId,
|
||||
transport: "relay",
|
||||
relayHandle: typeof obj.relayHandle === "string" ? obj.relayHandle : "",
|
||||
sendGrant: typeof obj.sendGrant === "string" ? obj.sendGrant : "",
|
||||
installationId: typeof obj.installationId === "string" ? obj.installationId : "",
|
||||
topic,
|
||||
environment,
|
||||
distribution: obj.distribution,
|
||||
tokenDebugSuffix: obj.tokenDebugSuffix,
|
||||
});
|
||||
} else {
|
||||
await registerApnsRegistration({
|
||||
nodeId,
|
||||
transport: "direct",
|
||||
token: typeof obj.token === "string" ? obj.token : "",
|
||||
topic,
|
||||
environment,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
ctx.logGateway.warn(`push apns register failed node=${nodeId}: ${formatForLog(err)}`);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ type RunBeforeToolCallHookArgs = Parameters<RunBeforeToolCallHook>[0];
|
||||
type RunBeforeToolCallHookResult = Awaited<ReturnType<RunBeforeToolCallHook>>;
|
||||
|
||||
const TEST_GATEWAY_TOKEN = "test-gateway-token-1234567890";
|
||||
|
||||
const hookMocks = vi.hoisted(() => ({
|
||||
resolveToolLoopDetectionConfig: vi.fn(() => ({ warnAt: 3 })),
|
||||
runBeforeToolCallHook: vi.fn(
|
||||
|
||||
@@ -39,7 +39,7 @@ export async function writeTextAtomic(
|
||||
await fs.mkdir(path.dirname(filePath), mkdirOptions);
|
||||
const tmp = `${filePath}.${randomUUID()}.tmp`;
|
||||
try {
|
||||
await fs.writeFile(tmp, payload, "utf8");
|
||||
await fs.writeFile(tmp, payload, { encoding: "utf8", mode });
|
||||
try {
|
||||
await fs.chmod(tmp, mode);
|
||||
} catch {
|
||||
|
||||
254
src/infra/push-apns.relay.ts
Normal file
254
src/infra/push-apns.relay.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { URL } from "node:url";
|
||||
import type { GatewayConfig } from "../config/types.gateway.js";
|
||||
import {
|
||||
loadOrCreateDeviceIdentity,
|
||||
signDevicePayload,
|
||||
type DeviceIdentity,
|
||||
} from "./device-identity.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 = value?.trim() ?? "";
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeTimeoutMs(value: string | number | undefined): number {
|
||||
const raw =
|
||||
typeof value === "number" ? value : typeof value === "string" ? value.trim() : 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 = value?.trim().toLowerCase();
|
||||
return normalized === "1" || normalized === "true" || normalized === "yes";
|
||||
}
|
||||
|
||||
function isLoopbackRelayHostname(hostname: string): boolean {
|
||||
const normalized = hostname.trim().toLowerCase();
|
||||
return (
|
||||
normalized === "localhost" ||
|
||||
normalized === "::1" ||
|
||||
normalized === "[::1]" ||
|
||||
/^127(?:\.\d{1,3}){3}$/.test(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
function parseReason(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : 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 = err instanceof Error ? err.message : String(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,
|
||||
});
|
||||
}
|
||||
@@ -4,18 +4,44 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
deriveDeviceIdFromPublicKey,
|
||||
publicKeyRawBase64UrlFromPem,
|
||||
verifyDeviceSignature,
|
||||
} from "./device-identity.js";
|
||||
import {
|
||||
clearApnsRegistration,
|
||||
clearApnsRegistrationIfCurrent,
|
||||
loadApnsRegistration,
|
||||
normalizeApnsEnvironment,
|
||||
registerApnsRegistration,
|
||||
registerApnsToken,
|
||||
resolveApnsAuthConfigFromEnv,
|
||||
resolveApnsRelayConfigFromEnv,
|
||||
sendApnsAlert,
|
||||
sendApnsBackgroundWake,
|
||||
shouldClearStoredApnsRegistration,
|
||||
shouldInvalidateApnsRegistration,
|
||||
} from "./push-apns.js";
|
||||
import { sendApnsRelayPush } from "./push-apns.relay.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const testAuthPrivateKey = generateKeyPairSync("ec", { namedCurve: "prime256v1" })
|
||||
.privateKey.export({ format: "pem", type: "pkcs8" })
|
||||
.toString();
|
||||
const relayGatewayIdentity = (() => {
|
||||
const { publicKey, privateKey } = generateKeyPairSync("ed25519");
|
||||
const publicKeyPem = publicKey.export({ format: "pem", type: "spki" }).toString();
|
||||
const publicKeyRaw = publicKeyRawBase64UrlFromPem(publicKeyPem);
|
||||
const deviceId = deriveDeviceIdFromPublicKey(publicKeyRaw);
|
||||
if (!deviceId) {
|
||||
throw new Error("failed to derive test gateway device id");
|
||||
}
|
||||
return {
|
||||
deviceId,
|
||||
publicKey: publicKeyRaw,
|
||||
privateKeyPem: privateKey.export({ format: "pem", type: "pkcs8" }).toString(),
|
||||
};
|
||||
})();
|
||||
|
||||
async function makeTempDir(): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-push-apns-test-"));
|
||||
@@ -24,6 +50,7 @@ async function makeTempDir(): Promise<string> {
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop();
|
||||
if (dir) {
|
||||
@@ -46,12 +73,46 @@ describe("push APNs registration store", () => {
|
||||
const loaded = await loadApnsRegistration("ios-node-1", baseDir);
|
||||
expect(loaded).not.toBeNull();
|
||||
expect(loaded?.nodeId).toBe("ios-node-1");
|
||||
expect(loaded?.token).toBe("abcd1234abcd1234abcd1234abcd1234");
|
||||
expect(loaded?.transport).toBe("direct");
|
||||
expect(loaded && loaded.transport === "direct" ? loaded.token : null).toBe(
|
||||
"abcd1234abcd1234abcd1234abcd1234",
|
||||
);
|
||||
expect(loaded?.topic).toBe("ai.openclaw.ios");
|
||||
expect(loaded?.environment).toBe("sandbox");
|
||||
expect(loaded?.updatedAtMs).toBe(saved.updatedAtMs);
|
||||
});
|
||||
|
||||
it("stores and reloads relay-backed APNs registrations without a raw token", async () => {
|
||||
const baseDir = await makeTempDir();
|
||||
const saved = await registerApnsRegistration({
|
||||
nodeId: "ios-node-relay",
|
||||
transport: "relay",
|
||||
relayHandle: "relay-handle-123",
|
||||
sendGrant: "send-grant-123",
|
||||
installationId: "install-123",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
distribution: "official",
|
||||
tokenDebugSuffix: "abcd1234",
|
||||
baseDir,
|
||||
});
|
||||
|
||||
const loaded = await loadApnsRegistration("ios-node-relay", baseDir);
|
||||
expect(saved.transport).toBe("relay");
|
||||
expect(loaded).toMatchObject({
|
||||
nodeId: "ios-node-relay",
|
||||
transport: "relay",
|
||||
relayHandle: "relay-handle-123",
|
||||
sendGrant: "send-grant-123",
|
||||
installationId: "install-123",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
distribution: "official",
|
||||
tokenDebugSuffix: "abcd1234",
|
||||
});
|
||||
expect(loaded && "token" in loaded).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects invalid APNs tokens", async () => {
|
||||
const baseDir = await makeTempDir();
|
||||
await expect(
|
||||
@@ -63,6 +124,156 @@ describe("push APNs registration store", () => {
|
||||
}),
|
||||
).rejects.toThrow("invalid APNs token");
|
||||
});
|
||||
|
||||
it("rejects oversized direct APNs registration fields", async () => {
|
||||
const baseDir = await makeTempDir();
|
||||
await expect(
|
||||
registerApnsToken({
|
||||
nodeId: "n".repeat(257),
|
||||
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
|
||||
topic: "ai.openclaw.ios",
|
||||
baseDir,
|
||||
}),
|
||||
).rejects.toThrow("nodeId required");
|
||||
await expect(
|
||||
registerApnsToken({
|
||||
nodeId: "ios-node-1",
|
||||
token: "A".repeat(513),
|
||||
topic: "ai.openclaw.ios",
|
||||
baseDir,
|
||||
}),
|
||||
).rejects.toThrow("invalid APNs token");
|
||||
await expect(
|
||||
registerApnsToken({
|
||||
nodeId: "ios-node-1",
|
||||
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
|
||||
topic: "a".repeat(256),
|
||||
baseDir,
|
||||
}),
|
||||
).rejects.toThrow("topic required");
|
||||
});
|
||||
|
||||
it("rejects relay registrations that do not use production/official values", async () => {
|
||||
const baseDir = await makeTempDir();
|
||||
await expect(
|
||||
registerApnsRegistration({
|
||||
nodeId: "ios-node-relay",
|
||||
transport: "relay",
|
||||
relayHandle: "relay-handle-123",
|
||||
sendGrant: "send-grant-123",
|
||||
installationId: "install-123",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "staging",
|
||||
distribution: "official",
|
||||
baseDir,
|
||||
}),
|
||||
).rejects.toThrow("relay registrations must use production environment");
|
||||
await expect(
|
||||
registerApnsRegistration({
|
||||
nodeId: "ios-node-relay",
|
||||
transport: "relay",
|
||||
relayHandle: "relay-handle-123",
|
||||
sendGrant: "send-grant-123",
|
||||
installationId: "install-123",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
distribution: "beta",
|
||||
baseDir,
|
||||
}),
|
||||
).rejects.toThrow("relay registrations must use official distribution");
|
||||
});
|
||||
|
||||
it("rejects oversized relay registration identifiers", async () => {
|
||||
const baseDir = await makeTempDir();
|
||||
const oversized = "x".repeat(257);
|
||||
await expect(
|
||||
registerApnsRegistration({
|
||||
nodeId: "ios-node-relay",
|
||||
transport: "relay",
|
||||
relayHandle: oversized,
|
||||
sendGrant: "send-grant-123",
|
||||
installationId: "install-123",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
distribution: "official",
|
||||
baseDir,
|
||||
}),
|
||||
).rejects.toThrow("relayHandle too long");
|
||||
await expect(
|
||||
registerApnsRegistration({
|
||||
nodeId: "ios-node-relay",
|
||||
transport: "relay",
|
||||
relayHandle: "relay-handle-123",
|
||||
sendGrant: "send-grant-123",
|
||||
installationId: oversized,
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
distribution: "official",
|
||||
baseDir,
|
||||
}),
|
||||
).rejects.toThrow("installationId too long");
|
||||
await expect(
|
||||
registerApnsRegistration({
|
||||
nodeId: "ios-node-relay",
|
||||
transport: "relay",
|
||||
relayHandle: "relay-handle-123",
|
||||
sendGrant: "x".repeat(1025),
|
||||
installationId: "install-123",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
distribution: "official",
|
||||
baseDir,
|
||||
}),
|
||||
).rejects.toThrow("sendGrant too long");
|
||||
});
|
||||
|
||||
it("clears registrations", async () => {
|
||||
const baseDir = await makeTempDir();
|
||||
await registerApnsToken({
|
||||
nodeId: "ios-node-1",
|
||||
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
|
||||
topic: "ai.openclaw.ios",
|
||||
baseDir,
|
||||
});
|
||||
|
||||
await expect(clearApnsRegistration("ios-node-1", baseDir)).resolves.toBe(true);
|
||||
await expect(loadApnsRegistration("ios-node-1", baseDir)).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it("only clears a registration when the stored entry still matches", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const baseDir = await makeTempDir();
|
||||
vi.setSystemTime(new Date("2026-03-11T00:00:00Z"));
|
||||
const stale = await registerApnsToken({
|
||||
nodeId: "ios-node-1",
|
||||
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "sandbox",
|
||||
baseDir,
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date("2026-03-11T00:00:01Z"));
|
||||
const fresh = await registerApnsToken({
|
||||
nodeId: "ios-node-1",
|
||||
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "sandbox",
|
||||
baseDir,
|
||||
});
|
||||
|
||||
await expect(
|
||||
clearApnsRegistrationIfCurrent({
|
||||
nodeId: "ios-node-1",
|
||||
registration: stale,
|
||||
baseDir,
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
await expect(loadApnsRegistration("ios-node-1", baseDir)).resolves.toEqual(fresh);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("push APNs env config", () => {
|
||||
@@ -97,6 +308,141 @@ describe("push APNs env config", () => {
|
||||
}
|
||||
expect(resolved.error).toContain("OPENCLAW_APNS_TEAM_ID");
|
||||
});
|
||||
|
||||
it("resolves APNs relay config from env", () => {
|
||||
const resolved = resolveApnsRelayConfigFromEnv({
|
||||
OPENCLAW_APNS_RELAY_BASE_URL: "https://relay.example.com",
|
||||
OPENCLAW_APNS_RELAY_TIMEOUT_MS: "2500",
|
||||
} as NodeJS.ProcessEnv);
|
||||
expect(resolved).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
baseUrl: "https://relay.example.com",
|
||||
timeoutMs: 2500,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves APNs relay config from gateway config", () => {
|
||||
const resolved = resolveApnsRelayConfigFromEnv({} as NodeJS.ProcessEnv, {
|
||||
push: {
|
||||
apns: {
|
||||
relay: {
|
||||
baseUrl: "https://relay.example.com/base/",
|
||||
timeoutMs: 2500,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(resolved).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
baseUrl: "https://relay.example.com/base",
|
||||
timeoutMs: 2500,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("lets relay env overrides win over gateway config", () => {
|
||||
const resolved = resolveApnsRelayConfigFromEnv(
|
||||
{
|
||||
OPENCLAW_APNS_RELAY_BASE_URL: "https://relay-override.example.com",
|
||||
OPENCLAW_APNS_RELAY_TIMEOUT_MS: "3000",
|
||||
} as NodeJS.ProcessEnv,
|
||||
{
|
||||
push: {
|
||||
apns: {
|
||||
relay: {
|
||||
baseUrl: "https://relay.example.com",
|
||||
timeoutMs: 2500,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(resolved).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
baseUrl: "https://relay-override.example.com",
|
||||
timeoutMs: 3000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects insecure APNs relay http URLs by default", () => {
|
||||
const resolved = resolveApnsRelayConfigFromEnv({
|
||||
OPENCLAW_APNS_RELAY_BASE_URL: "http://relay.example.com",
|
||||
} as NodeJS.ProcessEnv);
|
||||
expect(resolved).toMatchObject({
|
||||
ok: false,
|
||||
});
|
||||
if (resolved.ok) {
|
||||
return;
|
||||
}
|
||||
expect(resolved.error).toContain("OPENCLAW_APNS_RELAY_ALLOW_HTTP=true");
|
||||
});
|
||||
|
||||
it("allows APNs relay http URLs only when explicitly enabled", () => {
|
||||
const resolved = resolveApnsRelayConfigFromEnv({
|
||||
OPENCLAW_APNS_RELAY_BASE_URL: "http://127.0.0.1:8787",
|
||||
OPENCLAW_APNS_RELAY_ALLOW_HTTP: "true",
|
||||
} as NodeJS.ProcessEnv);
|
||||
expect(resolved).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
baseUrl: "http://127.0.0.1:8787",
|
||||
timeoutMs: 10_000,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects http relay URLs for non-loopback hosts even when explicitly enabled", () => {
|
||||
const resolved = resolveApnsRelayConfigFromEnv({
|
||||
OPENCLAW_APNS_RELAY_BASE_URL: "http://relay.example.com",
|
||||
OPENCLAW_APNS_RELAY_ALLOW_HTTP: "true",
|
||||
} as NodeJS.ProcessEnv);
|
||||
expect(resolved).toMatchObject({
|
||||
ok: false,
|
||||
});
|
||||
if (resolved.ok) {
|
||||
return;
|
||||
}
|
||||
expect(resolved.error).toContain("loopback hosts");
|
||||
});
|
||||
|
||||
it("rejects APNs relay URLs with query, fragment, or userinfo components", () => {
|
||||
const withQuery = resolveApnsRelayConfigFromEnv({
|
||||
OPENCLAW_APNS_RELAY_BASE_URL: "https://relay.example.com/path?debug=1",
|
||||
} as NodeJS.ProcessEnv);
|
||||
expect(withQuery.ok).toBe(false);
|
||||
if (!withQuery.ok) {
|
||||
expect(withQuery.error).toContain("query and fragment are not allowed");
|
||||
}
|
||||
|
||||
const withUserinfo = resolveApnsRelayConfigFromEnv({
|
||||
OPENCLAW_APNS_RELAY_BASE_URL: "https://user:pass@relay.example.com/path",
|
||||
} as NodeJS.ProcessEnv);
|
||||
expect(withUserinfo.ok).toBe(false);
|
||||
if (!withUserinfo.ok) {
|
||||
expect(withUserinfo.error).toContain("userinfo is not allowed");
|
||||
}
|
||||
});
|
||||
|
||||
it("reports the config key name for invalid gateway relay URLs", () => {
|
||||
const resolved = resolveApnsRelayConfigFromEnv({} as NodeJS.ProcessEnv, {
|
||||
push: {
|
||||
apns: {
|
||||
relay: {
|
||||
baseUrl: "https://relay.example.com/path?debug=1",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(resolved.ok).toBe(false);
|
||||
if (!resolved.ok) {
|
||||
expect(resolved.error).toContain("gateway.push.apns.relay.baseUrl");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("push APNs send semantics", () => {
|
||||
@@ -108,13 +454,9 @@ describe("push APNs send semantics", () => {
|
||||
});
|
||||
|
||||
const result = await sendApnsAlert({
|
||||
auth: {
|
||||
teamId: "TEAM123",
|
||||
keyId: "KEY123",
|
||||
privateKey: testAuthPrivateKey,
|
||||
},
|
||||
registration: {
|
||||
nodeId: "ios-node-alert",
|
||||
transport: "direct",
|
||||
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "sandbox",
|
||||
@@ -123,6 +465,11 @@ describe("push APNs send semantics", () => {
|
||||
nodeId: "ios-node-alert",
|
||||
title: "Wake",
|
||||
body: "Ping",
|
||||
auth: {
|
||||
teamId: "TEAM123",
|
||||
keyId: "KEY123",
|
||||
privateKey: testAuthPrivateKey,
|
||||
},
|
||||
requestSender: send,
|
||||
});
|
||||
|
||||
@@ -142,6 +489,7 @@ describe("push APNs send semantics", () => {
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.status).toBe(200);
|
||||
expect(result.transport).toBe("direct");
|
||||
});
|
||||
|
||||
it("sends background wake pushes with silent payload semantics", async () => {
|
||||
@@ -152,13 +500,9 @@ describe("push APNs send semantics", () => {
|
||||
});
|
||||
|
||||
const result = await sendApnsBackgroundWake({
|
||||
auth: {
|
||||
teamId: "TEAM123",
|
||||
keyId: "KEY123",
|
||||
privateKey: testAuthPrivateKey,
|
||||
},
|
||||
registration: {
|
||||
nodeId: "ios-node-wake",
|
||||
transport: "direct",
|
||||
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
@@ -166,6 +510,11 @@ describe("push APNs send semantics", () => {
|
||||
},
|
||||
nodeId: "ios-node-wake",
|
||||
wakeReason: "node.invoke",
|
||||
auth: {
|
||||
teamId: "TEAM123",
|
||||
keyId: "KEY123",
|
||||
privateKey: testAuthPrivateKey,
|
||||
},
|
||||
requestSender: send,
|
||||
});
|
||||
|
||||
@@ -189,6 +538,7 @@ describe("push APNs send semantics", () => {
|
||||
expect(aps?.sound).toBeUndefined();
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.environment).toBe("production");
|
||||
expect(result.transport).toBe("direct");
|
||||
});
|
||||
|
||||
it("defaults background wake reason when not provided", async () => {
|
||||
@@ -199,19 +549,20 @@ describe("push APNs send semantics", () => {
|
||||
});
|
||||
|
||||
await sendApnsBackgroundWake({
|
||||
auth: {
|
||||
teamId: "TEAM123",
|
||||
keyId: "KEY123",
|
||||
privateKey: testAuthPrivateKey,
|
||||
},
|
||||
registration: {
|
||||
nodeId: "ios-node-wake-default-reason",
|
||||
transport: "direct",
|
||||
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "sandbox",
|
||||
updatedAtMs: 1,
|
||||
},
|
||||
nodeId: "ios-node-wake-default-reason",
|
||||
auth: {
|
||||
teamId: "TEAM123",
|
||||
keyId: "KEY123",
|
||||
privateKey: testAuthPrivateKey,
|
||||
},
|
||||
requestSender: send,
|
||||
});
|
||||
|
||||
@@ -224,4 +575,158 @@ describe("push APNs send semantics", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("routes relay-backed alert pushes through the relay sender", async () => {
|
||||
const send = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
apnsId: "relay-apns-id",
|
||||
environment: "production",
|
||||
tokenSuffix: "abcd1234",
|
||||
});
|
||||
|
||||
const result = await sendApnsAlert({
|
||||
relayConfig: {
|
||||
baseUrl: "https://relay.example.com",
|
||||
timeoutMs: 1000,
|
||||
},
|
||||
registration: {
|
||||
nodeId: "ios-node-relay",
|
||||
transport: "relay",
|
||||
relayHandle: "relay-handle-123",
|
||||
sendGrant: "send-grant-123",
|
||||
installationId: "install-123",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
distribution: "official",
|
||||
updatedAtMs: 1,
|
||||
tokenDebugSuffix: "abcd1234",
|
||||
},
|
||||
nodeId: "ios-node-relay",
|
||||
title: "Wake",
|
||||
body: "Ping",
|
||||
relayGatewayIdentity: relayGatewayIdentity,
|
||||
relayRequestSender: send,
|
||||
});
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect(send.mock.calls[0]?.[0]).toMatchObject({
|
||||
relayHandle: "relay-handle-123",
|
||||
gatewayDeviceId: relayGatewayIdentity.deviceId,
|
||||
pushType: "alert",
|
||||
priority: "10",
|
||||
payload: {
|
||||
aps: {
|
||||
alert: { title: "Wake", body: "Ping" },
|
||||
sound: "default",
|
||||
},
|
||||
},
|
||||
});
|
||||
const sent = send.mock.calls[0]?.[0];
|
||||
expect(typeof sent?.signature).toBe("string");
|
||||
expect(typeof sent?.signedAtMs).toBe("number");
|
||||
const signedPayload = [
|
||||
"openclaw-relay-send-v1",
|
||||
sent?.gatewayDeviceId,
|
||||
String(sent?.signedAtMs),
|
||||
sent?.bodyJson,
|
||||
].join("\n");
|
||||
expect(
|
||||
verifyDeviceSignature(relayGatewayIdentity.publicKey, signedPayload, sent?.signature),
|
||||
).toBe(true);
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
status: 200,
|
||||
transport: "relay",
|
||||
environment: "production",
|
||||
tokenSuffix: "abcd1234",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not follow relay redirects", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 302,
|
||||
json: vi.fn().mockRejectedValue(new Error("no body")),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
|
||||
|
||||
const result = await sendApnsRelayPush({
|
||||
relayConfig: {
|
||||
baseUrl: "https://relay.example.com",
|
||||
timeoutMs: 1000,
|
||||
},
|
||||
sendGrant: "send-grant-123",
|
||||
relayHandle: "relay-handle-123",
|
||||
payload: { aps: { "content-available": 1 } },
|
||||
pushType: "background",
|
||||
priority: "5",
|
||||
gatewayIdentity: relayGatewayIdentity,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ redirect: "manual" });
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
status: 302,
|
||||
reason: "RelayRedirectNotAllowed",
|
||||
environment: "production",
|
||||
});
|
||||
});
|
||||
|
||||
it("flags invalid device responses for registration invalidation", () => {
|
||||
expect(shouldInvalidateApnsRegistration({ status: 400, reason: "BadDeviceToken" })).toBe(true);
|
||||
expect(shouldInvalidateApnsRegistration({ status: 410, reason: "Unregistered" })).toBe(true);
|
||||
expect(shouldInvalidateApnsRegistration({ status: 429, reason: "TooManyRequests" })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("only clears stored registrations for direct APNs failures without an override mismatch", () => {
|
||||
expect(
|
||||
shouldClearStoredApnsRegistration({
|
||||
registration: {
|
||||
nodeId: "ios-node-direct",
|
||||
transport: "direct",
|
||||
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "sandbox",
|
||||
updatedAtMs: 1,
|
||||
},
|
||||
result: { status: 400, reason: "BadDeviceToken" },
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
shouldClearStoredApnsRegistration({
|
||||
registration: {
|
||||
nodeId: "ios-node-relay",
|
||||
transport: "relay",
|
||||
relayHandle: "relay-handle-123",
|
||||
sendGrant: "send-grant-123",
|
||||
installationId: "install-123",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "production",
|
||||
distribution: "official",
|
||||
updatedAtMs: 1,
|
||||
},
|
||||
result: { status: 410, reason: "Unregistered" },
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
shouldClearStoredApnsRegistration({
|
||||
registration: {
|
||||
nodeId: "ios-node-direct",
|
||||
transport: "direct",
|
||||
token: "ABCD1234ABCD1234ABCD1234ABCD1234",
|
||||
topic: "ai.openclaw.ios",
|
||||
environment: "sandbox",
|
||||
updatedAtMs: 1,
|
||||
},
|
||||
result: { status: 400, reason: "BadDeviceToken" },
|
||||
overrideEnvironment: "production",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,18 +3,44 @@ import fs from "node:fs/promises";
|
||||
import http2 from "node:http2";
|
||||
import path from "node:path";
|
||||
import { resolveStateDir } from "../config/paths.js";
|
||||
import type { DeviceIdentity } from "./device-identity.js";
|
||||
import { createAsyncLock, readJsonFile, writeJsonAtomic } from "./json-files.js";
|
||||
import {
|
||||
type ApnsRelayConfig,
|
||||
type ApnsRelayConfigResolution,
|
||||
type ApnsRelayPushResponse,
|
||||
type ApnsRelayRequestSender,
|
||||
resolveApnsRelayConfigFromEnv,
|
||||
sendApnsRelayPush,
|
||||
} from "./push-apns.relay.js";
|
||||
|
||||
export type ApnsEnvironment = "sandbox" | "production";
|
||||
export type ApnsTransport = "direct" | "relay";
|
||||
|
||||
export type ApnsRegistration = {
|
||||
export type DirectApnsRegistration = {
|
||||
nodeId: string;
|
||||
transport: "direct";
|
||||
token: string;
|
||||
topic: string;
|
||||
environment: ApnsEnvironment;
|
||||
updatedAtMs: number;
|
||||
};
|
||||
|
||||
export type RelayApnsRegistration = {
|
||||
nodeId: string;
|
||||
transport: "relay";
|
||||
relayHandle: string;
|
||||
sendGrant: string;
|
||||
installationId: string;
|
||||
topic: string;
|
||||
environment: "production";
|
||||
distribution: "official";
|
||||
updatedAtMs: number;
|
||||
tokenDebugSuffix?: string;
|
||||
};
|
||||
|
||||
export type ApnsRegistration = DirectApnsRegistration | RelayApnsRegistration;
|
||||
|
||||
export type ApnsAuthConfig = {
|
||||
teamId: string;
|
||||
keyId: string;
|
||||
@@ -25,7 +51,7 @@ export type ApnsAuthConfigResolution =
|
||||
| { ok: true; value: ApnsAuthConfig }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export type ApnsPushAlertResult = {
|
||||
export type ApnsPushResult = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
apnsId?: string;
|
||||
@@ -33,17 +59,11 @@ export type ApnsPushAlertResult = {
|
||||
tokenSuffix: string;
|
||||
topic: string;
|
||||
environment: ApnsEnvironment;
|
||||
transport: ApnsTransport;
|
||||
};
|
||||
|
||||
export type ApnsPushWakeResult = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
apnsId?: string;
|
||||
reason?: string;
|
||||
tokenSuffix: string;
|
||||
topic: string;
|
||||
environment: ApnsEnvironment;
|
||||
};
|
||||
export type ApnsPushAlertResult = ApnsPushResult;
|
||||
export type ApnsPushWakeResult = ApnsPushResult;
|
||||
|
||||
type ApnsPushType = "alert" | "background";
|
||||
|
||||
@@ -66,9 +86,38 @@ type ApnsRegistrationState = {
|
||||
registrationsByNodeId: Record<string, ApnsRegistration>;
|
||||
};
|
||||
|
||||
type RegisterDirectApnsParams = {
|
||||
nodeId: string;
|
||||
transport?: "direct";
|
||||
token: string;
|
||||
topic: string;
|
||||
environment?: unknown;
|
||||
baseDir?: string;
|
||||
};
|
||||
|
||||
type RegisterRelayApnsParams = {
|
||||
nodeId: string;
|
||||
transport: "relay";
|
||||
relayHandle: string;
|
||||
sendGrant: string;
|
||||
installationId: string;
|
||||
topic: string;
|
||||
environment?: unknown;
|
||||
distribution?: unknown;
|
||||
tokenDebugSuffix?: unknown;
|
||||
baseDir?: string;
|
||||
};
|
||||
|
||||
type RegisterApnsParams = RegisterDirectApnsParams | RegisterRelayApnsParams;
|
||||
|
||||
const APNS_STATE_FILENAME = "push/apns-registrations.json";
|
||||
const APNS_JWT_TTL_MS = 50 * 60 * 1000;
|
||||
const DEFAULT_APNS_TIMEOUT_MS = 10_000;
|
||||
const MAX_NODE_ID_LENGTH = 256;
|
||||
const MAX_TOPIC_LENGTH = 255;
|
||||
const MAX_APNS_TOKEN_HEX_LENGTH = 512;
|
||||
const MAX_RELAY_IDENTIFIER_LENGTH = 256;
|
||||
const MAX_SEND_GRANT_LENGTH = 1024;
|
||||
const withLock = createAsyncLock();
|
||||
|
||||
let cachedJwt: { cacheKey: string; token: string; expiresAtMs: number } | null = null;
|
||||
@@ -82,6 +131,10 @@ function normalizeNodeId(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function isValidNodeId(value: string): boolean {
|
||||
return value.length > 0 && value.length <= MAX_NODE_ID_LENGTH;
|
||||
}
|
||||
|
||||
function normalizeApnsToken(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
@@ -89,12 +142,52 @@ function normalizeApnsToken(value: string): string {
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function normalizeRelayHandle(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function normalizeInstallationId(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function validateRelayIdentifier(
|
||||
value: string,
|
||||
fieldName: string,
|
||||
maxLength: number = MAX_RELAY_IDENTIFIER_LENGTH,
|
||||
): string {
|
||||
if (!value) {
|
||||
throw new Error(`${fieldName} required`);
|
||||
}
|
||||
if (value.length > maxLength) {
|
||||
throw new Error(`${fieldName} too long`);
|
||||
}
|
||||
if (/[^\x21-\x7e]/.test(value)) {
|
||||
throw new Error(`${fieldName} invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeTopic(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function isValidTopic(value: string): boolean {
|
||||
return value.length > 0 && value.length <= MAX_TOPIC_LENGTH;
|
||||
}
|
||||
|
||||
function normalizeTokenDebugSuffix(value: unknown): string | undefined {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^0-9a-z]/g, "");
|
||||
return normalized.length > 0 ? normalized.slice(-8) : undefined;
|
||||
}
|
||||
|
||||
function isLikelyApnsToken(value: string): boolean {
|
||||
return /^[0-9a-f]{32,}$/i.test(value);
|
||||
return value.length <= MAX_APNS_TOKEN_HEX_LENGTH && /^[0-9a-f]{32,}$/i.test(value);
|
||||
}
|
||||
|
||||
function parseReason(body: string): string | undefined {
|
||||
@@ -161,6 +254,105 @@ function normalizeNonEmptyString(value: string | undefined): string | null {
|
||||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeDistribution(value: unknown): "official" | null {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const normalized = value.trim().toLowerCase();
|
||||
return normalized === "official" ? "official" : null;
|
||||
}
|
||||
|
||||
function normalizeDirectRegistration(
|
||||
record: Partial<DirectApnsRegistration> & { nodeId?: unknown; token?: unknown },
|
||||
): DirectApnsRegistration | null {
|
||||
if (typeof record.nodeId !== "string" || typeof record.token !== "string") {
|
||||
return null;
|
||||
}
|
||||
const nodeId = normalizeNodeId(record.nodeId);
|
||||
const token = normalizeApnsToken(record.token);
|
||||
const topic = normalizeTopic(typeof record.topic === "string" ? record.topic : "");
|
||||
const environment = normalizeApnsEnvironment(record.environment) ?? "sandbox";
|
||||
const updatedAtMs =
|
||||
typeof record.updatedAtMs === "number" && Number.isFinite(record.updatedAtMs)
|
||||
? Math.trunc(record.updatedAtMs)
|
||||
: 0;
|
||||
if (!isValidNodeId(nodeId) || !isValidTopic(topic) || !isLikelyApnsToken(token)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
nodeId,
|
||||
transport: "direct",
|
||||
token,
|
||||
topic,
|
||||
environment,
|
||||
updatedAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRelayRegistration(
|
||||
record: Partial<RelayApnsRegistration> & {
|
||||
nodeId?: unknown;
|
||||
relayHandle?: unknown;
|
||||
sendGrant?: unknown;
|
||||
},
|
||||
): RelayApnsRegistration | null {
|
||||
if (
|
||||
typeof record.nodeId !== "string" ||
|
||||
typeof record.relayHandle !== "string" ||
|
||||
typeof record.sendGrant !== "string" ||
|
||||
typeof record.installationId !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const nodeId = normalizeNodeId(record.nodeId);
|
||||
const relayHandle = normalizeRelayHandle(record.relayHandle);
|
||||
const sendGrant = record.sendGrant.trim();
|
||||
const installationId = normalizeInstallationId(record.installationId);
|
||||
const topic = normalizeTopic(typeof record.topic === "string" ? record.topic : "");
|
||||
const environment = normalizeApnsEnvironment(record.environment);
|
||||
const distribution = normalizeDistribution(record.distribution);
|
||||
const updatedAtMs =
|
||||
typeof record.updatedAtMs === "number" && Number.isFinite(record.updatedAtMs)
|
||||
? Math.trunc(record.updatedAtMs)
|
||||
: 0;
|
||||
if (
|
||||
!isValidNodeId(nodeId) ||
|
||||
!relayHandle ||
|
||||
!sendGrant ||
|
||||
!installationId ||
|
||||
!isValidTopic(topic) ||
|
||||
environment !== "production" ||
|
||||
distribution !== "official"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
nodeId,
|
||||
transport: "relay",
|
||||
relayHandle,
|
||||
sendGrant,
|
||||
installationId,
|
||||
topic,
|
||||
environment,
|
||||
distribution,
|
||||
updatedAtMs,
|
||||
tokenDebugSuffix: normalizeTokenDebugSuffix(record.tokenDebugSuffix),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStoredRegistration(record: unknown): ApnsRegistration | null {
|
||||
if (!record || typeof record !== "object" || Array.isArray(record)) {
|
||||
return null;
|
||||
}
|
||||
const candidate = record as Record<string, unknown>;
|
||||
const transport =
|
||||
typeof candidate.transport === "string" ? candidate.transport.trim().toLowerCase() : "direct";
|
||||
if (transport === "relay") {
|
||||
return normalizeRelayRegistration(candidate as Partial<RelayApnsRegistration>);
|
||||
}
|
||||
return normalizeDirectRegistration(candidate as Partial<DirectApnsRegistration>);
|
||||
}
|
||||
|
||||
async function loadRegistrationsState(baseDir?: string): Promise<ApnsRegistrationState> {
|
||||
const filePath = resolveApnsRegistrationPath(baseDir);
|
||||
const existing = await readJsonFile<ApnsRegistrationState>(filePath);
|
||||
@@ -173,7 +365,16 @@ async function loadRegistrationsState(baseDir?: string): Promise<ApnsRegistratio
|
||||
!Array.isArray(existing.registrationsByNodeId)
|
||||
? existing.registrationsByNodeId
|
||||
: {};
|
||||
return { registrationsByNodeId: registrations };
|
||||
const normalized: Record<string, ApnsRegistration> = {};
|
||||
for (const [nodeId, record] of Object.entries(registrations)) {
|
||||
const registration = normalizeStoredRegistration(record);
|
||||
if (registration) {
|
||||
const normalizedNodeId = normalizeNodeId(nodeId);
|
||||
normalized[isValidNodeId(normalizedNodeId) ? normalizedNodeId : registration.nodeId] =
|
||||
registration;
|
||||
}
|
||||
}
|
||||
return { registrationsByNodeId: normalized };
|
||||
}
|
||||
|
||||
async function persistRegistrationsState(
|
||||
@@ -181,7 +382,11 @@ async function persistRegistrationsState(
|
||||
baseDir?: string,
|
||||
): Promise<void> {
|
||||
const filePath = resolveApnsRegistrationPath(baseDir);
|
||||
await writeJsonAtomic(filePath, state);
|
||||
await writeJsonAtomic(filePath, state, {
|
||||
mode: 0o600,
|
||||
ensureDirMode: 0o700,
|
||||
trailingNewline: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeApnsEnvironment(value: unknown): ApnsEnvironment | null {
|
||||
@@ -195,41 +400,90 @@ export function normalizeApnsEnvironment(value: unknown): ApnsEnvironment | null
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function registerApnsRegistration(
|
||||
params: RegisterApnsParams,
|
||||
): Promise<ApnsRegistration> {
|
||||
const nodeId = normalizeNodeId(params.nodeId);
|
||||
const topic = normalizeTopic(params.topic);
|
||||
if (!isValidNodeId(nodeId)) {
|
||||
throw new Error("nodeId required");
|
||||
}
|
||||
if (!isValidTopic(topic)) {
|
||||
throw new Error("topic required");
|
||||
}
|
||||
|
||||
return await withLock(async () => {
|
||||
const state = await loadRegistrationsState(params.baseDir);
|
||||
const updatedAtMs = Date.now();
|
||||
|
||||
let next: ApnsRegistration;
|
||||
if (params.transport === "relay") {
|
||||
const relayHandle = validateRelayIdentifier(
|
||||
normalizeRelayHandle(params.relayHandle),
|
||||
"relayHandle",
|
||||
);
|
||||
const sendGrant = validateRelayIdentifier(
|
||||
params.sendGrant.trim(),
|
||||
"sendGrant",
|
||||
MAX_SEND_GRANT_LENGTH,
|
||||
);
|
||||
const installationId = validateRelayIdentifier(
|
||||
normalizeInstallationId(params.installationId),
|
||||
"installationId",
|
||||
);
|
||||
const environment = normalizeApnsEnvironment(params.environment);
|
||||
const distribution = normalizeDistribution(params.distribution);
|
||||
if (environment !== "production") {
|
||||
throw new Error("relay registrations must use production environment");
|
||||
}
|
||||
if (distribution !== "official") {
|
||||
throw new Error("relay registrations must use official distribution");
|
||||
}
|
||||
next = {
|
||||
nodeId,
|
||||
transport: "relay",
|
||||
relayHandle,
|
||||
sendGrant,
|
||||
installationId,
|
||||
topic,
|
||||
environment,
|
||||
distribution,
|
||||
updatedAtMs,
|
||||
tokenDebugSuffix: normalizeTokenDebugSuffix(params.tokenDebugSuffix),
|
||||
};
|
||||
} else {
|
||||
const token = normalizeApnsToken(params.token);
|
||||
const environment = normalizeApnsEnvironment(params.environment) ?? "sandbox";
|
||||
if (!isLikelyApnsToken(token)) {
|
||||
throw new Error("invalid APNs token");
|
||||
}
|
||||
next = {
|
||||
nodeId,
|
||||
transport: "direct",
|
||||
token,
|
||||
topic,
|
||||
environment,
|
||||
updatedAtMs,
|
||||
};
|
||||
}
|
||||
|
||||
state.registrationsByNodeId[nodeId] = next;
|
||||
await persistRegistrationsState(state, params.baseDir);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
export async function registerApnsToken(params: {
|
||||
nodeId: string;
|
||||
token: string;
|
||||
topic: string;
|
||||
environment?: unknown;
|
||||
baseDir?: string;
|
||||
}): Promise<ApnsRegistration> {
|
||||
const nodeId = normalizeNodeId(params.nodeId);
|
||||
const token = normalizeApnsToken(params.token);
|
||||
const topic = normalizeTopic(params.topic);
|
||||
const environment = normalizeApnsEnvironment(params.environment) ?? "sandbox";
|
||||
|
||||
if (!nodeId) {
|
||||
throw new Error("nodeId required");
|
||||
}
|
||||
if (!topic) {
|
||||
throw new Error("topic required");
|
||||
}
|
||||
if (!isLikelyApnsToken(token)) {
|
||||
throw new Error("invalid APNs token");
|
||||
}
|
||||
|
||||
return await withLock(async () => {
|
||||
const state = await loadRegistrationsState(params.baseDir);
|
||||
const next: ApnsRegistration = {
|
||||
nodeId,
|
||||
token,
|
||||
topic,
|
||||
environment,
|
||||
updatedAtMs: Date.now(),
|
||||
};
|
||||
state.registrationsByNodeId[nodeId] = next;
|
||||
await persistRegistrationsState(state, params.baseDir);
|
||||
return next;
|
||||
});
|
||||
}): Promise<DirectApnsRegistration> {
|
||||
return (await registerApnsRegistration({
|
||||
...params,
|
||||
transport: "direct",
|
||||
})) as DirectApnsRegistration;
|
||||
}
|
||||
|
||||
export async function loadApnsRegistration(
|
||||
@@ -244,6 +498,95 @@ export async function loadApnsRegistration(
|
||||
return state.registrationsByNodeId[normalizedNodeId] ?? null;
|
||||
}
|
||||
|
||||
export async function clearApnsRegistration(nodeId: string, baseDir?: string): Promise<boolean> {
|
||||
const normalizedNodeId = normalizeNodeId(nodeId);
|
||||
if (!normalizedNodeId) {
|
||||
return false;
|
||||
}
|
||||
return await withLock(async () => {
|
||||
const state = await loadRegistrationsState(baseDir);
|
||||
if (!(normalizedNodeId in state.registrationsByNodeId)) {
|
||||
return false;
|
||||
}
|
||||
delete state.registrationsByNodeId[normalizedNodeId];
|
||||
await persistRegistrationsState(state, baseDir);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function isSameApnsRegistration(a: ApnsRegistration, b: ApnsRegistration): boolean {
|
||||
if (
|
||||
a.nodeId !== b.nodeId ||
|
||||
a.transport !== b.transport ||
|
||||
a.topic !== b.topic ||
|
||||
a.environment !== b.environment ||
|
||||
a.updatedAtMs !== b.updatedAtMs
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (a.transport === "direct" && b.transport === "direct") {
|
||||
return a.token === b.token;
|
||||
}
|
||||
if (a.transport === "relay" && b.transport === "relay") {
|
||||
return (
|
||||
a.relayHandle === b.relayHandle &&
|
||||
a.sendGrant === b.sendGrant &&
|
||||
a.installationId === b.installationId &&
|
||||
a.distribution === b.distribution &&
|
||||
a.tokenDebugSuffix === b.tokenDebugSuffix
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function clearApnsRegistrationIfCurrent(params: {
|
||||
nodeId: string;
|
||||
registration: ApnsRegistration;
|
||||
baseDir?: string;
|
||||
}): Promise<boolean> {
|
||||
const normalizedNodeId = normalizeNodeId(params.nodeId);
|
||||
if (!normalizedNodeId) {
|
||||
return false;
|
||||
}
|
||||
return await withLock(async () => {
|
||||
const state = await loadRegistrationsState(params.baseDir);
|
||||
const current = state.registrationsByNodeId[normalizedNodeId];
|
||||
if (!current || !isSameApnsRegistration(current, params.registration)) {
|
||||
return false;
|
||||
}
|
||||
delete state.registrationsByNodeId[normalizedNodeId];
|
||||
await persistRegistrationsState(state, params.baseDir);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function shouldInvalidateApnsRegistration(result: {
|
||||
status: number;
|
||||
reason?: string;
|
||||
}): boolean {
|
||||
if (result.status === 410) {
|
||||
return true;
|
||||
}
|
||||
return result.status === 400 && result.reason?.trim() === "BadDeviceToken";
|
||||
}
|
||||
|
||||
export function shouldClearStoredApnsRegistration(params: {
|
||||
registration: ApnsRegistration;
|
||||
result: { status: number; reason?: string };
|
||||
overrideEnvironment?: ApnsEnvironment | null;
|
||||
}): boolean {
|
||||
if (params.registration.transport !== "direct") {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
params.overrideEnvironment &&
|
||||
params.overrideEnvironment !== params.registration.environment
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return shouldInvalidateApnsRegistration(params.result);
|
||||
}
|
||||
|
||||
export async function resolveApnsAuthConfigFromEnv(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): Promise<ApnsAuthConfigResolution> {
|
||||
@@ -386,7 +729,10 @@ function resolveApnsTimeoutMs(timeoutMs: number | undefined): number {
|
||||
: DEFAULT_APNS_TIMEOUT_MS;
|
||||
}
|
||||
|
||||
function resolveApnsSendContext(params: { auth: ApnsAuthConfig; registration: ApnsRegistration }): {
|
||||
function resolveDirectSendContext(params: {
|
||||
auth: ApnsAuthConfig;
|
||||
registration: DirectApnsRegistration;
|
||||
}): {
|
||||
token: string;
|
||||
topic: string;
|
||||
environment: ApnsEnvironment;
|
||||
@@ -397,7 +743,7 @@ function resolveApnsSendContext(params: { auth: ApnsAuthConfig; registration: Ap
|
||||
throw new Error("invalid APNs token");
|
||||
}
|
||||
const topic = normalizeTopic(params.registration.topic);
|
||||
if (!topic) {
|
||||
if (!isValidTopic(topic)) {
|
||||
throw new Error("topic required");
|
||||
}
|
||||
return {
|
||||
@@ -408,24 +754,7 @@ function resolveApnsSendContext(params: { auth: ApnsAuthConfig; registration: Ap
|
||||
};
|
||||
}
|
||||
|
||||
function toApnsPushResult(params: {
|
||||
response: ApnsRequestResponse;
|
||||
token: string;
|
||||
topic: string;
|
||||
environment: ApnsEnvironment;
|
||||
}): ApnsPushWakeResult {
|
||||
return {
|
||||
ok: params.response.status === 200,
|
||||
status: params.response.status,
|
||||
apnsId: params.response.apnsId,
|
||||
reason: parseReason(params.response.body),
|
||||
tokenSuffix: params.token.slice(-8),
|
||||
topic: params.topic,
|
||||
environment: params.environment,
|
||||
};
|
||||
}
|
||||
|
||||
function createOpenClawPushMetadata(params: {
|
||||
function toPushMetadata(params: {
|
||||
kind: "push.test" | "node.wake";
|
||||
nodeId: string;
|
||||
reason?: string;
|
||||
@@ -438,16 +767,61 @@ function createOpenClawPushMetadata(params: {
|
||||
};
|
||||
}
|
||||
|
||||
async function sendApnsPush(params: {
|
||||
auth: ApnsAuthConfig;
|
||||
function resolveRegistrationDebugSuffix(
|
||||
registration: ApnsRegistration,
|
||||
relayResult?: Pick<ApnsRelayPushResponse, "tokenSuffix">,
|
||||
): string {
|
||||
if (registration.transport === "direct") {
|
||||
return registration.token.slice(-8);
|
||||
}
|
||||
return (
|
||||
relayResult?.tokenSuffix ?? registration.tokenDebugSuffix ?? registration.relayHandle.slice(-8)
|
||||
);
|
||||
}
|
||||
|
||||
function toPushResult(params: {
|
||||
registration: ApnsRegistration;
|
||||
response: ApnsRequestResponse | ApnsRelayPushResponse;
|
||||
tokenSuffix?: string;
|
||||
}): ApnsPushResult {
|
||||
const response =
|
||||
"body" in params.response
|
||||
? {
|
||||
ok: params.response.status === 200,
|
||||
status: params.response.status,
|
||||
apnsId: params.response.apnsId,
|
||||
reason: parseReason(params.response.body),
|
||||
environment: params.registration.environment,
|
||||
tokenSuffix: params.tokenSuffix,
|
||||
}
|
||||
: params.response;
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
apnsId: response.apnsId,
|
||||
reason: response.reason,
|
||||
tokenSuffix:
|
||||
params.tokenSuffix ??
|
||||
resolveRegistrationDebugSuffix(
|
||||
params.registration,
|
||||
"tokenSuffix" in response ? response : undefined,
|
||||
),
|
||||
topic: params.registration.topic,
|
||||
environment: params.registration.transport === "relay" ? "production" : response.environment,
|
||||
transport: params.registration.transport,
|
||||
};
|
||||
}
|
||||
|
||||
async function sendDirectApnsPush(params: {
|
||||
auth: ApnsAuthConfig;
|
||||
registration: DirectApnsRegistration;
|
||||
payload: object;
|
||||
timeoutMs?: number;
|
||||
requestSender?: ApnsRequestSender;
|
||||
pushType: ApnsPushType;
|
||||
priority: "10" | "5";
|
||||
}): Promise<ApnsPushWakeResult> {
|
||||
const { token, topic, environment, bearerToken } = resolveApnsSendContext({
|
||||
}): Promise<ApnsPushResult> {
|
||||
const { token, topic, environment, bearerToken } = resolveDirectSendContext({
|
||||
auth: params.auth,
|
||||
registration: params.registration,
|
||||
});
|
||||
@@ -462,19 +836,37 @@ async function sendApnsPush(params: {
|
||||
pushType: params.pushType,
|
||||
priority: params.priority,
|
||||
});
|
||||
return toApnsPushResult({ response, token, topic, environment });
|
||||
return toPushResult({
|
||||
registration: params.registration,
|
||||
response,
|
||||
tokenSuffix: token.slice(-8),
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendApnsAlert(params: {
|
||||
auth: ApnsAuthConfig;
|
||||
registration: ApnsRegistration;
|
||||
nodeId: string;
|
||||
title: string;
|
||||
body: string;
|
||||
timeoutMs?: number;
|
||||
requestSender?: ApnsRequestSender;
|
||||
}): Promise<ApnsPushAlertResult> {
|
||||
const payload = {
|
||||
async function sendRelayApnsPush(params: {
|
||||
relayConfig: ApnsRelayConfig;
|
||||
registration: RelayApnsRegistration;
|
||||
payload: object;
|
||||
pushType: ApnsPushType;
|
||||
priority: "10" | "5";
|
||||
gatewayIdentity?: Pick<DeviceIdentity, "deviceId" | "privateKeyPem">;
|
||||
requestSender?: ApnsRelayRequestSender;
|
||||
}): Promise<ApnsPushResult> {
|
||||
const response = await sendApnsRelayPush({
|
||||
relayConfig: params.relayConfig,
|
||||
sendGrant: params.registration.sendGrant,
|
||||
relayHandle: params.registration.relayHandle,
|
||||
payload: params.payload,
|
||||
pushType: params.pushType,
|
||||
priority: params.priority,
|
||||
gatewayIdentity: params.gatewayIdentity,
|
||||
requestSender: params.requestSender,
|
||||
});
|
||||
return toPushResult({ registration: params.registration, response });
|
||||
}
|
||||
|
||||
function createAlertPayload(params: { nodeId: string; title: string; body: string }): object {
|
||||
return {
|
||||
aps: {
|
||||
alert: {
|
||||
title: params.title,
|
||||
@@ -482,48 +874,136 @@ export async function sendApnsAlert(params: {
|
||||
},
|
||||
sound: "default",
|
||||
},
|
||||
openclaw: createOpenClawPushMetadata({
|
||||
openclaw: toPushMetadata({
|
||||
kind: "push.test",
|
||||
nodeId: params.nodeId,
|
||||
}),
|
||||
};
|
||||
|
||||
return await sendApnsPush({
|
||||
auth: params.auth,
|
||||
registration: params.registration,
|
||||
payload,
|
||||
timeoutMs: params.timeoutMs,
|
||||
requestSender: params.requestSender,
|
||||
pushType: "alert",
|
||||
priority: "10",
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendApnsBackgroundWake(params: {
|
||||
auth: ApnsAuthConfig;
|
||||
registration: ApnsRegistration;
|
||||
nodeId: string;
|
||||
wakeReason?: string;
|
||||
timeoutMs?: number;
|
||||
requestSender?: ApnsRequestSender;
|
||||
}): Promise<ApnsPushWakeResult> {
|
||||
const payload = {
|
||||
function createBackgroundPayload(params: { nodeId: string; wakeReason?: string }): object {
|
||||
return {
|
||||
aps: {
|
||||
"content-available": 1,
|
||||
},
|
||||
openclaw: createOpenClawPushMetadata({
|
||||
openclaw: toPushMetadata({
|
||||
kind: "node.wake",
|
||||
reason: params.wakeReason ?? "node.invoke",
|
||||
nodeId: params.nodeId,
|
||||
}),
|
||||
};
|
||||
return await sendApnsPush({
|
||||
auth: params.auth,
|
||||
registration: params.registration,
|
||||
}
|
||||
|
||||
type ApnsAlertCommonParams = {
|
||||
nodeId: string;
|
||||
title: string;
|
||||
body: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type DirectApnsAlertParams = ApnsAlertCommonParams & {
|
||||
registration: DirectApnsRegistration;
|
||||
auth: ApnsAuthConfig;
|
||||
requestSender?: ApnsRequestSender;
|
||||
relayConfig?: never;
|
||||
relayRequestSender?: never;
|
||||
};
|
||||
|
||||
type RelayApnsAlertParams = ApnsAlertCommonParams & {
|
||||
registration: RelayApnsRegistration;
|
||||
relayConfig: ApnsRelayConfig;
|
||||
relayRequestSender?: ApnsRelayRequestSender;
|
||||
relayGatewayIdentity?: Pick<DeviceIdentity, "deviceId" | "privateKeyPem">;
|
||||
auth?: never;
|
||||
requestSender?: never;
|
||||
};
|
||||
|
||||
type ApnsBackgroundWakeCommonParams = {
|
||||
nodeId: string;
|
||||
wakeReason?: string;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
type DirectApnsBackgroundWakeParams = ApnsBackgroundWakeCommonParams & {
|
||||
registration: DirectApnsRegistration;
|
||||
auth: ApnsAuthConfig;
|
||||
requestSender?: ApnsRequestSender;
|
||||
relayConfig?: never;
|
||||
relayRequestSender?: never;
|
||||
};
|
||||
|
||||
type RelayApnsBackgroundWakeParams = ApnsBackgroundWakeCommonParams & {
|
||||
registration: RelayApnsRegistration;
|
||||
relayConfig: ApnsRelayConfig;
|
||||
relayRequestSender?: ApnsRelayRequestSender;
|
||||
relayGatewayIdentity?: Pick<DeviceIdentity, "deviceId" | "privateKeyPem">;
|
||||
auth?: never;
|
||||
requestSender?: never;
|
||||
};
|
||||
|
||||
export async function sendApnsAlert(
|
||||
params: DirectApnsAlertParams | RelayApnsAlertParams,
|
||||
): Promise<ApnsPushAlertResult> {
|
||||
const payload = createAlertPayload({
|
||||
nodeId: params.nodeId,
|
||||
title: params.title,
|
||||
body: params.body,
|
||||
});
|
||||
|
||||
if (params.registration.transport === "relay") {
|
||||
const relayParams = params as RelayApnsAlertParams;
|
||||
return await sendRelayApnsPush({
|
||||
relayConfig: relayParams.relayConfig,
|
||||
registration: relayParams.registration,
|
||||
payload,
|
||||
pushType: "alert",
|
||||
priority: "10",
|
||||
gatewayIdentity: relayParams.relayGatewayIdentity,
|
||||
requestSender: relayParams.relayRequestSender,
|
||||
});
|
||||
}
|
||||
const directParams = params as DirectApnsAlertParams;
|
||||
return await sendDirectApnsPush({
|
||||
auth: directParams.auth,
|
||||
registration: directParams.registration,
|
||||
payload,
|
||||
timeoutMs: params.timeoutMs,
|
||||
requestSender: params.requestSender,
|
||||
timeoutMs: directParams.timeoutMs,
|
||||
requestSender: directParams.requestSender,
|
||||
pushType: "alert",
|
||||
priority: "10",
|
||||
});
|
||||
}
|
||||
|
||||
export async function sendApnsBackgroundWake(
|
||||
params: DirectApnsBackgroundWakeParams | RelayApnsBackgroundWakeParams,
|
||||
): Promise<ApnsPushWakeResult> {
|
||||
const payload = createBackgroundPayload({
|
||||
nodeId: params.nodeId,
|
||||
wakeReason: params.wakeReason,
|
||||
});
|
||||
|
||||
if (params.registration.transport === "relay") {
|
||||
const relayParams = params as RelayApnsBackgroundWakeParams;
|
||||
return await sendRelayApnsPush({
|
||||
relayConfig: relayParams.relayConfig,
|
||||
registration: relayParams.registration,
|
||||
payload,
|
||||
pushType: "background",
|
||||
priority: "5",
|
||||
gatewayIdentity: relayParams.relayGatewayIdentity,
|
||||
requestSender: relayParams.relayRequestSender,
|
||||
});
|
||||
}
|
||||
const directParams = params as DirectApnsBackgroundWakeParams;
|
||||
return await sendDirectApnsPush({
|
||||
auth: directParams.auth,
|
||||
registration: directParams.registration,
|
||||
payload,
|
||||
timeoutMs: directParams.timeoutMs,
|
||||
requestSender: directParams.requestSender,
|
||||
pushType: "background",
|
||||
priority: "5",
|
||||
});
|
||||
}
|
||||
|
||||
export { type ApnsRelayConfig, type ApnsRelayConfigResolution, resolveApnsRelayConfigFromEnv };
|
||||
|
||||
Reference in New Issue
Block a user