Notify a phone that has this installation installed

A `webpush` alert channel, and the PWA it needs to arrive. The payload is
encrypted to the subscription (RFC 8291) and the request signed with this
installation's own keypair (RFC 8292), both over `http-ece` — `pywebpush`
does the same in one call but brings `requests` and `aiohttp` with it, two
HTTP stacks beside httpx on a machine that may be a Raspberry Pi.

The manifest and the worker are hand-written rather than `vite-plugin-pwa`:
there is nothing worth precaching when the page carrying the credential is
`no-store`, so the worker handles `push` and `notificationclick` and nothing
else. `registration.scope` is the app's root in both places it runs, which is
why the payload carries no URL.

A run finishing in error is the first event worth waking someone for; `ok`
and `cancelled` describe to nothing, so a nightly batch that works stays
quiet. The events were already on the bus — only the filter changed.

`WEBPUSH_FILE` is a derived path, so the keypair lands on the data volume
with the alerts beside it. Off it, a rebuild would silently stop every phone
being notified: the key they subscribed against would be gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EbeFPm6WNC3YD9vrqqT3a
This commit is contained in:
2026-08-30 12:12:37 +02:00
co-authored by Claude Opus 5
parent 989d008d37
commit 45cc7504e1
23 changed files with 907 additions and 12 deletions
+32
View File
@@ -5,9 +5,11 @@ from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from fastapi.concurrency import run_in_threadpool
from pydantic import BaseModel
from fluksio.api.deps import FlowControllerDep, get_current_user
from fluksio.core.config import settings
from fluksio.flow import webpush
from fluksio.flow.alerts import Alert, AlertsConfig
from fluksio.models import Message
@@ -63,6 +65,36 @@ async def save_alerts_config(body: AlertsConfig, controller: FlowControllerDep)
return body
class WebPushKey(BaseModel):
key: str
class Unsubscribe(BaseModel):
endpoint: str
@router.get("/webpush/key", response_model=WebPushKey)
async def read_webpush_key() -> Any:
"""The key a browser subscribes against. Made on the first ask."""
return WebPushKey(key=await run_in_threadpool(webpush.public_key))
@router.post("/webpush/subscriptions", response_model=Message)
async def add_webpush_subscription(body: webpush.Subscription) -> Any:
"""Remember this browser, so a `webpush` channel can reach it."""
if not body.keys.get("p256dh") or not body.keys.get("auth"):
raise HTTPException(status_code=422, detail="The subscription has no keys")
await run_in_threadpool(webpush.add_subscription, body)
return Message(message="This browser will be notified")
@router.post("/webpush/unsubscribe", response_model=Message)
async def remove_webpush_subscription(body: Unsubscribe) -> Any:
"""Forget this browser."""
await run_in_threadpool(webpush.remove_subscription, body.endpoint)
return Message(message="This browser will no longer be notified")
@router.post("/test/{channel_name}", response_model=Message)
async def test_channel(channel_name: str, controller: FlowControllerDep) -> Any:
"""Send one alert, to prove the channel works before relying on it."""