M2: Web Push sender (VAPID) (ENG-255) #19

Merged demi.dev
targeting main from stoopkidddd/eng-255-m2-web-push-sender-vapid
Merged opened by and assigned to demi.dev
targeting main from stoopkidddd/eng-255-m2-web-push-sender-vapid

Linear: https://linear.app/engineereddev/issue/ENG-255

What's in this PR:

  • webpush/sender.ts: subscribes to the event bus push event. For every web device with a subscription, except the source, and only the target when there is one, it enqueues a send. Payload { t: "push", push } when the UTF-8 JSON is ≤ 3,000 bytes, else { t: "ref", id }. TTL 86,400 s, urgency high.
  • Queue: 1,000 waiting entries (drop oldest + warn), 4 workers, 10 s timeout per send; never awaited by the request path. Pushes from the 30 s reconcile notify once (10 min dedupe by push id).
  • 404/410 clears the device's webPush (persisted through the registry); other errors warn without endpoint URLs or keys.
  • The VAPID keypair still comes from webpush/vapid.ts (ENG-250); the subject is read from config at send time.
  • DeviceRegistry.getWebPush(): registry DTOs omit subscriptions, so the sender reads them through this accessor.

Verify (lead, rebased on 3594894):

  • pnpm install --frozen-lockfile → exit 0
  • mise run check:server → exit 0 (Tests 229 passed (229))
  • mise run e2e:server → exit 0 (5 files, 15 tests; includes a sender e2e)
  • No pneumo-e2e-* containers left.

Deviations:

  • A VAPID subject that isn't https:/mailto: (e.g. the http dev PUBLIC_URL) disables delivery with one sanitized warning, instead of failing boot or push creation. .env.example now shows VAPID_SUBJECT=mailto:owner@example.test (commented, fake).
  • devices/registry.ts gains getWebPush() (outside the issue's Touches).

🤖 Generated with Claude Code

Commits 0