feat(push): use scoped relay send grants

This commit is contained in:
Nimrod Gutman
2026-03-12 14:45:50 +02:00
parent 2fd046714e
commit ebf0bdcdfd
12 changed files with 59 additions and 26 deletions

View File

@@ -105,7 +105,7 @@ pnpm ios:beta -- --build-number 7
## APNs Expectations For Official Builds
- Official/TestFlight builds register with the external push relay before they publish `push.apns.register` to the gateway.
- The gateway registration for relay mode contains an opaque relay handle and installation metadata instead of the raw APNs token.
- The gateway registration for relay mode contains an opaque relay handle, a registration-scoped send grant, and installation metadata instead of the raw APNs token.
- The app persists the relay handle metadata locally so reconnects can republish the gateway registration without re-registering on every connect.
- Relay mode requires a reachable relay base URL and uses App Attest plus the app receipt during registration.

View File

@@ -11,6 +11,7 @@ private struct DirectGatewayPushRegistrationPayload: Encodable {
private struct RelayGatewayPushRegistrationPayload: Encodable {
var transport: String = PushTransportMode.relay.rawValue
var relayHandle: String
var sendGrant: String
var installationId: String
var topic: String
var environment: String
@@ -76,6 +77,7 @@ actor PushRegistrationManager {
return try Self.encodePayload(
RelayGatewayPushRegistrationPayload(
relayHandle: stored.relayHandle,
sendGrant: stored.sendGrant,
installationId: installationId,
topic: topic,
environment: self.buildConfig.apnsEnvironment.rawValue,
@@ -92,6 +94,7 @@ actor PushRegistrationManager {
apnsTokenHex: apnsTokenHex)
let registrationState = PushRelayRegistrationStore.RegistrationState(
relayHandle: response.relayHandle,
sendGrant: response.sendGrant,
relayHandleExpiresAtMs: response.expiresAtMs,
tokenDebugSuffix: Self.normalizeTokenSuffix(response.tokenSuffix),
lastAPNsTokenHashHex: tokenHashHex,
@@ -101,6 +104,7 @@ actor PushRegistrationManager {
return try Self.encodePayload(
RelayGatewayPushRegistrationPayload(
relayHandle: response.relayHandle,
sendGrant: response.sendGrant,
installationId: installationId,
topic: topic,
environment: self.buildConfig.apnsEnvironment.rawValue,

View File

@@ -71,6 +71,7 @@ private struct PushRelayRegisterRequest: Encodable {
struct PushRelayRegisterResponse: Decodable {
var relayHandle: String
var sendGrant: String
var expiresAtMs: Int64?
var tokenSuffix: String?
var status: String

View File

@@ -2,6 +2,7 @@ import Foundation
private struct StoredPushRelayRegistrationState: Codable {
var relayHandle: String
var sendGrant: String
var relayHandleExpiresAtMs: Int64?
var tokenDebugSuffix: String?
var lastAPNsTokenHashHex: String
@@ -17,6 +18,7 @@ enum PushRelayRegistrationStore {
struct RegistrationState: Codable {
var relayHandle: String
var sendGrant: String
var relayHandleExpiresAtMs: Int64?
var tokenDebugSuffix: String?
var lastAPNsTokenHashHex: String
@@ -35,6 +37,7 @@ enum PushRelayRegistrationStore {
}
return RegistrationState(
relayHandle: decoded.relayHandle,
sendGrant: decoded.sendGrant,
relayHandleExpiresAtMs: decoded.relayHandleExpiresAtMs,
tokenDebugSuffix: decoded.tokenDebugSuffix,
lastAPNsTokenHashHex: decoded.lastAPNsTokenHashHex,
@@ -46,6 +49,7 @@ enum PushRelayRegistrationStore {
static func saveRegistrationState(_ state: RegistrationState) -> Bool {
let stored = StoredPushRelayRegistrationState(
relayHandle: state.relayHandle,
sendGrant: state.sendGrant,
relayHandleExpiresAtMs: state.relayHandleExpiresAtMs,
tokenDebugSuffix: state.tokenDebugSuffix,
lastAPNsTokenHashHex: state.lastAPNsTokenHashHex,

View File

@@ -329,6 +329,7 @@ describe("node.invoke APNs wake path", () => {
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
@@ -340,7 +341,6 @@ describe("node.invoke APNs wake path", () => {
ok: true,
value: {
baseUrl: "https://relay.example.com",
authToken: "relay-secret",
timeoutMs: 1000,
},
});
@@ -373,6 +373,7 @@ describe("node.invoke APNs wake path", () => {
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",

View File

@@ -119,6 +119,7 @@ describe("push.test handler", () => {
nodeId: "ios-node-1",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-1",
topic: "ai.openclaw.ios",
environment: "production",
@@ -130,7 +131,6 @@ describe("push.test handler", () => {
ok: true,
value: {
baseUrl: "https://relay.example.com",
authToken: "relay-secret",
timeoutMs: 1000,
},
});
@@ -213,6 +213,7 @@ describe("push.test handler", () => {
nodeId: "ios-node-1",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
@@ -224,7 +225,6 @@ describe("push.test handler", () => {
ok: true,
value: {
baseUrl: "https://relay.example.com",
authToken: "relay-secret",
timeoutMs: 1000,
},
});
@@ -252,6 +252,7 @@ describe("push.test handler", () => {
nodeId: "ios-node-1",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",

View File

@@ -290,6 +290,7 @@ describe("node exec events", () => {
payloadJSON: JSON.stringify({
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
@@ -302,6 +303,7 @@ describe("node exec events", () => {
nodeId: "node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",

View File

@@ -598,6 +598,7 @@ export const handleNodeEvent = async (ctx: NodeEventContext, nodeId: string, evt
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,

View File

@@ -3,12 +3,18 @@ import type { AddressInfo } from "node:net";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const TEST_GATEWAY_TOKEN = "test-gateway-token-1234567890";
type BeforeToolCallHookResult =
| { blocked: true; reason: string }
| { blocked: false; params: unknown };
const hookMocks = vi.hoisted(() => ({
resolveToolLoopDetectionConfig: vi.fn(() => ({ warnAt: 3 })),
runBeforeToolCallHook: vi.fn(async ({ params }: { params: unknown }) => ({
blocked: false as const,
params,
})),
runBeforeToolCallHook: vi.fn(
async ({ params }: { params: unknown }): Promise<BeforeToolCallHookResult> => ({
blocked: false,
params,
}),
),
}));
let cfg: Record<string, unknown> = {};

View File

@@ -4,7 +4,6 @@ export type ApnsRelayPushType = "alert" | "background";
export type ApnsRelayConfig = {
baseUrl: string;
authToken: string;
timeoutMs: number;
};
@@ -23,6 +22,7 @@ export type ApnsRelayPushResponse = {
export type ApnsRelayRequestSender = (params: {
relayConfig: ApnsRelayConfig;
sendGrant: string;
relayHandle: string;
pushType: ApnsRelayPushType;
priority: "10" | "5";
@@ -71,12 +71,10 @@ export function resolveApnsRelayConfigFromEnv(
env: NodeJS.ProcessEnv = process.env,
): ApnsRelayConfigResolution {
const baseUrl = normalizeNonEmptyString(env.OPENCLAW_APNS_RELAY_BASE_URL);
const authToken = normalizeNonEmptyString(env.OPENCLAW_APNS_RELAY_AUTH_TOKEN);
if (!baseUrl || !authToken) {
if (!baseUrl) {
return {
ok: false,
error:
"APNs relay config missing: set OPENCLAW_APNS_RELAY_BASE_URL and OPENCLAW_APNS_RELAY_AUTH_TOKEN",
error: "APNs relay config missing: set OPENCLAW_APNS_RELAY_BASE_URL",
};
}
@@ -106,7 +104,6 @@ export function resolveApnsRelayConfigFromEnv(
ok: true,
value: {
baseUrl: parsed.toString().replace(/\/+$/, ""),
authToken,
timeoutMs: normalizeTimeoutMs(env.OPENCLAW_APNS_RELAY_TIMEOUT_MS),
},
};
@@ -121,6 +118,7 @@ export function resolveApnsRelayConfigFromEnv(
async function sendApnsRelayRequest(params: {
relayConfig: ApnsRelayConfig;
sendGrant: string;
relayHandle: string;
pushType: ApnsRelayPushType;
priority: "10" | "5";
@@ -130,7 +128,7 @@ async function sendApnsRelayRequest(params: {
method: "POST",
redirect: "manual",
headers: {
authorization: `Bearer ${params.relayConfig.authToken}`,
authorization: `Bearer ${params.sendGrant}`,
"content-type": "application/json",
},
body: JSON.stringify({
@@ -177,6 +175,7 @@ async function sendApnsRelayRequest(params: {
export async function sendApnsRelayPush(params: {
relayConfig: ApnsRelayConfig;
sendGrant: string;
relayHandle: string;
pushType: ApnsRelayPushType;
priority: "10" | "5";
@@ -186,6 +185,7 @@ export async function sendApnsRelayPush(params: {
const sender = params.requestSender ?? sendApnsRelayRequest;
return await sender({
relayConfig: params.relayConfig,
sendGrant: params.sendGrant,
relayHandle: params.relayHandle,
pushType: params.pushType,
priority: params.priority,

View File

@@ -69,6 +69,7 @@ describe("push APNs registration store", () => {
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
@@ -83,6 +84,7 @@ describe("push APNs registration store", () => {
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
@@ -111,6 +113,7 @@ describe("push APNs registration store", () => {
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "staging",
@@ -123,6 +126,7 @@ describe("push APNs registration store", () => {
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
@@ -140,6 +144,7 @@ describe("push APNs registration store", () => {
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: oversized,
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",
@@ -152,6 +157,7 @@ describe("push APNs registration store", () => {
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: oversized,
topic: "ai.openclaw.ios",
environment: "production",
@@ -246,14 +252,12 @@ describe("push APNs env config", () => {
it("resolves APNs relay config from env", () => {
const resolved = resolveApnsRelayConfigFromEnv({
OPENCLAW_APNS_RELAY_BASE_URL: "https://relay.example.com",
OPENCLAW_APNS_RELAY_AUTH_TOKEN: "relay-secret",
OPENCLAW_APNS_RELAY_TIMEOUT_MS: "2500",
} as NodeJS.ProcessEnv);
expect(resolved).toMatchObject({
ok: true,
value: {
baseUrl: "https://relay.example.com",
authToken: "relay-secret",
timeoutMs: 2500,
},
});
@@ -262,7 +266,6 @@ describe("push APNs env config", () => {
it("rejects insecure APNs relay http URLs by default", () => {
const resolved = resolveApnsRelayConfigFromEnv({
OPENCLAW_APNS_RELAY_BASE_URL: "http://relay.example.com",
OPENCLAW_APNS_RELAY_AUTH_TOKEN: "relay-secret",
} as NodeJS.ProcessEnv);
expect(resolved).toMatchObject({
ok: false,
@@ -276,14 +279,12 @@ describe("push APNs env config", () => {
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_AUTH_TOKEN: "relay-secret",
OPENCLAW_APNS_RELAY_ALLOW_HTTP: "true",
} as NodeJS.ProcessEnv);
expect(resolved).toMatchObject({
ok: true,
value: {
baseUrl: "http://127.0.0.1:8787",
authToken: "relay-secret",
timeoutMs: 10_000,
},
});
@@ -292,7 +293,6 @@ describe("push APNs env config", () => {
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_AUTH_TOKEN: "relay-secret",
OPENCLAW_APNS_RELAY_ALLOW_HTTP: "true",
} as NodeJS.ProcessEnv);
expect(resolved).toMatchObject({
@@ -307,7 +307,6 @@ describe("push APNs env config", () => {
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",
OPENCLAW_APNS_RELAY_AUTH_TOKEN: "relay-secret",
} as NodeJS.ProcessEnv);
expect(withQuery.ok).toBe(false);
if (!withQuery.ok) {
@@ -316,7 +315,6 @@ describe("push APNs env config", () => {
const withUserinfo = resolveApnsRelayConfigFromEnv({
OPENCLAW_APNS_RELAY_BASE_URL: "https://user:pass@relay.example.com/path",
OPENCLAW_APNS_RELAY_AUTH_TOKEN: "relay-secret",
} as NodeJS.ProcessEnv);
expect(withUserinfo.ok).toBe(false);
if (!withUserinfo.ok) {
@@ -468,13 +466,13 @@ describe("push APNs send semantics", () => {
const result = await sendApnsAlert({
relayConfig: {
baseUrl: "https://relay.example.com",
authToken: "relay-secret",
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",
@@ -520,9 +518,9 @@ describe("push APNs send semantics", () => {
const result = await sendApnsRelayPush({
relayConfig: {
baseUrl: "https://relay.example.com",
authToken: "relay-secret",
timeoutMs: 1000,
},
sendGrant: "send-grant-123",
relayHandle: "relay-handle-123",
payload: { aps: { "content-available": 1 } },
pushType: "background",
@@ -568,6 +566,7 @@ describe("push APNs send semantics", () => {
nodeId: "ios-node-relay",
transport: "relay",
relayHandle: "relay-handle-123",
sendGrant: "send-grant-123",
installationId: "install-123",
topic: "ai.openclaw.ios",
environment: "production",

View File

@@ -29,6 +29,7 @@ export type RelayApnsRegistration = {
nodeId: string;
transport: "relay";
relayHandle: string;
sendGrant: string;
installationId: string;
topic: string;
environment: "production";
@@ -97,6 +98,7 @@ type RegisterRelayApnsParams = {
nodeId: string;
transport: "relay";
relayHandle: string;
sendGrant: string;
installationId: string;
topic: string;
environment?: unknown;
@@ -271,17 +273,23 @@ function normalizeDirectRegistration(
}
function normalizeRelayRegistration(
record: Partial<RelayApnsRegistration> & { nodeId?: unknown; relayHandle?: unknown },
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);
@@ -293,6 +301,7 @@ function normalizeRelayRegistration(
if (
!nodeId ||
!relayHandle ||
!sendGrant ||
!installationId ||
!topic ||
environment !== "production" ||
@@ -304,6 +313,7 @@ function normalizeRelayRegistration(
nodeId,
transport: "relay",
relayHandle,
sendGrant,
installationId,
topic,
environment,
@@ -393,6 +403,7 @@ export async function registerApnsRegistration(
normalizeRelayHandle(params.relayHandle),
"relayHandle",
);
const sendGrant = validateRelayIdentifier(params.sendGrant.trim(), "sendGrant");
const installationId = validateRelayIdentifier(
normalizeInstallationId(params.installationId),
"installationId",
@@ -409,6 +420,7 @@ export async function registerApnsRegistration(
nodeId,
transport: "relay",
relayHandle,
sendGrant,
installationId,
topic,
environment,
@@ -495,6 +507,7 @@ function isSameApnsRegistration(a: ApnsRegistration, b: ApnsRegistration): boole
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
@@ -817,6 +830,7 @@ async function sendRelayApnsPush(params: {
}): Promise<ApnsPushResult> {
const response = await sendApnsRelayPush({
relayConfig: params.relayConfig,
sendGrant: params.registration.sendGrant,
relayHandle: params.registration.relayHandle,
payload: params.payload,
pushType: params.pushType,