Install Iris on a phone and get its alerts there (#154) #178

Merged
claude-bot merged 3 commits from feat/154-pwa-push into main 2026-09-26 11:28:33 +00:00
Collaborator

Closes #154, except the manual device checks listed at the end.

Part 1: installable

  • Package: vite-plugin-pwa@1.3.0 (injectManifest) and Workbox 7.4.1, pinned exactly.
  • Manifest:
    • standalone display, with icons at 192, 512 and 512-maskable, generated once from public/iris.svg
    • shortcuts to Remote, Review (/?filter=unapproved) and Season
    • this also fixes the favicon 404: index.html always referenced /iris.svg, which never existed
  • The service worker serves the static shell and nothing else (frontend/src/sw/policy.ts).
    • Its first fetch listener calls stopImmediatePropagation() for /api/*, /auth/*, /metrics, /openapi.json and cross-origin requests, and never calls respondWith. The browser then fetches exactly as if there were no worker, OIDC redirects included.
    • That's stronger than a Workbox NetworkOnly route, which would still proxy the request.
    • The navigation-fallback denylist mirrors spa.py (#164).
  • Tested three ways:
    • unit tests on the policy
    • sw.ts run against a fake worker global
    • an E2E with the worker in control: a hashed asset is served by the worker (the positive control), every /api response has fromServiceWorker() === false, and Cache Storage holds nothing under /api, /auth or /metrics
  • Updates: registerType: "prompt", with a "New version available: Reload" toast.

Part 2: Web Push

  • Package: pywebpush==2.5.0, in pyproject.toml and uv.lock (the image installs from the lock since #173).
  • Keys: WEB_PUSH_VAPID_* come from env only. Tests prove they're absent from the config export and can't be stored as settings. A mismatched key pair is reported as the reason push is off. python -m app.vapid_keys generates a pair.
  • Storage: a push_subscriptions table (migration 0011, checked up, down and up again).
  • API: own-subscription CRUD, GET /push/config, and POST /push/test.
  • Dispatch is a second channel inside webhook_dispatcher.dispatch_event, with no second event list. Every WebhookEvent, including #149's and #148's, gets a formatter, and a test enforces that. Push failures never affect webhook delivery. A 404 or 410 deletes the subscription.
  • Hardening:
    • Endpoints must be https:// on port 443 at a known push-service host (FCM, Mozilla, Apple, WNS). Without that, any user could aim the server's POSTs at an internal address.
    • Keys are length- and pattern-checked, with at most 20 subscriptions per user.
    • An endpoint moves to a different user only if the request carries the same keys; otherwise 409.
    • Logs name only the push service's host.
    • A notification tap never opens another origin.
    • Logging out unsubscribes the device.
  • Viewers may subscribe only to scheme_pushed and quick_push. It's an allowlist, so new events default to admin-only. The role is refreshed at login and re-checked at dispatch.
  • UI: a "Notifications on this device" card, including an "Add to Home Screen first" hint on iOS Safari when the app isn't installed.

Verification (rebased onto main with #148, #151 and #173)

  • Backend: pytest 1222 passed (77 in test_web_push.py). ruff and strict mypy are clean, uv lock --check passes, and Alembic up, down, up and check all pass.
  • Frontend: lint (0 warnings), tsc, vitest 271 passed, and build (the output contains manifest.webmanifest and sw.js).
  • E2E, run the CI way: 23 passed.
    • A separate pwa Playwright project uses full Chromium with --unsafely-treat-insecure-origin-as-secure for the CI origin only, since http://iris-<run>:7842 isn't a secure context. The existing project still runs where no service worker can register.
  • The #173 import check against this image: every module imports, and app.vapid_keys has no import-time side effects.
  • Read-only container: docker diff is empty, so there are no new write paths.

Manual verification (checklist in docs/setup.md §9–10)

Install on Android and iOS; the OIDC round trip and session persistence in the installed app; the shortcuts; the update prompt; a test notification arriving on each platform; a tap opening the right page; and sign-out stopping notifications. Real push delivery can't be tested headless.

🤖 Generated with Claude Code

Closes #154, except the manual device checks listed at the end. ## Part 1: installable - **Package:** `vite-plugin-pwa@1.3.0` (injectManifest) and Workbox 7.4.1, pinned exactly. - **Manifest:** - standalone display, with icons at 192, 512 and 512-maskable, generated once from `public/iris.svg` - shortcuts to **Remote**, **Review** (`/?filter=unapproved`) and **Season** - this also fixes the favicon 404: `index.html` always referenced `/iris.svg`, which never existed - **The service worker serves the static shell and nothing else** (`frontend/src/sw/policy.ts`). - Its first fetch listener calls `stopImmediatePropagation()` for `/api/*`, `/auth/*`, `/metrics`, `/openapi.json` and cross-origin requests, and never calls `respondWith`. The browser then fetches exactly as if there were no worker, OIDC redirects included. - That's stronger than a Workbox `NetworkOnly` route, which would still proxy the request. - The navigation-fallback denylist mirrors `spa.py` (#164). - **Tested three ways:** - unit tests on the policy - `sw.ts` run against a fake worker global - an E2E with the worker in control: a hashed asset *is* served by the worker (the positive control), every `/api` response has `fromServiceWorker() === false`, and Cache Storage holds nothing under `/api`, `/auth` or `/metrics` - **Updates:** `registerType: "prompt"`, with a "New version available: Reload" toast. ## Part 2: Web Push - **Package:** `pywebpush==2.5.0`, in `pyproject.toml` and `uv.lock` (the image installs from the lock since #173). - **Keys:** `WEB_PUSH_VAPID_*` come from env only. Tests prove they're absent from the config export and can't be stored as settings. A mismatched key pair is reported as the reason push is off. `python -m app.vapid_keys` generates a pair. - **Storage:** a `push_subscriptions` table (migration `0011`, checked up, down and up again). - **API:** own-subscription CRUD, `GET /push/config`, and `POST /push/test`. - **Dispatch** is a second channel inside `webhook_dispatcher.dispatch_event`, with **no second event list**. Every `WebhookEvent`, including #149's and #148's, gets a formatter, and a test enforces that. Push failures never affect webhook delivery. A 404 or 410 deletes the subscription. - **Hardening:** - Endpoints must be `https://` on port 443 at a known push-service host (FCM, Mozilla, Apple, WNS). Without that, any user could aim the server's POSTs at an internal address. - Keys are length- and pattern-checked, with at most 20 subscriptions per user. - An endpoint moves to a different user only if the request carries the same keys; otherwise 409. - Logs name only the push service's host. - A notification tap never opens another origin. - Logging out unsubscribes the device. - **Viewers** may subscribe only to `scheme_pushed` and `quick_push`. It's an allowlist, so new events default to admin-only. The role is refreshed at login and re-checked at dispatch. - **UI:** a "Notifications on this device" card, including an "Add to Home Screen first" hint on iOS Safari when the app isn't installed. ## Verification (rebased onto `main` with #148, #151 and #173) - **Backend:** pytest 1222 passed (77 in `test_web_push.py`). ruff and strict mypy are clean, `uv lock --check` passes, and Alembic up, down, up and `check` all pass. - **Frontend:** lint (0 warnings), `tsc`, vitest 271 passed, and build (the output contains `manifest.webmanifest` and `sw.js`). - **E2E, run the CI way:** 23 passed. - A separate `pwa` Playwright project uses full Chromium with `--unsafely-treat-insecure-origin-as-secure` for the CI origin only, since `http://iris-<run>:7842` isn't a secure context. The existing project still runs where no service worker can register. - **The #173 import check** against this image: every module imports, and `app.vapid_keys` has no import-time side effects. - **Read-only container:** `docker diff` is empty, so there are no new write paths. ## Manual verification (checklist in `docs/setup.md` §9–10) Install on Android and iOS; the OIDC round trip and session persistence in the installed app; the shortcuts; the update prompt; a test notification arriving on each platform; a tap opening the right page; and sign-out stopping notifications. Real push delivery can't be tested headless. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Iris now has a web app manifest and a service worker, so Android's
"Install app" and iOS's "Add to Home Screen" give it an icon, a
full-screen window, and long-press shortcuts to the Remote and the
review queue.

The service worker precaches the static app shell only. Requests under
/api/, /auth/ and /metrics (the calendar feed included) never reach
Workbox: the worker's first fetch listener steps aside for them, so the
browser fetches them exactly as it would with no worker, OIDC redirects
included, and the navigation fallback's denylist mirrors spa.py's
reserved paths. A new version waits behind a "New version available"
toast instead of reloading under someone mid-edit.

Tests: vitest runs the worker against a fake worker global; the E2E
asserts, with the worker in control, that API responses never come
from it, that navigating to an API URL still returns JSON, and that
nothing from the backend is cached. The E2E project uses full Chromium
with --unsafely-treat-insecure-origin-as-secure for the app's origin
only, because CI reaches the container over plain http by name.

Also adds the /iris.svg favicon index.html has always referenced.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Settings has a "Notifications on this device" card: enable, choose
events, send a test, disable, and remove your other devices. A tap on
a notification opens the page it is about, e.g. the night in the review
queue for "Tonight's lights will be dark: Christmas Eve needs approval".

Push is the webhook bus's second channel, not a second bus:
dispatch_event sends each event to webhooks and to push at the same
point, with the same context, validated against the one WebhookEvent
list, so an event added there (#148's watchdog events, say) is pushed
too. A push failure is logged like a webhook failure and never costs
the webhook or the triggering job; a 404/410 from the push service
deletes the subscription.

Any signed-in user manages only their own subscriptions. Viewers may
subscribe to scheme_pushed and quick_push; everything else, and any
future event, is admin-only. The role is re-checked at dispatch against
the role at last login. Endpoints must belong to a known push service,
since any user can register one and the server posts to it.

VAPID keys are env-only (WEB_PUSH_VAPID_*), never in the settings table
or the config export; unset or mismatched keys disable push and the card
says why. pywebpush 2.5.0, pinned. New push_subscriptions table
(migration 0011). iOS needs the app added to the Home Screen first, and
the card says so in a Safari tab.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Notify on watchdog corrections, and add a Season shortcut (#154)
All checks were successful
CI / Dockerfile lint (pull_request) Successful in 9s
CI / Alembic migration check (pull_request) Successful in 1m4s
CI / Pre-commit hooks (pull_request) Successful in 1m21s
CI / Python lint & type-check (pull_request) Successful in 1m55s
CI / Frontend lint, test & build (pull_request) Successful in 2m22s
CI / Python tests (pull_request) Successful in 6m31s
CI / Docker build, health smoke & E2E (pull_request) Successful in 2m40s
4024df079e
Picks up what landed on main while #154 was in progress. The watchdog's
state_corrected and state_drift events (#148) get their own wording:
"Lights restored after the controller rebooted" (or "came back", or
"put back on schedule" under enforce) and "The lights don't match the
schedule", with the differences as the body. Both stay admin-only, as
the allowlist already made them; a test now asserts every current event
has a formatter, so the generic fallback is only for future ones.

The installed app gets a third long-press shortcut, to the season
preview (#151).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude-bot deleted branch feat/154-pwa-push 2026-09-26 11:28:34 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rbrooks/Iris-WLED!178
No description provided.