Add push provider architecture with Apple Push Notification support

Split the push delivery library into a provider architecture. devplacepy/push
becomes a package: a PushProvider protocol with a registry, the existing Web
Push implementation moved unchanged behind it, a new APNs provider, a store
owning every push_registration access, and a delivery loop that groups a user's
subscriptions by provider, prepares each provider's payload once and sends over
a single shared client.

APNs delivers over HTTP/2 with an ES256 provider token cached per credential
fingerprint, so a worker signs at most one token per 45 minutes. Registrations
carry a hexadecimal device token; 410 and the Unregistered class of reasons soft
delete the subscription exactly like a gone Web Push endpoint.

All provider configuration is edited at /admin/services/push through the same
ConfigField surface every other subsystem uses, assembled from the registry so a
future provider needs no edit to the service. A provider that is disabled,
unconfigured or holding an unusable credential accepts no registrations and is
skipped during delivery, never failing the other providers.

POST /push.json accepts a registration for any active provider; a body without a
provider field is a Web Push body, so existing clients are unchanged. GET
/push.json keeps publicKey at the top level and adds the active providers.
push_registration gains provider and token columns, ensured in init_db with a
converging backfill; existing rows are never rewritten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-31 22:43:10 +02:00
co-authored by Claude Opus 5
parent 5079f40f46
commit 53ddf4f233
14 changed files with 839 additions and 99 deletions
+23 -15
View File
@@ -5,6 +5,7 @@ from fastapi import APIRouter, Request
from fastapi.responses import FileResponse, JSONResponse
from devplacepy import push
from devplacepy.config import STATIC_DIR
from devplacepy.push import providers
from devplacepy.utils import require_user_api
from urllib.parse import urlparse
from devplacepy.services.audit import record as audit
@@ -22,7 +23,11 @@ WELCOME_PAYLOAD = {
@router.get("/push.json")
async def push_public_key() -> JSONResponse:
return JSONResponse({"publicKey": push.public_key_standard_b64()})
configs = providers.client_config()
webpush = configs.get(providers.DEFAULT_PROVIDER, {})
return JSONResponse(
{"publicKey": webpush.get("publicKey", ""), "providers": configs}
)
@router.post("/push.json")
@@ -33,21 +38,18 @@ async def push_register(request: Request) -> JSONResponse:
except ValueError:
return JSONResponse({"error": "Invalid JSON"}, status_code=400)
keys = body.get("keys") if isinstance(body, dict) else None
if not (
isinstance(keys, dict)
and body.get("endpoint")
and keys.get("p256dh")
and keys.get("auth")
):
if not isinstance(body, dict):
return JSONResponse({"error": "Invalid request"}, status_code=400)
_, created = await push.register(
user_uid=user["uid"],
endpoint=body["endpoint"],
key_auth=keys["auth"],
key_p256dh=keys["p256dh"],
)
provider = providers.get(body.get("provider"))
if provider is None or not providers.is_active(provider):
return JSONResponse({"error": "Unknown provider"}, status_code=400)
fields = provider.parse_registration(body)
if fields is None:
return JSONResponse({"error": "Invalid request"}, status_code=400)
_, created = push.register(user["uid"], provider.name, fields)
if created:
try:
@@ -62,7 +64,13 @@ async def push_register(request: Request) -> JSONResponse:
target_type="user",
target_uid=user["uid"],
target_label=user.get("username"),
metadata={"endpoint_host": urlparse(body["endpoint"]).hostname, "created": created},
metadata={
"provider": provider.name,
"endpoint_host": urlparse(fields["endpoint"]).hostname
if fields.get("endpoint")
else None,
"created": created,
},
summary=f"{user.get('username')} {'registered' if created else 'updated'} a push subscription",
links=[audit.target("user", user["uid"], user.get("username"))],
)