M2: backend Worker (space weather, aurora alerts) !8
12 files changed, +465 −1
Layout: unified · split
PLAN.org +16 −1
| @@ -255,7 +255,8 @@ needs a key. CelesTrak: never more than once per 2-hour update. | |||
| 255 | - v1: per-IP rate limits. Later: App Attest verification in the Worker. | 255 | - v1: per-IP rate limits. Later: App Attest verification in the Worker. |
| 256 | 256 | ||
| 257 | ** operations | 257 | ** 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. | ||
| 259 | - Staging and production Workers with separate D1 databases and APNs | 260 | - Staging and production Workers with separate D1 databases and APNs |
| 260 | environments. | 261 | environments. |
| 261 | - Workers Logs for errors; feed staleness in =/healthz=. | 262 | - Workers Logs for errors; feed staleness in =/healthz=. |
| @@ -329,6 +330,20 @@ Not done: | |||
| 329 | - Launches and Voyager in the Today sheet: backend (M2, M3). | 330 | - Launches and Voyager in the Today sheet: backend (M2, M3). |
| 330 | - Automatic night mode after astronomical dusk. | 331 | - Automatic night mode after astronomical dusk. |
| 331 | 332 | ||
| 333 | ** M2 status (2026-10-17) | ||
| 334 | Stacked 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 | |||
| 332 | * roadmap | 347 | * roadmap |
| 333 | ** v1: now | 348 | ** v1: now |
| 334 | Sky, System, Sun, Launches, Library core (above). | 349 | Sky, 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 | |||
| 3 | Cloudflare Worker: SWPC polling every 5 minutes, =/v1/today=, device | ||
| 4 | registration, aurora pushes over APNs. D1 for storage. | ||
| 5 | |||
| 6 | * local | ||
| 7 | #+begin_src sh | ||
| 8 | wrangler d1 migrations apply moonrise --local | ||
| 9 | wrangler dev --test-scheduled | ||
| 10 | curl "http://localhost:8787/__scheduled?cron=*/5+*+*+*+*" | ||
| 11 | curl http://localhost:8787/v1/today | ||
| 12 | #+end_src | ||
| 13 | |||
| 14 | APNs cannot be reached from =wrangler dev= (no HTTP/2); test pushes | ||
| 15 | against a deployed Worker. | ||
| 16 | |||
| 17 | * deploy | ||
| 18 | #+begin_src sh | ||
| 19 | wrangler d1 create moonrise # put the id in wrangler.jsonc | ||
| 20 | wrangler d1 migrations apply moonrise --remote | ||
| 21 | wrangler deploy | ||
| 22 | wrangler 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 | ||
| 35 | node --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. | ||
| 3 | CREATE 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 | |||
| 12 | CREATE 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). | ||
| 15 | CREATE 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. | ||
| 24 | CREATE 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 | |||
| 3 | export 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 | |||
| 12 | export interface ApnsResult { | ||
| 13 | status: number; | ||
| 14 | apnsId: string | null; | ||
| 15 | reason: string | null; | ||
| 16 | } | ||
| 17 | |||
| 18 | const encoder = new TextEncoder(); | ||
| 19 | |||
| 20 | function 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 | |||
| 26 | function 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 | |||
| 31 | export 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. */ | ||
| 36 | export 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. */ | ||
| 45 | let cached: { token: string; issuedAt: number; keyId: string } | null = null; | ||
| 46 | |||
| 47 | async 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 | |||
| 55 | export 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 | */ | ||
| 7 | export 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. */ | ||
| 12 | export const HORIZON_MARGIN = 6; | ||
| 13 | |||
| 14 | /** Lowest geomagnetic latitude (absolute) where aurora may be seen on the horizon. */ | ||
| 15 | export 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. */ | ||
| 20 | export 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. */ | ||
| 26 | export 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. */ | ||
| 32 | export 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. */ | ||
| 38 | export 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. */ | ||
| 44 | export 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 @@ | |||
| 1 | import { send, type ApnsConfig } from "./apns"; | ||
| 2 | import { actionableKp, alertBands, eventKey, isNight } from "./aurora"; | ||
| 3 | import { fetchSpaceWeather, next24h, type SpaceWeather } from "./swpc"; | ||
| 4 | |||
| 5 | interface Env { | ||
| 6 | DB: D1Database; | ||
| 7 | APNS_KEY_ID: string; | ||
| 8 | APNS_TEAM_ID: string; | ||
| 9 | APNS_TOPIC: string; | ||
| 10 | APNS_KEY: string; | ||
| 11 | } | ||
| 12 | |||
| 13 | interface DeviceRequest { | ||
| 14 | token: string; | ||
| 15 | environment: "sandbox" | "production"; | ||
| 16 | band: number; | ||
| 17 | utcOffsetMinutes: number; | ||
| 18 | aurora?: boolean; | ||
| 19 | } | ||
| 20 | |||
| 21 | const json = (body: unknown, status = 200) => Response.json(body, { status }); | ||
| 22 | |||
| 23 | function 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 | |||
| 32 | async 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 | |||
| 42 | export 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 | |||
| 77 | async 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 | |||
| 92 | async 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 | |||
| 3 | export const KP_NOW_URL = "https://services.swpc.noaa.gov/json/planetary_k_index_1m.json"; | ||
| 4 | export const KP_FORECAST_URL = "https://services.swpc.noaa.gov/products/noaa-planetary-k-index-forecast.json"; | ||
| 5 | |||
| 6 | export 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. */ | ||
| 13 | function parse(tag: string): Date { | ||
| 14 | return new Date(tag.endsWith("Z") ? tag : `${tag}Z`); | ||
| 15 | } | ||
| 16 | |||
| 17 | export 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 | |||
| 29 | export 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. */ | ||
| 36 | export 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 @@ | |||
| 1 | import assert from "node:assert/strict"; | ||
| 2 | import { test } from "node:test"; | ||
| 3 | import { actionableKp, alertBands, bandFor, eventKey, isNight, ovalEdge, visibleFrom } from "../src/aurora.ts"; | ||
| 4 | |||
| 5 | test("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 | |||
| 11 | test("bands", () => { | ||
| 12 | assert.equal(bandFor(52.4), 50); | ||
| 13 | assert.equal(bandFor(49.9), 45); | ||
| 14 | assert.equal(bandFor(-52.4), -55); | ||
| 15 | }); | ||
| 16 | |||
| 17 | test("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 | |||
| 23 | test("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 | |||
| 30 | test("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 | |||
| 35 | test("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 @@ | |||
| 1 | import assert from "node:assert/strict"; | ||
| 2 | import { test } from "node:test"; | ||
| 3 | import { decode, next24h } from "../src/swpc.ts"; | ||
| 4 | |||
| 5 | // Shapes copied from the live SWPC feeds on 2026-10-17. | ||
| 6 | const 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 | ]; | ||
| 10 | const 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 | |||
| 17 | test("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 | |||
| 25 | test("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 | } | ||