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
+21
View File
@@ -134,7 +134,28 @@ def init_db():
)
_index(db, "notifications", "idx_notifications_user", ["user_uid"])
_index(db, "notifications", "idx_notifications_user_read", ["user_uid", "read"])
push_registration = get_table("push_registration")
for column, example in (
("uid", ""),
("user_uid", ""),
("provider", "webpush"),
("endpoint", ""),
("key_auth", ""),
("key_p256dh", ""),
("token", ""),
("created_at", ""),
("deleted_at", ""),
):
if not push_registration.has_column(column):
push_registration.create_column_by_example(column, example)
_index(db, "push_registration", "idx_push_registration_user", ["user_uid"])
_index(db, "push_registration", "idx_push_registration_provider", ["provider"])
if "push_registration" in db.tables:
with db:
db.query(
"UPDATE push_registration SET provider = 'webpush' "
"WHERE provider IS NULL OR provider = ''"
)
_index(db, "sessions", "idx_sessions_token", ["session_token"])
projects = get_table("projects")
for column, example in (