@@ -23,6 +23,10 @@ type ApnsRelayConfigResolution =
2323 | { ok : true ; value : ApnsRelayConfig }
2424 | { ok : false ; error : string } ;
2525
26+ type ApnsRelayConfigResolutionOptions = {
27+ registrationRelayOrigin ?: string ;
28+ } ;
29+
2630export type ApnsRelayPushResponse = {
2731 ok : boolean ;
2832 status : number ;
@@ -94,6 +98,38 @@ function parseReason(value: unknown): string | undefined {
9498 return typeof value === "string" ? normalizeOptionalString ( value ) : undefined ;
9599}
96100
101+ export function normalizeApnsRelayBaseUrl (
102+ baseUrl : string ,
103+ env : NodeJS . ProcessEnv = process . env ,
104+ ) : { ok : true ; value : string } | { ok : false ; error : string } {
105+ try {
106+ const parsed = new URL ( baseUrl ) ;
107+ if ( parsed . protocol !== "https:" && parsed . protocol !== "http:" ) {
108+ throw new Error ( "unsupported protocol" ) ;
109+ }
110+ if ( ! parsed . hostname ) {
111+ throw new Error ( "host required" ) ;
112+ }
113+ if ( parsed . protocol === "http:" && ! readAllowHttp ( env . OPENCLAW_APNS_RELAY_ALLOW_HTTP ) ) {
114+ throw new Error (
115+ "http relay URLs require OPENCLAW_APNS_RELAY_ALLOW_HTTP=true (development only)" ,
116+ ) ;
117+ }
118+ if ( parsed . protocol === "http:" && ! isLoopbackRelayHostname ( parsed . hostname ) ) {
119+ throw new Error ( "http relay URLs are limited to loopback hosts" ) ;
120+ }
121+ if ( parsed . username || parsed . password ) {
122+ throw new Error ( "userinfo is not allowed" ) ;
123+ }
124+ if ( parsed . search || parsed . hash ) {
125+ throw new Error ( "query and fragment are not allowed" ) ;
126+ }
127+ return { ok : true , value : parsed . toString ( ) . replace ( / \/ + $ / , "" ) } ;
128+ } catch ( err ) {
129+ return { ok : false , error : formatErrorMessage ( err ) } ;
130+ }
131+ }
132+
97133function buildRelayGatewaySignaturePayload ( params : {
98134 gatewayDeviceId : string ;
99135 signedAtMs : number ;
@@ -110,55 +146,65 @@ function buildRelayGatewaySignaturePayload(params: {
110146export function resolveApnsRelayConfigFromEnv (
111147 env : NodeJS . ProcessEnv = process . env ,
112148 gatewayConfig ?: GatewayConfig ,
149+ options : ApnsRelayConfigResolutionOptions = { } ,
113150) : ApnsRelayConfigResolution {
114151 const configuredRelay = gatewayConfig ?. push ?. apns ?. relay ;
115152 const envBaseUrl = normalizeNonEmptyString ( env . OPENCLAW_APNS_RELAY_BASE_URL ) ;
116153 const configBaseUrl = normalizeNonEmptyString ( configuredRelay ?. baseUrl ) ;
117- const baseUrl = envBaseUrl ?? configBaseUrl ?? DEFAULT_APNS_RELAY_BASE_URL ;
154+ const explicitBaseUrl = envBaseUrl ?? configBaseUrl ;
155+ const normalizedRegistrationOrigin = options . registrationRelayOrigin
156+ ? normalizeApnsRelayBaseUrl ( options . registrationRelayOrigin , env )
157+ : undefined ;
158+ if ( normalizedRegistrationOrigin && ! normalizedRegistrationOrigin . ok ) {
159+ return {
160+ ok : false ,
161+ error : `invalid relay registration origin (${ options . registrationRelayOrigin } ): ${ normalizedRegistrationOrigin . error } ` ,
162+ } ;
163+ }
164+
165+ const baseUrl =
166+ explicitBaseUrl ??
167+ ( normalizedRegistrationOrigin ?. value === DEFAULT_APNS_RELAY_BASE_URL
168+ ? DEFAULT_APNS_RELAY_BASE_URL
169+ : undefined ) ;
118170 const baseUrlSource = envBaseUrl
119171 ? "OPENCLAW_APNS_RELAY_BASE_URL"
120172 : configBaseUrl
121173 ? "gateway.push.apns.relay.baseUrl"
122174 : "default APNs relay base URL" ;
175+ if ( ! baseUrl ) {
176+ return {
177+ ok : false ,
178+ error :
179+ "APNs relay config missing: set gateway.push.apns.relay.baseUrl or OPENCLAW_APNS_RELAY_BASE_URL for relay registrations without the hosted relay origin" ,
180+ } ;
181+ }
123182
124- try {
125- const parsed = new URL ( baseUrl ) ;
126- if ( parsed . protocol !== "https:" && parsed . protocol !== "http:" ) {
127- throw new Error ( "unsupported protocol" ) ;
128- }
129- if ( ! parsed . hostname ) {
130- throw new Error ( "host required" ) ;
131- }
132- if ( parsed . protocol === "http:" && ! readAllowHttp ( env . OPENCLAW_APNS_RELAY_ALLOW_HTTP ) ) {
133- throw new Error (
134- "http relay URLs require OPENCLAW_APNS_RELAY_ALLOW_HTTP=true (development only)" ,
135- ) ;
136- }
137- if ( parsed . protocol === "http:" && ! isLoopbackRelayHostname ( parsed . hostname ) ) {
138- throw new Error ( "http relay URLs are limited to loopback hosts" ) ;
139- }
140- if ( parsed . username || parsed . password ) {
141- throw new Error ( "userinfo is not allowed" ) ;
142- }
143- if ( parsed . search || parsed . hash ) {
144- throw new Error ( "query and fragment are not allowed" ) ;
145- }
183+ const normalizedBaseUrl = normalizeApnsRelayBaseUrl ( baseUrl , env ) ;
184+ if ( ! normalizedBaseUrl . ok ) {
146185 return {
147- ok : true ,
148- value : {
149- baseUrl : parsed . toString ( ) . replace ( / \/ + $ / , "" ) ,
150- timeoutMs : normalizeTimeoutMs (
151- env . OPENCLAW_APNS_RELAY_TIMEOUT_MS ?? configuredRelay ?. timeoutMs ,
152- ) ,
153- } ,
186+ ok : false ,
187+ error : `invalid ${ baseUrlSource } (${ baseUrl } ): ${ normalizedBaseUrl . error } ` ,
154188 } ;
155- } catch ( err ) {
156- const message = formatErrorMessage ( err ) ;
189+ }
190+ if (
191+ normalizedRegistrationOrigin &&
192+ normalizedRegistrationOrigin . value !== normalizedBaseUrl . value
193+ ) {
157194 return {
158195 ok : false ,
159- error : `invalid ${ baseUrlSource } ( ${ baseUrl } ): ${ message } ` ,
196+ error : `APNs relay config origin mismatch: registration uses ${ normalizedRegistrationOrigin . value } but ${ baseUrlSource } is ${ normalizedBaseUrl . value } ` ,
160197 } ;
161198 }
199+ return {
200+ ok : true ,
201+ value : {
202+ baseUrl : normalizedBaseUrl . value ,
203+ timeoutMs : normalizeTimeoutMs (
204+ env . OPENCLAW_APNS_RELAY_TIMEOUT_MS ?? configuredRelay ?. timeoutMs ,
205+ ) ,
206+ } ,
207+ } ;
162208}
163209
164210async function sendApnsRelayRequest ( params : {
0 commit comments