# PWA Web Push (Local, Apprise) — MVP Implementation Plan **Goal:** Ship a fully local/browser-push MVP (no Firebase). Frontend collects Web Push subscriptions (VAPID); backend stores and routes; a local dispatcher sends notifications via **Apprise** (`vapid://`). Optional Kafka for decoupling. --- ## Phase 0 — Foundations & Decisions (1 day) **Outcomes** - PWA target browsers: Chrome/Edge/Firefox (desktop/mobile), Safari iOS 16.4+ (installed PWA). - Tech choices: - Frontend: existing web app + Service Worker. - Backend: **FastAPI (Python)** or **Node/Express** (pick one). - Dispatcher: Python + Apprise. - Storage: Postgres (or SQLite for dev). - Messaging (optional but recommended): Kafka (local), else direct HTTP call. - Domain + TLS (required for Push): HTTPS everywhere. **Deliverables** - `.env.example` (VAPID\_PRIVATE\_KEY\_PATH, VAPID\_PUBLIC\_KEY, DB\_URL, KAFKA\_BROKERS, APPRISE\_STORAGE\_PATH). - VAPID keypair generated. ```bash # Example: generate VAPID keys (node-web-push) npx web-push generate-vapid-keys # save PUBLIC / PRIVATE into secure storage or PEM files ``` --- ## Phase 1 — PWA Frontend (Service Worker & Subscription) (0.5–1 day) **Tasks** - Register Service Worker: `sw.js`. - Permission flow: `Notification.requestPermission()` + feature checks. - Subscribe user: `registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: VAPID_PUBLIC })`. - Send `subscription` JSON to backend; handle revoke/refresh. **Minimal code (TypeScript/JS)** ```js // app-push.ts export async function ensurePushSubscription(vapidPublicKey) { if (!('serviceWorker' in navigator) || !('PushManager' in window)) return null; const reg = await navigator.serviceWorker.register('/sw.js'); const perm = await Notification.requestPermission(); if (perm !== 'granted') return null; const sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: urlBase64ToUint8Array(vapidPublicKey), }); // POST to backend await fetch('/api/push/subscriptions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(sub) }); return sub; } ``` **Service Worker** ```js // sw.js self.addEventListener('push', event => { const data = event.data ? event.data.json() : {}; event.waitUntil(self.registration.showNotification(data.title || 'Notification', { body: data.body, icon: data.icon, badge: data.badge, data: data.data, tag: data.tag, // collapseId equivalent requireInteraction: !!data.requireInteraction, })); }); self.addEventListener('notificationclick', event => { event.notification.close(); const url = event.notification.data?.url || '/'; event.waitUntil(clients.openWindow(url)); }); ``` --- ## Phase 2 — Backend Subscription API & Storage (0.5 day) **Tables (Postgres)** ```sql create table push_subscriptions ( id uuid primary key default gen_random_uuid(), user_id uuid not null, endpoint text not null unique, p256dh text not null, auth text not null, ua text, created_at timestamptz default now(), updated_at timestamptz default now(), last_status int, active boolean default true ); create index on push_subscriptions(user_id); ``` **HTTP API** - `POST /api/push/subscriptions` — upsert subscription (by `endpoint`). - `DELETE /api/push/subscriptions/:id` — deactivate. - `GET /api/push/subscriptions/me` — list current user’s subs. **Validation**: ensure `endpoint`, `keys.p256dh`, `keys.auth` present. --- ## Phase 3 — Message Contract & Routing (0.5 day) **Unified message (Kafka or HTTP)** ```json { "user_id": "uuid", "channels": ["webpush"], "webpush": { "title": "ParentFlow", "body": "Reminder: feeding due", "icon": "/icons/app.png", "badge": "/icons/badge.png", "tag": "timeline-123", "data": { "url": "/timeline/123" } }, "dedupe_key": "timeline-123", "ttl_seconds": 3600, "priority": "normal" } ``` **Routing** - App publishes message per user/segment → Kafka topic `notify.events` (or POST `/api/push/send`). - Dispatcher consumes and fans out to each active subscription for that user. --- ## Phase 4 — Local Dispatcher (Python + Apprise) (1 day) **Responsibilities** - Consume messages (Kafka) or receive HTTP. - Load active subscriptions for `user_id`. - For each subscription → invoke Apprise `vapid://` with per-subscription `subfile` data. - Update delivery result (`last_status`, deactivate on 404/410). **Apprise usage (per send)** ```python import apprise, json, tempfile ap = apprise.Apprise() # write subscription JSON to a temp file (or pass inline as data URI) subfile = tempfile.NamedTemporaryFile(delete=False) subfile.write(json.dumps(subscription).encode()); subfile.flush() ap.add( f"vapid://{MAILTO_IDENT}/{subscription['endpoint']}?" f"keyfile={VAPID_PRIVATE_KEY_PATH}&subfile={subfile.name}" ) ap.notify(title=msg['webpush']['title'], body=msg['webpush']['body']) ``` **Failures handling** - HTTP 404/410 → mark `active=false`. - 429/5xx → exponential backoff (retry queue with max attempts). **Performance** - Batch fan-out with worker pool (e.g., `concurrent.futures.ThreadPoolExecutor`). - Keep Apprise in-memory; enable persistent storage `AUTO` for token caching. --- ## Phase 5 — Admin & Lifecycle (0.5 day) - Subscription pruning cron: deactivate stale (`updated_at < now()-90d`) or failed endpoints. - Unsubscribe endpoint (user action) → delete/deactivate. - Privacy: per-user export & hard delete subscriptions on request. --- ## Phase 6 — Observability (0.5 day) - Structured logs (JSON) for send attempts with `endpoint_hash` only (no PII). - Metrics: sends, success rate, failures by code, active subs, opt-in rate. - Dashboards: Grafana/Prometheus (optional) or simple SQL views. --- ## Phase 7 — Security & Compliance (0.5 day) - Store VAPID private key on disk with strict permissions or in a local vault. - HTTPS only; set `Strict-Transport-Security`. - CSRF for subscription endpoints; auth required. - Rate limit `/api/push/subscriptions` + `/api/push/send`. - Content rules: cap payload size (<4KB), sanitize URLs. --- ## Phase 8 — iOS/Safari Specifics (notes) - Web Push works for **installed** PWA only (Add to Home Screen). - Permission must be user-gesture initiated. - Background delivery may be throttled; design for non-guaranteed delivery. --- ## Phase 9 — Testing & Load (0.5–1 day) - Unit: subscription CRUD, dispatcher send mock. - E2E: subscribe → send → receive across browsers. - Load: N users × M subs; verify throughput and backoff. --- ## Phase 10 — Rollout & Feature Flags (0.5 day) - Feature flag `webpush_enabled` per user/tenant. - Gradual rollout: 5% → 25% → 100%. - Fallback channel (email/Telegram via Apprise) if webpush not available. --- ## Upgrade Path — Firebase/OneSignal (when needed) - Abstract `Notifier` with drivers: `webpush_vapid`, `fcm`, `onesignal`. - Mirror message schema; add provider-specific fields. - Migration: dual-write for 1–2 weeks, compare delivery metrics, then switch. --- ## Acceptance Criteria - Users can opt-in, receive a test notification within 3s median on desktop Chrome. - Subscriptions are persisted and deduplicated by `endpoint`. - Dead endpoints are auto-pruned on 404/410 within 24h. - No VAPID private keys leak in logs; payload ≤ 4KB; HTTPS enforced. --- ## Quick Reference (Snippets) **VAPID env** ```ini VAPID_PUBLIC_KEY=... VAPID_PRIVATE_KEY_PATH=/secrets/vapid_private_key.pem MAILTO_IDENT=push@yourdomain.com ``` **HTTP publish (no Kafka) — example contract** ```http POST /api/push/send Content-Type: application/json { "user_id": "...", "webpush": { "title": "Hi", "body": "…", "data": {"url":"/"} } } ``` **Kafka topics (optional)** - `notify.events` (ingress) - `notify.retry` (backoff) - `notify.deadletter` --- ## Risks & Mitigations - **Browser variability** → test matrix; graceful degradation. - **Quota / payload limits** → compact payloads; use `tag` to collapse duplicates. - **No delivery guarantees** → show in-app inbox as source of truth. --- ## Done Means - End-to-end working on Chrome desktop + Android Chrome. - At least 1 iOS PWA device validated. - Metrics panel shows ≥95% success on active endpoints over 48h.