Files
ds-chat/backend/app/cli.py
T
ksmithandClaude Sonnet 5 d09bf4a30a 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>
2026-08-14 06:53:07 -06:00

87 lines
3.0 KiB
Python

"""Command-line user management.
Public self-registration is disabled (invite-only site), so accounts are
created by an operator running this script directly on the app server.
"""
import argparse
import asyncio
import base64
from pydantic import ValidationError
from app.database import async_session_factory
from app.schemas.user import UserCreate
from app.services.auth_service import DuplicateUserError, register_user
async def _create_user(username: str, email: str, password: str, is_admin: bool) -> None:
try:
data = UserCreate(username=username, email=email, password=password)
except ValidationError as exc:
raise SystemExit(str(exc))
async with async_session_factory() as db:
try:
user = await register_user(db, data)
except DuplicateUserError:
raise SystemExit(f"Username or email already taken: {username} / {email}")
if is_admin:
user.is_site_admin = True
await db.commit()
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)
create_user = subparsers.add_parser("create-user", help="Create a new user account")
create_user.add_argument("username")
create_user.add_argument("email")
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__":
main()