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

+19 -2
View File
@@ -8,6 +8,8 @@ Iris pairs with your running `hermes gateway` over a private WebSocket and gives
- **Native Hermes-Gateway integration** — your hermes → gateway → Iris app
- **Absolute Privacy!** — everything stays on your own infrastructure
(push: ntfy by default; FCM is opt-in and routes push metadata via Google —
see [Push notifications](#push-notifications))
- **No file limit**
- **No character limit**
- **Full markdown support** — tables, checkmarks, bold, inline code, code blocks + syntax highlighting…
@@ -36,7 +38,22 @@ hermes-agent ──> hermes gateway ──(WebSocket :8790)──> Iris app (And
- The app is a first-class hermes *messaging platform*, so everything the gateway
already does just works: slash commands, cron delivery, `send_message` routing,
coexistence with Telegram/Discord/etc.
- Push notifications: FCM (primary) or ntfy (fallback).
- Push notifications: ntfy (default) or FCM (opt-in).
## Push notifications
Push wakes a backgrounded/offline device; on reconnect the app syncs the
outbox, so nothing is lost.
- **ntfy (default)** — push metadata stays on your own infrastructure
(self-hosted ntfy recommended). This is the backend for truly private
communication.
- **FCM (opt-in, `IRIS_PUSH_BACKEND=fcm`)** — standard/reliable, but FCM push
metadata (notification title, device token) is routed through **Google's
servers**. If you want truly private communication, use ntfy instead.
Setup: [`docs/setup.md`](docs/setup.md) §4; details:
[`docs/08-push.md`](docs/08-push.md).
## Build from source
@@ -99,7 +116,7 @@ Notes:
- Remote access: Tailscale/WireGuard, or a reverse proxy with CA-signed WSS
(`ANDROID_WS_CERT` / `ANDROID_WS_KEY`).
Full walkthrough, push setup (FCM/ntfy), and troubleshooting:
Full walkthrough, push setup (ntfy/FCM), and troubleshooting:
[`docs/setup.md`](docs/setup.md).
## Contributing
+1 -1
View File
@@ -57,7 +57,7 @@ Everything in the feature checklist below.
| Decision | Choice |
|---|---|
| Desktop app tech | **Compose Multiplatform** (shares Android code; "tweaked" for big screen) |
| Push backend | **Both** — FCM primary, ntfy fallback (`IRIS_PUSH_BACKEND`) |
| Push backend | **Both** — ntfy default, FCM optional (`IRIS_PUSH_BACKEND`) |
| Media transport | **Over the WebSocket** (chunked binary frames; no extra Python deps) |
| Phone default layout | **User-toggleable, single-pane default** (auto two-pane on large screens) |
+2 -2
View File
@@ -61,8 +61,8 @@ iris_x_hermes/
- **`media.py`** — inbound chunked upload → `cache_*_from_bytes`; outbound
`media.offer`/`media.pull` chunked streaming.
- **`outbox.py`** — SQLite outbox per `chat_id` + monotonic sync cursor.
- **`push.py`** — `PushBackend` interface; `FcmBackend` (httpx, FCM HTTP v1) and
`NtfyBackend` (reuses hermes ntfy publish). Selected by `IRIS_PUSH_BACKEND`.
- **`push.py`** — `PushBackend` interface; `NtfyBackend` (default) and
`FcmBackend` (httpx, FCM HTTP v1). Selected by `IRIS_PUSH_BACKEND`.
- **`pairing.py`** — token generation/verification (constant-time), device
registry (SQLite), QR payload.
- **`search.py`** — FTS5 query bridge over the hermes session store.
+1 -1
View File
@@ -48,7 +48,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
+7 -4
View File
@@ -1,7 +1,10 @@
# 08 — Push Notifications, Outbox & Sync
The gateway can't reach a sleeping phone directly. Push goes through a cloud
relay. **Decision: FCM primary, ntfy fallback** (`IRIS_PUSH_BACKEND`).
relay. **Decision: ntfy default, FCM optional** (`IRIS_PUSH_BACKEND`).
Privacy: FCM push metadata (notification title, device token) is routed
through Google's servers — for truly private communication use ntfy
(self-hosted), which keeps everything on your own infrastructure.
## 8.1 When push fires
@@ -54,9 +57,9 @@ class PushBackend(Protocol):
def configured(self) -> bool: ...
```
Selected at adapter init by `IRIS_PUSH_BACKEND` (`fcm` default, `ntfy`).
Selected at adapter init by `IRIS_PUSH_BACKEND` (`ntfy` default, `fcm`).
### 8.2.1 `FcmBackend` (primary)
### 8.2.1 `FcmBackend` (optional; metadata via Google)
- **FCM HTTP v1 API** via `httpx` (core dep). Auth = Firebase **service
account** (`IRIS_FCM_SERVICE_ACCOUNT` JSON path) → mint a short-lived
@@ -74,7 +77,7 @@ Selected at adapter init by `IRIS_PUSH_BACKEND` (`fcm` default, `ntfy`).
- Batch: FCM allows up to 500 tokens/message; we send per-device (1 user, few
devices).
### 8.2.2 `NtfyBackend` (fallback, self-host friendly)
### 8.2.2 `NtfyBackend` (default, self-host friendly)
- Reuses hermes's existing ntfy publish path (hermes ships an ntfy adapter).
- Publish to `NTFY_TOPIC` on `NTFY_SERVER_URL` (default `https://ntfy.sh`) via
+4 -3
View File
@@ -77,15 +77,16 @@ hermes --version # sanity
4. The app's `FirebaseMessagingService` obtains the FCM token at runtime and
registers it via `hello` / `fcm.register`.
> Skip Firebase → set `IRIS_PUSH_BACKEND=ntfy` and configure `NTFY_TOPIC` /
> `NTFY_SERVER_URL` (self-host ntfy or use ntfy.sh). See `08-push.md`.
> Skip Firebase → the default is already ntfy: leave `IRIS_PUSH_BACKEND` unset
> (or set it to `ntfy`) and configure `NTFY_TOPIC` / `NTFY_SERVER_URL`
> (self-host ntfy or use ntfy.sh). See `08-push.md`.
## 12.6 Environment variables (summary)
**Secrets (`~/.hermes/.env`):**
```
IRIS_TOKEN=<64-hex>
IRIS_PUSH_BACKEND=fcm # or ntfy
IRIS_PUSH_BACKEND=ntfy # default; fcm = opt-in (metadata via Google)
IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json
# IRIS_FCM_SERVER_KEY=<legacy key> # fallback if no service account
# NTFY_TOPIC=iris-push # when ntfy
+1 -1
View File
@@ -5,7 +5,7 @@
| # | Decision | Choice | Rationale |
| --- | --- | --- | --- |
| 1 | Desktop app tech | **Compose Multiplatform** | Desktop = "the Android app, tweaked"; share protocol/state/UI. |
| 2 | Push backend | **Both — FCM primary, ntfy fallback** | FCM is standard/reliable; ntfy for self-hosters with no Firebase. `IRIS_PUSH_BACKEND`. |
| 2 | Push backend | **Both — ntfy default, FCM optional** (issue #10: FCM metadata — title, device token — is routed via Google's servers, contradicting the privacy claim; ntfy keeps it on your own infrastructure) | `IRIS_PUSH_BACKEND`. |
| 3 | Media transport | **Over the WebSocket** | One transport, zero new Python deps; chunked binary frames. |
| 4 | Phone default layout | **User-toggleable, single-pane default** | App-like on phones; auto two-pane on large screens; desktop defaults two-pane. |
+2 -1
View File
@@ -30,7 +30,7 @@ top-to-bottom once, then use the numbered docs as a lookup while implementing.
| 5 | [`05-streaming.md`](05-streaming.md) | Streaming / reasoning / tools / intermediate. |
| 6 | [`06-channels-cron-search.md`](06-channels-cron-search.md) | Channels, threads, cron delivery, search. |
| 7 | [`07-media.md`](07-media.md) | Media upload/download + playback. |
| 8 | [`08-push.md`](08-push.md) | Push (FCM + ntfy), outbox, sync. |
| 8 | [`08-push.md`](08-push.md) | Push (ntfy default + FCM optional), outbox, sync. |
| 9 | [`09-pairing-security.md`](09-pairing-security.md) | Pairing, auth, security model. |
| 10 | [`10-android-app.md`](10-android-app.md) | When building the Android app. |
| 11 | [`11-desktop-app.md`](11-desktop-app.md) | When building the Desktop app. |
@@ -47,6 +47,7 @@ Machine-readable / diagrams:
- [`protocol/frames.schema.json`](protocol/frames.schema.json) — wire-frame schema.
- [`diagrams/architecture.mmd`](diagrams/architecture.mmd) — mermaid architecture.
- [`playstore-listing.md`](playstore-listing.md) — Play Store listing text (incl. the FCM/ntfy privacy note).
---
+40
View File
@@ -0,0 +1,40 @@
# Play Store Listing
Copy-paste text for the Play Console (and the F-Droid / sideload pages).
Keep this file in sync with the README whenever the privacy story changes.
## Short description (≤ 80 chars)
Private chat for your personal AI agent — Android & desktop.
## Full description
Iris is a native chat app for your personal AI agent (hermes-agent):
streaming replies, visible reasoning, structured tool activity, channels,
threads, media, search — Telegram-quality, on your own infrastructure.
**Absolute Privacy!** — everything stays on your own infrastructure:
- Your gateway, your machine, your data. No cloud middleman for chat.
- **Push notifications:** ntfy by default — push metadata stays on your own
(self-hosted) ntfy server.
- **FCM is opt-in** (`IRIS_PUSH_BACKEND=fcm`): standard and reliable, but FCM
push metadata (notification title, device token) is routed through
**Google's servers**. If you want truly private communication, use ntfy
(self-hosted) instead — it is the default.
No file limit. No character limit. Full markdown + HTML artifact preview.
All settings live in the app.
Runs on Android and desktop (Linux, macOS, Windows) from one shared codebase.
## Privacy note (for the "Data safety" section / FAQ)
Iris talks directly to your own hermes gateway over a private, token-authenticated
connection. By default, push notifications use ntfy, which you can self-host so
that push metadata never leaves your infrastructure. If you explicitly enable
FCM, push metadata (notification title, device token) is sent via Google's FCM
servers; chat content itself is not sent to Google — FCM only carries a short
preview, and full content is fetched from your gateway over the authenticated
connection. For truly private communication, use the default ntfy backend
(self-hosted).
+23 -19
View File
@@ -39,7 +39,8 @@ What it does:
- Generates `IRIS_TOKEN` (64 hex chars) if none exists and stores it in
`~/.hermes/.env` (it prints the token once, at generation).
- Prompts for the WS bind host (default `127.0.0.1`), port (default `8790`),
and push backend (`fcm` or `ntfy`, default `fcm`).
and push backend (`ntfy` or `fcm`, default `ntfy`); warns when `fcm` is
chosen (push metadata via Google's servers).
- Prints the pairing payload (a QR-encodable `iris://pair?host=…&port=…&token=…`
string), a scannable QR of that payload, and the server URL
(`ws://<host>:8790/ws`).
@@ -100,26 +101,10 @@ Push wakes a backgrounded/offline device; on reconnect the app syncs the
outbox, so nothing is lost. Push fires when the device is offline, plus for
high-priority events (approvals, clarifies, cron) even when a device is live.
### FCM (default; needs a Firebase project)
1. Create a Firebase project (console.firebase.google.com) and add an Android
app with the app's applicationId; download `google-services.json` into
`app/androidApp/`.
2. Create a service account (Project settings → Service accounts → Generate new
private key) and store the JSON path in `~/.hermes/.env`:
`IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json`.
3. Keep `IRIS_PUSH_BACKEND=fcm` (the default).
Without a Firebase project the FCM path is **inert** (the app's FCM service
does nothing) — use ntfy below, or add Firebase later.
**What you see:** system notifications for new messages when the app is
backgrounded; tapping one deep-links to the chat.
### ntfy (zero-config fallback)
### ntfy (default; zero-config)
```
IRIS_PUSH_BACKEND=ntfy
IRIS_PUSH_BACKEND=ntfy # the default — can be left unset
```
- The device **generates its own topic** automatically (no `NTFY_TOPIC` needed);
@@ -128,10 +113,29 @@ IRIS_PUSH_BACKEND=ntfy
recommended** — the public ntfy.sh SSE endpoint is flaky (it has served its
web UI instead of the stream), while a self-hosted instance gives reliable
SSE. For a real trust boundary use a private topic + `NTFY_AUTH_TOKEN`.
- **Privacy:** ntfy keeps push metadata (title, topic) on your own
infrastructure — this is the backend for truly private communication.
**What you see:** a low-priority foreground "ntfy listener" notification while
the app is off; incoming pushes trigger a silent sync.
### FCM (opt-in; needs a Firebase project)
> **Privacy note:** FCM push metadata (notification title, device token) is
> routed through **Google's servers**. If you want truly private
> communication, use ntfy (self-hosted) instead — it is the default.
1. Create a Firebase project (console.firebase.google.com) and add an Android
app with the app's applicationId; download `google-services.json` into
`app/androidApp/`.
2. Create a service account (Project settings → Service accounts → Generate new
private key) and store the JSON path in `~/.hermes/.env`:
`IRIS_FCM_SERVICE_ACCOUNT=/path/to/service-account.json`.
3. Set `IRIS_PUSH_BACKEND=fcm`.
Without a Firebase project the FCM path is **inert** (the app's FCM service
does nothing) — use ntfy (the default), or add Firebase later.
## 5. Remote access
- **Tailscale / WireGuard (recommended):** the gateway gets a stable tailnet IP;
+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