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
124 lines
4.3 KiB
Python
124 lines
4.3 KiB
Python
"""Alerting configuration: which failures go where."""
|
|
|
|
from pathlib import Path
|
|
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
|
|
|
|
router = APIRouter(
|
|
prefix="/alerts", tags=["alerts"], dependencies=[Depends(get_current_user)]
|
|
)
|
|
|
|
|
|
def _path() -> Path:
|
|
return settings.ALERTS_FILE
|
|
|
|
|
|
def read_config() -> AlertsConfig:
|
|
"""The stored configuration, or an empty one. Blocking."""
|
|
path = _path()
|
|
if not path.exists():
|
|
return AlertsConfig()
|
|
try:
|
|
return AlertsConfig.model_validate_json(path.read_text())
|
|
except Exception:
|
|
# A hand-edited file that no longer parses must not stop the engine.
|
|
return AlertsConfig()
|
|
|
|
|
|
def write_config(config: AlertsConfig) -> None:
|
|
"""Blocking."""
|
|
path = _path()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(config.model_dump_json(indent=2))
|
|
|
|
|
|
@router.get("/config", response_model=AlertsConfig)
|
|
async def read_alerts_config() -> Any:
|
|
"""What the engine alerts on, and where it sends it."""
|
|
return await run_in_threadpool(read_config)
|
|
|
|
|
|
@router.put("/config", response_model=AlertsConfig)
|
|
async def save_alerts_config(body: AlertsConfig, controller: FlowControllerDep) -> Any:
|
|
"""Replace the configuration. Takes effect immediately."""
|
|
known = {channel.name for channel in body.channels}
|
|
for rule in body.rules:
|
|
missing = [name for name in rule.channels if name not in known]
|
|
if missing:
|
|
raise HTTPException(
|
|
status_code=422,
|
|
detail=f"No channel named {', '.join(repr(m) for m in missing)}",
|
|
)
|
|
|
|
await run_in_threadpool(write_config, body)
|
|
if controller.alerts is not None:
|
|
controller.alerts.config = body
|
|
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."""
|
|
if controller.alerts is None:
|
|
raise HTTPException(status_code=503, detail="Alerting is not running")
|
|
|
|
config = controller.alerts.config
|
|
channel = next((c for c in config.channels if c.name == channel_name), None)
|
|
if channel is None:
|
|
raise HTTPException(status_code=404, detail=f"No channel '{channel_name}'")
|
|
|
|
try:
|
|
await controller.alerts.send(
|
|
channel,
|
|
Alert(
|
|
title="Fluksio test alert",
|
|
body="If you are reading this, the channel works.",
|
|
severity="warning",
|
|
),
|
|
raise_on_error=True,
|
|
)
|
|
except Exception as exc:
|
|
# Whatever the sender said, verbatim: it is the only clue the operator
|
|
# has about why the channel does not work.
|
|
raise HTTPException(status_code=502, detail=str(exc) or type(exc).__name__)
|
|
return Message(message=f"Sent a test alert through '{channel_name}'")
|