push-worker.js (9632B)
1 export class PushRegistry { 2 constructor(state, env) { 3 this.state = state; 4 this.env = env; 5 this.cachedJWT = null; 6 this.cachedJWTExpiresAt = 0; 7 } 8 9 async fetch(request) { 10 const url = new URL(request.url); 11 const auth = this.authenticate(request); 12 if (!auth.ok) { 13 return new Response(auth.message, { status: auth.status }); 14 } 15 16 if (url.pathname === "/register" && request.method === "POST") { 17 return this.handleRegister(request); 18 } 19 if (url.pathname === "/register" && request.method === "DELETE") { 20 return this.handleUnregister(request); 21 } 22 if (url.pathname === "/publish" && request.method === "POST") { 23 return this.handlePublish(request); 24 } 25 return new Response("Not found", { status: 404 }); 26 } 27 28 authenticate(request) { 29 const header = request.headers.get("Authorization") || ""; 30 const expected = `Bearer ${this.env.PUSH_BEARER || ""}`; 31 if (!this.env.PUSH_BEARER) { 32 return { ok: false, status: 500, message: "Worker missing PUSH_BEARER" }; 33 } 34 if (!timingSafeEqual(header, expected)) { 35 return { ok: false, status: 401, message: "Bad bearer" }; 36 } 37 return { ok: true }; 38 } 39 40 async handleRegister(request) { 41 const body = await readJSON(request); 42 if (!body) return badRequest("Body must be JSON"); 43 const { deviceID, token, environment, addresses } = body; 44 if (!deviceID || !token || !Array.isArray(addresses)) { 45 return badRequest("deviceID, token, addresses required"); 46 } 47 if (environment !== "sandbox" && environment !== "production") { 48 return badRequest("environment must be 'sandbox' or 'production'"); 49 } 50 // Bind this device's APNs token to each per-(account, game) address it 51 // knows. The address is the lookup key; identity never reaches the worker. 52 const updatedAt = Date.now(); 53 for (const address of addresses) { 54 if (typeof address !== "string" || address.length === 0) continue; 55 await this.state.storage.put(`addr:${address}:${deviceID}`, { 56 token, 57 environment, 58 updatedAt 59 }); 60 } 61 return new Response(null, { status: 204 }); 62 } 63 64 async handleUnregister(request) { 65 const body = await readJSON(request); 66 if (!body) return badRequest("Body must be JSON"); 67 const { deviceID, addresses } = body; 68 if (!deviceID || !Array.isArray(addresses)) { 69 return badRequest("deviceID and addresses required"); 70 } 71 for (const address of addresses) { 72 if (typeof address !== "string" || address.length === 0) continue; 73 await this.state.storage.delete(`addr:${address}:${deviceID}`); 74 } 75 return new Response(null, { status: 204 }); 76 } 77 78 async handlePublish(request) { 79 const body = await readJSON(request); 80 if (!body) return badRequest("Body must be JSON"); 81 const { 82 kind, 83 addressees, 84 gameID, 85 fromAuthorID, 86 senderDeviceID, 87 readAt, 88 title, 89 alertBody, 90 background 91 } = body; 92 if (!kind || !Array.isArray(addressees) || addressees.length === 0) { 93 return badRequest("kind and non-empty addressees required"); 94 } 95 96 const targets = await this.resolveTargets(addressees, senderDeviceID); 97 if (targets.length === 0) { 98 return Response.json({ delivered: 0, removed: 0, failed: 0 }); 99 } 100 101 let delivered = 0; 102 let removed = 0; 103 let failed = 0; 104 for (const target of targets) { 105 const result = await this.sendOne(target, { 106 kind, 107 gameID, 108 fromAuthorID, 109 senderDeviceID, 110 readAt, 111 title, 112 body: target.body || alertBody, 113 payload: target.payload, 114 background: background === true 115 }); 116 if (result === "ok") delivered += 1; 117 else if (result === "drop") { 118 await this.state.storage.delete(`addr:${target.address}:${target.deviceID}`); 119 removed += 1; 120 } else { 121 failed += 1; 122 } 123 } 124 return Response.json({ delivered, removed, failed }); 125 } 126 127 async resolveTargets(addressees, senderDeviceID) { 128 const targets = []; 129 for (const addressee of addressees) { 130 if (!addressee || !addressee.address) continue; 131 const body = typeof addressee.body === "string" ? addressee.body : undefined; 132 // Opaque, app-encoded semantics (base64 JSON of `PushPayload`). The 133 // worker never inspects it — it just forwards it into the APNs userInfo 134 // so the notification service extension can decode it. Keeping it opaque 135 // is what lets the app evolve notification meaning without a worker deploy. 136 const payload = typeof addressee.payload === "string" ? addressee.payload : undefined; 137 const prefix = `addr:${addressee.address}:`; 138 const map = await this.state.storage.list({ prefix }); 139 for (const [key, value] of map) { 140 const deviceID = key.slice(prefix.length); 141 if (senderDeviceID && deviceID === senderDeviceID) continue; 142 targets.push({ 143 address: addressee.address, 144 deviceID, 145 body, 146 payload, 147 ...value 148 }); 149 } 150 } 151 return targets; 152 } 153 154 async sendOne(target, message) { 155 const topic = this.env.APNS_TOPIC || "net.inqk.crossmate"; 156 const host = target.environment === "sandbox" 157 ? "api.sandbox.push.apple.com" 158 : "api.push.apple.com"; 159 const jwt = await this.providerJWT(); 160 const alert = {}; 161 if (message.title) alert.title = message.title; 162 if (message.body) alert.body = message.body; 163 const apnsPayload = { 164 aps: message.background 165 ? { "content-available": 1 } 166 : { alert, sound: "default", "mutable-content": 1 }, 167 kind: message.kind 168 }; 169 if (message.gameID) apnsPayload.gameID = message.gameID; 170 if (message.fromAuthorID) apnsPayload.fromAuthorID = message.fromAuthorID; 171 if (message.senderDeviceID) apnsPayload.senderDeviceID = message.senderDeviceID; 172 if (message.readAt) apnsPayload.readAt = message.readAt; 173 // Forward the opaque app payload verbatim when present. Absent for older 174 // app builds, which the extension handles by falling back to `kind`. 175 if (message.payload) apnsPayload.payload = message.payload; 176 177 const response = await fetch(`https://${host}/3/device/${target.token}`, { 178 method: "POST", 179 headers: { 180 authorization: `bearer ${jwt}`, 181 "apns-topic": topic, 182 "apns-push-type": message.background ? "background" : "alert", 183 "apns-priority": message.background ? "5" : "10", 184 "apns-expiration": "0", 185 "content-type": "application/json" 186 }, 187 body: JSON.stringify(apnsPayload) 188 }); 189 190 if (response.status === 200) return "ok"; 191 if (response.status === 410) return "drop"; 192 if (response.status === 400) { 193 const text = await response.text(); 194 if (text.includes("BadDeviceToken") || text.includes("DeviceTokenNotForTopic")) { 195 return "drop"; 196 } 197 } 198 return "fail"; 199 } 200 201 async providerJWT() { 202 const nowSeconds = Math.floor(Date.now() / 1000); 203 if (this.cachedJWT && nowSeconds < this.cachedJWTExpiresAt - 60) { 204 return this.cachedJWT; 205 } 206 const jwt = await signProviderJWT({ 207 keyPEM: this.env.APNS_KEY, 208 keyID: this.env.APNS_KEY_ID, 209 teamID: this.env.APNS_TEAM_ID, 210 issuedAt: nowSeconds 211 }); 212 this.cachedJWT = jwt; 213 // Refresh well before APNs' 1-hour ceiling; the rate-limit floor is ~20 min. 214 this.cachedJWTExpiresAt = nowSeconds + 40 * 60; 215 return jwt; 216 } 217 } 218 219 export default { 220 async fetch(request, env) { 221 const url = new URL(request.url); 222 if (url.pathname === "/health") { 223 return new Response("ok"); 224 } 225 const id = env.PUSH_REGISTRY.idFromName("registry"); 226 return env.PUSH_REGISTRY.get(id).fetch(request); 227 } 228 }; 229 230 async function readJSON(request) { 231 try { 232 return await request.json(); 233 } catch { 234 return null; 235 } 236 } 237 238 function badRequest(message) { 239 return new Response(message, { status: 400 }); 240 } 241 242 async function signProviderJWT({ keyPEM, keyID, teamID, issuedAt }) { 243 if (!keyPEM || !keyID || !teamID) { 244 throw new Error("APNS_KEY, APNS_KEY_ID, APNS_TEAM_ID must all be set"); 245 } 246 const key = await importP8(keyPEM); 247 const header = base64URLEncode(new TextEncoder().encode(JSON.stringify({ 248 alg: "ES256", 249 kid: keyID 250 }))); 251 const claims = base64URLEncode(new TextEncoder().encode(JSON.stringify({ 252 iss: teamID, 253 iat: issuedAt 254 }))); 255 const signingInput = `${header}.${claims}`; 256 const signature = await crypto.subtle.sign( 257 { name: "ECDSA", hash: "SHA-256" }, 258 key, 259 new TextEncoder().encode(signingInput) 260 ); 261 return `${signingInput}.${base64URLEncode(new Uint8Array(signature))}`; 262 } 263 264 async function importP8(pem) { 265 const stripped = pem 266 .replace(/-----BEGIN PRIVATE KEY-----/g, "") 267 .replace(/-----END PRIVATE KEY-----/g, "") 268 .replace(/\s+/g, ""); 269 const der = Uint8Array.from(atob(stripped), (char) => char.charCodeAt(0)); 270 return crypto.subtle.importKey( 271 "pkcs8", 272 der, 273 { name: "ECDSA", namedCurve: "P-256" }, 274 false, 275 ["sign"] 276 ); 277 } 278 279 function base64URLEncode(bytes) { 280 let binary = ""; 281 for (const byte of bytes) { 282 binary += String.fromCharCode(byte); 283 } 284 return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, ""); 285 } 286 287 function timingSafeEqual(a, b) { 288 const left = new TextEncoder().encode(a); 289 const right = new TextEncoder().encode(b); 290 if (left.length !== right.length) return false; 291 let diff = 0; 292 for (let index = 0; index < left.length; index += 1) { 293 diff |= left[index] ^ right[index]; 294 } 295 return diff === 0; 296 }