diff --git a/CHANGELOG.md b/CHANGELOG.md index 827c1e2749e..391291777ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai ### Changes +- Gateway/APNs: add a push-test pipeline for APNs delivery validation in gateway flows. (#20307) Thanks @mbelinky. - iOS/Watch: add an Apple Watch companion MVP with watch inbox UI, watch notification relay handling, and gateway command surfaces for watch status/send flows. (#20054) Thanks @mbelinky. - Gateway/CLI: add paired-device hygiene flows with `device.pair.remove`, plus `openclaw devices remove` and guarded `openclaw devices clear --yes [--pending]` commands for removing paired entries and optionally rejecting pending requests. (#20057) Thanks @mbelinky. - Skills: harden coding-agent skill guidance by removing shell-command examples that interpolate untrusted issue text directly into command strings. diff --git a/src/cli/nodes-cli/register.push.ts b/src/cli/nodes-cli/register.push.ts new file mode 100644 index 00000000000..a4a6fa37626 --- /dev/null +++ b/src/cli/nodes-cli/register.push.ts @@ -0,0 +1,88 @@ +import type { Command } from "commander"; +import { defaultRuntime } from "../../runtime.js"; +import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; +import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; +import type { NodesRpcOpts } from "./types.js"; + +type NodesPushOpts = NodesRpcOpts & { + node?: string; + title?: string; + body?: string; + environment?: string; +}; + +function normalizeEnvironment(value: unknown): "sandbox" | "production" | null { + if (typeof value !== "string") { + return null; + } + const normalized = value.trim().toLowerCase(); + if (normalized === "sandbox" || normalized === "production") { + return normalized; + } + return null; +} + +export function registerNodesPushCommand(nodes: Command) { + nodesCallOpts( + nodes + .command("push") + .description("Send an APNs test push to an iOS node") + .requiredOption("--node ", "Node id, name, or IP") + .option("--title ", "Push title", "OpenClaw") + .option("--body ", "Push body") + .option("--environment ", "Override APNs environment") + .action(async (opts: NodesPushOpts) => { + await runNodesCommand("push", async () => { + const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); + const title = String(opts.title ?? "").trim() || "OpenClaw"; + const body = String(opts.body ?? "").trim() || `Push test for node ${nodeId}`; + const environment = normalizeEnvironment(opts.environment); + if (opts.environment && !environment) { + throw new Error("invalid --environment (use sandbox|production)"); + } + + const params: Record = { + nodeId, + title, + body, + }; + if (environment) { + params.environment = environment; + } + + const result = await callGatewayCli("push.test", opts, params); + if (opts.json) { + defaultRuntime.log(JSON.stringify(result, null, 2)); + return; + } + + const parsed = + typeof result === "object" && result !== null + ? (result as { + ok?: unknown; + status?: unknown; + reason?: unknown; + environment?: unknown; + }) + : {}; + const ok = parsed.ok === true; + const status = typeof parsed.status === "number" ? parsed.status : 0; + const reason = + typeof parsed.reason === "string" && parsed.reason.trim().length > 0 + ? parsed.reason.trim() + : undefined; + const env = + typeof parsed.environment === "string" && parsed.environment.trim().length > 0 + ? parsed.environment.trim() + : "unknown"; + const { ok: okLabel, error: errorLabel } = getNodesTheme(); + const label = ok ? okLabel : errorLabel; + defaultRuntime.log(label(`push.test status=${status} ok=${ok} env=${env}`)); + if (reason) { + defaultRuntime.log(`reason: ${reason}`); + } + }); + }), + { timeoutMs: 25_000 }, + ); +} diff --git a/src/cli/nodes-cli/register.ts b/src/cli/nodes-cli/register.ts index 2082996ca43..5672c332a13 100644 --- a/src/cli/nodes-cli/register.ts +++ b/src/cli/nodes-cli/register.ts @@ -8,6 +8,7 @@ import { registerNodesInvokeCommands } from "./register.invoke.js"; import { registerNodesLocationCommands } from "./register.location.js"; import { registerNodesNotifyCommand } from "./register.notify.js"; import { registerNodesPairingCommands } from "./register.pairing.js"; +import { registerNodesPushCommand } from "./register.push.js"; import { registerNodesScreenCommands } from "./register.screen.js"; import { registerNodesStatusCommands } from "./register.status.js"; @@ -30,6 +31,7 @@ export function registerNodesCli(program: Command) { registerNodesPairingCommands(nodes); registerNodesInvokeCommands(nodes); registerNodesNotifyCommand(nodes); + registerNodesPushCommand(nodes); registerNodesCanvasCommands(nodes); registerNodesCameraCommands(nodes); registerNodesScreenCommands(nodes); diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index c57d8355b54..e8daa0f4dbc 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -157,6 +157,9 @@ import { type PollParams, PollParamsSchema, PROTOCOL_VERSION, + type PushTestParams, + PushTestParamsSchema, + PushTestResultSchema, type PresenceEntry, PresenceEntrySchema, ProtocolSchemas, @@ -277,6 +280,7 @@ export const validateNodeInvokeResultParams = ajv.compile(NodeEventParamsSchema); +export const validatePushTestParams = ajv.compile(PushTestParamsSchema); export const validateSessionsListParams = ajv.compile(SessionsListParamsSchema); export const validateSessionsPreviewParams = ajv.compile( SessionsPreviewParamsSchema, @@ -428,6 +432,8 @@ export { AgentIdentityParamsSchema, AgentIdentityResultSchema, WakeParamsSchema, + PushTestParamsSchema, + PushTestResultSchema, NodePairRequestParamsSchema, NodePairListParamsSchema, NodePairApproveParamsSchema, diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index 61494200884..d4d80df05c3 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -10,6 +10,7 @@ export * from "./schema/frames.js"; export * from "./schema/logs-chat.js"; export * from "./schema/nodes.js"; export * from "./schema/protocol-schemas.js"; +export * from "./schema/push.js"; export * from "./schema/sessions.js"; export * from "./schema/snapshot.js"; export * from "./schema/types.js"; diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index 2d273aab633..312b62314b3 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -118,6 +118,7 @@ import { NodePairVerifyParamsSchema, NodeRenameParamsSchema, } from "./nodes.js"; +import { PushTestParamsSchema, PushTestResultSchema } from "./push.js"; import { SessionsCompactParamsSchema, SessionsDeleteParamsSchema, @@ -171,6 +172,8 @@ export const ProtocolSchemas: Record = { NodeInvokeResultParams: NodeInvokeResultParamsSchema, NodeEventParams: NodeEventParamsSchema, NodeInvokeRequestEvent: NodeInvokeRequestEventSchema, + PushTestParams: PushTestParamsSchema, + PushTestResult: PushTestResultSchema, SessionsListParams: SessionsListParamsSchema, SessionsPreviewParams: SessionsPreviewParamsSchema, SessionsResolveParams: SessionsResolveParamsSchema, diff --git a/src/gateway/protocol/schema/push.ts b/src/gateway/protocol/schema/push.ts new file mode 100644 index 00000000000..ded9bbb44c3 --- /dev/null +++ b/src/gateway/protocol/schema/push.ts @@ -0,0 +1,27 @@ +import { Type } from "@sinclair/typebox"; +import { NonEmptyString } from "./primitives.js"; + +const ApnsEnvironmentSchema = Type.String({ enum: ["sandbox", "production"] }); + +export const PushTestParamsSchema = Type.Object( + { + nodeId: NonEmptyString, + title: Type.Optional(Type.String()), + body: Type.Optional(Type.String()), + environment: Type.Optional(ApnsEnvironmentSchema), + }, + { additionalProperties: false }, +); + +export const PushTestResultSchema = Type.Object( + { + ok: Type.Boolean(), + status: Type.Integer(), + apnsId: Type.Optional(Type.String()), + reason: Type.Optional(Type.String()), + tokenSuffix: Type.String(), + topic: Type.String(), + environment: ApnsEnvironmentSchema, + }, + { additionalProperties: false }, +); diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index 42cc3427c1e..311a7b1f0e7 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -111,6 +111,7 @@ import type { NodePairVerifyParamsSchema, NodeRenameParamsSchema, } from "./nodes.js"; +import type { PushTestParamsSchema, PushTestResultSchema } from "./push.js"; import type { SessionsCompactParamsSchema, SessionsDeleteParamsSchema, @@ -160,6 +161,8 @@ export type NodeDescribeParams = Static; export type NodeInvokeParams = Static; export type NodeInvokeResultParams = Static; export type NodeEventParams = Static; +export type PushTestParams = Static; +export type PushTestResult = Static; export type SessionsListParams = Static; export type SessionsPreviewParams = Static; export type SessionsResolveParams = Static; diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index c9d8e703fde..44907ff5f1b 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -13,6 +13,7 @@ import { healthHandlers } from "./server-methods/health.js"; import { logsHandlers } from "./server-methods/logs.js"; import { modelsHandlers } from "./server-methods/models.js"; import { nodeHandlers } from "./server-methods/nodes.js"; +import { pushHandlers } from "./server-methods/push.js"; import { sendHandlers } from "./server-methods/send.js"; import { sessionsHandlers } from "./server-methods/sessions.js"; import { skillsHandlers } from "./server-methods/skills.js"; @@ -95,6 +96,7 @@ const WRITE_METHODS = new Set([ "chat.send", "chat.abort", "browser.request", + "push.test", ]); function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["client"]) { @@ -190,6 +192,7 @@ export const coreGatewayHandlers: GatewayRequestHandlers = { ...systemHandlers, ...updateHandlers, ...nodeHandlers, + ...pushHandlers, ...sendHandlers, ...usageHandlers, ...agentHandlers, diff --git a/src/gateway/server-methods/push.test.ts b/src/gateway/server-methods/push.test.ts new file mode 100644 index 00000000000..5bf6730a5bd --- /dev/null +++ b/src/gateway/server-methods/push.test.ts @@ -0,0 +1,101 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ErrorCodes } from "../protocol/index.js"; +import { pushHandlers } from "./push.js"; + +vi.mock("../../infra/push-apns.js", () => ({ + loadApnsRegistration: vi.fn(), + normalizeApnsEnvironment: vi.fn(), + resolveApnsAuthConfigFromEnv: vi.fn(), + sendApnsAlert: vi.fn(), +})); + +import { + loadApnsRegistration, + normalizeApnsEnvironment, + resolveApnsAuthConfigFromEnv, + sendApnsAlert, +} from "../../infra/push-apns.js"; + +type RespondCall = [boolean, unknown?, { code: number; message: string }?]; + +function createInvokeParams(params: Record) { + const respond = vi.fn(); + return { + respond, + invoke: async () => + await pushHandlers["push.test"]({ + params, + respond: respond as never, + context: {} as never, + client: null, + req: { type: "req", id: "req-1", method: "push.test" }, + isWebchatConnect: () => false, + }), + }; +} + +describe("push.test handler", () => { + beforeEach(() => { + vi.mocked(loadApnsRegistration).mockReset(); + vi.mocked(normalizeApnsEnvironment).mockReset(); + vi.mocked(resolveApnsAuthConfigFromEnv).mockReset(); + vi.mocked(sendApnsAlert).mockReset(); + }); + + it("rejects invalid params", async () => { + const { respond, invoke } = createInvokeParams({ title: "hello" }); + await invoke(); + const call = respond.mock.calls[0] as RespondCall | undefined; + expect(call?.[0]).toBe(false); + expect(call?.[2]?.code).toBe(ErrorCodes.INVALID_REQUEST); + expect(call?.[2]?.message).toContain("invalid push.test params"); + }); + + it("returns invalid request when node has no APNs registration", async () => { + vi.mocked(loadApnsRegistration).mockResolvedValue(null); + const { respond, invoke } = createInvokeParams({ nodeId: "ios-node-1" }); + await invoke(); + const call = respond.mock.calls[0] as RespondCall | undefined; + expect(call?.[0]).toBe(false); + expect(call?.[2]?.code).toBe(ErrorCodes.INVALID_REQUEST); + expect(call?.[2]?.message).toContain("has no APNs registration"); + }); + + it("sends push test when registration and auth are available", async () => { + vi.mocked(loadApnsRegistration).mockResolvedValue({ + nodeId: "ios-node-1", + 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-----", + }, + }); + vi.mocked(normalizeApnsEnvironment).mockReturnValue(null); + vi.mocked(sendApnsAlert).mockResolvedValue({ + ok: true, + status: 200, + tokenSuffix: "1234abcd", + topic: "ai.openclaw.ios", + environment: "sandbox", + }); + + const { respond, invoke } = createInvokeParams({ + nodeId: "ios-node-1", + title: "Wake", + body: "Ping", + }); + await invoke(); + + 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 }); + }); +}); diff --git a/src/gateway/server-methods/push.ts b/src/gateway/server-methods/push.ts new file mode 100644 index 00000000000..5ce25146bd0 --- /dev/null +++ b/src/gateway/server-methods/push.ts @@ -0,0 +1,73 @@ +import { + loadApnsRegistration, + normalizeApnsEnvironment, + resolveApnsAuthConfigFromEnv, + sendApnsAlert, +} from "../../infra/push-apns.js"; +import { ErrorCodes, errorShape, validatePushTestParams } from "../protocol/index.js"; +import { respondInvalidParams, respondUnavailableOnThrow } from "./nodes.helpers.js"; +import type { GatewayRequestHandlers } from "./types.js"; + +function normalizeOptionalString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +export const pushHandlers: GatewayRequestHandlers = { + "push.test": async ({ params, respond }) => { + if (!validatePushTestParams(params)) { + respondInvalidParams({ + respond, + method: "push.test", + validator: validatePushTestParams, + }); + return; + } + + const nodeId = String(params.nodeId ?? "").trim(); + if (!nodeId) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "nodeId required")); + return; + } + + const title = normalizeOptionalString(params.title) ?? "OpenClaw"; + const body = normalizeOptionalString(params.body) ?? `Push test for node ${nodeId}`; + + await respondUnavailableOnThrow(respond, async () => { + const registration = await loadApnsRegistration(nodeId); + if (!registration) { + respond( + false, + undefined, + errorShape( + ErrorCodes.INVALID_REQUEST, + `node ${nodeId} has no APNs registration (connect iOS node first)`, + ), + ); + return; + } + + const auth = await resolveApnsAuthConfigFromEnv(process.env); + if (!auth.ok) { + respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, auth.error)); + return; + } + + const overrideEnvironment = normalizeApnsEnvironment(params.environment); + const result = await sendApnsAlert({ + auth: auth.value, + registration: { + ...registration, + environment: overrideEnvironment ?? registration.environment, + }, + nodeId, + title, + body, + }); + respond(true, result, undefined); + }); + }, +}; diff --git a/src/gateway/server-node-events.ts b/src/gateway/server-node-events.ts index 6ee2714ac84..202bd4862df 100644 --- a/src/gateway/server-node-events.ts +++ b/src/gateway/server-node-events.ts @@ -8,6 +8,7 @@ import { updateSessionStore } from "../config/sessions.js"; import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; import { deliverOutboundPayloads } from "../infra/outbound/deliver.js"; import { resolveOutboundTarget } from "../infra/outbound/targets.js"; +import { registerApnsToken } from "../infra/push-apns.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import { normalizeMainKey } from "../routing/session-key.js"; import { defaultRuntime } from "../runtime.js"; @@ -508,6 +509,33 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt requestHeartbeatNow({ reason: "exec-event" }); return; } + case "push.apns.register": { + if (!evt.payloadJSON) { + return; + } + let payload: unknown; + try { + payload = JSON.parse(evt.payloadJSON) as unknown; + } catch { + return; + } + const obj = + typeof payload === "object" && payload !== null ? (payload as Record) : {}; + const token = typeof obj.token === "string" ? obj.token : ""; + const topic = typeof obj.topic === "string" ? obj.topic : ""; + const environment = obj.environment; + try { + await registerApnsToken({ + nodeId, + token, + topic, + environment, + }); + } catch (err) { + ctx.logGateway.warn(`push apns register failed node=${nodeId}: ${formatForLog(err)}`); + } + return; + } default: return; } diff --git a/src/infra/push-apns.test.ts b/src/infra/push-apns.test.ts new file mode 100644 index 00000000000..bdfc017f888 --- /dev/null +++ b/src/infra/push-apns.test.ts @@ -0,0 +1,94 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + loadApnsRegistration, + normalizeApnsEnvironment, + registerApnsToken, + resolveApnsAuthConfigFromEnv, +} from "./push-apns.js"; + +const tempDirs: string[] = []; + +async function makeTempDir(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-push-apns-test-")); + tempDirs.push(dir); + return dir; +} + +afterEach(async () => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) { + await fs.rm(dir, { recursive: true, force: true }); + } + } +}); + +describe("push APNs registration store", () => { + it("stores and reloads node APNs registration", async () => { + const baseDir = await makeTempDir(); + const saved = await registerApnsToken({ + nodeId: "ios-node-1", + token: "ABCD1234ABCD1234ABCD1234ABCD1234", + topic: "ai.openclaw.ios", + environment: "sandbox", + baseDir, + }); + + 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?.topic).toBe("ai.openclaw.ios"); + expect(loaded?.environment).toBe("sandbox"); + expect(loaded?.updatedAtMs).toBe(saved.updatedAtMs); + }); + + it("rejects invalid APNs tokens", async () => { + const baseDir = await makeTempDir(); + await expect( + registerApnsToken({ + nodeId: "ios-node-1", + token: "not-a-token", + topic: "ai.openclaw.ios", + baseDir, + }), + ).rejects.toThrow("invalid APNs token"); + }); +}); + +describe("push APNs env config", () => { + it("normalizes APNs environment values", () => { + expect(normalizeApnsEnvironment("sandbox")).toBe("sandbox"); + expect(normalizeApnsEnvironment("PRODUCTION")).toBe("production"); + expect(normalizeApnsEnvironment("staging")).toBeNull(); + }); + + it("resolves inline private key and unescapes newlines", async () => { + const env = { + OPENCLAW_APNS_TEAM_ID: "TEAM123", + OPENCLAW_APNS_KEY_ID: "KEY123", + OPENCLAW_APNS_PRIVATE_KEY_P8: + "-----BEGIN PRIVATE KEY-----\\nline-a\\nline-b\\n-----END PRIVATE KEY-----", + } as NodeJS.ProcessEnv; + const resolved = await resolveApnsAuthConfigFromEnv(env); + expect(resolved.ok).toBe(true); + if (!resolved.ok) { + return; + } + expect(resolved.value.privateKey).toContain("\nline-a\n"); + expect(resolved.value.teamId).toBe("TEAM123"); + expect(resolved.value.keyId).toBe("KEY123"); + }); + + it("returns an error when required APNs auth vars are missing", async () => { + const resolved = await resolveApnsAuthConfigFromEnv({} as NodeJS.ProcessEnv); + expect(resolved.ok).toBe(false); + if (resolved.ok) { + return; + } + expect(resolved.error).toContain("OPENCLAW_APNS_TEAM_ID"); + }); +}); diff --git a/src/infra/push-apns.ts b/src/infra/push-apns.ts new file mode 100644 index 00000000000..45ca5a665d9 --- /dev/null +++ b/src/infra/push-apns.ts @@ -0,0 +1,409 @@ +import { createHash, createPrivateKey, sign as signJwt } from "node:crypto"; +import fs from "node:fs/promises"; +import http2 from "node:http2"; +import path from "node:path"; +import { resolveStateDir } from "../config/paths.js"; +import { createAsyncLock, readJsonFile, writeJsonAtomic } from "./json-files.js"; + +export type ApnsEnvironment = "sandbox" | "production"; + +export type ApnsRegistration = { + nodeId: string; + token: string; + topic: string; + environment: ApnsEnvironment; + updatedAtMs: number; +}; + +export type ApnsAuthConfig = { + teamId: string; + keyId: string; + privateKey: string; +}; + +export type ApnsAuthConfigResolution = + | { ok: true; value: ApnsAuthConfig } + | { ok: false; error: string }; + +export type ApnsPushAlertResult = { + ok: boolean; + status: number; + apnsId?: string; + reason?: string; + tokenSuffix: string; + topic: string; + environment: ApnsEnvironment; +}; + +type ApnsRegistrationState = { + registrationsByNodeId: Record; +}; + +const APNS_STATE_FILENAME = "push/apns-registrations.json"; +const APNS_JWT_TTL_MS = 50 * 60 * 1000; +const DEFAULT_APNS_TIMEOUT_MS = 10_000; +const withLock = createAsyncLock(); + +let cachedJwt: { cacheKey: string; token: string; expiresAtMs: number } | null = null; + +function resolveApnsRegistrationPath(baseDir?: string): string { + const root = baseDir ?? resolveStateDir(); + return path.join(root, APNS_STATE_FILENAME); +} + +function normalizeNodeId(value: string): string { + return value.trim(); +} + +function normalizeApnsToken(value: string): string { + return value + .trim() + .replace(/[<>\s]/g, "") + .toLowerCase(); +} + +function normalizeTopic(value: string): string { + return value.trim(); +} + +function isLikelyApnsToken(value: string): boolean { + return /^[0-9a-f]{32,}$/i.test(value); +} + +function parseReason(body: string): string | undefined { + const trimmed = body.trim(); + if (!trimmed) { + return undefined; + } + try { + const parsed = JSON.parse(trimmed) as { reason?: unknown }; + return typeof parsed.reason === "string" && parsed.reason.trim().length > 0 + ? parsed.reason.trim() + : trimmed.slice(0, 200); + } catch { + return trimmed.slice(0, 200); + } +} + +function toBase64UrlBytes(value: Uint8Array): string { + return Buffer.from(value) + .toString("base64") + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +function toBase64UrlJson(value: object): string { + return toBase64UrlBytes(Buffer.from(JSON.stringify(value))); +} + +function getJwtCacheKey(auth: ApnsAuthConfig): string { + const keyHash = createHash("sha256").update(auth.privateKey).digest("hex"); + return `${auth.teamId}:${auth.keyId}:${keyHash}`; +} + +function getApnsBearerToken(auth: ApnsAuthConfig, nowMs: number = Date.now()): string { + const cacheKey = getJwtCacheKey(auth); + if (cachedJwt && cachedJwt.cacheKey === cacheKey && nowMs < cachedJwt.expiresAtMs) { + return cachedJwt.token; + } + + const iat = Math.floor(nowMs / 1000); + const header = toBase64UrlJson({ alg: "ES256", kid: auth.keyId, typ: "JWT" }); + const payload = toBase64UrlJson({ iss: auth.teamId, iat }); + const signingInput = `${header}.${payload}`; + const signature = signJwt("sha256", Buffer.from(signingInput, "utf8"), { + key: createPrivateKey(auth.privateKey), + dsaEncoding: "ieee-p1363", + }); + const token = `${signingInput}.${toBase64UrlBytes(signature)}`; + cachedJwt = { + cacheKey, + token, + expiresAtMs: nowMs + APNS_JWT_TTL_MS, + }; + return token; +} + +function normalizePrivateKey(value: string): string { + return value.trim().replace(/\\n/g, "\n"); +} + +function normalizeNonEmptyString(value: string | undefined): string | null { + const trimmed = value?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : null; +} + +async function loadRegistrationsState(baseDir?: string): Promise { + const filePath = resolveApnsRegistrationPath(baseDir); + const existing = await readJsonFile(filePath); + if (!existing || typeof existing !== "object") { + return { registrationsByNodeId: {} }; + } + const registrations = + existing.registrationsByNodeId && + typeof existing.registrationsByNodeId === "object" && + !Array.isArray(existing.registrationsByNodeId) + ? existing.registrationsByNodeId + : {}; + return { registrationsByNodeId: registrations }; +} + +async function persistRegistrationsState( + state: ApnsRegistrationState, + baseDir?: string, +): Promise { + const filePath = resolveApnsRegistrationPath(baseDir); + await writeJsonAtomic(filePath, state); +} + +export function normalizeApnsEnvironment(value: unknown): ApnsEnvironment | null { + if (typeof value !== "string") { + return null; + } + const normalized = value.trim().toLowerCase(); + if (normalized === "sandbox" || normalized === "production") { + return normalized; + } + return null; +} + +export async function registerApnsToken(params: { + nodeId: string; + token: string; + topic: string; + environment?: unknown; + baseDir?: string; +}): Promise { + 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; + }); +} + +export async function loadApnsRegistration( + nodeId: string, + baseDir?: string, +): Promise { + const normalizedNodeId = normalizeNodeId(nodeId); + if (!normalizedNodeId) { + return null; + } + const state = await loadRegistrationsState(baseDir); + return state.registrationsByNodeId[normalizedNodeId] ?? null; +} + +export async function resolveApnsAuthConfigFromEnv( + env: NodeJS.ProcessEnv = process.env, +): Promise { + const teamId = normalizeNonEmptyString(env.OPENCLAW_APNS_TEAM_ID); + const keyId = normalizeNonEmptyString(env.OPENCLAW_APNS_KEY_ID); + if (!teamId || !keyId) { + return { + ok: false, + error: "APNs auth missing: set OPENCLAW_APNS_TEAM_ID and OPENCLAW_APNS_KEY_ID", + }; + } + + const inlineKeyRaw = + normalizeNonEmptyString(env.OPENCLAW_APNS_PRIVATE_KEY_P8) ?? + normalizeNonEmptyString(env.OPENCLAW_APNS_PRIVATE_KEY); + if (inlineKeyRaw) { + return { + ok: true, + value: { + teamId, + keyId, + privateKey: normalizePrivateKey(inlineKeyRaw), + }, + }; + } + + const keyPath = normalizeNonEmptyString(env.OPENCLAW_APNS_PRIVATE_KEY_PATH); + if (!keyPath) { + return { + ok: false, + error: + "APNs private key missing: set OPENCLAW_APNS_PRIVATE_KEY_P8 or OPENCLAW_APNS_PRIVATE_KEY_PATH", + }; + } + try { + const privateKey = normalizePrivateKey(await fs.readFile(keyPath, "utf8")); + return { + ok: true, + value: { + teamId, + keyId, + privateKey, + }, + }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + ok: false, + error: `failed reading OPENCLAW_APNS_PRIVATE_KEY_PATH (${keyPath}): ${message}`, + }; + } +} + +async function sendApnsRequest(params: { + token: string; + topic: string; + environment: ApnsEnvironment; + bearerToken: string; + payload: object; + timeoutMs: number; +}): Promise<{ status: number; apnsId?: string; body: string }> { + const authority = + params.environment === "production" + ? "https://api.push.apple.com" + : "https://api.sandbox.push.apple.com"; + + const body = JSON.stringify(params.payload); + const requestPath = `/3/device/${params.token}`; + + return await new Promise((resolve, reject) => { + const client = http2.connect(authority); + let settled = false; + const fail = (err: unknown) => { + if (settled) { + return; + } + settled = true; + client.destroy(); + reject(err); + }; + const finish = (result: { status: number; apnsId?: string; body: string }) => { + if (settled) { + return; + } + settled = true; + client.close(); + resolve(result); + }; + + client.once("error", (err) => fail(err)); + + const req = client.request({ + ":method": "POST", + ":path": requestPath, + authorization: `bearer ${params.bearerToken}`, + "apns-topic": params.topic, + "apns-push-type": "alert", + "apns-priority": "10", + "apns-expiration": "0", + "content-type": "application/json", + "content-length": Buffer.byteLength(body).toString(), + }); + + let statusCode = 0; + let apnsId: string | undefined; + let responseBody = ""; + + req.setEncoding("utf8"); + req.setTimeout(params.timeoutMs, () => { + req.close(http2.constants.NGHTTP2_CANCEL); + fail(new Error(`APNs request timed out after ${params.timeoutMs}ms`)); + }); + req.on("response", (headers) => { + const statusHeader = headers[":status"]; + statusCode = typeof statusHeader === "number" ? statusHeader : Number(statusHeader ?? 0); + const idHeader = headers["apns-id"]; + if (typeof idHeader === "string" && idHeader.trim().length > 0) { + apnsId = idHeader.trim(); + } + }); + req.on("data", (chunk) => { + if (typeof chunk === "string") { + responseBody += chunk; + } + }); + req.on("end", () => { + finish({ status: statusCode, apnsId, body: responseBody }); + }); + req.on("error", (err) => fail(err)); + + req.end(body); + }); +} + +export async function sendApnsAlert(params: { + auth: ApnsAuthConfig; + registration: ApnsRegistration; + nodeId: string; + title: string; + body: string; + timeoutMs?: number; +}): Promise { + const token = normalizeApnsToken(params.registration.token); + if (!isLikelyApnsToken(token)) { + throw new Error("invalid APNs token"); + } + const topic = normalizeTopic(params.registration.topic); + if (!topic) { + throw new Error("topic required"); + } + const environment = params.registration.environment; + const bearerToken = getApnsBearerToken(params.auth); + + const payload = { + aps: { + alert: { + title: params.title, + body: params.body, + }, + sound: "default", + }, + openclaw: { + kind: "push.test", + nodeId: params.nodeId, + ts: Date.now(), + }, + }; + + const response = await sendApnsRequest({ + token, + topic, + environment, + bearerToken, + payload, + timeoutMs: + typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs) + ? Math.max(1000, Math.trunc(params.timeoutMs)) + : DEFAULT_APNS_TIMEOUT_MS, + }); + + return { + ok: response.status === 200, + status: response.status, + apnsId: response.apnsId, + reason: parseReason(response.body), + tokenSuffix: token.slice(-8), + topic, + environment, + }; +}