# ADR-023: Push Notifications Use Self-Hosted VAPID Server, Not FCM or Third-Party

## Status

Accepted, 2026-06-25.

## Status History

```yaml
status_history:
  - date: 2026-06-25
    status: Proposed
    changed_by: hkl
    reason: Formalising push infrastructure decision — self-hosted server already live
    changed_via: adr-kit (360lm)
  - date: 2026-06-25
    status: Accepted
    changed_by: hkl
    reason: push-server live on port 8770; expense PWA uses it; pattern stable
    changed_via: adr-kit (360lm)
```

## Context

360lm needs push notifications to alert employees of pending actions (expense approvals, tour assignments). Browser push notifications use the Web Push protocol which requires: VAPID key pair (auth), a push server that calls browser push endpoints, and a service worker to receive and display notifications. The options are a self-hosted push server, Firebase Cloud Messaging (FCM), or a third-party SaaS (OneSignal, Brevo Push, etc.). 360lm is already self-hosted on a VPS with Python available — and the data being pushed (expense approvals, internal alerts) is internal/sensitive, making third-party routing undesirable.

## Decision

Push notifications are delivered via a self-hosted Python/Flask push server (`push-server`, port 8770, using `pywebpush`) running as a Docker container on the VPS. VAPID keys are generated once and stored in the server's environment. PWAs subscribe via the browser Web Push API and POST the subscription to the push server. The push server stores subscriptions and sends notifications directly to browser push endpoints (Google/Mozilla infrastructure) without routing through any third-party service beyond the browser vendor's own push relay.

**Decision Maker:** hkl

## Alternatives Considered

- **Firebase Cloud Messaging (FCM).** Rejected: requires a Google Firebase project, FCM SDK in every PWA, and all notification payloads to route through Google's servers — adds a Google dependency and sends internal notification content (employee names, approval amounts) to a third party.
- **OneSignal / Brevo Push / other SaaS.** Rejected: monthly cost at per-notification or per-subscriber pricing; notification content (internal HR/finance data) routes through vendor servers; vendor lock-in; no benefit over self-hosted for the current subscriber count (< 30 employees).
- **SMS notifications (instead of push).** Rejected: per-message cost scales with usage; requires a telecom provider integration; SMS is not end-to-end like Web Push; employees already have the PWA installed — push is lower friction.
- **In-app polling (no push, just badge on next open).** Rejected: employees may not open the app for hours after an event; expense approvals need timely alerts; polling adds battery drain and server load for no UX improvement over push.

## Consequences

**Positive:**
- No per-notification cost — fully self-hosted.
- Notification payload never leaves the VPS except to the browser vendor's push relay (which is unavoidable with Web Push).
- No third-party SDK in PWA code — standard browser `PushManager` API only.
- VAPID keys fully under our control — rotate without vendor coordination.

**Negative / Trade-offs:**
- Self-hosted server must be running for notifications to send — no managed failover.
- Push delivery relies on browser vendor relay (Google for Chrome, Mozilla for Firefox) — if these are blocked (e.g., some corporate networks), push fails silently.
- Subscription management (storing, expiring, cleaning up dead endpoints) is our responsibility.
- iOS Safari push requires PWA to be installed to home screen — not all field workers do this.

**Risks and mitigations:**
- Push server down: notifications queue up or are lost; app-level badge/count shown on next open as fallback. Mitigated by `restart: always` Docker policy.
- VAPID key rotation breaks all existing subscriptions: subscribers must re-subscribe. Mitigated by only rotating keys when compromised; document key location in infra notes.
- Dead push endpoints accumulate (uninstalled apps): push server receives 410 Gone responses from browser relay → should delete those subscriptions. Mitigated: push-server handles 410 cleanup.

## Related Decisions

- ADR-017 (Traefik routing) — push-server exposed via Traefik labels at `/push-server/`.
- ADR-012 (hub as SSO) — push subscription is tied to the employee's hub session (employee_id stored with subscription).

## References

- `memory/dbt_archive.md` — push server build record (Python/Flask pywebpush, port 8770)
- `expense/index.html` — first PWA to use push notifications (expense approval alerts)
- `hr/index.html` — push used for attendance/HR alerts
