Phase 4: Web Push notifications (pywebpush + VAPID)

Backend: PushSubscription model/migration, VAPID config + `cli.py
generate-vapid-keys`, push_service.send_push_to_user (upsert-by-endpoint
subscribe/unsubscribe, auto-cleanup of expired 404/410 subscriptions),
/api/push/* router, and ConnectionManager now tracks connected user IDs
per room so chat.py can push only to offline members after broadcasting
to online ones.

Two test-infra bugs found and fixed along the way: send_push_to_user
takes the caller's AsyncSession and is awaited inline rather than fired
via asyncio.create_task with its own session (background tasks were
outliving the test event loop); and the ws_client fixture now uses
NullPool to eliminate a connection-pool checkout race that was failing
WS tests intermittently.

Frontend: service worker rebuilt with vite-plugin-pwa's injectManifest
strategy (custom src/sw.ts) so it can add push/notificationclick
handlers alongside the existing precaching and StaleWhileRevalidate
routes ported over from generateSW. New subscribe/unsubscribe flow
(lib/push.ts, api/push.ts) with a toggle in the account menu.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-14 06:53:07 -06:00
co-authored by Claude Sonnet 5
parent aeb2f3f6a5
commit d09bf4a30a
27 changed files with 876 additions and 95 deletions
+33
View File
@@ -6,6 +6,7 @@ created by an operator running this script directly on the app server.
import argparse
import asyncio
import base64
from pydantic import ValidationError
@@ -33,6 +34,34 @@ async def _create_user(username: str, email: str, password: str, is_admin: bool)
print(f"Created user {username!r} (id={user.id}, admin={is_admin})")
def _generate_vapid_keys() -> None:
# py_vapid works in DER/PEM internally, but both pywebpush's
# vapid_private_key argument and the browser's PushManager
# applicationServerKey expect base64url-encoded *raw* key bytes -- the
# format used in every Web Push tutorial/example. Encode explicitly
# rather than relying on py_vapid's own (PEM-oriented) save helpers.
from py_vapid import Vapid02
vapid = Vapid02()
vapid.generate_keys()
private_raw = vapid.private_key.private_numbers().private_value.to_bytes(32, "big")
private_b64 = base64.urlsafe_b64encode(private_raw).decode().rstrip("=")
from cryptography.hazmat.primitives import serialization
public_raw = vapid.public_key.public_bytes(
encoding=serialization.Encoding.X962,
format=serialization.PublicFormat.UncompressedPoint,
)
public_b64 = base64.urlsafe_b64encode(public_raw).decode().rstrip("=")
print("Add these to backend/.env:")
print(f"VAPID_PUBLIC_KEY={public_b64}")
print(f"VAPID_PRIVATE_KEY={private_b64}")
print("VAPID_SUBJECT=mailto:you@example.com")
def main() -> None:
parser = argparse.ArgumentParser(prog="python -m app.cli")
subparsers = parser.add_subparsers(dest="command", required=True)
@@ -43,10 +72,14 @@ def main() -> None:
create_user.add_argument("password")
create_user.add_argument("--admin", action="store_true", help="Grant is_site_admin")
subparsers.add_parser("generate-vapid-keys", help="Generate a VAPID key pair for push notifications")
args = parser.parse_args()
if args.command == "create-user":
asyncio.run(_create_user(args.username, args.email, args.password, args.admin))
elif args.command == "generate-vapid-keys":
_generate_vapid_keys()
if __name__ == "__main__":
+7
View File
@@ -9,5 +9,12 @@ class Settings(BaseSettings):
session_https_only: bool = True
session_max_age_seconds: int = 60 * 60 * 24 * 14
# Optional: push notifications are skipped (logged, not an error) if
# unset, so existing deployments don't have to configure this to keep
# running. Generate a pair with `python -m app.cli generate-vapid-keys`.
vapid_public_key: str | None = None
vapid_private_key: str | None = None
vapid_subject: str = "mailto:admin@example.com"
settings = Settings()
+2 -1
View File
@@ -2,7 +2,7 @@ from fastapi import FastAPI
from starlette.middleware.sessions import SessionMiddleware
from app.config import settings
from app.routers import auth, health, invites, rooms
from app.routers import auth, health, invites, push, rooms
from app.ws.chat import router as ws_router
from app.ws.connection_manager import ConnectionManager
@@ -24,6 +24,7 @@ def create_app() -> FastAPI:
app.include_router(auth.router)
app.include_router(rooms.router)
app.include_router(invites.router)
app.include_router(push.router)
app.include_router(ws_router)
return app
+2
View File
@@ -2,6 +2,7 @@ from app.models.base import Base
from app.models.invite import InviteStatus, RoomInvite
from app.models.membership import RoomMembership, RoomRole
from app.models.message import Message
from app.models.push_subscription import PushSubscription
from app.models.room import Room
from app.models.user import User
@@ -14,4 +15,5 @@ __all__ = [
"Message",
"RoomInvite",
"InviteStatus",
"PushSubscription",
]
+22
View File
@@ -0,0 +1,22 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import Base
class PushSubscription(Base):
__tablename__ = "push_subscriptions"
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), index=True, nullable=False)
endpoint: Mapped[str] = mapped_column(String(1024), unique=True, index=True, nullable=False)
p256dh_key: Mapped[str] = mapped_column(String(255), nullable=False)
auth_key: Mapped[str] = mapped_column(String(255), nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
user = relationship("User")
+40
View File
@@ -0,0 +1,40 @@
from fastapi import APIRouter, Depends, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.database import get_db
from app.dependencies import get_current_user
from app.models import User
from app.schemas.push import (
PushSubscriptionCreate,
PushUnsubscribeRequest,
VapidPublicKeyRead,
)
from app.services.push_service import subscribe, unsubscribe
router = APIRouter(prefix="/api/push", tags=["push"])
@router.get("/vapid-public-key", response_model=VapidPublicKeyRead)
async def get_vapid_public_key(current_user: User = Depends(get_current_user)):
return VapidPublicKeyRead(public_key=settings.vapid_public_key)
@router.post("/subscribe", status_code=204)
async def subscribe_endpoint(
data: PushSubscriptionCreate,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Response:
await subscribe(db, current_user.id, data)
return Response(status_code=204)
@router.delete("/subscribe", status_code=204)
async def unsubscribe_endpoint(
data: PushUnsubscribeRequest,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
) -> Response:
await unsubscribe(db, current_user.id, data.endpoint)
return Response(status_code=204)
+19
View File
@@ -0,0 +1,19 @@
from pydantic import BaseModel
class PushSubscriptionKeys(BaseModel):
p256dh: str
auth: str
class PushSubscriptionCreate(BaseModel):
endpoint: str
keys: PushSubscriptionKeys
class PushUnsubscribeRequest(BaseModel):
endpoint: str
class VapidPublicKeyRead(BaseModel):
public_key: str | None
+98
View File
@@ -0,0 +1,98 @@
import asyncio
import json
import logging
import uuid
from pywebpush import WebPushException, webpush
from sqlalchemy import delete, select
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings
from app.models import PushSubscription
from app.schemas.push import PushSubscriptionCreate
logger = logging.getLogger(__name__)
async def subscribe(
db: AsyncSession, user_id: uuid.UUID, data: PushSubscriptionCreate
) -> PushSubscription:
# Upsert by endpoint: the same device/browser re-subscribing (e.g. after
# a key rotation, or logging in as someone else on a shared device)
# updates the existing row rather than erroring on the unique constraint.
stmt = (
pg_insert(PushSubscription)
.values(
user_id=user_id,
endpoint=data.endpoint,
p256dh_key=data.keys.p256dh,
auth_key=data.keys.auth,
)
.on_conflict_do_update(
index_elements=[PushSubscription.endpoint],
set_={
"user_id": user_id,
"p256dh_key": data.keys.p256dh,
"auth_key": data.keys.auth,
},
)
.returning(PushSubscription)
)
result = await db.execute(stmt)
await db.commit()
return result.scalar_one()
async def unsubscribe(db: AsyncSession, user_id: uuid.UUID, endpoint: str) -> None:
await db.execute(
delete(PushSubscription).where(
PushSubscription.user_id == user_id, PushSubscription.endpoint == endpoint
)
)
await db.commit()
def _send_one(subscription: PushSubscription, payload: dict) -> None:
webpush(
subscription_info={
"endpoint": subscription.endpoint,
"keys": {"p256dh": subscription.p256dh_key, "auth": subscription.auth_key},
},
data=json.dumps(payload),
vapid_private_key=settings.vapid_private_key,
vapid_claims={"sub": settings.vapid_subject},
)
async def send_push_to_user(db: AsyncSession, user_id: uuid.UUID, payload: dict) -> None:
"""Called (awaited) from the WS handler after broadcasting to connected
clients, so it never delays delivery to anyone actually online. Runs
sequentially against the caller's session rather than firing background
asyncio.create_task()s -- those can easily outlive the request/test event
loop they were created on, and AsyncSession isn't safe to touch from two
coroutines concurrently, so a fire-and-forget task per subscription would
risk exactly that. Each webpush() call itself still runs off the event
loop via asyncio.to_thread (pywebpush is synchronous)."""
if not settings.vapid_private_key:
logger.debug("VAPID keys not configured; skipping push to %s", user_id)
return
result = await db.execute(
select(PushSubscription).where(PushSubscription.user_id == user_id)
)
subscriptions = list(result.scalars().all())
for subscription in subscriptions:
try:
await asyncio.to_thread(_send_one, subscription, payload)
except WebPushException as exc:
status = exc.response.status_code if exc.response is not None else None
if status in (404, 410):
# Subscription is gone (browser unsubscribed, expired, etc.)
await db.execute(
delete(PushSubscription).where(PushSubscription.id == subscription.id)
)
await db.commit()
else:
logger.warning("Push delivery failed for %s: %s", subscription.id, exc)
+32 -2
View File
@@ -6,8 +6,10 @@ from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import RoomMembership, User
from app.models import Room, RoomMembership, User
from app.services.message_service import create_message
from app.services.push_service import send_push_to_user
from app.ws.connection_manager import ConnectionManager
router = APIRouter(tags=["ws"])
@@ -29,6 +31,31 @@ async def _is_room_member(db: AsyncSession, room_id: uuid.UUID, user_id: uuid.UU
return result.scalar_one_or_none() is not None
async def _notify_offline_members(
db: AsyncSession,
manager: ConnectionManager,
room_id: uuid.UUID,
sender: User,
content: str,
) -> None:
result = await db.execute(
select(RoomMembership.user_id).where(RoomMembership.room_id == room_id)
)
member_ids = {row[0] for row in result.all()}
offline_ids = member_ids - manager.connected_user_ids(room_id)
if not offline_ids:
return
room = await db.get(Room, room_id)
payload = {
"title": f"#{room.name}" if room else "New message",
"body": f"{sender.username}: {content}"[:120],
"room_id": str(room_id),
}
for user_id in offline_ids:
await send_push_to_user(db, user_id, payload)
@router.websocket("/ws/chat")
async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)) -> None:
user_id_raw = websocket.session.get("user_id")
@@ -63,7 +90,7 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
{"type": "error", "detail": "Not a member of this room"}
)
continue
manager.join(envelope.room_id, websocket)
manager.join(envelope.room_id, websocket, user.id)
joined_rooms.add(envelope.room_id)
await websocket.send_json({"type": "joined", "room_id": str(envelope.room_id)})
@@ -100,6 +127,9 @@ async def chat_endpoint(websocket: WebSocket, db: AsyncSession = Depends(get_db)
"created_at": message.created_at.isoformat(),
},
)
await _notify_offline_members(
db, manager, envelope.room_id, user, envelope.content
)
else:
await websocket.send_json(
+15 -1
View File
@@ -13,9 +13,14 @@ class ConnectionManager:
def __init__(self) -> None:
self._rooms: dict[uuid.UUID, set[WebSocket]] = defaultdict(set)
# A single connection can be joined to multiple rooms at once (one
# `join` message per room over the same socket), so this is keyed on
# the socket alone, not per-room.
self._ws_user: dict[WebSocket, uuid.UUID] = {}
def join(self, room_id: uuid.UUID, websocket: WebSocket) -> None:
def join(self, room_id: uuid.UUID, websocket: WebSocket, user_id: uuid.UUID) -> None:
self._rooms[room_id].add(websocket)
self._ws_user[websocket] = user_id
def leave(self, room_id: uuid.UUID, websocket: WebSocket) -> None:
self._rooms[room_id].discard(websocket)
@@ -25,6 +30,15 @@ class ConnectionManager:
def leave_all(self, websocket: WebSocket) -> None:
for room_id in list(self._rooms.keys()):
self.leave(room_id, websocket)
self._ws_user.pop(websocket, None)
def connected_user_ids(self, room_id: uuid.UUID) -> set[uuid.UUID]:
"""Users (not just sockets) with an active connection to this room --
used to skip push notifications for anyone already watching, per
ARCHITECTURE.md's "members with no active connection" push flow."""
return {
self._ws_user[ws] for ws in self._rooms.get(room_id, ()) if ws in self._ws_user
}
async def broadcast(self, room_id: uuid.UUID, payload: dict) -> None:
for websocket in list(self._rooms.get(room_id, ())):