Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 9 additions & 0 deletions .env.prod.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions docs/PUSH.md
Original file line number Diff line number Diff line change
@@ -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`.
23 changes: 23 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from contextlib import asynccontextmanager
from pathlib import Path

from fastapi import FastAPI, HTTPException
from fastapi.responses import Response
Expand All @@ -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
Expand Down Expand Up @@ -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)
19 changes: 19 additions & 0 deletions migrations/008_push_subscriptions.sql
Original file line number Diff line number Diff line change
@@ -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);
6 changes: 6 additions & 0 deletions requirements.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
50 changes: 50 additions & 0 deletions scripts/gen_vapid.py
Original file line number Diff line number Diff line change
@@ -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()
74 changes: 74 additions & 0 deletions scripts/send_push.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
#!/usr/bin/env python3
"""Send a Web Push notification to a single account.

python scripts/send_push.py --user <uuid|username> \
--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()))
12 changes: 12 additions & 0 deletions server/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions server/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Loading
Loading