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:
@@ -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."""
|
||||
|
||||
@@ -22,6 +22,7 @@ DERIVED_PATHS = {
|
||||
"FLOWS_DIR": "flows",
|
||||
"SECRETS_FILE": "secrets.enc",
|
||||
"ALERTS_FILE": "alerts.json",
|
||||
"WEBPUSH_FILE": "webpush.json",
|
||||
"PANELS_FILE": "panels.json",
|
||||
"OAUTH_PRIVATE_KEY_FILE": "oauth-key.pem",
|
||||
"CLOUD_CONFIG_FILE": "cloud.json",
|
||||
@@ -71,6 +72,10 @@ class Settings(BaseSettings):
|
||||
# Which failures reach which channel. Beside the flows, not in them:
|
||||
# alerting is the deployment's concern, not any one flow's.
|
||||
ALERTS_FILE: Path = Path("flow-data/alerts.json")
|
||||
# This installation's web push keypair and the browsers subscribed to it.
|
||||
# Beside the alerts it serves; deleting it makes every device subscribe
|
||||
# again.
|
||||
WEBPUSH_FILE: Path = Path("flow-data/webpush.json")
|
||||
# Where machines can be started from when a node needs one and nothing that
|
||||
# could take it is attached. Operator-authored, like the alerts beside it,
|
||||
# and absent on an installation that has nowhere to start one.
|
||||
|
||||
@@ -36,6 +36,7 @@ ALERTING_EVENTS = {
|
||||
"engine_degraded",
|
||||
"cascade_dropped",
|
||||
"queue_unavailable",
|
||||
"run_finished",
|
||||
}
|
||||
|
||||
# The same fault repeating is the same alert.
|
||||
@@ -52,10 +53,11 @@ class Channel(BaseModel):
|
||||
"""Somewhere to send an alert."""
|
||||
|
||||
name: str
|
||||
kind: Literal["ntfy", "smtp", "webhook", "dashboard"]
|
||||
kind: Literal["ntfy", "smtp", "webhook", "dashboard", "webpush"]
|
||||
enabled: bool = True
|
||||
# ntfy: server + topic + optional token. smtp: to. webhook: url.
|
||||
# dashboard: message — the record a notification widget reads.
|
||||
# webpush: nothing — it goes to whichever browsers subscribed here.
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -144,6 +146,15 @@ def describe(event: dict[str, Any]) -> Alert | None:
|
||||
)
|
||||
if kind == "queue_unavailable":
|
||||
return Alert(title="The work queue is unreachable", body=error or "")
|
||||
if kind == "run_finished":
|
||||
# Every run ends; only the ones that ended badly are worth a phone.
|
||||
if event.get("status") != "error":
|
||||
return None
|
||||
return Alert(
|
||||
title=f"A run of '{flow}' failed",
|
||||
body=f"Run {event.get('run')} finished with errors.",
|
||||
flow=flow,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@@ -304,6 +315,8 @@ class AlertManager:
|
||||
await self._send_webhook(config, alert)
|
||||
elif channel.kind == "dashboard":
|
||||
await self._send_dashboard(config, alert)
|
||||
elif channel.kind == "webpush":
|
||||
await self._send_webpush(alert)
|
||||
else:
|
||||
await self._send_email(config, alert)
|
||||
except Exception as exc:
|
||||
@@ -344,6 +357,18 @@ class AlertManager:
|
||||
# Blocking: it reads the store to find the declared message.
|
||||
await asyncio.to_thread(self.publish, message, alert.model_dump())
|
||||
|
||||
async def _send_webpush(self, alert: Alert) -> None:
|
||||
"""Wake every browser that subscribed to this installation.
|
||||
|
||||
No settings of its own: a browser subscribes by pressing a button on
|
||||
the alerts screen, and this channel goes to whichever ones did. Saying
|
||||
so when none have is the point of the test button.
|
||||
"""
|
||||
from fluksio.flow import webpush
|
||||
|
||||
if await webpush.send_to_all(alert.model_dump()) == 0:
|
||||
raise ValueError("No browsers are subscribed")
|
||||
|
||||
async def _send_webhook(self, config: dict[str, Any], alert: Alert) -> None:
|
||||
url = config.get("url")
|
||||
if not url:
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
"""Web Push: an alert reaching a phone that has this installation installed.
|
||||
|
||||
The browser hands us a subscription — an endpoint URL at its own push service,
|
||||
plus two keys — and from then on the engine can wake that device without it
|
||||
holding a connection open. Two pieces of crypto are involved and both are
|
||||
specified: the payload is encrypted to the subscription's keys (RFC 8291,
|
||||
aes128gcm) so the push service carries something it cannot read, and the
|
||||
request is signed with this installation's own keypair (VAPID, RFC 8292) so the
|
||||
service knows who is sending.
|
||||
|
||||
Only ``http-ece`` is new here; the signing is `pyjwt` and the request is
|
||||
`httpx`, both of which the engine already carries. ``pywebpush`` does all of
|
||||
this in one call, but brings `requests` *and* `aiohttp` with it — two more HTTP
|
||||
stacks on a machine that may well be a Raspberry Pi.
|
||||
|
||||
The keypair is this installation's identity to the push services and lives with
|
||||
the subscriptions in one file. Losing it means every browser has to subscribe
|
||||
again; it is regenerated on the spot if the file goes missing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import http_ece
|
||||
import httpx
|
||||
import jwt
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from fluksio.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
#: How long the push service keeps a notification for a device that is offline.
|
||||
TTL_S = 86400
|
||||
#: A VAPID token is good for twelve hours; well under the 24 the RFC allows.
|
||||
TOKEN_LIFETIME_S = 12 * 60 * 60
|
||||
|
||||
# ponytail: one lock over the whole file. Subscriptions change when somebody
|
||||
# presses a button, and pruning happens once per send — per-record locking
|
||||
# would be machinery for a file with single-digit rows in it.
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
class Subscription(BaseModel):
|
||||
"""What a browser handed us. Opaque apart from the endpoint's host."""
|
||||
|
||||
endpoint: str
|
||||
#: The subscription's public key and its authentication secret, both
|
||||
#: base64url as `PushSubscription.toJSON()` writes them.
|
||||
keys: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class _Store(BaseModel):
|
||||
#: This installation's VAPID private key: the raw P-256 scalar, base64url.
|
||||
private_key: str = ""
|
||||
subscriptions: list[Subscription] = Field(default_factory=list)
|
||||
|
||||
|
||||
def _b64url(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
||||
|
||||
|
||||
def _unb64url(text: str) -> bytes:
|
||||
# A browser writes these unpadded; base64 refuses them that way.
|
||||
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
|
||||
|
||||
|
||||
def _path() -> Path:
|
||||
return settings.WEBPUSH_FILE
|
||||
|
||||
|
||||
def _read() -> _Store:
|
||||
"""Blocking. Call under `_lock` when a write follows."""
|
||||
path = _path()
|
||||
if not path.exists():
|
||||
return _Store()
|
||||
try:
|
||||
return _Store.model_validate_json(path.read_text())
|
||||
except Exception:
|
||||
# A file that no longer parses must not stop the engine alerting
|
||||
# through its other channels.
|
||||
logger.error("The web push store is unreadable; starting a new one")
|
||||
return _Store()
|
||||
|
||||
|
||||
def _write(store: _Store) -> None:
|
||||
path = _path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(store.model_dump_json(indent=2))
|
||||
|
||||
|
||||
def _private_key(store: _Store) -> ec.EllipticCurvePrivateKey:
|
||||
return ec.derive_private_key(
|
||||
int.from_bytes(_unb64url(store.private_key), "big"), ec.SECP256R1()
|
||||
)
|
||||
|
||||
|
||||
def _public_bytes(key: ec.EllipticCurvePrivateKey) -> bytes:
|
||||
return key.public_key().public_bytes(
|
||||
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
|
||||
)
|
||||
|
||||
|
||||
def public_key() -> str:
|
||||
"""This installation's VAPID public key, generating the pair on first ask.
|
||||
|
||||
Base64url of the uncompressed point, which is the shape
|
||||
`pushManager.subscribe` wants for `applicationServerKey`. Blocking.
|
||||
"""
|
||||
with _lock:
|
||||
store = _read()
|
||||
if not store.private_key:
|
||||
key = ec.generate_private_key(ec.SECP256R1())
|
||||
store.private_key = _b64url(
|
||||
key.private_numbers().private_value.to_bytes(32, "big")
|
||||
)
|
||||
_write(store)
|
||||
logger.info("Generated this installation's web push keypair")
|
||||
return _b64url(_public_bytes(_private_key(store)))
|
||||
|
||||
|
||||
def add_subscription(subscription: Subscription) -> None:
|
||||
"""Remember a browser, replacing what we knew about that endpoint. Blocking."""
|
||||
with _lock:
|
||||
store = _read()
|
||||
store.subscriptions = [
|
||||
s for s in store.subscriptions if s.endpoint != subscription.endpoint
|
||||
]
|
||||
store.subscriptions.append(subscription)
|
||||
_write(store)
|
||||
|
||||
|
||||
def remove_subscription(endpoint: str) -> None:
|
||||
"""Blocking."""
|
||||
with _lock:
|
||||
store = _read()
|
||||
store.subscriptions = [s for s in store.subscriptions if s.endpoint != endpoint]
|
||||
_write(store)
|
||||
|
||||
|
||||
def subscriptions() -> list[Subscription]:
|
||||
"""Blocking."""
|
||||
with _lock:
|
||||
return _read().subscriptions
|
||||
|
||||
|
||||
def _vapid_headers(key: ec.EllipticCurvePrivateKey, endpoint: str) -> dict[str, str]:
|
||||
"""Prove to the push service which installation is sending (RFC 8292)."""
|
||||
origin = urlparse(endpoint)
|
||||
token = jwt.encode(
|
||||
{
|
||||
"aud": f"{origin.scheme}://{origin.netloc}",
|
||||
"exp": int(time.time()) + TOKEN_LIFETIME_S,
|
||||
# Who to shout at about this traffic. A push service will not read
|
||||
# it unless something has gone wrong.
|
||||
"sub": f"mailto:{settings.EMAILS_FROM_EMAIL or 'admin@fluksio.invalid'}",
|
||||
},
|
||||
key,
|
||||
algorithm="ES256",
|
||||
)
|
||||
return {"Authorization": f"vapid t={token},k={_b64url(_public_bytes(key))}"}
|
||||
|
||||
|
||||
def _encrypt(payload: bytes, subscription: Subscription) -> bytes:
|
||||
"""Encrypt to this subscription's keys, so only that browser can read it."""
|
||||
# Ephemeral, per message: the whole point of the exchange is that this key
|
||||
# and the subscription's agree on a secret nobody else can derive.
|
||||
ours = ec.generate_private_key(ec.SECP256R1())
|
||||
encrypted: bytes = http_ece.encrypt(
|
||||
payload,
|
||||
private_key=ours,
|
||||
dh=_unb64url(subscription.keys["p256dh"]),
|
||||
auth_secret=_unb64url(subscription.keys["auth"]),
|
||||
version="aes128gcm",
|
||||
)
|
||||
return encrypted
|
||||
|
||||
|
||||
async def send_to_all(payload: dict[str, Any]) -> int:
|
||||
"""Push one payload to every subscribed browser. Returns how many took it.
|
||||
|
||||
A subscription the push service has retired (404/410) is dropped here —
|
||||
that is the only signal we get that a browser is never coming back, and
|
||||
without acting on it the store grows a dead entry per reinstalled phone.
|
||||
"""
|
||||
# The keypair exists by now — nothing can have subscribed without asking
|
||||
# for it — but generating it here costs nothing and keeps this callable.
|
||||
await asyncio.to_thread(public_key)
|
||||
with _lock:
|
||||
store = _read()
|
||||
known = store.subscriptions
|
||||
if not known:
|
||||
return 0
|
||||
key = _private_key(store)
|
||||
|
||||
body = json.dumps(payload).encode()
|
||||
delivered = 0
|
||||
gone: list[str] = []
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
for subscription in known:
|
||||
try:
|
||||
encrypted = await asyncio.to_thread(_encrypt, body, subscription)
|
||||
response = await client.post(
|
||||
subscription.endpoint,
|
||||
content=encrypted,
|
||||
headers={
|
||||
"Content-Encoding": "aes128gcm",
|
||||
"Content-Type": "application/octet-stream",
|
||||
"TTL": str(TTL_S),
|
||||
**_vapid_headers(key, subscription.endpoint),
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Could not push to a subscription: %s", exc)
|
||||
continue
|
||||
if response.status_code in (404, 410):
|
||||
gone.append(subscription.endpoint)
|
||||
elif response.is_success:
|
||||
delivered += 1
|
||||
else:
|
||||
logger.error(
|
||||
"A push service refused the notification: %s %s",
|
||||
response.status_code,
|
||||
response.text[:200],
|
||||
)
|
||||
|
||||
for endpoint in gone:
|
||||
await asyncio.to_thread(remove_subscription, endpoint)
|
||||
logger.info("Dropped a subscription the push service has retired")
|
||||
return delivered
|
||||
@@ -72,6 +72,11 @@ dependencies = [
|
||||
"rich>=13",
|
||||
# `fluksio serve` opens a dashboard with it at a terminal.
|
||||
"textual>=1.0",
|
||||
# The encrypted-payload half of web push (RFC 8291). It brings only
|
||||
# cryptography, which is already here. `pywebpush` would do the signing and
|
||||
# the request too, at the cost of `requests` *and* `aiohttp` — two HTTP
|
||||
# stacks beside httpx, on a machine that may be a Raspberry Pi.
|
||||
"http-ece>=1.2",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
@@ -143,6 +148,11 @@ implicit_reexport = true
|
||||
module = ["pyarrow", "pyarrow.*"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
# The web push payload encoder carries no annotations.
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["http_ece"]
|
||||
ignore_missing_imports = true
|
||||
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py312"
|
||||
|
||||
@@ -188,6 +188,13 @@ def test_alerting_can_be_switched_off():
|
||||
"The work queue is unreachable",
|
||||
),
|
||||
({"type": "engine_degraded", "reason": "lag"}, "The engine is struggling"),
|
||||
# Every run ends. Only a failed one is worth a phone buzzing.
|
||||
(
|
||||
{"type": "run_finished", "flow": "nightly", "run": 7, "status": "error"},
|
||||
"A run of 'nightly' failed",
|
||||
),
|
||||
({"type": "run_finished", "flow": "nightly", "status": "ok"}, None),
|
||||
({"type": "run_finished", "flow": "nightly", "status": "cancelled"}, None),
|
||||
({"type": "node_health", "health": "ok"}, None),
|
||||
# The engine publishes `health`, not `status`: reading the wrong key
|
||||
# meant a device dropping never alerted anyone.
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
"""Web push: the crypto is the part that cannot be checked by reading it."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
|
||||
import http_ece
|
||||
import httpx
|
||||
import jwt
|
||||
import pytest
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import ec
|
||||
|
||||
from fluksio.core.config import settings
|
||||
from fluksio.flow import webpush
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def store(tmp_path, monkeypatch):
|
||||
"""A store per test, so nothing writes into the real installation."""
|
||||
monkeypatch.setattr(settings, "WEBPUSH_FILE", tmp_path / "webpush.json")
|
||||
return tmp_path / "webpush.json"
|
||||
|
||||
|
||||
def browser(
|
||||
name: str = "one",
|
||||
) -> tuple[ec.EllipticCurvePrivateKey, bytes, webpush.Subscription]:
|
||||
"""What a browser hands over when it subscribes."""
|
||||
key = ec.generate_private_key(ec.SECP256R1())
|
||||
auth = os.urandom(16)
|
||||
point = key.public_key().public_bytes(
|
||||
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint
|
||||
)
|
||||
return (
|
||||
key,
|
||||
auth,
|
||||
webpush.Subscription(
|
||||
endpoint=f"https://push.example.com/{name}",
|
||||
keys={"p256dh": webpush._b64url(point), "auth": webpush._b64url(auth)},
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_only_the_subscribed_browser_can_read_the_payload():
|
||||
"""RFC 8291, and the reason the push service is not trusted with content."""
|
||||
key, auth, subscription = browser()
|
||||
payload = b'{"title":"A run failed"}'
|
||||
|
||||
sealed = webpush._encrypt(payload, subscription)
|
||||
|
||||
assert sealed != payload
|
||||
assert (
|
||||
http_ece.decrypt(sealed, private_key=key, auth_secret=auth, version="aes128gcm")
|
||||
== payload
|
||||
)
|
||||
|
||||
|
||||
def test_the_request_is_signed_for_the_push_service_that_gets_it():
|
||||
"""RFC 8292: the audience is the endpoint's origin, never its path."""
|
||||
key = ec.generate_private_key(ec.SECP256R1())
|
||||
|
||||
headers = webpush._vapid_headers(key, "https://push.example.com/quite/a/long/path")
|
||||
|
||||
scheme, rest = headers["Authorization"].split(" ", 1)
|
||||
parts = dict(part.split("=", 1) for part in rest.split(","))
|
||||
assert scheme == "vapid"
|
||||
claims = jwt.decode(
|
||||
parts["t"],
|
||||
key.public_key(),
|
||||
algorithms=["ES256"],
|
||||
audience="https://push.example.com",
|
||||
)
|
||||
assert claims["sub"].startswith("mailto:")
|
||||
# The service looks this key up against the one the browser subscribed with.
|
||||
assert webpush._unb64url(parts["k"]) == webpush._public_bytes(key)
|
||||
|
||||
|
||||
def test_the_keypair_survives_the_next_ask():
|
||||
first = webpush.public_key()
|
||||
assert webpush.public_key() == first
|
||||
|
||||
|
||||
def push_to(handler) -> int:
|
||||
"""Send one alert to two browsers, the push services answering `handler`."""
|
||||
webpush.add_subscription(browser("gone")[2])
|
||||
webpush.add_subscription(browser("here")[2])
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
original = httpx.AsyncClient
|
||||
|
||||
def client(**kwargs):
|
||||
kwargs["transport"] = transport
|
||||
return original(**kwargs)
|
||||
|
||||
webpush.httpx.AsyncClient = client # type: ignore[misc]
|
||||
try:
|
||||
return asyncio.run(webpush.send_to_all({"title": "t", "body": "b"}))
|
||||
finally:
|
||||
webpush.httpx.AsyncClient = original # type: ignore[misc]
|
||||
|
||||
|
||||
def test_every_subscribed_browser_is_pushed_to():
|
||||
seen: list[str] = []
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
seen.append(str(request.url))
|
||||
assert request.headers["content-encoding"] == "aes128gcm"
|
||||
assert request.headers["authorization"].startswith("vapid t=")
|
||||
return httpx.Response(201)
|
||||
|
||||
assert push_to(handler) == 2
|
||||
assert len(seen) == 2
|
||||
|
||||
|
||||
def test_a_retired_subscription_is_dropped():
|
||||
"""410 is the only word we get that a browser is never coming back."""
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(410 if request.url.path.endswith("gone") else 201)
|
||||
|
||||
delivered = push_to(handler)
|
||||
|
||||
assert delivered == 1
|
||||
assert [s.endpoint for s in webpush.subscriptions()] == [
|
||||
"https://push.example.com/here"
|
||||
]
|
||||
|
||||
|
||||
def test_a_channel_with_nobody_subscribed_says_so():
|
||||
"""What the test button on the alerts screen reports."""
|
||||
from fluksio.flow.alerts import Alert, AlertManager, Channel
|
||||
from fluksio.flow.events import EventBus
|
||||
|
||||
alerts = AlertManager(EventBus())
|
||||
|
||||
with pytest.raises(ValueError, match="No browsers"):
|
||||
asyncio.run(
|
||||
alerts.send(
|
||||
Channel(name="phones", kind="webpush"),
|
||||
Alert(title="t", body="b"),
|
||||
raise_on_error=True,
|
||||
)
|
||||
)
|
||||
@@ -129,6 +129,10 @@ services:
|
||||
# be lost on the next rebuild — un-pairing every screen.
|
||||
- ALERTS_FILE=/data/alerts.json
|
||||
- PANELS_FILE=/data/panels.json
|
||||
# The push keypair and the browsers subscribed to it. Off the volume, a
|
||||
# rebuild would silently stop every phone being notified: the key they
|
||||
# subscribed against would be gone.
|
||||
- WEBPUSH_FILE=/data/webpush.json
|
||||
# Schedules are written in local time: "off at 02:00" means the house's
|
||||
# two in the morning, not the container's. Unset, an image is UTC, and a
|
||||
# cron would be right twice a year.
|
||||
|
||||
@@ -5,6 +5,11 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#59849b" />
|
||||
<!-- `%BASE_URL%` is `/` on an installation of its own and `/app-shell/` in
|
||||
the build the portal serves, where the hub rewrites this line to the
|
||||
installation's own path so the install is scoped to one house. -->
|
||||
<link rel="manifest" href="%BASE_URL%manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="%BASE_URL%apple-touch-icon.png" />
|
||||
<title>Fluksio</title>
|
||||
<!-- Set the theme class before first paint so the app never flashes light. -->
|
||||
<script>
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
# nginx's mime.types predates the manifest, so without this it goes out as
|
||||
# application/octet-stream.
|
||||
types {
|
||||
application/manifest+json webmanifest;
|
||||
}
|
||||
include /etc/nginx/mime.types;
|
||||
|
||||
# The bundle is ~770 kB of JavaScript and ~85 kB of CSS, and nginx's base
|
||||
# image ships gzip commented out — so every first load shipped all of it
|
||||
# uncompressed. Roughly a third of the bytes with this on.
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "Fluksio",
|
||||
"short_name": "Fluksio",
|
||||
"description": "Flows, dashboards and the house they run",
|
||||
"id": "/",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"theme_color": "#59849b",
|
||||
"background_color": "#ffffff",
|
||||
"icons": [
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||
{
|
||||
"src": "/icon-512-maskable.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* The service worker, which exists only so a push can arrive when no tab is
|
||||
* open. There is deliberately no `fetch` handler and nothing is cached: the
|
||||
* page this app is served from carries a credential and is sent `no-store`,
|
||||
* so caching it would hand the next visitor somebody else's session.
|
||||
*
|
||||
* `registration.scope` is the app's root in both places it runs — `/` on an
|
||||
* installation of its own, `/i/{id}/` through the portal — which is why the
|
||||
* push payload does not carry a URL.
|
||||
*/
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
const alert = event.data ? event.data.json() : {}
|
||||
const scope = new URL(self.registration.scope)
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(alert.title || "Fluksio", {
|
||||
body: alert.body || "",
|
||||
// Under the portal the icons are the shared bundle's, not this
|
||||
// installation's path.
|
||||
icon: scope.pathname.startsWith("/i/")
|
||||
? "/app-shell/icon-192.png"
|
||||
: `${scope.pathname}icon-192.png`,
|
||||
// The same fault repeating replaces its notification rather than
|
||||
// stacking another one up.
|
||||
tag: alert.flow || alert.title || "fluksio",
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close()
|
||||
const scope = self.registration.scope
|
||||
event.waitUntil(
|
||||
self.clients
|
||||
.matchAll({ type: "window", includeUncontrolled: true })
|
||||
.then((clients) => {
|
||||
// A tab on this installation is already open: raise it rather than
|
||||
// opening a second one.
|
||||
const open = clients.find((client) => client.url.startsWith(scope))
|
||||
if (open) return open.focus()
|
||||
return self.clients.openWindow(scope)
|
||||
}),
|
||||
)
|
||||
})
|
||||
@@ -103,6 +103,17 @@ export const ArtifactRowSchema = {
|
||||
media_type: {
|
||||
type: 'string',
|
||||
title: 'Media Type'
|
||||
},
|
||||
filename: {
|
||||
anyOf: [
|
||||
{
|
||||
type: 'string'
|
||||
},
|
||||
{
|
||||
type: 'null'
|
||||
}
|
||||
],
|
||||
title: 'Filename'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
@@ -351,7 +362,7 @@ export const ChannelSchema = {
|
||||
},
|
||||
kind: {
|
||||
type: 'string',
|
||||
enum: ['ntfy', 'smtp', 'webhook', 'dashboard'],
|
||||
enum: ['ntfy', 'smtp', 'webhook', 'dashboard', 'webpush'],
|
||||
title: 'Kind'
|
||||
},
|
||||
enabled: {
|
||||
@@ -3130,6 +3141,26 @@ export const ShareRequestSchema = {
|
||||
title: 'ShareRequest'
|
||||
} as const;
|
||||
|
||||
export const SubscriptionSchema = {
|
||||
properties: {
|
||||
endpoint: {
|
||||
type: 'string',
|
||||
title: 'Endpoint'
|
||||
},
|
||||
keys: {
|
||||
additionalProperties: {
|
||||
type: 'string'
|
||||
},
|
||||
type: 'object',
|
||||
title: 'Keys'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['endpoint'],
|
||||
title: 'Subscription',
|
||||
description: "What a browser handed us. Opaque apart from the endpoint's host."
|
||||
} as const;
|
||||
|
||||
export const SweepCreateSchema = {
|
||||
properties: {
|
||||
runs: {
|
||||
@@ -3293,6 +3324,18 @@ export const TriggerRequestSchema = {
|
||||
title: 'TriggerRequest'
|
||||
} as const;
|
||||
|
||||
export const UnsubscribeSchema = {
|
||||
properties: {
|
||||
endpoint: {
|
||||
type: 'string',
|
||||
title: 'Endpoint'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['endpoint'],
|
||||
title: 'Unsubscribe'
|
||||
} as const;
|
||||
|
||||
export const UpdatePasswordSchema = {
|
||||
properties: {
|
||||
current_password: {
|
||||
@@ -3699,6 +3742,18 @@ export const WaitingNodeSchema = {
|
||||
title: 'WaitingNode'
|
||||
} as const;
|
||||
|
||||
export const WebPushKeySchema = {
|
||||
properties: {
|
||||
key: {
|
||||
type: 'string',
|
||||
title: 'Key'
|
||||
}
|
||||
},
|
||||
type: 'object',
|
||||
required: ['key'],
|
||||
title: 'WebPushKey'
|
||||
} as const;
|
||||
|
||||
export const WidgetDefSchema = {
|
||||
properties: {
|
||||
id: {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -34,6 +34,7 @@ export type ArtifactRow = {
|
||||
digest: string;
|
||||
size: number;
|
||||
media_type: string;
|
||||
filename?: (string | null);
|
||||
};
|
||||
|
||||
export type Body_login_login_access_token = {
|
||||
@@ -97,14 +98,14 @@ export type BrainNode = {
|
||||
*/
|
||||
export type Channel = {
|
||||
name: string;
|
||||
kind: 'ntfy' | 'smtp' | 'webhook' | 'dashboard';
|
||||
kind: 'ntfy' | 'smtp' | 'webhook' | 'dashboard' | 'webpush';
|
||||
enabled?: boolean;
|
||||
config?: {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
export type kind = 'ntfy' | 'smtp' | 'webhook' | 'dashboard';
|
||||
export type kind = 'ntfy' | 'smtp' | 'webhook' | 'dashboard' | 'webpush';
|
||||
|
||||
/**
|
||||
* A dashboard as stored, and as the API hands it over.
|
||||
@@ -1116,6 +1117,16 @@ export type ShareRequest = {
|
||||
lib_name: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* What a browser handed us. Opaque apart from the endpoint's host.
|
||||
*/
|
||||
export type Subscription = {
|
||||
endpoint: string;
|
||||
keys?: {
|
||||
[key: string]: (string);
|
||||
};
|
||||
};
|
||||
|
||||
export type SweepCreate = {
|
||||
runs?: Array<SweepEntry>;
|
||||
draft?: boolean;
|
||||
@@ -1163,6 +1174,10 @@ export type TriggerRequest = {
|
||||
};
|
||||
};
|
||||
|
||||
export type Unsubscribe = {
|
||||
endpoint: string;
|
||||
};
|
||||
|
||||
export type UpdatePassword = {
|
||||
current_password: string;
|
||||
new_password: string;
|
||||
@@ -1249,6 +1264,10 @@ export type WaitingNode = {
|
||||
seconds: number;
|
||||
};
|
||||
|
||||
export type WebPushKey = {
|
||||
key: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One tile: what it shows or does, and where it sits.
|
||||
*
|
||||
@@ -1312,6 +1331,20 @@ export type AlertsSaveAlertsConfigData = {
|
||||
|
||||
export type AlertsSaveAlertsConfigResponse = (AlertsConfig);
|
||||
|
||||
export type AlertsReadWebpushKeyResponse = (WebPushKey);
|
||||
|
||||
export type AlertsAddWebpushSubscriptionData = {
|
||||
requestBody: Subscription;
|
||||
};
|
||||
|
||||
export type AlertsAddWebpushSubscriptionResponse = (Message);
|
||||
|
||||
export type AlertsRemoveWebpushSubscriptionData = {
|
||||
requestBody: Unsubscribe;
|
||||
};
|
||||
|
||||
export type AlertsRemoveWebpushSubscriptionResponse = (Message);
|
||||
|
||||
export type AlertsTestChannelData = {
|
||||
channelName: string;
|
||||
};
|
||||
@@ -1677,6 +1710,8 @@ export type ObservabilityReadTimeseriesData = {
|
||||
flow?: (string | null);
|
||||
hours?: number;
|
||||
node?: (string | null);
|
||||
since?: (string | null);
|
||||
until?: (string | null);
|
||||
};
|
||||
|
||||
export type ObservabilityReadTimeseriesResponse = (Array<SeriesPoint>);
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* Subscribing this browser to the installation's notifications.
|
||||
*
|
||||
* A push arrives through the service worker, so it reaches a phone with no tab
|
||||
* open — which is the whole point, and the reason this is not the in-app
|
||||
* notification stack. The engine decides *what* is worth sending in its alert
|
||||
* rules; this only says which browsers hear it.
|
||||
*
|
||||
* Everything here is per browser and per installation: the subscription is
|
||||
* stored by the installation it was made against, so a phone that opens two
|
||||
* houses through the portal is two subscriptions and hears each separately.
|
||||
*/
|
||||
|
||||
import { AlertsService } from "@/client"
|
||||
import { appPath } from "@/lib/portal"
|
||||
|
||||
/** Whether this browser can do push at all.
|
||||
*
|
||||
* Plain HTTP over a LAN cannot: service workers need a secure context, and
|
||||
* `http://…local` addresses are not one. Safari delivers push only to an
|
||||
* installed PWA, but says so itself by refusing the permission, so there is
|
||||
* nothing to detect here. */
|
||||
export function supported(): boolean {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
window.isSecureContext &&
|
||||
"serviceWorker" in navigator &&
|
||||
"PushManager" in window &&
|
||||
"Notification" in window
|
||||
)
|
||||
}
|
||||
|
||||
/** Register the worker, or reuse the registration this browser already has. */
|
||||
async function worker(): Promise<ServiceWorkerRegistration> {
|
||||
// The scope comes from where the file is served, which is the app's root in
|
||||
// both contexts — `/sw.js` alone, `/i/{id}/sw.js` through the portal.
|
||||
return navigator.serviceWorker.register(appPath("/sw.js"))
|
||||
}
|
||||
|
||||
/** Start the worker in the background; a push cannot arrive without it. */
|
||||
export function registerSW(): void {
|
||||
if (!supported()) return
|
||||
worker().catch((error) => console.warn("No service worker:", error))
|
||||
}
|
||||
|
||||
/** The subscription this browser already holds here, if any. */
|
||||
export async function current(): Promise<PushSubscription | null> {
|
||||
if (!supported()) return null
|
||||
const registration = await navigator.serviceWorker.getRegistration(
|
||||
appPath("/sw.js"),
|
||||
)
|
||||
return (await registration?.pushManager.getSubscription()) ?? null
|
||||
}
|
||||
|
||||
/** A base64url key as the `applicationServerKey` bytes `subscribe` wants. */
|
||||
function keyBytes(key: string): Uint8Array<ArrayBuffer> {
|
||||
const padded = key.replace(/-/g, "+").replace(/_/g, "/")
|
||||
const raw = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4))
|
||||
const bytes = new Uint8Array(new ArrayBuffer(raw.length))
|
||||
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i)
|
||||
return bytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask permission, subscribe, and tell the installation where to reach us.
|
||||
*
|
||||
* Throws with something worth reading if the person says no — the caller puts
|
||||
* it on screen, because a silently ignored button is worse than a refusal.
|
||||
*/
|
||||
export async function subscribe(): Promise<void> {
|
||||
if (!supported()) throw new Error("This browser cannot receive notifications")
|
||||
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== "granted") {
|
||||
throw new Error(
|
||||
permission === "denied"
|
||||
? "Notifications are blocked for this site in the browser's settings"
|
||||
: "Notifications were not allowed",
|
||||
)
|
||||
}
|
||||
|
||||
const registration = await worker()
|
||||
await navigator.serviceWorker.ready
|
||||
const { key } = await AlertsService.readWebpushKey()
|
||||
const subscription =
|
||||
(await registration.pushManager.getSubscription()) ??
|
||||
(await registration.pushManager.subscribe({
|
||||
// Every push carries a payload, and a browser will not deliver one it
|
||||
// cannot show; both Chrome and Firefox require this to be true anyway.
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: keyBytes(key),
|
||||
}))
|
||||
|
||||
const { endpoint, keys } = subscription.toJSON() as {
|
||||
endpoint: string
|
||||
keys: Record<string, string>
|
||||
}
|
||||
await AlertsService.addWebpushSubscription({
|
||||
requestBody: { endpoint, keys },
|
||||
})
|
||||
}
|
||||
|
||||
/** Stop this browser hearing about it, here and at the push service. */
|
||||
export async function unsubscribe(): Promise<void> {
|
||||
const subscription = await current()
|
||||
if (!subscription) return
|
||||
await AlertsService.removeWebpushSubscription({
|
||||
requestBody: { endpoint: subscription.endpoint },
|
||||
})
|
||||
await subscription.unsubscribe()
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { connectionStore, offlineDetail } from "./lib/connectionStore"
|
||||
import { notify } from "./lib/notificationStore"
|
||||
import { apiToken, appPath, appRoute, portalConfig } from "./lib/portal"
|
||||
import { safeStorage } from "./lib/safeStorage"
|
||||
import { registerSW } from "./lib/webpush"
|
||||
import { routeTree } from "./routeTree.gen"
|
||||
|
||||
const portal = portalConfig()
|
||||
@@ -124,6 +125,10 @@ declare module "@tanstack/react-router" {
|
||||
}
|
||||
}
|
||||
|
||||
// The worker only exists to receive pushes, so registering it costs a request
|
||||
// and nothing else. A browser that cannot have one is left alone.
|
||||
registerSW()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ThemeProvider defaultTheme="system" storageKey="fluksio-ui-theme">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Plus, Send, Trash2 } from "lucide-react"
|
||||
import { Bell, BellOff, Plus, Send, Trash2 } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
import { type AlertsConfig, AlertsService, type Channel } from "@/client"
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import * as webpush from "@/lib/webpush"
|
||||
import { handleError } from "@/utils"
|
||||
|
||||
export const Route = createFileRoute("/_layout/alerts")({
|
||||
@@ -47,6 +48,7 @@ const EVENTS: [string, string][] = [
|
||||
["engine_degraded", "The engine is struggling"],
|
||||
["cascade_dropped", "Work was given up on"],
|
||||
["queue_unavailable", "The queue is unreachable"],
|
||||
["run_finished", "A run failed"],
|
||||
]
|
||||
|
||||
/** The settings each kind of channel needs, in the order they read best. */
|
||||
@@ -61,9 +63,18 @@ const FIELDS: Record<Channel["kind"], [string, string, string][]> = {
|
||||
// The message has to be one a flow declares, like anything a dashboard
|
||||
// writes to. A notification widget bound to it is what shows the alert.
|
||||
dashboard: [["message", "Message", "house.notice"]],
|
||||
// Nothing to configure: it goes to whichever browsers subscribed here, which
|
||||
// is a button rather than a setting.
|
||||
webpush: [],
|
||||
}
|
||||
|
||||
const KINDS: Channel["kind"][] = ["ntfy", "smtp", "webhook", "dashboard"]
|
||||
const KINDS: Channel["kind"][] = [
|
||||
"ntfy",
|
||||
"smtp",
|
||||
"webhook",
|
||||
"dashboard",
|
||||
"webpush",
|
||||
]
|
||||
|
||||
/** A setting may hold a `{"$secret": "name"}` reference rather than a literal,
|
||||
* so text that parses as JSON is stored as JSON and survives a round trip. */
|
||||
@@ -83,6 +94,62 @@ function showSetting(value: unknown): string {
|
||||
return typeof value === "string" ? value : JSON.stringify(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* The one channel whose setting is on the device rather than in the config:
|
||||
* a browser has to ask its own permission, and what it hands back is stored
|
||||
* against this installation.
|
||||
*/
|
||||
function ThisBrowser() {
|
||||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||
const { data: subscribed, refetch } = useQuery({
|
||||
queryKey: ["alerts", "webpush", "this-browser"],
|
||||
queryFn: async () => (await webpush.current()) !== null,
|
||||
enabled: webpush.supported(),
|
||||
})
|
||||
|
||||
const change = useMutation({
|
||||
mutationFn: async (wanted: boolean) =>
|
||||
wanted ? webpush.subscribe() : webpush.unsubscribe(),
|
||||
onSuccess: (_result, wanted) => {
|
||||
showSuccessToast(
|
||||
wanted
|
||||
? "This browser will be notified"
|
||||
: "This browser will no longer be notified",
|
||||
)
|
||||
refetch()
|
||||
},
|
||||
onError: (error: Error) => showErrorToast(error.message),
|
||||
})
|
||||
|
||||
if (!webpush.supported()) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This browser cannot receive notifications. They need HTTPS — over plain
|
||||
http on a local address, no browser will allow them.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{subscribed
|
||||
? "This browser is subscribed."
|
||||
: "This browser is not subscribed yet. Each device subscribes itself."}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={change.isPending}
|
||||
onClick={() => change.mutate(!subscribed)}
|
||||
>
|
||||
{subscribed ? <BellOff /> : <Bell />}
|
||||
{subscribed ? "Unsubscribe" : "Subscribe"}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Alerts() {
|
||||
const { data } = useQuery({
|
||||
queryKey: alertsKey,
|
||||
@@ -266,6 +333,8 @@ function AlertsForm({ initial }: { initial: AlertsConfig }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{channel.kind === "webpush" ? <ThisBrowser /> : null}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{FIELDS[channel.kind].map(([key, label, placeholder]) => (
|
||||
<div key={key} className="grid gap-1.5">
|
||||
|
||||
@@ -878,6 +878,7 @@ dependencies = [
|
||||
{ name = "email-validator" },
|
||||
{ name = "fastapi", extra = ["standard"] },
|
||||
{ name = "fluksio-worker" },
|
||||
{ name = "http-ece" },
|
||||
{ name = "httpx" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "mcp" },
|
||||
@@ -925,6 +926,7 @@ requires-dist = [
|
||||
{ name = "emails", marker = "extra == 'server'", specifier = ">=0.6,<1.0" },
|
||||
{ name = "fastapi", extras = ["standard"], specifier = ">=0.114.2,<1.0.0" },
|
||||
{ name = "fluksio-worker", editable = "worker" },
|
||||
{ name = "http-ece", specifier = ">=1.2" },
|
||||
{ name = "httpx", specifier = ">=0.25.1,<1.0.0" },
|
||||
{ name = "influxdb-client", extras = ["async"], marker = "extra == 'server'", specifier = ">=1.40.0" },
|
||||
{ name = "jinja2", specifier = ">=3.1.4,<4.0.0" },
|
||||
@@ -1099,6 +1101,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-ece"
|
||||
version = "1.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7c/af/249d1576653b69c20b9ac30e284b63bd94af6a175d72d87813235caf2482/http_ece-1.2.1.tar.gz", hash = "sha256:8c6ab23116bbf6affda894acfd5f2ca0fb8facbcbb72121c11c75c33e7ce8cff", size = 8830, upload-time = "2024-08-08T00:10:47.301Z" }
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
|
||||
Reference in New Issue
Block a user