From 7681a4fc7efe1190f2900aa0e9f7c097070fedc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 06:01:57 +0000 Subject: [PATCH] Add Web Push (VAPID) notification framework MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stand up the framework for browser push notifications, end to end: a signed-in account can subscribe and a notification can be sent to any account by UUID or username from a script. No game-event triggers yet — those are an additive next step. Backend: - server/push.py: the single send path (send_to_user) + subscription storage; pywebpush encryption runs in a thread, dead subs (404/410) are pruned, failures are metered and never raised. - server/push_routes.py: GET /push/vapid-public-key, JWT-authed POST /push/subscribe + /push/unsubscribe (shared require_user dep added to server/auth.py). - migrations/008_push_subscriptions.sql: one row per device, keyed to users.id (ON DELETE CASCADE). - config flags (PUSH_ENABLED + VAPID_*), Prometheus counters, and the /sw.js root-scope route wired in main.py. Frontend: - static/sw.js: service worker (push + notificationclick). - static/js/push.js: opt-in (permission -> subscribe -> save); fired best-effort on load, no-ops unless signed in and push is enabled. Tooling/docs: - scripts/gen_vapid.py (self-validating key generation), scripts/send_push.py (send to a user), docs/PUSH.md, compose + env wiring (off by default). Verified against the docker-compose stack: migration applies, /sw.js registers at root scope, the subscribe endpoint stores a row, send_to_user encrypts + POSTs to the push service (delivered=1) and prunes on 410, and the CLI resolves username->UUID with correct exit codes. App loads clean at mobile viewport (390x844) with the push module imported. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0177rY2Jfvn9po32avwRvfFB --- .env.example | 7 ++ .env.prod.example | 9 ++ docker-compose.prod.yml | 6 + docker-compose.yml | 6 + docs/PUSH.md | 72 ++++++++++++ main.py | 23 ++++ migrations/008_push_subscriptions.sql | 19 +++ requirements.lock | 6 + requirements.txt | 4 + scripts/gen_vapid.py | 50 ++++++++ scripts/send_push.py | 74 ++++++++++++ server/auth.py | 12 ++ server/config.py | 12 ++ server/push.py | 161 ++++++++++++++++++++++++++ server/push_routes.py | 62 ++++++++++ server/telemetry/metrics.py | 11 ++ static/js/app.js | 5 + static/js/push.js | 89 ++++++++++++++ static/sw.js | 45 +++++++ 19 files changed, 673 insertions(+) create mode 100644 docs/PUSH.md create mode 100644 migrations/008_push_subscriptions.sql create mode 100644 scripts/gen_vapid.py create mode 100644 scripts/send_push.py create mode 100644 server/push.py create mode 100644 server/push_routes.py create mode 100644 static/js/push.js create mode 100644 static/sw.js diff --git a/.env.example b/.env.example index 18e78abb..6c6cbc8d 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,10 @@ GRAFANA_ROOT_URL=http://localhost:8889 # DISCORD_PUBLIC_KEY=your-app-public-key # DISCORD_APPLICATION_ID=123456789012345678 # DISCORD_GUILD_ID=123456789012345678 + +# Web Push notifications (optional, off by default). Generate a VAPID keypair +# with `python scripts/gen_vapid.py` and paste its output here. See docs/PUSH.md. +# PUSH_ENABLED=1 +# VAPID_PUBLIC_KEY=your-vapid-public-key +# VAPID_PRIVATE_KEY=your-vapid-private-key +# VAPID_SUBJECT=mailto:you@example.com diff --git a/.env.prod.example b/.env.prod.example index f690b36c..7e44497b 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -65,3 +65,12 @@ ENABLE_DRAND_ROLLING=1 # DISCORD_PUBLIC_KEY=change-me # DISCORD_APPLICATION_ID=123456789012345678 # DISCORD_GUILD_ID=123456789012345678 + +# Web Push notifications (optional, off by default). Generate a VAPID keypair +# with `python scripts/gen_vapid.py`. The private key is a secret — keep it out +# of git. VAPID_SUBJECT is a mailto:/https: contact for the push service. See +# docs/PUSH.md. +# PUSH_ENABLED=1 +# VAPID_PUBLIC_KEY=change-me +# VAPID_PRIVATE_KEY=change-me +# VAPID_SUBJECT=mailto:ops@tensies.example.com diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index d2529acb..8f0c8086 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -83,6 +83,12 @@ services: DISCORD_PUBLIC_KEY: "${DISCORD_PUBLIC_KEY:-}" DISCORD_APPLICATION_ID: "${DISCORD_APPLICATION_ID:-}" DISCORD_GUILD_ID: "${DISCORD_GUILD_ID:-}" + # Web Push (optional). Off unless PUSH_ENABLED is set; the private key is a + # secret — pass it via .env.prod, never inline. See docs/PUSH.md. + PUSH_ENABLED: "${PUSH_ENABLED:-0}" + VAPID_PUBLIC_KEY: "${VAPID_PUBLIC_KEY:-}" + VAPID_PRIVATE_KEY: "${VAPID_PRIVATE_KEY:-}" + VAPID_SUBJECT: "${VAPID_SUBJECT:-}" # Prod static-asset mode: serve the prebuilt, fingerprinted index.html that # the build baked into the image (nginx serves everything under /static). # The app does zero asset work — no in-process JS cache, no StaticFiles. diff --git a/docker-compose.yml b/docker-compose.yml index a3d4c7fc..3750d488 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,12 @@ services: DISCORD_PUBLIC_KEY: ${DISCORD_PUBLIC_KEY:-} DISCORD_APPLICATION_ID: ${DISCORD_APPLICATION_ID:-} DISCORD_GUILD_ID: ${DISCORD_GUILD_ID:-} + # Web Push (optional, off by default). Generate keys with + # `python scripts/gen_vapid.py`. See docs/PUSH.md. + PUSH_ENABLED: ${PUSH_ENABLED:-0} + VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-} + VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-} + VAPID_SUBJECT: ${VAPID_SUBJECT:-mailto:michael@simmonstx.com} depends_on: postgres: condition: service_healthy diff --git a/docs/PUSH.md b/docs/PUSH.md new file mode 100644 index 00000000..a6664f10 --- /dev/null +++ b/docs/PUSH.md @@ -0,0 +1,72 @@ +# Web Push notifications + +Browser push via the standard [Web Push protocol](https://web.dev/articles/push-notifications-overview) +with VAPID. Self-hosted — no third-party service. This is the **framework**: a +signed-in account can subscribe, and you can send a notification to any account +from a script. Game-event triggers (e.g. "your turn") are a later, additive step. + +## How it fits together + +``` +browser ──subscribe──▶ POST /push/subscribe ──▶ push_subscriptions (Postgres) + │ + scripts/send_push.py ──▶ server.push.send_to_user ────┘──▶ push service ──▶ device +``` + +- **`server/push.py`** — the single send path (`send_to_user`) + subscription + storage. Both the HTTP routes and the CLI call it. +- **`server/push_routes.py`** — `GET /push/vapid-public-key`, + `POST /push/subscribe`, `POST /push/unsubscribe` (the last two are JWT-authed). +- **`static/sw.js`** — service worker (served at `/sw.js`, root scope) that + renders the notification and handles clicks. +- **`static/js/push.js`** — client opt-in (permission → subscribe → save). Fired + best-effort on load from `app.js`; no-ops unless signed in and push is enabled. +- **`migrations/008_push_subscriptions.sql`** — one row per browser/device, + keyed to `users.id`. + +Only **signed-in accounts** can receive pushes — a subscription is owned by a +`users.id`. Anonymous players have no persistent identity to target. + +## Setup + +1. **Generate a VAPID keypair:** + + ```bash + python scripts/gen_vapid.py + ``` + + Paste the printed `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` / `VAPID_SUBJECT` + into your `.env` and set `PUSH_ENABLED=1`. The private key is a secret — keep + it out of git. + +2. **Restart the app** so it picks up the new env (`docker compose up -d`). + +3. **Subscribe a device:** open the app, sign in, and allow notifications when + prompted. The client subscribes automatically on load. + +## Sending + +```bash +# by username or by users.id UUID +python scripts/send_push.py --user alice --title "Your turn" --body "Tap to roll" +python scripts/send_push.py --user 5f3a... --title "Hi" --body "Test" --url /ABCDE + +# inside the stack: +docker compose exec web python scripts/send_push.py --user alice \ + --title "Your turn" --body "Tap to roll" +``` + +`--url` is the path opened when the notification is clicked (default `/`). The +command prints how many of the account's subscriptions accepted the push; dead +ones (the browser unsubscribed) are pruned automatically. + +## Notes & caveats + +- **iOS** only delivers web push when the PWA is **installed to the home screen** + (Add to Home Screen), per Apple. Desktop Chrome/Firefox and Android Chrome + work directly in the browser. +- Push is **off by default**. With `PUSH_ENABLED` unset or the VAPID keys + missing, `/push/vapid-public-key` returns 503 and the client opt-in quietly + does nothing. +- Metrics: `tensies_push_sent_total`, `tensies_push_failed_total{reason}`, + `tensies_push_pruned_total`. diff --git a/main.py b/main.py index 8ea304da..01ffc8c9 100644 --- a/main.py +++ b/main.py @@ -1,4 +1,5 @@ from contextlib import asynccontextmanager +from pathlib import Path from fastapi import FastAPI, HTTPException from fastapi.responses import Response @@ -8,6 +9,7 @@ from server.auth import router as auth_router from server.config import FRONTEND_DIST from server.discord_interactions import router as discord_router +from server.push_routes import router as push_router from server.routes import router as http_router from server.security import SecurityHeadersMiddleware from server.ws import router as ws_router @@ -74,7 +76,28 @@ async def static_js(path: str): app.mount("/static", StaticFiles(directory="static"), name="static") + +# The service worker is served from the site root so its scope is "/" (a worker +# under /static/ could only control /static/). Read once at startup from the +# source tree (present in both dev and the prod image). Declared before the +# routers so the /{code} join-deeplink catch-all can't shadow it. +_SW_JS = (Path(__file__).resolve().parent / "static" / "sw.js").read_text() + + +@app.get("/sw.js") +async def service_worker() -> Response: + return Response( + content=_SW_JS, + media_type="text/javascript; charset=utf-8", + headers={ + "Cache-Control": "no-cache", + "Service-Worker-Allowed": "/", + }, + ) + + app.include_router(auth_router) app.include_router(http_router) app.include_router(discord_router) +app.include_router(push_router) app.include_router(ws_router) diff --git a/migrations/008_push_subscriptions.sql b/migrations/008_push_subscriptions.sql new file mode 100644 index 00000000..e9acc0ca --- /dev/null +++ b/migrations/008_push_subscriptions.sql @@ -0,0 +1,19 @@ +-- Web Push (VAPID) subscriptions, one row per browser/device per account. +-- A user may have several (phone, laptop, …); the endpoint is globally unique +-- and is what the Push API addresses. p256dh + auth are the client's encryption +-- keys (base64url) needed to encrypt each payload. Dead subscriptions are pruned +-- by the sender on a 404/410 from the push service. ON DELETE CASCADE drops a +-- user's subscriptions when the account is removed. +CREATE TABLE IF NOT EXISTS push_subscriptions ( + id BIGSERIAL PRIMARY KEY, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + endpoint TEXT NOT NULL UNIQUE, + p256dh TEXT NOT NULL, + auth TEXT NOT NULL, + user_agent TEXT, + created_ts TIMESTAMPTZ NOT NULL DEFAULT now(), + last_used_ts TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS push_subscriptions_user_id_idx + ON push_subscriptions (user_id); diff --git a/requirements.lock b/requirements.lock index d6d70245..03175432 100644 --- a/requirements.lock +++ b/requirements.lock @@ -33,3 +33,9 @@ pyasn1-modules==0.4.2 pycparser==3.0 pyOpenSSL==26.3.0 blspy==2.0.3 + +# Web Push (VAPID). pywebpush pulls in py-vapid + http-ece (+ requests); pinned +# here so the locked build is reproducible. +pywebpush==2.0.3 +py-vapid==1.9.2 +http-ece==1.2.1 diff --git a/requirements.txt b/requirements.txt index de651796..c8b5e1ce 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,5 +20,9 @@ h11==0.16.0 webauthn==2.8.0 PyJWT==2.13.0 +# Web Push (VAPID) notifications — encrypts + signs payloads for the browser +# Push API. Pulls in py-vapid, http-ece, and cryptography. +pywebpush==2.0.3 + # BLS12-381 signature verification for drand beacon blspy==2.0.3 diff --git a/scripts/gen_vapid.py b/scripts/gen_vapid.py new file mode 100644 index 00000000..8fb0ded1 --- /dev/null +++ b/scripts/gen_vapid.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Generate a VAPID keypair for Web Push. + +Prints the public/private keys in the base64url forms the rest of the stack +expects — the public key goes to the browser as the applicationServerKey, the +private key signs outgoing pushes via pywebpush. Paste both into your .env: + + VAPID_PUBLIC_KEY=... + VAPID_PRIVATE_KEY=... + VAPID_SUBJECT=mailto:you@example.com + PUSH_ENABLED=1 + +The script validates that pywebpush's loader accepts the private key it prints, +so a key it emits is guaranteed usable by server/push.py. +""" +import base64 + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +from py_vapid import Vapid + + +def _b64url(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +def main() -> None: + private_key = ec.generate_private_key(ec.SECP256R1()) + + priv_scalar = private_key.private_numbers().private_value.to_bytes(32, "big") + private_b64 = _b64url(priv_scalar) + + public_point = private_key.public_key().public_bytes( + serialization.Encoding.X962, + serialization.PublicFormat.UncompressedPoint, + ) + public_b64 = _b64url(public_point) + + # Fail loudly here if the format isn't what pywebpush will load at send time. + Vapid.from_string(private_key=private_b64) + + print("# Web Push VAPID keys — add to your .env") + print(f"VAPID_PUBLIC_KEY={public_b64}") + print(f"VAPID_PRIVATE_KEY={private_b64}") + print("VAPID_SUBJECT=mailto:michael@simmonstx.com") + print("PUSH_ENABLED=1") + + +if __name__ == "__main__": + main() diff --git a/scripts/send_push.py b/scripts/send_push.py new file mode 100644 index 00000000..2b468c8c --- /dev/null +++ b/scripts/send_push.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Send a Web Push notification to a single account. + + python scripts/send_push.py --user \ + --title "Your turn" --body "Tap to roll" [--url /ABCDE] + +`--user` accepts either a users.id UUID or a username (case-insensitive). The +send goes through the same server/push.send_to_user path the app uses, so dead +subscriptions are pruned and the result count is authoritative. + +Run it where the env is configured (POSTGRES_DSN + the VAPID_* vars + +PUSH_ENABLED=1), e.g. inside the web container: + + docker compose exec web python scripts/send_push.py --user alice \ + --title "Hi" --body "Test" +""" +import argparse +import asyncio +import pathlib +import sys +import uuid + +# Allow `python scripts/send_push.py` from the repo root: the script's own dir +# (scripts/) is what Python puts on sys.path, so add the repo root for `server`. +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[1])) + +from server import db, push # noqa: E402 + + +async def _resolve_user_id(token: str) -> str | None: + """A UUID is taken as-is; anything else is looked up as a username.""" + try: + return str(uuid.UUID(token)) + except ValueError: + pass + async with db.pool().acquire() as con: + row = await con.fetchrow( + "SELECT id FROM users WHERE username_lower = $1", token.lower() + ) + return str(row["id"]) if row else None + + +async def main() -> int: + ap = argparse.ArgumentParser(description="Send a Web Push to one account.") + ap.add_argument("--user", required=True, help="user UUID or username") + ap.add_argument("--title", required=True) + ap.add_argument("--body", required=True) + ap.add_argument("--url", default=None, help="path to open on click (default /)") + args = ap.parse_args() + + if not push.is_configured(): + print( + "push not configured: set PUSH_ENABLED=1 and VAPID_PUBLIC_KEY/" + "VAPID_PRIVATE_KEY (see scripts/gen_vapid.py).", + file=sys.stderr, + ) + return 2 + + await db.init() + try: + user_id = await _resolve_user_id(args.user) + if user_id is None: + print(f"no account matches {args.user!r}", file=sys.stderr) + return 1 + delivered = await push.send_to_user(user_id, args.title, args.body, args.url) + finally: + await db.close() + + print(f"delivered to {delivered} subscription(s) for user {user_id}") + return 0 if delivered else 1 + + +if __name__ == "__main__": + raise SystemExit(asyncio.run(main())) diff --git a/server/auth.py b/server/auth.py index 9062dab3..04915150 100644 --- a/server/auth.py +++ b/server/auth.py @@ -88,6 +88,18 @@ def _decode_jwt(token: str) -> dict: raise HTTPException(401, "Invalid token") from None +def require_user(authorization: str | None = Header(None)) -> dict: + """FastAPI dependency: extract + verify the bearer JWT, return its claims + ({"sub": user_id, "username": ...}). Raises 401 when missing/invalid. + + Shared by any endpoint that must act as a signed-in account (e.g. the push + subscribe/unsubscribe routes), so they authenticate exactly like /auth/me. + """ + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(401, "Not authenticated") + return _decode_jwt(authorization[7:]) + + # ─── Challenge storage (Redis, 120s TTL) ────────────────────────────── CHALLENGE_TTL = 120 # seconds diff --git a/server/config.py b/server/config.py index a6522dab..c2bffb3b 100644 --- a/server/config.py +++ b/server/config.py @@ -225,3 +225,15 @@ def _list(name: str) -> list[str]: ] JWT_SECRET = os.environ.get("JWT_SECRET", "dev-secret-change-in-prod") JWT_EXPIRY_DAYS = _int("JWT_EXPIRY_DAYS", 30) + + +# ─── Web Push (VAPID) ────────────────────────────────────────────────── +# Browser push notifications via the Web Push protocol. Off by default; needs a +# VAPID keypair (generate one with `python scripts/gen_vapid.py`). The public key +# is handed to the client so it can subscribe; the private key signs outgoing +# pushes. VAPID_SUBJECT is a contact URI (mailto: or https:) push services use to +# reach the operator. See docs/PUSH.md. +PUSH_ENABLED = _flag("PUSH_ENABLED", False) +VAPID_PUBLIC_KEY = os.environ.get("VAPID_PUBLIC_KEY") or None +VAPID_PRIVATE_KEY = os.environ.get("VAPID_PRIVATE_KEY") or None +VAPID_SUBJECT = os.environ.get("VAPID_SUBJECT", "mailto:michael@simmonstx.com") diff --git a/server/push.py b/server/push.py new file mode 100644 index 00000000..11b51f93 --- /dev/null +++ b/server/push.py @@ -0,0 +1,161 @@ +"""Web Push (VAPID) — the send path + subscription storage. + +The single source of truth for delivering a browser push notification to an +account. Both the HTTP layer (server/push_routes.py) and the CLI +(scripts/send_push.py) call `send_to_user`, so there's one code path to reason +about. + +A "user" here is a WebAuthn account (users.id UUID). Each account may have +several subscriptions (one per browser/device); a push fans out to all of them. +The `pywebpush` call is blocking (it does the ECDH/HKDF payload encryption and a +synchronous HTTPS POST), so it's run in a thread to keep the event loop free — +this is fire-and-forget plumbing, never on a gameplay hot path. + +Failures never raise to the caller: a 404/410 means the browser unsubscribed and +the row is pruned; anything else is logged + metered and the other devices still +get their push. +""" +import asyncio +import json +import logging + +from pywebpush import WebPushException, webpush + +from server import db +from server.config import ( + PUSH_ENABLED, + VAPID_PRIVATE_KEY, + VAPID_PUBLIC_KEY, + VAPID_SUBJECT, +) +from server.telemetry import metrics + +log = logging.getLogger("tensies.push") + + +def is_configured() -> bool: + """True when push is enabled AND a VAPID keypair is present.""" + return bool(PUSH_ENABLED and VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY) + + +def public_key() -> str | None: + """The VAPID public key the client needs to subscribe (None when off).""" + return VAPID_PUBLIC_KEY if is_configured() else None + + +# ─── Subscription storage ─────────────────────────────────────────────────── + +async def save_subscription( + user_id: str, subscription: dict, user_agent: str | None = None +) -> None: + """Upsert one browser PushSubscription against an account. + + `subscription` is the raw object from `pushManager.subscribe().toJSON()`: + {"endpoint": "...", "keys": {"p256dh": "...", "auth": "..."}} + The endpoint is unique, so re-subscribing the same browser re-points the row + at the (possibly new) owner and refreshes the keys. + """ + endpoint = subscription["endpoint"] + keys = subscription.get("keys", {}) + p256dh = keys["p256dh"] + auth = keys["auth"] + async with db.pool().acquire() as con: + await con.execute( + """ + INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth, user_agent) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (endpoint) DO UPDATE + SET user_id = EXCLUDED.user_id, + p256dh = EXCLUDED.p256dh, + auth = EXCLUDED.auth, + user_agent = EXCLUDED.user_agent, + last_used_ts = now() + """, + user_id, + endpoint, + p256dh, + auth, + user_agent, + ) + + +async def delete_subscription(endpoint: str) -> None: + """Remove a subscription by endpoint (client-initiated unsubscribe).""" + async with db.pool().acquire() as con: + await con.execute( + "DELETE FROM push_subscriptions WHERE endpoint = $1", endpoint + ) + + +# ─── Sending ──────────────────────────────────────────────────────────────── + +def _vapid_claims() -> dict: + return {"sub": VAPID_SUBJECT} + + +def _send_one(sub: dict, payload: str) -> None: + """Blocking single-subscription send (runs in a worker thread).""" + webpush( + subscription_info=sub, + data=payload, + vapid_private_key=VAPID_PRIVATE_KEY, + vapid_claims=_vapid_claims(), + ) + + +async def send_to_user( + user_id: str, + title: str, + body: str, + url: str | None = None, +) -> int: + """Push a notification to every subscription an account has. + + Returns the number of subscriptions that accepted the push. Dead ones + (404/410) are pruned; other failures are logged + metered without raising. + """ + if not is_configured(): + log.warning("push not configured (PUSH_ENABLED + VAPID keys) — nothing sent") + return 0 + + async with db.pool().acquire() as con: + rows = await con.fetch( + "SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = $1", + user_id, + ) + if not rows: + log.info("push user=%s no subscriptions", user_id) + return 0 + + payload = json.dumps({"title": title, "body": body, "url": url or "/"}) + delivered = 0 + for row in rows: + sub = { + "endpoint": row["endpoint"], + "keys": {"p256dh": row["p256dh"], "auth": row["auth"]}, + } + try: + await asyncio.to_thread(_send_one, sub, payload) + delivered += 1 + metrics.push_sent_total.inc() + except WebPushException as e: + status = getattr(e.response, "status_code", None) + if status in (404, 410): + await delete_subscription(row["endpoint"]) + metrics.push_pruned_total.inc() + log.info("push pruned dead subscription status=%s", status) + else: + metrics.push_failed_total.labels(reason=str(status or "error")).inc() + log.warning("push send failed status=%s %s", status, e) + except Exception as e: # noqa: BLE001 — never let a bad send escape + metrics.push_failed_total.labels(reason="exception").inc() + log.warning("push unexpected send error: %s", e) + + if delivered: + async with db.pool().acquire() as con: + await con.execute( + "UPDATE push_subscriptions SET last_used_ts = now() WHERE user_id = $1", + user_id, + ) + log.info("push user=%s delivered=%d/%d", user_id, delivered, len(rows)) + return delivered diff --git a/server/push_routes.py b/server/push_routes.py new file mode 100644 index 00000000..287c2ea5 --- /dev/null +++ b/server/push_routes.py @@ -0,0 +1,62 @@ +"""Web Push subscription endpoints. + +The browser flow: + 1. GET /push/vapid-public-key → the applicationServerKey to subscribe with + 2. POST /push/subscribe → store the resulting PushSubscription + 3. POST /push/unsubscribe → drop it (on permission revoke / sign-out) + +Subscribe/unsubscribe authenticate as the signed-in account via the shared +`require_user` JWT dependency, so a push is always owned by a real users.id. +""" +import logging + +from fastapi import APIRouter, Depends, Header, HTTPException +from pydantic import BaseModel + +from server import push +from server.auth import require_user + +log = logging.getLogger("tensies.push") + +router = APIRouter(prefix="/push", tags=["push"]) + + +class SubscribeRequest(BaseModel): + subscription: dict + + +class UnsubscribeRequest(BaseModel): + endpoint: str + + +@router.get("/vapid-public-key") +async def vapid_public_key() -> dict: + key = push.public_key() + if key is None: + raise HTTPException(503, "Push notifications are not configured") + return {"public_key": key} + + +@router.post("/subscribe") +async def subscribe( + body: SubscribeRequest, + claims: dict = Depends(require_user), + user_agent: str | None = Header(None), +) -> dict: + if not push.is_configured(): + raise HTTPException(503, "Push notifications are not configured") + try: + await push.save_subscription(claims["sub"], body.subscription, user_agent) + except KeyError as e: + raise HTTPException(400, f"Malformed subscription (missing {e})") from None + log.info("push subscribed user=%s", claims["sub"]) + return {"ok": True} + + +@router.post("/unsubscribe") +async def unsubscribe( + body: UnsubscribeRequest, + claims: dict = Depends(require_user), +) -> dict: + await push.delete_subscription(body.endpoint) + return {"ok": True} diff --git a/server/telemetry/metrics.py b/server/telemetry/metrics.py index 447710d4..b058f8fc 100644 --- a/server/telemetry/metrics.py +++ b/server/telemetry/metrics.py @@ -110,6 +110,17 @@ "Discord slash-command interactions handled", ["command", "outcome"] ) +# ─── Web Push (VAPID) ───────────────────────────────────────────────── +push_sent_total = Counter( + "tensies_push_sent_total", "Web Push messages delivered to a subscription" +) +push_failed_total = Counter( + "tensies_push_failed_total", "Web Push send failures", ["reason"] +) +push_pruned_total = Counter( + "tensies_push_pruned_total", "Dead push subscriptions pruned (404/410)" +) + # ─── drand beacon ───────────────────────────────────────────────────── drand_beacon_fetches_total = Counter( "tensies_drand_beacon_fetches_total", "Successful drand beacon fetches" diff --git a/static/js/app.js b/static/js/app.js index 49dfbbdb..81cd24dd 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -13,9 +13,14 @@ import './components/game-detail-screen.js'; import './components/nav-menu.js'; import { maybeReconnect } from './net.js'; +import { subscribeToPush } from './push.js'; import { bootstrap } from './router.js'; import { installTouchGuard } from './touch.js'; installTouchGuard(); bootstrap({ resumeSession: maybeReconnect }); + +// Best-effort Web Push opt-in. No-ops unless the user is signed in, the browser +// supports push, and push is enabled server-side; otherwise it quietly returns. +subscribeToPush().catch(() => {}); diff --git a/static/js/push.js b/static/js/push.js new file mode 100644 index 00000000..34d05ea6 --- /dev/null +++ b/static/js/push.js @@ -0,0 +1,89 @@ +// @ts-check + +/** + * Web Push opt-in: register the service worker, ask permission, subscribe with + * the server's VAPID key, and POST the subscription to the account. + * + * Only signed-in users can receive pushes (a push targets a users.id), so the + * whole flow no-ops unless there's an auth token. Everything is best-effort — + * an unsupported browser, a denied permission, or push being disabled + * server-side just leaves the user un-subscribed. + */ + +import { getAuthToken, isSignedIn } from './auth.js'; + +/** @param {string} base64 base64url VAPID public key */ +function urlBase64ToUint8Array(base64) { + const padding = '='.repeat((4 - (base64.length % 4)) % 4); + const b64 = (base64 + padding).replace(/-/g, '+').replace(/_/g, '/'); + const raw = atob(b64); + const out = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); + return out; +} + +/** @returns {boolean} */ +function pushSupported() { + return 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window; +} + +/** + * Register the service worker (idempotent — the browser dedupes by URL). + * @returns {Promise} + */ +export async function registerServiceWorker() { + if (!('serviceWorker' in navigator)) return null; + try { + return await navigator.serviceWorker.register('/sw.js'); + } catch (err) { + console.warn('[push] service worker registration failed', err); + return null; + } +} + +/** + * Full opt-in: permission → subscribe → save. Safe to call on every load; if a + * subscription already exists it's just re-saved (the server upserts by + * endpoint). No-ops when push is unsupported, the user is signed out, push is + * disabled server-side, or permission isn't granted. + * @returns {Promise} whether a subscription was saved + */ +export async function subscribeToPush() { + if (!pushSupported() || !isSignedIn()) return false; + + // The VAPID key doubles as a feature flag: a 503 means push is off server-side. + let publicKey; + try { + const res = await fetch('/push/vapid-public-key'); + if (!res.ok) return false; + publicKey = (await res.json()).public_key; + } catch { + return false; + } + if (!publicKey) return false; + + const reg = await registerServiceWorker(); + if (!reg) return false; + await navigator.serviceWorker.ready; + + const permission = await Notification.requestPermission(); + if (permission !== 'granted') return false; + + let subscription = await reg.pushManager.getSubscription(); + if (!subscription) { + subscription = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey), + }); + } + + const res = await fetch('/push/subscribe', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${getAuthToken()}`, + }, + body: JSON.stringify({ subscription: subscription.toJSON() }), + }); + return res.ok; +} diff --git a/static/sw.js b/static/sw.js new file mode 100644 index 00000000..066bf44a --- /dev/null +++ b/static/sw.js @@ -0,0 +1,45 @@ +/* Tensies service worker — Web Push only (no offline caching). + * + * Served from the site root (/sw.js) so its scope is "/". It does two things: + * push → render the notification the server sent + * notificationclick → focus an existing tab or open the target URL + * + * The payload shape is set by server/push.py: {title, body, url}. + */ + +self.addEventListener('push', (event) => { + let data = {}; + try { + data = event.data ? event.data.json() : {}; + } catch { + data = { title: 'Tensies', body: event.data ? event.data.text() : '' }; + } + + const title = data.title || 'Tensies'; + const options = { + body: data.body || '', + icon: '/static/images/icon-192.png', + badge: '/static/images/icon-192.png', + data: { url: data.url || '/' }, + }; + + event.waitUntil(self.registration.showNotification(title, options)); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const url = (event.notification.data && event.notification.data.url) || '/'; + + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => { + for (const client of clients) { + if ('focus' in client) { + client.focus(); + if ('navigate' in client && url !== '/') client.navigate(url); + return undefined; + } + } + return self.clients.openWindow(url); + }), + ); +});