feat(push): add the push relay that sends the phone app its boxes' notifications

Merged
PU
8d ago
+1197-0

Third of the phone app's PRs (independent of #206 and the box PR). Apple delivers a notification only from a server holding the app's APNs key, which a box can't have, so push/ is a small relay for push.berthd.app.

How it works (push/README.md)

  • POST /v1/register {platform, token, env, bundle} seals the device token (AES-256-GCM, by key id so keys can rotate) into a key only the relay opens. The app gives the key to its boxes; boxes never see the token.
  • POST /v1/send with Bearer <key> and a notice: a strict schema (fixed kind and state, short single-line text, no extra fields), and the relay builds the APNs payload itself. Token-based APNs auth (ES256 JWT, renewed every 50 minutes, retried once on ExpiredProviderToken) over HTTP/2, one connection per host.
  • It keeps nothing. Logs hold the outcome and a key-hash prefix, never a body, token or key. Gone devices answer 410, so the box forgets them. Limits: 30/min and 300/day per key, 20 registrations/hour per address in memory, with Firewall rules for the hard limit; bodies over 2 KB answer 413.

Build: no runtime dependencies. esbuild bundles src/node.ts into Vercel's Build Output API layout (.vercel/output, one Node 22 function), so the deploy is exactly what CI built, without relying on how Vercel compiles .ts imports.

To deploy: a Vercel project with root push/, the domain, and APNS_KEY_P8, APNS_KEY_ID, APNS_TEAM_ID, APNS_TOPIC=dev.berth.phone, SEAL_KEYS, SEAL_KID (see the README). Not deployed by this PR.

Checks: pnpm typecheck && pnpm test && pnpm build (new push CI job). 12 tests cover sealing, the schema, limits, the JWT and payload, APNs answers, the HTTP/2 transport against a local server, and the routes end to end. The bundled function was also smoke-tested locally (health, register, 401, 413).

🤖 Generated with Claude Code