krz/moonrise

Night-sky companion for iPhone, Apple Watch and Vision Pro: what's up tonight, the Moon, planets, launches and missions, and a Library of the solar system. SwiftUI app with a Cloudflare Workers backend. astronomy cloudflare-workers ios night-sky space swiftui visionos watchos

Commit b8e47f7bbd

b8e47f7bbd5098815233b14f4751c9d605298635

parent: 711c14244f

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-17 23:49 UTC

Add backend Worker for space weather and aurora alerts

- server/: Cloudflare Worker with D1 (devices, alerts_sent,
  feed_snapshots). Cron every 5 minutes polls SWPC Kp and forecast,
  stores a snapshot, and sends aurora pushes to devices in bands the
  oval reaches (NOAA Kp-to-latitude, 6 degrees horizon margin), at
  night local time, once per band per 12 h. Deletes tokens on 410.
- API: GET /v1/today, POST and DELETE /v1/devices, GET /healthz.
- Devices send a 5-degree geomagnetic latitude band and UTC offset,
  no location.
- PLAN.org: M2 status; backend kept in this repo.

Layout: unified · split

PLAN.org +16 −1
@@ -255,7 +255,8 @@ needs a key. CelesTrak: never more than once per 2-hour update.
255255- v1: per-IP rate limits. Later: App Attest verification in the Worker.
256256
257257** operations
258- Repo =moonrise-server= on gitbay; deploy with Wrangler from CI.
258- Lives in =server/= in this repo (a separate =moonrise-server= repo is
259 optional); deploy with Wrangler.
259260- Staging and production Workers with separate D1 databases and APNs
260261 environments.
261262- Workers Logs for errors; feed staleness in =/healthz=.
@@ -329,6 +330,20 @@ Not done:
329330- Launches and Voyager in the Today sheet: backend (M2, M3).
330331- Automatic night mode after astronomical dusk.
331332
333** M2 status (2026-10-17)
334Stacked MRs: !7 Sun tab, !8 backend.
335
336- Sun tab: done, checked in the simulator with live data.
337- Backend (=server/=): SWPC polling, =/v1/today=, device registration,
338 aurora alert rules and APNs sending. Unit-tested; smoke-tested under
339 =wrangler dev= with a local D1. Not deployed.
340- Simplifications against the design above: aurora pushes are sent
341 in-line from the cron (no Queue yet); "dark" is 20:00–04:00 local time
342 rather than sun altitude; no rate limiting or App Attest on
343 registration yet.
344- Not done: app registration with the backend (needs a deployed URL),
345 aurora oval map, launch caching, Live Activities.
346
332347* roadmap
333348** v1: now
334349Sky, System, Sun, Launches, Library core (above).
server/.gitignore added +1
@@ -0,0 +1 @@
1.dev.vars
server/README.org added +36
@@ -0,0 +1,36 @@
1#+title: moonrise-server
2
3Cloudflare Worker: SWPC polling every 5 minutes, =/v1/today=, device
4registration, aurora pushes over APNs. D1 for storage.
5
6* local
7#+begin_src sh
8wrangler d1 migrations apply moonrise --local
9wrangler dev --test-scheduled
10curl "http://localhost:8787/__scheduled?cron=*/5+*+*+*+*"
11curl http://localhost:8787/v1/today
12#+end_src
13
14APNs cannot be reached from =wrangler dev= (no HTTP/2); test pushes
15against a deployed Worker.
16
17* deploy
18#+begin_src sh
19wrangler d1 create moonrise # put the id in wrangler.jsonc
20wrangler d1 migrations apply moonrise --remote
21wrangler deploy
22wrangler secret put APNS_KEY < AuthKey_644XS4F2RG.p8
23#+end_src
24
25* API
26| Method and path | Body / result |
27|-
28| =GET /v1/today= | ={spaceWeather: {kpNow, observedAt, kpNext24h, fetchedAt}}= |
29| =POST /v1/devices= | ={token, environment, band, utcOffsetMinutes, aurora?}=; band is a 5° geomagnetic latitude band, south negative |
30| =DELETE /v1/devices/{token}= | |
31| =GET /healthz= | feed status |
32
33* tests
34#+begin_src sh
35node --test test/
36#+end_src
server/migrations/0001_init.sql added +29
@@ -0,0 +1,29 @@
1-- Devices registered for pushes. No location: the app sends a 5-degree
2-- geomagnetic latitude band (signed, south negative) and its UTC offset.
3CREATE TABLE devices (
4 token TEXT PRIMARY KEY,
5 environment TEXT NOT NULL CHECK (environment IN ('sandbox', 'production')),
6 band INTEGER NOT NULL,
7 utc_offset_minutes INTEGER NOT NULL,
8 aurora INTEGER NOT NULL DEFAULT 1,
9 last_seen TEXT NOT NULL
10);
11
12CREATE INDEX devices_band ON devices (band) WHERE aurora = 1;
13
14-- One alert per kind, band and event key (e.g. aurora per band per storm window).
15CREATE TABLE alerts_sent (
16 kind TEXT NOT NULL,
17 band INTEGER NOT NULL,
18 event TEXT NOT NULL,
19 sent_at TEXT NOT NULL,
20 PRIMARY KEY (kind, band, event)
21);
22
23-- Latest result per upstream feed.
24CREATE TABLE feed_snapshots (
25 feed TEXT PRIMARY KEY,
26 fetched_at TEXT NOT NULL,
27 ok INTEGER NOT NULL,
28 body TEXT
29);
server/package.json added +11
@@ -0,0 +1,11 @@
1{
2 "name": "moonrise-server",
3 "private": true,
4 "type": "module",
5 "scripts": {
6 "test": "node --test test/",
7 "dev": "wrangler dev --test-scheduled",
8 "migrate:local": "wrangler d1 migrations apply moonrise --local",
9 "deploy": "wrangler deploy"
10 }
11}
server/src/apns.ts added +78
@@ -0,0 +1,78 @@
1// APNs provider-token auth and sending, using only Web APIs (Workers, Node).
2
3export interface ApnsConfig {
4 keyId: string;
5 teamId: string;
6 /** Contents of the .p8 key file (PKCS#8 PEM). */
7 privateKey: string;
8 /** App bundle ID. */
9 topic: string;
10}
11
12export interface ApnsResult {
13 status: number;
14 apnsId: string | null;
15 reason: string | null;
16}
17
18const encoder = new TextEncoder();
19
20function base64url(bytes: Uint8Array): string {
21 let s = "";
22 for (const b of bytes) s += String.fromCharCode(b);
23 return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
24}
25
26function pemToDer(pem: string): Uint8Array {
27 const body = pem.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "");
28 return Uint8Array.from(atob(body), (c) => c.charCodeAt(0));
29}
30
31export async function importKey(pem: string): Promise<CryptoKey> {
32 return crypto.subtle.importKey("pkcs8", pemToDer(pem), { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]);
33}
34
35/** ES256 provider token. WebCrypto returns the raw r||s signature JWS expects. */
36export async function providerToken(keyId: string, teamId: string, key: CryptoKey, issuedAt = Math.floor(Date.now() / 1000)): Promise<string> {
37 const header = base64url(encoder.encode(JSON.stringify({ alg: "ES256", kid: keyId })));
38 const claims = base64url(encoder.encode(JSON.stringify({ iss: teamId, iat: issuedAt })));
39 const input = `${header}.${claims}`;
40 const sig = await crypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, key, encoder.encode(input));
41 return `${input}.${base64url(new Uint8Array(sig))}`;
42}
43
44/** Apple accepts a token for up to 60 minutes and rejects refreshes more often than every 20. */
45let cached: { token: string; issuedAt: number; keyId: string } | null = null;
46
47async function token(config: ApnsConfig): Promise<string> {
48 const now = Math.floor(Date.now() / 1000);
49 if (cached && cached.keyId === config.keyId && now - cached.issuedAt < 50 * 60) return cached.token;
50 const t = await providerToken(config.keyId, config.teamId, await importKey(config.privateKey), now);
51 cached = { token: t, issuedAt: now, keyId: config.keyId };
52 return t;
53}
54
55export async function send(config: ApnsConfig, deviceToken: string, payload: unknown, sandbox: boolean): Promise<ApnsResult> {
56 const host = sandbox ? "api.sandbox.push.apple.com" : "api.push.apple.com";
57 const res = await fetch(`https://${host}/3/device/${deviceToken}`, {
58 method: "POST",
59 headers: {
60 authorization: `bearer ${await token(config)}`,
61 "apns-topic": config.topic,
62 "apns-push-type": "alert",
63 "apns-priority": "10",
64 "content-type": "application/json",
65 },
66 body: JSON.stringify(payload),
67 });
68 let reason: string | null = null;
69 if (!res.ok) {
70 const text = await res.text();
71 try {
72 reason = (JSON.parse(text) as { reason?: string }).reason ?? text;
73 } catch {
74 reason = text;
75 }
76 }
77 return { status: res.status, apnsId: res.headers.get("apns-id"), reason };
78}
server/src/aurora.ts added +47
@@ -0,0 +1,47 @@
1// Aurora alert rules. Pure functions; the Worker supplies data and sends.
2
3/**
4 * Geomagnetic latitude of the auroral oval's equatorward edge for a Kp value,
5 * after NOAA SWPC's Kp-to-latitude chart (66.5° at Kp 0, about 2° lower per Kp).
6 */
7export function ovalEdge(kp: number): number {
8 return 66.5 - 2.04 * kp;
9}
10
11/** Aurora low on the poleward horizon is often visible about this far beyond the oval's edge. */
12export const HORIZON_MARGIN = 6;
13
14/** Lowest geomagnetic latitude (absolute) where aurora may be seen on the horizon. */
15export function visibleFrom(kp: number): number {
16 return ovalEdge(kp) - HORIZON_MARGIN;
17}
18
19/** 5° bands, identified by their equatorward edge: 52 covers 52–57°N, -57 covers 52–57°S. */
20export function bandFor(geomagneticLatitude: number): number {
21 const b = Math.floor(Math.abs(geomagneticLatitude) / 5) * 5;
22 return geomagneticLatitude < 0 ? -(b + 5) : b;
23}
24
25/** Bands whose poleward edge reaches the visible region for this Kp. */
26export function alertBands(kp: number, bands: number[]): number[] {
27 const limit = visibleFrom(kp);
28 return bands.filter((b) => (b >= 0 ? b + 5 : -b) > limit);
29}
30
31/** Local hours treated as dark enough to bother someone: 20:00 to 04:00. */
32export function isNight(now: Date, utcOffsetMinutes: number): boolean {
33 const hour = (now.getUTCHours() + now.getUTCMinutes() / 60 + utcOffsetMinutes / 60 + 48) % 24;
34 return hour >= 20 || hour < 4;
35}
36
37/** One alert per band per 12-hour UTC window. */
38export function eventKey(now: Date): string {
39 const d = now.toISOString().slice(0, 10);
40 return `${d}${now.getUTCHours() < 12 ? "a" : "b"}`;
41}
42
43/** Kp to act on: the larger of now and the forecast for the next 3 hours. */
44export function actionableKp(kpNow: number, forecast: { start: Date; kp: number }[], now: Date): number {
45 const soon = forecast.filter((f) => f.start.getTime() <= now.getTime() + 3 * 3600_000 && f.start.getTime() + 3 * 3600_000 > now.getTime());
46 return Math.max(kpNow, ...soon.map((f) => f.kp));
47}
server/src/index.ts added +115
@@ -0,0 +1,115 @@
1import { send, type ApnsConfig } from "./apns";
2import { actionableKp, alertBands, eventKey, isNight } from "./aurora";
3import { fetchSpaceWeather, next24h, type SpaceWeather } from "./swpc";
4
5interface Env {
6 DB: D1Database;
7 APNS_KEY_ID: string;
8 APNS_TEAM_ID: string;
9 APNS_TOPIC: string;
10 APNS_KEY: string;
11}
12
13interface DeviceRequest {
14 token: string;
15 environment: "sandbox" | "production";
16 band: number;
17 utcOffsetMinutes: number;
18 aurora?: boolean;
19}
20
21const json = (body: unknown, status = 200) => Response.json(body, { status });
22
23function validDevice(d: Partial<DeviceRequest>): d is DeviceRequest {
24 return (
25 typeof d.token === "string" && /^[0-9a-f]{64,200}$/i.test(d.token) &&
26 (d.environment === "sandbox" || d.environment === "production") &&
27 Number.isInteger(d.band) && Math.abs(d.band!) <= 90 && d.band! % 5 === 0 &&
28 Number.isInteger(d.utcOffsetMinutes) && Math.abs(d.utcOffsetMinutes!) <= 14 * 60
29 );
30}
31
32async function today(env: Env, now: Date): Promise<Response> {
33 const row = await env.DB.prepare("SELECT fetched_at, body FROM feed_snapshots WHERE feed = 'swpc' AND ok = 1").first<{ fetched_at: string; body: string }>();
34 if (!row) return json({ spaceWeather: null });
35 const w = JSON.parse(row.body) as SpaceWeather;
36 w.forecast = w.forecast.map((f) => ({ ...f, start: new Date(f.start) }));
37 return json({
38 spaceWeather: { kpNow: w.kpNow, observedAt: w.observedAt, kpNext24h: next24h(w, now), fetchedAt: row.fetched_at },
39 });
40}
41
42export default {
43 async fetch(request: Request, env: Env): Promise<Response> {
44 const url = new URL(request.url);
45 const now = new Date();
46
47 if (request.method === "GET" && url.pathname === "/healthz") {
48 const rows = await env.DB.prepare("SELECT feed, fetched_at, ok FROM feed_snapshots").all();
49 return json({ ok: true, feeds: rows.results });
50 }
51 if (request.method === "GET" && url.pathname === "/v1/today") return today(env, now);
52
53 if (request.method === "POST" && url.pathname === "/v1/devices") {
54 const d = (await request.json().catch(() => ({}))) as Partial<DeviceRequest>;
55 if (!validDevice(d)) return json({ error: "invalid device" }, 400);
56 await env.DB.prepare(
57 `INSERT INTO devices (token, environment, band, utc_offset_minutes, aurora, last_seen) VALUES (?1, ?2, ?3, ?4, ?5, ?6)
58 ON CONFLICT (token) DO UPDATE SET environment = ?2, band = ?3, utc_offset_minutes = ?4, aurora = ?5, last_seen = ?6`,
59 ).bind(d.token, d.environment, d.band, d.utcOffsetMinutes, d.aurora === false ? 0 : 1, now.toISOString()).run();
60 return json({ ok: true });
61 }
62
63 const del = url.pathname.match(/^\/v1\/devices\/([0-9a-f]{64,200})$/i);
64 if (request.method === "DELETE" && del) {
65 await env.DB.prepare("DELETE FROM devices WHERE token = ?1").bind(del[1]).run();
66 return json({ ok: true });
67 }
68
69 return json({ error: "not found" }, 404);
70 },
71
72 async scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
73 ctx.waitUntil(pollSpaceWeather(env, new Date()));
74 },
75} satisfies ExportedHandler<Env>;
76
77async function pollSpaceWeather(env: Env, now: Date): Promise<void> {
78 let w: SpaceWeather;
79 try {
80 w = await fetchSpaceWeather();
81 } catch (e) {
82 console.error("swpc", String(e));
83 await env.DB.prepare("INSERT INTO feed_snapshots (feed, fetched_at, ok) VALUES ('swpc', ?1, 0) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?1, ok = 0").bind(now.toISOString()).run();
84 return;
85 }
86 await env.DB.prepare(
87 "INSERT INTO feed_snapshots (feed, fetched_at, ok, body) VALUES ('swpc', ?1, 1, ?2) ON CONFLICT (feed) DO UPDATE SET fetched_at = ?1, ok = 1, body = ?2",
88 ).bind(now.toISOString(), JSON.stringify(w)).run();
89 await sendAuroraAlerts(env, w, now);
90}
91
92async function sendAuroraAlerts(env: Env, w: SpaceWeather, now: Date): Promise<void> {
93 const kp = actionableKp(w.kpNow, w.forecast, now);
94 const bandRows = await env.DB.prepare("SELECT DISTINCT band FROM devices WHERE aurora = 1").all<{ band: number }>();
95 const bands = alertBands(kp, bandRows.results.map((r) => r.band));
96 if (!bands.length || !env.APNS_KEY) return;
97
98 const config: ApnsConfig = { keyId: env.APNS_KEY_ID, teamId: env.APNS_TEAM_ID, privateKey: env.APNS_KEY, topic: env.APNS_TOPIC };
99 const event = eventKey(now);
100 for (const band of bands) {
101 const claimed = await env.DB.prepare("INSERT OR IGNORE INTO alerts_sent (kind, band, event, sent_at) VALUES ('aurora', ?1, ?2, ?3)")
102 .bind(band, event, now.toISOString()).run();
103 if (!claimed.meta.changes) continue;
104
105 const devices = await env.DB.prepare("SELECT token, environment, utc_offset_minutes FROM devices WHERE aurora = 1 AND band = ?1")
106 .bind(band).all<{ token: string; environment: string; utc_offset_minutes: number }>();
107 for (const d of devices.results) {
108 if (!isNight(now, d.utc_offset_minutes)) continue;
109 const payload = { aps: { alert: { title: "Aurora possible tonight", body: `Kp ${kp.toFixed(1)}. Look toward the ${band >= 0 ? "north" : "south"} after dark.` }, sound: "default" } };
110 const r = await send(config, d.token, payload, d.environment === "sandbox");
111 if (r.status === 410) await env.DB.prepare("DELETE FROM devices WHERE token = ?1").bind(d.token).run();
112 else if (r.status !== 200) console.error("apns", r.status, r.reason);
113 }
114 }
115}
server/src/swpc.ts added +40
@@ -0,0 +1,40 @@
1// NOAA SWPC feeds. Responses carry cache-control max-age=60; poll no faster.
2
3export const KP_NOW_URL = "https://services.swpc.noaa.gov/json/planetary_k_index_1m.json";
4export const KP_FORECAST_URL = "https://services.swpc.noaa.gov/products/noaa-planetary-k-index-forecast.json";
5
6export interface SpaceWeather {
7 kpNow: number;
8 observedAt: string;
9 forecast: { start: Date; kp: number; predicted: boolean }[];
10}
11
12/** SWPC time tags are UTC without a zone designator. */
13function parse(tag: string): Date {
14 return new Date(tag.endsWith("Z") ? tag : `${tag}Z`);
15}
16
17export function decode(nowJson: unknown, forecastJson: unknown): SpaceWeather {
18 const minutes = nowJson as { time_tag: string; estimated_kp: number }[];
19 const last = minutes.at(-1);
20 if (!last) throw new Error("empty Kp feed");
21 const rows = forecastJson as { time_tag: string; kp: number; observed: string }[];
22 return {
23 kpNow: last.estimated_kp,
24 observedAt: parse(last.time_tag).toISOString(),
25 forecast: rows.map((r) => ({ start: parse(r.time_tag), kp: r.kp, predicted: r.observed !== "observed" })),
26 };
27}
28
29export async function fetchSpaceWeather(): Promise<SpaceWeather> {
30 const [a, b] = await Promise.all([fetch(KP_NOW_URL), fetch(KP_FORECAST_URL)]);
31 if (!a.ok || !b.ok) throw new Error(`SWPC ${a.status}/${b.status}`);
32 return decode(await a.json(), await b.json());
33}
34
35/** Highest predicted Kp in the next 24 hours. */
36export function next24h(w: SpaceWeather, now: Date): number | null {
37 const end = now.getTime() + 24 * 3600_000;
38 const kps = w.forecast.filter((f) => f.predicted && f.start.getTime() + 3 * 3600_000 > now.getTime() && f.start.getTime() <= end).map((f) => f.kp);
39 return kps.length ? Math.max(...kps) : null;
40}
server/test/aurora.test.ts added +43
@@ -0,0 +1,43 @@
1import assert from "node:assert/strict";
2import { test } from "node:test";
3import { actionableKp, alertBands, bandFor, eventKey, isNight, ovalEdge, visibleFrom } from "../src/aurora.ts";
4
5test("oval edge follows the NOAA chart", () => {
6 assert.equal(ovalEdge(0), 66.5);
7 assert.ok(Math.abs(ovalEdge(5) - 56.3) < 0.1);
8 assert.ok(Math.abs(visibleFrom(5) - 50.3) < 0.1);
9});
10
11test("bands", () => {
12 assert.equal(bandFor(52.4), 50);
13 assert.equal(bandFor(49.9), 45);
14 assert.equal(bandFor(-52.4), -55);
15});
16
17test("Kp 5 alerts bands reaching 50.3° geomagnetic, both hemispheres", () => {
18 assert.deepEqual(alertBands(5, [40, 45, 50, 55, -45, -55]), [50, 55, -55]);
19 // Kp 2: visible from 56.4°, which the 55–60° band reaches.
20 assert.deepEqual(alertBands(2, [45, 50, 55]), [55]);
21});
22
23test("night is 20:00 to 04:00 local", () => {
24 const t = new Date("2026-10-18T02:00:00Z"); // 21:00 CDT
25 assert.equal(isNight(t, -300), true);
26 assert.equal(isNight(new Date("2026-10-17T18:00:00Z"), -300), false); // 13:00 CDT
27 assert.equal(isNight(new Date("2026-10-18T08:30:00Z"), -300), true); // 03:30 CDT
28});
29
30test("event key splits the UTC day in two", () => {
31 assert.equal(eventKey(new Date("2026-10-18T02:00:00Z")), "2026-10-18a");
32 assert.equal(eventKey(new Date("2026-10-18T13:00:00Z")), "2026-10-18b");
33});
34
35test("actionable Kp takes the forecast for the next 3 hours", () => {
36 const now = new Date("2026-10-18T01:00:00Z");
37 const f = [
38 { start: new Date("2026-10-18T00:00:00Z"), kp: 4 },
39 { start: new Date("2026-10-18T03:00:00Z"), kp: 6 },
40 { start: new Date("2026-10-18T09:00:00Z"), kp: 8 },
41 ];
42 assert.equal(actionableKp(2, f, now), 6);
43});
server/test/swpc.test.ts added +28
@@ -0,0 +1,28 @@
1import assert from "node:assert/strict";
2import { test } from "node:test";
3import { decode, next24h } from "../src/swpc.ts";
4
5// Shapes copied from the live SWPC feeds on 2026-10-17.
6const now = [
7 { time_tag: "2026-10-17T23:32:00", kp_index: 0, estimated_kp: 0.33, kp: "0P" },
8 { time_tag: "2026-10-17T23:33:00", kp_index: 0, estimated_kp: 0.67, kp: "1M" },
9];
10const forecast = [
11 { time_tag: "2026-10-17T18:00:00", kp: 2.33, observed: "observed", noaa_scale: null },
12 { time_tag: "2026-10-18T00:00:00", kp: 3.67, observed: "predicted", noaa_scale: null },
13 { time_tag: "2026-10-18T09:00:00", kp: 5.33, observed: "predicted", noaa_scale: "G1" },
14 { time_tag: "2026-10-19T21:00:00", kp: 7.0, observed: "predicted", noaa_scale: "G3" },
15];
16
17test("decodes latest Kp and forecast", () => {
18 const w = decode(now, forecast);
19 assert.equal(w.kpNow, 0.67);
20 assert.equal(w.observedAt, "2026-10-17T23:33:00.000Z");
21 assert.equal(w.forecast.length, 4);
22 assert.equal(w.forecast[0].predicted, false);
23});
24
25test("next 24 h maximum ignores observed and later rows", () => {
26 const w = decode(now, forecast);
27 assert.equal(next24h(w, new Date("2026-10-17T23:34:00Z")), 5.33);
28});
server/wrangler.jsonc added +21
@@ -0,0 +1,21 @@
1{
2 "name": "moonrise-server",
3 "main": "src/index.ts",
4 "compatibility_date": "2026-10-01",
5 "triggers": { "crons": ["*/5 * * * *"] },
6 "d1_databases": [
7 {
8 "binding": "DB",
9 "database_name": "moonrise",
10 // Replace with the id from `wrangler d1 create moonrise` before deploying.
11 "database_id": "00000000-0000-0000-0000-000000000000",
12 "migrations_dir": "migrations"
13 }
14 ],
15 "vars": {
16 "APNS_KEY_ID": "644XS4F2RG",
17 "APNS_TEAM_ID": "ZCNAX3VL9D",
18 "APNS_TOPIC": "sh.krz.moonrise"
19 }
20 // Secret: APNS_KEY (.p8 contents), set with `wrangler secret put APNS_KEY`.
21}