Server (kez-chat/src/)
- push.rs: VAPID (PEM/PKCS#8) auto-generated on first run;
StoredSubscription store table; PushSender using
IsahcWebPushClient; fanout drops 410/404 subs automatically.
Push payload carries metadata only ({type,to,seq}) — never
plaintext or ciphertext.
- api.rs: GET /v1/push/vapid-public-key,
POST /v1/push/subscribe/:handle, POST /v1/push/unsubscribe/:handle.
Auth via X-KEZ-Auth: <ts>:<sig>, canonical message binds the
endpoint URL so headers can't be replayed against other subs.
- messages.rs: after broker.publish, fire-and-forget
push.fanout for offline recipients.
- config.rs: --vapid-key-path, --vapid-subject (env-backed).
- main.rs: load_or_generate_vapid on startup.
Web client (kez-chat/web/src/)
- vite.config.ts: switched vite-plugin-pwa to injectManifest mode.
- sw.ts: custom service worker with workbox precache,
NetworkOnly for /v1/*, NavigationRoute SPA fallback, push +
notificationclick handlers (focus existing tab via postMessage,
or open a new one).
- lib/push.ts: enablePush / disablePush / isPushSubscribed +
iOS PWA-install detection.
- routes/Settings.svelte: "Background notifications (Web Push)"
section with toggle and iOS Add-to-Home-Screen nudge.
- main.ts: bridge from SW navigate message to svelte-spa-router
via location.hash.
Chat UX (routes/Messages.svelte)
- Bubbles now shrink-wrap to content with WhatsApp-style asymmetric
corners and inline bottom-right timestamps. Old layout used
nested block-level divs inside max-w-[78%], which stretched
every bubble to full width regardless of content.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>