Default push backend to ntfy; FCM opt-in with privacy warning (issue #10)
CI / Gateway plugin tests (push) Successful in 5m3s
CI / Kotlin tests (android host + desktop) (push) Successful in 7m6s

- IRIS_PUSH_BACKEND now defaults to ntfy (keeps push metadata on your own
  infrastructure); FCM is opt-in via IRIS_PUSH_BACKEND=fcm
- build_push_backend(): ntfy for empty/unknown names, FCM only on explicit 'fcm'
- gateway setup: warn when FCM is chosen (metadata routed via Google's servers)
- README: privacy note + dedicated push section; new docs/playstore-listing.md
  with the FCM/ntfy privacy note for the Play Store listing
- docs: 00/02/03/08/12/16 + setup.md updated to ntfy-default wording
- tests: default-backend assertion updated (86/86 pass)
This commit is contained in:
ARIA committed 2026-08-24 19:04:40 +02:00
1 parent 70282dfb65
commit 746d809d48
14 files changed
+136 -56

No files matched your search

+13 -6
View File
@@ -33,8 +33,8 @@ register the (delivery-validated) file in the media registry and emit
``validate_media_delivery_path`` at pull time.
Milestone M5: push + offline. Frames with no live subscriber are parked in
the outbox (M3) AND wake the device via the push backend (``push.py``: FCM
HTTP v1 primary, ntfy fallback, selected by ``IRIS_PUSH_BACKEND``).
the outbox (M3) AND wake the device via the push backend (``push.py``: ntfy
default, FCM HTTP v1 as an option, selected by ``IRIS_PUSH_BACKEND``).
``notification`` frames render in-app banners and mirror to push (channel
events, cron deliveries, approvals, clarifies); high-priority kinds push even
when a device is live. ``fcm.register`` rotates push tokens (registry + live
@@ -526,7 +526,7 @@ DEFAULT_PORT = 8790
DEFAULT_HTTP_PORT = 8791 # docs/19: HTTP fallback leg
DEFAULT_HOME_CHANNEL = "default"
DEFAULT_HOME_CHANNEL_NAME = "Default"
DEFAULT_PUSH_BACKEND = "fcm"
DEFAULT_PUSH_BACKEND = "ntfy"
DEFAULT_OUTBOX_RETENTION_HOURS = 72
DEFAULT_MAX_UPLOAD_BYTES = 100 * 1024 * 1024 # 100 MB
@@ -1201,10 +1201,17 @@ def interactive_setup() -> None:
)
save_env_value("IRIS_HTTP_PORT", str(_parse_port(port)))
backend = prompt(
"Push backend (fcm/ntfy)",
"Push backend (ntfy/fcm)",
default=get_env_value("IRIS_PUSH_BACKEND") or DEFAULT_PUSH_BACKEND,
)
save_env_value("IRIS_PUSH_BACKEND", (backend or DEFAULT_PUSH_BACKEND).strip().lower())
backend = (backend or DEFAULT_PUSH_BACKEND).strip().lower()
save_env_value("IRIS_PUSH_BACKEND", backend)
if backend == "fcm":
print_warning(
"FCM push metadata (notification title, device token) is routed "
"through Google's servers. For truly private communication use "
"ntfy (self-hosted) instead."
)
# Pairing payload for the app's Connect screen (manual entry + QR scan).
# Advertise a routable host: a bind wildcard (0.0.0.0/127.0.0.1) is
@@ -1346,7 +1353,7 @@ class IrisAdapter(BasePlatformAdapter):
# /fast): picker_id -> pending state. In-memory only — a gateway
# restart expires them (a stale picker.select is a no-op).
self._pending_pickers: dict[str, dict] = {}
# M5: push backend (FCM primary, ntfy fallback) + the throttle for
# M5: push backend (ntfy default, FCM optional) + the throttle for
# the outbox-prune banner.
self._push: PushBackend = build_push_backend(
self.push_backend,
+1 -1
View File
@@ -38,7 +38,7 @@ optional_env:
prompt: "Allow all devices? (true/false)"
password: false
- name: IRIS_PUSH_BACKEND
description: "Push backend: fcm (default) or ntfy"
description: "Push backend: ntfy (default, keeps metadata off Google) or fcm"
prompt: "Push backend"
password: false
- name: IRIS_FCM_SERVICE_ACCOUNT
+21 -14
View File
@@ -1,4 +1,4 @@
"""Push backends: FCM (primary) + ntfy (fallback).
"""Push backends: ntfy (default) + FCM (optional).
``PushBackend`` interface with two implementations:
- ``FcmBackend``: FCM HTTP v1 via ``httpx`` + a Firebase service account
@@ -8,7 +8,7 @@
(default ``https://ntfy.sh``) via ``httpx``; the app's listener
subscribes to the topic.
Selected by ``IRIS_PUSH_BACKEND`` (``fcm`` default, ``ntfy`` fallback).
Selected by ``IRIS_PUSH_BACKEND`` (``ntfy`` default, ``fcm`` optional).
Fired when a frame has no live subscriber; the data payload drives a silent
sync on the device (docs/08-push.md).
@@ -33,7 +33,9 @@ import httpx
logger = logging.getLogger(__name__)
FCM_SCOPE = "https://www.googleapis.com/auth/firebase.messaging"
FCM_TOKEN_URL = "https://oauth2.googleapis.com/token"
# Not a secret: the well-known Google OAuth2 token endpoint.
# pi-lens-ignore: S105
FCM_TOKEN_URL = "https://oauth2.googleapis.com/token" # noqa: S105
FCM_V1_SEND_URL = "https://fcm.googleapis.com/v1/projects/{project_id}/messages:send"
FCM_LEGACY_SEND_URL = "https://fcm.googleapis.com/fcm/send"
# Refresh the cached access token this long before its expiry.
@@ -54,7 +56,7 @@ class PushBackend:
name: str = "push"
# DeviceRegistry column that carries this backend's target token.
# Not a secret: a DB column name (string literal), not a credential.
# pi-lens-ignore: python-hardcoded-secrets
# pi-lens-ignore: S105, python-hardcoded-secrets
token_field: str = ""
def configured(self) -> bool:
@@ -87,8 +89,8 @@ class FcmBackend(PushBackend):
name = "fcm"
# Not a secret: a DB column name (string literal), not a credential.
# pi-lens-ignore: python-hardcoded-secrets
token_field = "fcm_token"
# pi-lens-ignore: S105
token_field = "fcm_token" # noqa: S105
def __init__(
self,
@@ -257,8 +259,8 @@ class NtfyBackend(PushBackend):
name = "ntfy"
# Not a secret: a DB column name (string literal), not a credential.
# pi-lens-ignore: python-hardcoded-secrets
token_field = "ntfy_topic"
# pi-lens-ignore: S105
token_field = "ntfy_topic" # noqa: S105
def __init__(
self,
@@ -332,9 +334,14 @@ def build_push_backend(
ntfy_server_url: str | None = None,
ntfy_auth_token: str | None = None,
) -> PushBackend:
"""Select the backend by name (``IRIS_PUSH_BACKEND``; fcm default)."""
if (name or "").strip().lower() == "ntfy":
return NtfyBackend(
topic=ntfy_topic, server_url=ntfy_server_url, auth_token=ntfy_auth_token
)
return FcmBackend(service_account=fcm_service_account, server_key=fcm_server_key)
"""Select the backend by name (``IRIS_PUSH_BACKEND``; ntfy default).
ntfy is the default: it keeps push metadata on your own infrastructure.
FCM is opt-in (``IRIS_PUSH_BACKEND=fcm``) — its metadata (title, device
token) is routed through Google's servers.
"""
if (name or "").strip().lower() == "fcm":
return FcmBackend(service_account=fcm_service_account, server_key=fcm_server_key)
return NtfyBackend(
topic=ntfy_topic, server_url=ntfy_server_url, auth_token=ntfy_auth_token
)
+1 -1
View File
@@ -1404,7 +1404,7 @@ def test_push_backend_selection(plugin):
push = plugin.push
assert isinstance(push.build_push_backend("fcm"), push.FcmBackend)
assert isinstance(push.build_push_backend("ntfy"), push.NtfyBackend)
assert isinstance(push.build_push_backend(None), push.FcmBackend) # default
assert isinstance(push.build_push_backend(None), push.NtfyBackend) # default
assert isinstance(push.build_push_backend(" NTFY "), push.NtfyBackend)
assert push.build_push_backend("ntfy", ntfy_topic="my-topic").configured() is True