diff --git a/docs/design_index.md b/docs/design_index.md index 0c0035bd..eb2f5ddb 100644 --- a/docs/design_index.md +++ b/docs/design_index.md @@ -164,6 +164,10 @@ human acceptance and promotion into a current public authority surface. ## 7. Dated snapshots, diagnostics, and proposals +- [Mobile Web Push candidate and operator handoff](engineering/mobile_web_push.md) + records the bounded JVNAUTOSCI-2757 implementation and unfulfilled browser + transport acceptance; it does not establish a deployed notification service. + These files retain evidence and rationale, but must not drive current task ordering or implementation without live revalidation: diff --git a/docs/engineering/mobile_web_push.md b/docs/engineering/mobile_web_push.md new file mode 100644 index 00000000..7f91ae7c --- /dev/null +++ b/docs/engineering/mobile_web_push.md @@ -0,0 +1,196 @@ +# Mobile Web Push — JVNAUTOSCI-2757 + +- **Kind:** Bounded implementation and operator handoff +- **Lifecycle:** Local browser acceptance complete; public activation is controller-owned +- **Owner:** Von maintainers, for Michael Witbrock +- **Scope:** Direct-message notification opt-in for one actor/organisation context + per app installation; multiple devices enrol independently +- **Review trigger:** Delivery acceptance, supported browser/provider changes, + authentication changes, or service-worker work in JVNAUTOSCI-2758 +- **Decision surface:** [JVNAUTOSCI-2757](https://naoinstitute.atlassian.net/browse/JVNAUTOSCI-2757) + +## Capability and boundary + +Settings → Conversations offers Enable, a once-per-minute test, Refresh status, +and device-wide Disable. Browser permission is requested only by Enable. +Unsupported and uninstalled iOS cases explain what is needed. Denied/dismissed +permission does not cause another automatic prompt. Lost worker state requires +an explicit reconnect. An existing confirmed opt-in renews on a Settings visit; +unvisited subscriptions expire after 30 days. + +The manifest uses the existing Von artwork. The service worker handles push and +clicks only; it neither caches authenticated responses nor intercepts fetches. +Coordinate `/von/service-worker.js`, `/von/manifest.webmanifest`, and manifest +links with the independent PWA task before merging either candidate. + +Canonical direct messages remain the source of truth. A separate leased sender +reads recipient-scoped message concepts, including coding-agent messages, without +depending on workflow launch or invoking a model. Notifications concern messages +created since opt-in and within the past day. Read messages are skipped. A +recipient/organisation/date index supports the bounded scan, and an indexed +anti-join against terminal receipts handles late canonical inserts without +repeating completed deliveries. There is no second task-event alert source. + +Subscriptions and navigation receipts are derived transactional records in +`web_push_subscriptions` and `web_push_receipts`. Endpoints and browser keys are +encrypted with the existing token-encryption helper. Receipt URLs and notification +tags are opaque, contain no sender/recipient identifiers or message text, and +expire after seven days. Lock-screen text is always generic; richer previews are +outside this delivery. + +Browser actor identity comes from the authenticated, assurance-checked server +session. A signed HttpOnly SameSite device cookie scopes enrolment/revocation. +The existing actor-bound window context supplies the organisation, with a live +membership check. A challenge sent exclusively through the proposed endpoint +must return from the same signed-in device before activation. JSON mutation +requests require the same Origin. Provider URL restrictions and disabled HTTP +redirects prevent a submitted endpoint from granting arbitrary server requests. +Supported providers are FCM, Mozilla's production push endpoint, and Apple's +`*.push.apple.com` endpoints. + +Each enrolment has a fresh generation, preventing queued pushes from an earlier +account or scope from matching a reused endpoint. The service worker rechecks +the live actor/device/generation before display. Logout revokes the server record +before clearing authentication; a storage failure leaves logout visibly +unfinished for retry. Browser cleanup also unsubscribes and closes notifications. +Current organisation membership and canonical message access are rechecked on +click. Only the same-origin `/von/?notification=OPAQUE_RECEIPT` route can open. + +**Delivery limits:** validating the current session requires a working connection +to Von at receipt time. Offline, expired-login, or unavailable-origin deliveries +may be missed; WebKit may revoke subscriptions that repeatedly cannot display. +The app exposes re-enrolment and retains canonical messages. In-flight OS alerts +and system notification history cannot be recalled reliably. This limitation +needs evaluation on the actual supported device before acceptance. + +Provider requests have a ten-second transport timeout and a 60-second provider +TTL. Three attempts maximum are counted durably before I/O, with one/two-minute +retry delays. A 120-second lease permits recovery after process death. A stable +provider Topic and notification tag, plus worker receipt memory, suppress repeat +alerts; this does not claim exactly-once OS delivery. Provider acceptance is +recorded separately from display. Permanent 404/410 responses retire endpoints. + +## Protected configuration and rollback + +The operator must provide these through the existing protected runtime +configuration; do not copy them into the checkout, browser evidence or logs: + +- `VON_WEB_PUSH_PRIVATE_KEY`: base64url VAPID EC private key in the format accepted + by `pywebpush`, or an operator-protected PEM path. +- `VON_WEB_PUSH_PUBLIC_KEY`: matching base64url uncompressed P-256 public key. +- `VON_WEB_PUSH_SUBJECT`: VAPID contact URI (`mailto:` or HTTPS). +- `VON_WEB_PUSH_ENCRYPTION_KEY`: dedicated Fernet key for subscription storage. + +The worker starts only with complete configuration. Missing configuration leaves +the UI explicitly unavailable. Startup failure is logged without subscription +material. PDM installs the locked `pywebpush` dependency. New indexes and TTL +collections are created at sender startup; there is no rewrite of existing +concept meaning, migration database, or user membership. + +Keep the encryption/VAPID keys across code rollback. Stop the candidate sender +by reverting to the previous coherent release or removing its configuration and +restarting through the controller. Retain the encrypted derived stores for +recovery; do not delete canonical messages. The worker has no fetch interception, +and after rollback its missing validation endpoint prevents further notification +display. Before rotating either key, disable enrolments through the service and +require fresh opt-in; replacing the encryption key alone makes old subscriptions +unreadable. The controller's code rollback is not key/configuration rollback. + +## Accepted local path and activation handoff + +On 14 September 2026, the clean candidate +`70fc2819b67975cdc426eda47519106470ffd5ae` passed actual local browser delivery: + +- Synthetic Alice and Bob logged in through the normal localhost Browser test + login. Both task-owned profiles reported the exact candidate in `/health`. +- Settings → Conversations → Enable performed a real PushManager subscription, + encrypted provider send and device-bound confirmation. The Settings iframe is + outside `/von/`; the client now awaits the registered worker's activation + rather than waiting indefinitely for that iframe to be controlled. +- A canonical message from Bob to Alice produced one generic notification while + Alice's Von tab was closed and her browser remained running. The private + message body did not appear in the alert. Bob received HTTP 404 attempting to + open Alice's opaque notification receipt. +- A screenshot of the task-owned desktop showed the actual alert. A real X11 + mouse click opened its canonical URL and the accessible message exchange. + Device Disable returned `disabled`, removed the PushManager subscription and + left no notifications. Owned subscriptions were also cleaned up in the + runner's finalisation path. + +Environment: headed persistent Chromium 153.0.8010.12 on Linux ARM64, ordinary +browser tab, automation-granted notification permission, localhost secure +context. An isolated Dunst 1.9.2 notification daemon and private D-Bus supplied +native display/click on task display `:98`; both child processes stopped when +acceptance ended. The default session bus could not query host AppArmor policy +from this sandbox. The private test bus used its own same-user Unix socket and +configuration; no host policy, system service or unrelated desktop was changed. +This is **not** an installed-app, Android-phone, iPhone or public-HTTPS result. + +The browser's `PushMessagingGcmEndpointWebpushPath` feature selected its actual +FCM `https://fcm.googleapis.com/preprod/wp/` transport. The unbranded browser's +older `jmt17.google.com/fcm/send/` endpoint rejected its subscription as expired. +The application provider allowlist remains unchanged: no Google-wide or staging +host exception was shipped. A permanent rejection of a confirmation now retires +its server record and clears the rejected browser subscription, offering fresh +opt-in instead of a misleading indefinite pending state. + +The earlier diagnostic HTTP 403 came from importing unversioned `apiService.js` +into a document already using the release-scoped module graph. That second +coordinator created a new, unbound window selector. The actual Settings request +succeeded with its actor-bound selector; strict window validation was preserved. +Browser harnesses should use the page's actual module graph or its current +window header, not load a parallel copy of the coordinator. + +Evidence is retained in the task worktree's `.run/push-resume/acceptance.json`, +`desktop-alert.png`, `click.png` and `accept.cjs`. The screenshot and canonical +message IDs are bound in the receipt. The source repair handoff was independently +checksum-verified as `#V#computer_file_copy_47a3732ac0294834ac602067ab67e644`, SHA256 +`6a114a6798762d391cef8af2ff29508f62fa73f4b9b09f7f543970256a0e05da`. +Its earlier login/subscription preparation did not itself establish display. + +Validation: 80 targeted backend notification/message/window-isolation tests +passed after incorporating current main. The final expired-confirmation change +passed all 29 notification backend cases; all 12 notification frontend cases, +affected static lint and diff checks passed. These cover permission dismissal +and denial, canonical coding reports with workflow launches suppressed, stale +subscriptions, expiry, duplicate/retry/crash bounds, actor/device/scope denial, +queued delivery after logout, and access-checked navigation. Synthetic unit +Push events are not counted as real display evidence. + +The supported local delivery path now satisfies the bounded source merge gate. +Physical Android/iOS verification remains unavailable. The existing offline and +expired-session limitations above remain; no broader delivery guarantee is made. +Public activation still requires the controller to: + +1. Deploy the exact merged `origin/main` revision through its authorised release + route, keeping the prior release as code rollback. +2. Verify the protected VAPID/encryption configuration described above, the + locked dependency environment and actual sender activation. Test keys exist + only in the isolated fixture; their availability says nothing about public + runtime configuration. Code rollback does not provision or rotate keys. +3. Read back the public served revision and HTTPS manifest/worker paths, then + verify authenticated notification configuration and an installed-app delivery + on the target device. Keep that evidence separate from localhost acceptance. +4. Reconcile the engineering issue through the coordinating operator: the Jira + connector still returned `oauth_token_invalid_grant` during this continuation. + +The coding run requests deployment through its structured result only. It does +not mutate the live service or send canonical task/completion messages; the +controller owns those effects. Shared service-worker/manifest changes in +JVNAUTOSCI-2758 still need to preserve this notification path. + +## Platform sources checked + +- [WebKit: Web Push for Home Screen apps on iOS/iPadOS](https://webkit.org/blog/13878/web-push-for-web-apps-on-ios-and-ipados/) + documents the installation and user-gesture permission requirements. +- [WebKit: Declarative Web Push](https://webkit.org/blog/16535/meet-declarative-web-push/) + describes a newer backward-compatible route. This candidate retains the + standard service-worker path because it needs live revocation checks; it does + not claim declarative offline delivery. +- [MDN Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API) + and [pywebpush's implementation](https://github.com/web-push-libs/pywebpush) + describe browser subscriptions and encrypted provider transport. + +- [Chromium push endpoint selection](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/components/push_messaging/push_messaging_utils.cc) + and [endpoint constants](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/components/push_messaging/push_messaging_constants.cc) + identify the newer Web Push and legacy staging routes checked during recovery. diff --git a/pdm.lock b/pdm.lock index f03ed092..d3bbfd39 100644 --- a/pdm.lock +++ b/pdm.lock @@ -14,7 +14,7 @@ requires-python = ">=3.11" sources = [] [metadata.lock_inputs.project.groups] -default = ["Jinja2", "MarkupSafe", "PyYAML", "Pygments", "SPARQLWrapper", "Werkzeug", "Whoosh", "annotated-types", "anyio", "asttokens", "beautifulsoup4", "beautifulsoup4", "bibtexparser", "black", "bleach", "blinker", "boto3", "cachetools", "certifi", "charset-normalizer", "click", "colorama", "comm", "cryptography", "customtkinter", "darkdetect", "datetime", "debugpy", "decorator", "distro", "docutils", "exceptiongroup", "executing", "extract-msg", "fastapi", "feedparser", "flask", "flask-testing", "gcloud", "google-api-core", "google-api-python-client", "google-auth", "google-auth-httplib2", "google-auth-oauthlib", "google-genai", "googleapis-common-protos", "h11", "httpcore", "httplib2", "httpx", "idna", "ipykernel", "ipython", "itsdangerous", "jedi", "jupyter-client", "jupyter-core", "latexcodec", "llama-index", "markdown", "matplotlib-inline", "mcp", "mypy-extensions", "nest-asyncio", "node", "npm", "numpy", "oauth2client", "oauthlib", "odfpy", "odict", "ollama", "openai", "opencv-python-headless", "openpyxl", "openstacksdk", "optional-django", "packaging", "pandas", "parso", "pathspec", "pdfminer-six", "pillow", "platformdirs", "plumber", "prompt-toolkit", "proto-plus", "psutil", "pure-eval", "pyasn1", "pyasn1-modules", "pybtex", "pybtex-apa7-style", "pybtex-docutils", "pydantic", "pydantic-core", "pymongo", "pymupdf", "pyparsing", "pysimplegui", "pytesseract", "python-dateutil", "python-docx", "python-dotenv", "python-pptx", "pytz", "pywin32; platform_system == \"Windows\"", "pyzmq", "rdflib", "requests", "requests-oauthlib", "rsa", "setuptools", "six", "slack-bolt", "sniffio", "stack-data", "striprtf", "tavily-python", "tokenize-rt", "tornado", "tqdm", "traitlets", "typing-extensions", "tzdata", "uritemplate", "urllib3", "uv", "uvicorn", "waitress", "wcwidth", "zope-component", "zope-deferredimport", "zope-deprecation", "zope-event", "zope-hookable", "zope-interface", "zope-lifecycleevent", "zope-proxy"] +default = ["Jinja2", "MarkupSafe", "PyYAML", "Pygments", "SPARQLWrapper", "Werkzeug", "Whoosh", "annotated-types", "anyio", "asttokens", "beautifulsoup4", "beautifulsoup4", "bibtexparser", "black", "bleach", "blinker", "boto3", "cachetools", "certifi", "charset-normalizer", "click", "colorama", "comm", "cryptography", "customtkinter", "darkdetect", "datetime", "debugpy", "decorator", "distro", "docutils", "exceptiongroup", "executing", "extract-msg", "fastapi", "feedparser", "flask", "flask-testing", "gcloud", "google-api-core", "google-api-python-client", "google-auth", "google-auth-httplib2", "google-auth-oauthlib", "google-genai", "googleapis-common-protos", "h11", "httpcore", "httplib2", "httpx", "idna", "ipykernel", "ipython", "itsdangerous", "jedi", "jupyter-client", "jupyter-core", "latexcodec", "llama-index", "markdown", "matplotlib-inline", "mcp", "mypy-extensions", "nest-asyncio", "node", "npm", "numpy", "oauth2client", "oauthlib", "odfpy", "odict", "ollama", "openai", "opencv-python-headless", "openpyxl", "openstacksdk", "optional-django", "packaging", "pandas", "parso", "pathspec", "pdfminer-six", "pillow", "platformdirs", "plumber", "prompt-toolkit", "proto-plus", "psutil", "pure-eval", "pyasn1", "pyasn1-modules", "pybtex", "pybtex-apa7-style", "pybtex-docutils", "pydantic", "pydantic-core", "pymongo", "pymupdf", "pyparsing", "pysimplegui", "pytesseract", "python-dateutil", "python-docx", "python-dotenv", "python-pptx", "pytz", "pywebpush", "pywin32; platform_system == \"Windows\"", "pyzmq", "rdflib", "requests", "requests-oauthlib", "rsa", "setuptools", "six", "slack-bolt", "sniffio", "stack-data", "striprtf", "tavily-python", "tokenize-rt", "tornado", "tqdm", "traitlets", "typing-extensions", "tzdata", "uritemplate", "urllib3", "uv", "uvicorn", "waitress", "wcwidth", "zope-component", "zope-deferredimport", "zope-deprecation", "zope-event", "zope-hookable", "zope-interface", "zope-lifecycleevent", "zope-proxy"] dev = ["annotated-types", "anyio", "blinker", "mongomock", "pymongo", "pyright", "pytest", "pytest-asyncio", "pytest-cov", "pytest-mock", "ruff"] geo = ["geopandas", "pyogrio", "pyproj", "shapely"] @@ -1774,6 +1774,18 @@ files = [ {file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"}, ] +[[package]] +name = "http-ece" +version = "1.2.1" +summary = "Encrypted Content Encoding for HTTP" +groups = ["default"] +dependencies = [ + "cryptography>=2.5", +] +files = [ + {file = "http_ece-1.2.1.tar.gz", hash = "sha256:8c6ab23116bbf6affda894acfd5f2ca0fb8facbcbb72121c11c75c33e7ce8cff"}, +] + [[package]] name = "httpcore" version = "1.0.9" @@ -3574,6 +3586,19 @@ files = [ {file = "pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42"}, ] +[[package]] +name = "py-vapid" +version = "1.9.4" +summary = "Simple VAPID header generation library" +groups = ["default"] +dependencies = [ + "cryptography>=46", +] +files = [ + {file = "py_vapid-1.9.4-py2.py3-none-any.whl", hash = "sha256:f165a5bf90dcf966b226114f01f178f137579a09784c7f0628fa2f0a299741b6"}, + {file = "py_vapid-1.9.4.tar.gz", hash = "sha256:a004023560cbc54e34fc06380a0580f04ffcc788e84fb6d19e9339eeb6551a28"}, +] + [[package]] name = "pyasn1" version = "0.6.4" @@ -4235,6 +4260,24 @@ files = [ {file = "pytz-2026.3.post1.tar.gz", hash = "sha256:2211d3fcf9a797d3405cac96ac7f61d80e6a644f72a3309607282fe8a2010c5d"}, ] +[[package]] +name = "pywebpush" +version = "2.5.0" +requires_python = ">=3.10" +summary = "WebPush publication library" +groups = ["default"] +dependencies = [ + "aiohttp", + "cryptography>=47.0.0", + "http-ece>=1.1.0", + "py-vapid>=1.7.0", + "requests>=2.21.0", +] +files = [ + {file = "pywebpush-2.5.0-py3-none-any.whl", hash = "sha256:b5f5d45024223a04e73fe17f7a8e62540f35bbcc62b49e64d0a1080ff2dc26f2"}, + {file = "pywebpush-2.5.0.tar.gz", hash = "sha256:8260f8e42d70ed95a3bd00527ffcb43b5b3df8983a4a48b059fe29b06dc3d92c"}, +] + [[package]] name = "pywin32" version = "312" diff --git a/pyproject.toml b/pyproject.toml index 021e9e5f..1d8b04dc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -151,6 +151,7 @@ dependencies = [ "beautifulsoup4>=4.12.3", "extract-msg>=0.48.7", "boto3>=1.40.0", + "pywebpush<3,>=2.0.3", ] [build-system] diff --git a/src/backend/server/routes/auth_routes.py b/src/backend/server/routes/auth_routes.py index d928b3ba..ed1e2fc3 100644 --- a/src/backend/server/routes/auth_routes.py +++ b/src/backend/server/routes/auth_routes.py @@ -77,6 +77,9 @@ def _clear_scope_if_authenticated_identity_changes( session.get(key) is not None for key in _ACTOR_SCOPE_SESSION_KEYS ) if (had_previous_identity and not same_actor) or has_stale_scope_without_identity: + from .web_push_routes import revoke_browser_notifications + + revoke_browser_notifications() for key in _ACTOR_SCOPE_SESSION_KEYS: session.pop(key, None) @@ -630,6 +633,12 @@ def browser_test_login(): @auth_bp.route("/api/auth/logout", methods=["POST"]) def logout(): """Log out the current user by clearing their session.""" + from .web_push_routes import revoke_browser_notifications + + try: + revoke_browser_notifications() + except Exception: + return jsonify(error="Notification opt-out unavailable; retry logout"), 503 cloudflare_logout = session.get("auth_provider") == "cloudflare_access" user_email = session.get("user_email") user_id = ( diff --git a/src/backend/server/routes/web_push_routes.py b/src/backend/server/routes/web_push_routes.py new file mode 100644 index 00000000..57b89921 --- /dev/null +++ b/src/backend/server/routes/web_push_routes.py @@ -0,0 +1,246 @@ +"""Interactive, session-authenticated notification controls; no caller actor IDs.""" + +import secrets +from urllib.parse import urlsplit + +from flask import Blueprint, current_app, jsonify, request, session, send_from_directory +from itsdangerous import BadSignature, URLSafeSerializer + +from ...security.authentication_assurance import ( + session_has_required_authentication_assurance, +) +from ...services import web_push_service as push + +web_push_bp = Blueprint("web_push", __name__) +COOKIE = "von_push_device" + + +def device_id(): + raw = request.cookies.get(COOKIE) + if not raw: + return None + try: + value = URLSafeSerializer(current_app.secret_key, salt=COOKIE).loads(raw) + return value if isinstance(value, str) and len(value) == 64 else None + except BadSignature: + return None + + +def revoke_browser_notifications(): + """Used before logout/account replacement. Failure must remain observable.""" + device = device_id() + if device: + push.revoke_device(device) + + +def actor_id(): + actor = session.get("user_concept_id") + if ( + session.get("user_email") + and isinstance(actor, str) + and session_has_required_authentication_assurance(session) + ): + return actor + return None + + +@web_push_bp.before_request +def protect_controls(): + if not request.path.startswith("/api/notifications"): + return None + if not actor_id(): + return jsonify(error="Sign in to manage notifications"), 401 + if request.method != "GET": + if request.content_length and request.content_length > 8192: + return jsonify(error="Subscription payload too large"), 413 + origin = request.headers.get("Origin") + expected = urlsplit(request.host_url) + actual = urlsplit(origin or "") + if (actual.scheme, actual.netloc) != (expected.scheme, expected.netloc): + return jsonify(error="Same-origin request required"), 403 + if not request.is_json: + return jsonify(error="JSON required"), 415 + return None + + +@web_push_bp.after_request +def no_cache(response): + response.headers["Cache-Control"] = "no-store" + return response + + +@web_push_bp.errorhandler(Exception) +def unavailable(exc): + # Provider and database exceptions can contain endpoint credentials. + if isinstance(exc, PermissionError): + return jsonify(error=str(exc)), 403 + if isinstance(exc, ValueError): + return jsonify(error=str(exc)), 400 + current_app.logger.warning("Notification operation unavailable") + return jsonify(error="Notifications unavailable; try again later"), 503 + + +def scope(): + from .message_routes import _get_current_org_concept_id + + organisation = _get_current_org_concept_id(actor_id(), require_known_window=True) + if not push.scope_allowed(actor_id(), organisation): + raise PermissionError("Organisation access required") + return organisation + + +@web_push_bp.get("/api/notifications/status") +def status(): + config = push.configuration() + if not config["configured"]: + return jsonify(**config, state="unavailable") + device = device_id() or secrets.token_hex(32) + response = jsonify(**config, **push.status(actor_id(), device, scope())) + response.set_cookie( + COOKIE, + URLSafeSerializer(current_app.secret_key, salt=COOKIE).dumps(device), + secure=request.is_secure, + httponly=True, + samesite="Strict", + max_age=365 * 86400, + path="/", + ) + return response + + +@web_push_bp.post("/api/notifications/subscribe") +def subscribe(): + if not push.configuration()["configured"] or not device_id(): + return jsonify(error="Refresh notification status first"), 409 + payload = request.get_json() or {} + return jsonify( + push.subscribe(actor_id(), device_id(), scope(), payload.get("subscription")) + ) + + +@web_push_bp.post("/api/notifications/confirm") +def confirm(): + payload = request.get_json() or {} + identifier, challenge = payload.get("id"), payload.get("challenge") + if ( + not isinstance(identifier, str) + or not isinstance(challenge, str) + or len(challenge) > 128 + ): + raise ValueError("Invalid confirmation") + if not push.confirm(actor_id(), device_id(), identifier, challenge): + return jsonify(error="Confirmation expired or belongs to another device"), 403 + return jsonify(state="active") + + +@web_push_bp.post("/api/notifications/disable") +def disable(): + # Device-wide opt-out also covers prior actor/scope records. The signed + # HttpOnly device cookie is the authority, never a supplied endpoint. + revoke_browser_notifications() + return jsonify(state="disabled") + + +@web_push_bp.post("/api/notifications/test") +def test(): + payload = request.get_json() or {} + identifier = payload.get("id") + if not isinstance(identifier, str): + raise ValueError("Invalid subscription") + return jsonify(push.test_notification(actor_id(), device_id(), identifier)) + + +@web_push_bp.post("/api/notifications/validate") +def validate_delivery(): + payload = request.get_json() or {} + identifier = payload.get("id") + generation = payload.get("generation") + if not isinstance(identifier, str) or not isinstance(generation, str): + raise ValueError("Invalid subscription") + row = push.collection().find_one( + { + "_id": identifier, + "actor": actor_id(), + "device": device_id(), + "state": "active", + "generation": generation, + "expires_at": {"$gt": push.now()}, + } + ) + if not row or not push.scope_allowed(actor_id(), row["organisation"]): + return jsonify(error="Notification subscription no longer active"), 403 + return jsonify(active=True) + + +@web_push_bp.get("/api/notifications/open/") +def open_message(receipt_id): + from ...services.message_service import ( + get_message_for_user, + PREDICATE_RECIPIENT, + PREDICATE_SENDER, + ) + from ...services.message_catalogue_service import exchange_id + from ...security.access_control import override_current_actor + + actor = actor_id() + receipt = push.collection(push.RECEIPTS).find_one( + { + "_id": receipt_id, + "actor": actor, + "expires_at": {"$gt": push.now()}, + } + ) + if not receipt or not push.scope_allowed(actor, receipt["organisation"]): + return jsonify(error="Message unavailable for this account"), 404 + with override_current_actor(actor, receipt["organisation"]): + message = get_message_for_user(receipt["message_id"], actor) + if ( + not message + or message.get("concept_data", {}).get("deleted") + or actor not in message.get("relationships", {}).get(PREDICATE_RECIPIENT, []) + ): + return jsonify(error="Message unavailable for this account"), 404 + rel = message["relationships"] + participants = sorted( + set(rel.get(PREDICATE_SENDER, []) + rel.get(PREDICATE_RECIPIENT, [])) + ) + return jsonify( + session_id=exchange_id(participants, receipt["organisation"]), + source_kind="message_exchange", + participant_ids=participants, + other_participant_ids=[p for p in participants if p != actor], + viewer_id=actor, + organisation_concept_id=receipt["organisation"], + session_name="Von messages", + last_message_id=message["concept_id"], + ) + + +@web_push_bp.get("/von/service-worker.js") +def service_worker(): + response = send_from_directory(current_app.static_folder, "service-worker.js") + response.headers["Service-Worker-Allowed"] = "/von/" + return response + + +@web_push_bp.get("/von/manifest.webmanifest") +def manifest(): + return jsonify( + id="/von/", + name="Von", + short_name="Von", + start_url="/von/", + scope="/von/", + display="standalone", + background_color="#ffffff", + theme_color="#245e47", + icons=[ + { + "src": f"/static/pwa-icon-{size}.png", + "sizes": f"{size}x{size}", + "type": "image/png", + "purpose": "any", + } + for size in (192, 512) + ], + ) diff --git a/src/backend/server/utils_flask.py b/src/backend/server/utils_flask.py index 00de485a..144673bb 100644 --- a/src/backend/server/utils_flask.py +++ b/src/backend/server/utils_flask.py @@ -1582,6 +1582,12 @@ def create_flask_app( _bootstrap_constitutive_relation_requirements_for_startup(app) _configure_durable_workflow_startup(app) _maybe_start_external_conversation_import_worker(app) + from ..services.web_push_service import start_worker as start_web_push_worker + + try: + start_web_push_worker() + except Exception: + app.logger.warning("Web Push startup unavailable; canonical messaging is unaffected") _maybe_start_chat_prompt_queue_dispatcher(app) _log_prompt_concept_health(app) @@ -2605,6 +2611,9 @@ def _register_default_blueprints(app: Flask) -> None: app.register_blueprint(admin_bp, url_prefix="/admin") app.register_blueprint(task_bp, url_prefix="/api/tasks") app.register_blueprint(message_bp, url_prefix="/api/messages") + from .routes.web_push_routes import web_push_bp + + app.register_blueprint(web_push_bp) app.register_blueprint(ontology_authority_bp) app.register_blueprint(auth_bp, url_prefix="/von") app.register_blueprint(agent_gmail_oauth_bp, url_prefix="/von") diff --git a/src/backend/services/web_push_service.py b/src/backend/services/web_push_service.py new file mode 100644 index 00000000..aa993409 --- /dev/null +++ b/src/backend/services/web_push_service.py @@ -0,0 +1,523 @@ +"""Device-bound Web Push delivery from durable canonical direct messages. + +This is a derived delivery store, not Vontology authority. Message creation never +depends on push or launches a model. Leased subscriptions and durable receipts +keep retries bounded, reusing the opaque notification tag/navigation ID. +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import logging +import os +import secrets +import threading +from datetime import datetime, timedelta, timezone +from urllib.parse import urlsplit + +import requests +from cryptography.hazmat.primitives.asymmetric import ec +from pymongo import ReturnDocument +from pymongo.errors import DuplicateKeyError + +from ..db.mongo_client import get_db, get_concepts_collection +from ..security.token_encryption import encrypt_json, decrypt_json, get_fernet + +KEY_ENV = "VON_WEB_PUSH_ENCRYPTION_KEY" +COLLECTION = "web_push_subscriptions" +RECEIPTS = "web_push_receipts" +LEASE_SECONDS = 120 # One bounded provider request (10 s), with recovery headroom. +_log = logging.getLogger(__name__) +_worker_lock = threading.Lock() +_worker = None + + +def now(): + return datetime.now(timezone.utc) + + +def digest(value): + return hashlib.sha256(value.encode()).hexdigest() + + +def collection(name=COLLECTION): + db = get_db() + if db is None: + raise RuntimeError("Notification storage unavailable") + return db[name] + + +def configuration(): + """Return public setup state without leaking keys or provider errors.""" + public = os.getenv("VON_WEB_PUSH_PUBLIC_KEY", "").strip() + configured = all( + os.getenv(key, "").strip() + for key in ( + "VON_WEB_PUSH_PRIVATE_KEY", + "VON_WEB_PUSH_PUBLIC_KEY", + "VON_WEB_PUSH_SUBJECT", + KEY_ENV, + ) + ) + if configured: + try: + get_fernet(env_var=KEY_ENV) + from pywebpush import webpush # noqa: F401 + except Exception: + configured = False + return {"configured": configured, "public_key": public if configured else None} + + +def validate_subscription(value): + """Constrain the server's outbound request to supported push providers. + + A browser-supplied URL is not arbitrary server-side fetch authority. Exact + provider hosts/suffixes plus HTTPS and no redirects exclude local targets. + Endpoint possession still needs the push challenge below. + """ + if not isinstance(value, dict): + raise ValueError("Invalid subscription") + endpoint = value.get("endpoint") + if not isinstance(endpoint, str) or len(endpoint) > 4096: + raise ValueError("Invalid subscription") + parsed = urlsplit(endpoint) + host = parsed.hostname or "" + allowed = host in {"fcm.googleapis.com", "updates.push.services.mozilla.com"} or ( + host.endswith(".push.apple.com") and host != ".push.apple.com" + ) + if ( + not allowed + or parsed.scheme != "https" + or parsed.port not in (None, 443) + or parsed.username + or parsed.password + or parsed.fragment + ): + raise ValueError("Unsupported push provider") + keys = value.get("keys") or {} + try: + public = base64.urlsafe_b64decode(keys["p256dh"] + "===") + auth = base64.urlsafe_b64decode(keys["auth"] + "===") + if len(public) != 65 or len(auth) != 16: + raise ValueError() + ec.EllipticCurvePublicKey.from_encoded_point(ec.SECP256R1(), public) + except Exception as exc: + raise ValueError("Invalid subscription keys") from exc + return {"endpoint": endpoint, "keys": {k: keys[k] for k in ("p256dh", "auth")}} + + +class _ProviderSession(requests.Session): + def request(self, method, url, **kwargs): + kwargs["allow_redirects"] = False + return super().request(method, url, **kwargs) + + +def send(subscription, payload, *, topic): + """Return provider acceptance only. Never log endpoint/key/response bodies.""" + from pywebpush import WebPushException, webpush + + try: + with _ProviderSession() as http: + response = webpush( + subscription_info=subscription, + data=json.dumps(payload, separators=(",", ":")), + vapid_private_key=os.environ["VON_WEB_PUSH_PRIVATE_KEY"], + vapid_claims={"sub": os.environ["VON_WEB_PUSH_SUBJECT"]}, + ttl=60, + timeout=10, + requests_session=http, + headers={"Topic": topic[:32], "Urgency": "normal"}, + ) + return "accepted" if 200 <= response.status_code < 300 else "failed" + except WebPushException as exc: + code = exc.response.status_code if exc.response is not None else None + return "expired" if code in (404, 410) else "failed" + except Exception: + return "failed" + + +def scope_allowed(actor, organisation): + if not organisation: + return True + from .organisation_membership_service import get_user_memberships + + return any( + row.get("organisation_concept_id") == organisation + for row in get_user_memberships(actor).get("memberships", []) + ) + + +def revoke_device(device): + if device: + collection().delete_many({"device": device}) + + +def status(actor, device, organisation): + # Browser-wide identity changes revoke the old actor on this device. Other + # devices and same-actor organisation opt-ins are independent. + coll = collection() + coll.delete_many({"device": device, "actor": {"$ne": actor}}) + row = coll.find_one( + { + "device": device, + "actor": actor, + "organisation": organisation, + "expires_at": {"$gt": now()}, + } + ) + return { + "state": row["state"] if row else "disabled", + "subscription_id": row["_id"] if row else None, + "generation": row.get("generation") if row else None, + "last_delivery": row.get("last_delivery") if row else None, + } + + +def subscribe(actor, device, organisation, subscription): + subscription = validate_subscription(subscription) + if not scope_allowed(actor, organisation): + raise PermissionError("Organisation access required") + coll = collection() + identifier = digest(subscription["endpoint"]) + device_row = coll.find_one({"device": device}) + if device_row and device_row["_id"] != identifier: + # One PushManager subscription per app installation. Do not accumulate + # stale endpoints or bypass the bounded confirmation action by rotating URLs. + if ( + device_row.get("challenge_after", now()).replace(tzinfo=timezone.utc) + > now() + ): + raise ValueError("Wait one minute before enabling another subscription") + coll.delete_one({"_id": device_row["_id"], "device": device}) + existing = coll.find_one({"_id": identifier}) + if existing: + if (existing["actor"], existing["device"], existing["organisation"]) != ( + actor, + device, + organisation, + ): + raise PermissionError( + "Disable notifications in the original account or scope first" + ) + if existing["state"] == "active": + coll.update_one( + {"_id": identifier, "state": "active"}, + { + "$set": { + "encrypted": encrypt_json(subscription, env_var=KEY_ENV), + "expires_at": now() + timedelta(days=30), + } + }, + ) + return {"state": "active", "subscription_id": identifier} + # Pending/failed challenges are deliberately rate limited too. + if existing["retry_at"].replace(tzinfo=timezone.utc) > now(): + return {"state": existing["state"], "subscription_id": identifier} + coll.delete_one({"_id": identifier, "state": existing["state"]}) + challenge = secrets.token_urlsafe(32) + stamp = now() + row = { + "_id": identifier, + "actor": actor, + "device": device, + "organisation": organisation, + "state": "pending", + "encrypted": encrypt_json(subscription, env_var=KEY_ENV), + "challenge": digest(challenge), + "challenge_until": stamp + timedelta(minutes=5), + "challenge_after": stamp + timedelta(minutes=1), + "expires_at": stamp + timedelta(days=30), + "enabled_at": stamp, + "generation": secrets.token_hex(16), + "retry_at": stamp + timedelta(minutes=1), + "lease_until": stamp, + } + try: + coll.insert_one(row) + except DuplicateKeyError as exc: + raise ValueError("Subscription changed; refresh its status") from exc + outcome = send( + subscription, + { + "kind": "confirmation", + "id": identifier, + "generation": row["generation"], + "challenge": challenge, + }, + topic=identifier, + ) + if outcome == "expired": + coll.delete_one({"_id": identifier, "generation": row["generation"]}) + return {"state": "disabled", "provider_result": outcome} + coll.update_one({"_id": identifier}, {"$set": {"last_delivery": outcome}}) + return { + "state": "pending", + "subscription_id": identifier, + "provider_result": outcome, + } + + +def confirm(actor, device, identifier, challenge): + result = collection().update_one( + { + "_id": identifier, + "actor": actor, + "device": device, + "state": "pending", + "challenge": digest(challenge), + "challenge_until": {"$gt": now()}, + }, + { + "$set": {"state": "active", "retry_at": now()}, + "$unset": {"challenge": "", "challenge_until": ""}, + }, + ) + return bool(result.modified_count) + + +def test_notification(actor, device, identifier): + stamp = now() + row = collection().find_one_and_update( + { + "_id": identifier, + "actor": actor, + "device": device, + "state": "active", + "expires_at": {"$gt": stamp}, + "$or": [ + {"test_after": {"$exists": False}}, + {"test_after": {"$lte": stamp}}, + ], + }, + {"$set": {"test_after": stamp + timedelta(minutes=1)}}, + ) + if not row or not scope_allowed(actor, row["organisation"]): + raise ValueError( + "Enable notifications first; tests are limited to once a minute" + ) + outcome = send( + decrypt_json(row["encrypted"], env_var=KEY_ENV), + {"kind": "test", "id": identifier, "generation": row["generation"]}, + topic=identifier, + ) + if outcome == "expired": + collection().delete_one({"_id": identifier}) + return {"provider_result": outcome} + + +def _next_message(row): + from ..security.access_control import ( + override_current_actor, + apply_concept_query_filter, + ) + from .message_service import MESSAGE_TYPE_CONCEPT_ID, PREDICATE_RECIPIENT + + query = { + "relationships.is_an_instance_of": MESSAGE_TYPE_CONCEPT_ID, + f"relationships.{PREDICATE_RECIPIENT}": row["actor"], + "concept_data.organisation_concept_id": row["organisation"], + "concept_data.deleted": {"$ne": True}, + "created_at": { + "$gte": max( + row["enabled_at"].replace(tzinfo=timezone.utc), + now() - timedelta(days=1), + ) + }, + } + with override_current_actor(row["actor"], row["organisation"]): + # Anti-join terminal receipts instead of trusting a wall-clock cursor: + # a concurrent canonical insert may finish after a newer message. + messages = list( + get_concepts_collection().aggregate( + [ + {"$match": apply_concept_query_filter(query)}, + {"$sort": {"created_at": 1, "concept_id": 1}}, + { + "$lookup": { + "from": RECEIPTS, + "localField": "concept_id", + "foreignField": "message_id", + "as": "push_receipts", + } + }, + { + "$match": { + "push_receipts": { + "$not": { + "$elemMatch": { + "generation": row["generation"], + "terminal": True, + } + } + } + } + }, + {"$limit": 1}, + {"$project": {"push_receipts": 0}}, + ] + ) + ) + return messages[0] if messages else None + + +def deliver_one(): + """One durable leased delivery; multiple web processes may safely compete.""" + stamp = now() + lease = secrets.token_hex(16) + coll = collection() + row = coll.find_one_and_update( + { + "state": "active", + "expires_at": {"$gt": stamp}, + "retry_at": {"$lte": stamp}, + "lease_until": {"$lte": stamp}, + }, + { + "$set": { + "lease": lease, + "lease_until": stamp + timedelta(seconds=LEASE_SECONDS), + } + }, + sort=[("retry_at", 1)], + return_document=ReturnDocument.AFTER, + ) + if not row: + return False + owned = {"_id": row["_id"], "lease": lease, "state": "active"} + try: + if not scope_allowed(row["actor"], row["organisation"]): + coll.delete_one(owned) + return True + message = _next_message(row) + if not message: + coll.update_one( + owned, {"$set": {"retry_at": stamp + timedelta(seconds=30)}} + ) + return True + # Deterministic delivery identity survives process death and provider + # acceptance followed by a lost response. It contains no actor/message ID. + receipt_id = digest(row["generation"] + message["concept_id"]) + receipts = collection(RECEIPTS) + receipts.update_one( + {"_id": receipt_id}, + { + "$setOnInsert": { + "actor": row["actor"], + "organisation": row["organisation"], + "message_id": message["concept_id"], + "subscription_id": row["_id"], + "generation": row["generation"], + "terminal": False, + "attempts": 0, + "expires_at": stamp + timedelta(days=7), + } + }, + upsert=True, + ) + # Count before I/O so crashes cannot cause unlimited retries. + if not coll.find_one(owned): + return True + claimed = receipts.find_one_and_update( + {"_id": receipt_id}, + {"$inc": {"attempts": 1}}, + return_document=ReturnDocument.AFTER, + ) + if claimed["attempts"] > 3: + outcome = "failed" + elif row["actor"] in message.get("concept_data", {}).get( + "read_by", [] + ) or message["created_at"].replace(tzinfo=timezone.utc) < stamp - timedelta( + days=1 + ): + outcome = "skipped" + else: + outcome = send( + decrypt_json(row["encrypted"], env_var=KEY_ENV), + { + "kind": "message", + "id": row["_id"], + "generation": row["generation"], + "receipt": receipt_id, + }, + topic=receipt_id, + ) + receipts.update_one( + {"_id": receipt_id}, + { + "$set": { + "provider_result": outcome, + "attempts": claimed["attempts"], + "updated_at": stamp, + "terminal": outcome != "failed" or claimed["attempts"] >= 3, + } + }, + ) + if outcome == "expired": + coll.delete_one(owned) + elif outcome != "failed" or claimed["attempts"] >= 3: + coll.update_one( + owned, + { + "$set": { + "last_delivery": outcome, + "retry_at": stamp, + } + }, + ) + else: + coll.update_one( + owned, + { + "$set": { + "last_delivery": outcome, + "retry_at": stamp + timedelta(minutes=claimed["attempts"]), + } + }, + ) + return True + finally: + coll.update_one( + owned, {"$set": {"lease_until": now()}, "$unset": {"lease": ""}} + ) + + +def start_worker(): + """Independent of model/workflow launch suppression; explicitly configured.""" + global _worker + if not configuration()["configured"]: + return + with _worker_lock: + if _worker and _worker.is_alive(): + return + + def run(): + while True: + try: + for _ in range(100): + if not deliver_one(): + break + except Exception: + _log.warning( + "Web Push delivery unavailable; canonical messages retained" + ) + threading.Event().wait(30) + + # No data rewrite/migration. These indexes bound routine device lookup + # and delivery scans; expired capability/navigation records self-delete. + collection().create_index("device", unique=True) + collection().create_index([("state", 1), ("retry_at", 1), ("lease_until", 1)]) + collection(RECEIPTS).create_index("message_id") + get_concepts_collection().create_index( + [ + ("relationships.#V#has_recipient", 1), + ("concept_data.organisation_concept_id", 1), + ("created_at", 1), + ], + name="direct_message_push_recipient_org_created", + ) + for name in (COLLECTION, RECEIPTS): + collection(name).create_index("expires_at", expireAfterSeconds=0) + _worker = threading.Thread(target=run, name="web-push-delivery", daemon=True) + _worker.start() diff --git a/src/frontend/web/von_interface/static/js/main.js b/src/frontend/web/von_interface/static/js/main.js index 54ad46ba..ab7a0a1c 100644 --- a/src/frontend/web/von_interface/static/js/main.js +++ b/src/frontend/web/von_interface/static/js/main.js @@ -136,6 +136,13 @@ document.addEventListener('DOMContentLoaded', async () => { } // Start background preload of Vontology data while chat is active + try { + const { openNotificationMessage } = await import('./pushNotifications.js'); + await openNotificationMessage(); + } catch (error) { + console.warn('[main] Notification navigation unavailable'); + } + try { preloadVontologyData(); startHealthPolling(); diff --git a/src/frontend/web/von_interface/static/js/pushNotifications.js b/src/frontend/web/von_interface/static/js/pushNotifications.js new file mode 100644 index 00000000..262aeab7 --- /dev/null +++ b/src/frontend/web/von_interface/static/js/pushNotifications.js @@ -0,0 +1,181 @@ +import { getJsonDetailed, postJson } from './apiService.js'; + +const API = '/api/notifications'; + +export function notificationSupport() { + if (!window.isSecureContext) return 'Notifications require a secure HTTPS connection.'; + const isAppleMobile = /iPad|iPhone|iPod/.test(navigator.userAgent) + || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); + if (isAppleMobile && !window.matchMedia('(display-mode: standalone)').matches && !navigator.standalone) { + return 'On iPhone or iPad, add Von to the Home Screen, then open that app to enable notifications.'; + } + if (!('serviceWorker' in navigator) || !('PushManager' in window) || !('Notification' in window)) { + return 'This browser does not support Von push notifications. Try a current supported browser.'; + } + return null; +} + +async function registration() { + const reg = await navigator.serviceWorker.register('/von/service-worker.js', { scope: '/von/' }); + // Settings lives in /settings iframe, outside the /von/ worker scope. + // navigator.serviceWorker.ready would wait forever for that frame to be + // controlled. Wait for this registration's activation instead. + if (reg.active?.state === 'activated') return reg; + const worker = reg.installing || reg.waiting || reg.active; + if (!worker) throw new Error('Notification service worker unavailable. Refresh to retry.'); + await new Promise((resolve, reject) => { + const changed = () => { + if (worker.state === 'activated' || worker.state === 'redundant') { + worker.removeEventListener('statechange', changed); + if (worker.state === 'activated') resolve(); + else reject(new Error('Notification service worker could not activate. Refresh to retry.')); + } + }; + worker.addEventListener('statechange', changed); + changed(); + }); + return reg; +} + +export async function disableBrowserNotifications() { + // Server revocation first; report failures instead of presenting a false opt-out. + await postJson(`${API}/disable`, {}); + await clearLocalNotifications(); +} + +export async function clearLocalNotifications() { + if (!('serviceWorker' in navigator)) return; + const reg = await navigator.serviceWorker.getRegistration('/von/'); + if (!reg) return; + const sub = await reg.pushManager.getSubscription(); + if (sub) await sub.unsubscribe(); + for (const notification of await reg.getNotifications()) notification.close(); + reg.active?.postMessage({ type: 'von-push-disable' }); +} + +export async function initialiseNotificationControls() { + const root = document.getElementById('pushNotificationSettings'); + if (!root || root.dataset.initialised) return; + root.dataset.initialised = 'true'; + const status = root.querySelector('[data-push-status]'); + const enable = root.querySelector('[data-push-enable]'); + const disable = root.querySelector('[data-push-disable]'); + const test = root.querySelector('[data-push-test]'); + const refresh = root.querySelector('[data-push-refresh]'); + let server = null; + let reg = null; + let busy = false; + + async function update() { + enable.disabled = disable.disabled = test.disabled = true; + const unsupported = notificationSupport(); + if (unsupported) { status.textContent = unsupported; return; } + try { + ({ data: server } = await getJsonDetailed(`${API}/status`)); + if (!server.configured) { + status.textContent = 'Notifications are not configured on this server yet.'; + return; + } + reg = await registration(); + const sub = await reg.pushManager.getSubscription(); + const permission = Notification.permission; + const cached = await caches.match('/von/.push-binding', { cacheName: 'von-push-state-v1' }); + const binding = cached ? await cached.json() : null; + const needsReconnect = server.state === 'active' && !!sub + && (binding?.id !== server.subscription_id || binding?.generation !== server.generation); + const active = permission === 'granted' && !!sub && server.state === 'active' && !needsReconnect; + status.textContent = permission === 'denied' + ? 'Notifications are blocked in browser or system settings. Change that permission there, then refresh.' + : needsReconnect ? 'This device lost its notification binding. Disable on this device, then Enable again to reconnect.' + : active ? 'Notifications enabled for direct messages in this organisation context on this device. Lock-screen text stays private.' + : server.state === 'pending' ? 'Waiting for the confirmation push. Refresh status after it arrives; if it fails, retry Enable after one minute.' + : 'Notifications are off. Enable them for direct messages in the current organisation context.'; + enable.disabled = active || needsReconnect || permission === 'denied'; + disable.disabled = !sub && server.state === 'disabled'; + test.disabled = !active; + if (permission !== 'granted' && server.state === 'active') { + await disableBrowserNotifications(); + } + if (active) { + // Renew an existing, confirmed opt-in while this actor is present. + // Permission is never requested as part of refresh. + await postJson(`${API}/subscribe`, { subscription: sub.toJSON() }); + } + } catch (error) { + status.textContent = `Notification status unavailable. ${error.message}`; + } + } + + async function action(fn) { + if (busy) return; + busy = true; + enable.disabled = disable.disabled = test.disabled = refresh.disabled = true; + try { await fn(); } catch (error) { status.textContent = error.message; } + finally { busy = false; refresh.disabled = false; } + } + + enable.addEventListener('click', () => { + // Invoke before any await: iOS requires direct user activation. + const permission = Notification.requestPermission(); + void action(async () => { + const result = await permission; + if (result !== 'granted') { + await update(); + if (result === 'default') status.textContent = 'Permission was dismissed. Notifications remain off; you can enable them later.'; + return; + } + let sub = await reg.pushManager.getSubscription(); + if (!sub) { + const padded = server.public_key.replace(/-/g, '+').replace(/_/g, '/'); + const key = Uint8Array.from(atob(padded), character => character.charCodeAt(0)); + sub = await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key }); + } + const enrolment = await postJson(`${API}/subscribe`, { subscription: sub.toJSON() }); + if (enrolment.provider_result === 'expired') { + await clearLocalNotifications(); + await update(); + status.textContent = 'The push service rejected this subscription. Enable again to request a fresh one; if it repeats, try a current supported browser.'; + return; + } + await update(); + }); + }); + disable.addEventListener('click', () => void action(async () => { + await disableBrowserNotifications(); await update(); + })); + test.addEventListener('click', () => void action(async () => { + const result = await postJson(`${API}/test`, { id: server.subscription_id }); + await update(); + status.textContent = result.provider_result === 'accepted' + ? 'The push provider accepted the test. Check your device for the notification; acceptance does not prove display.' + : 'The test was not accepted. Refresh status and try enabling notifications again.'; + })); + refresh.addEventListener('click', () => void update()); + document.addEventListener('visibilitychange', () => { + if (!document.hidden && !busy) void update(); + }); + await update(); +} + +export async function openNotificationMessage() { + const receipt = new URL(window.location.href).searchParams.get('notification'); + if (!receipt || !/^[a-f0-9]{64}$/.test(receipt)) return; + try { + const { data: row } = await getJsonDetailed(`${API}/open/${receipt}`); + const { selectMessageConversation } = await import('./components/conversationCatalogue.js'); + const { activateTab } = await import('./tabNavigation.js'); + activateTab('chatTab'); + await selectMessageConversation(row); + } catch (error) { + const message = document.createElement('p'); + message.setAttribute('role', 'status'); + message.textContent = `This notification cannot be opened in the current account. ${error.message}`; + document.getElementById('vonAuthenticatedApp')?.prepend(message); + } +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', () => void initialiseNotificationControls()); +} else { + void initialiseNotificationControls(); +} diff --git a/src/frontend/web/von_interface/static/pwa-icon-192.png b/src/frontend/web/von_interface/static/pwa-icon-192.png new file mode 100644 index 00000000..0d3810c1 Binary files /dev/null and b/src/frontend/web/von_interface/static/pwa-icon-192.png differ diff --git a/src/frontend/web/von_interface/static/pwa-icon-512.png b/src/frontend/web/von_interface/static/pwa-icon-512.png new file mode 100644 index 00000000..e84a3c0c Binary files /dev/null and b/src/frontend/web/von_interface/static/pwa-icon-512.png differ diff --git a/src/frontend/web/von_interface/static/service-worker.js b/src/frontend/web/von_interface/static/service-worker.js new file mode 100644 index 00000000..439fa48e --- /dev/null +++ b/src/frontend/web/von_interface/static/service-worker.js @@ -0,0 +1,97 @@ +/* Push only: do not cache authenticated pages or intercept application fetches. */ +const STATE_CACHE = 'von-push-state-v1'; +const STATE_URL = new URL('/von/.push-binding', self.location.origin).href; + +async function readBinding() { + const cache = await caches.open(STATE_CACHE); + const entry = await cache.match(STATE_URL); + return entry ? entry.json() : null; +} + +async function saveBinding(value) { + const cache = await caches.open(STATE_CACHE); + await cache.put(STATE_URL, new Response(JSON.stringify(value))); +} + +async function disable() { + await caches.delete(STATE_CACHE); + for (const notification of await self.registration.getNotifications()) notification.close(); + const subscription = await self.registration.pushManager.getSubscription(); + if (subscription) await subscription.unsubscribe(); +} + +self.addEventListener('install', event => event.waitUntil(self.skipWaiting())); +self.addEventListener('activate', event => event.waitUntil(self.clients.claim())); +self.addEventListener('message', event => { + // Commands are local lifecycle actions from our own app windows. + if (!event.source?.url || new URL(event.source.url).origin !== self.location.origin) return; + if (event.data?.type === 'von-push-disable') { + event.waitUntil(disable().then(() => event.ports[0]?.postMessage({ ok: true }))); + } +}); + +self.addEventListener('push', event => { + event.waitUntil((async () => { + let payload; + try { payload = event.data.json(); } catch { return; } + if (!/^[a-f0-9]{64}$/.test(payload.id || '')) return; + if (!/^[a-f0-9]{32}$/.test(payload.generation || '')) return; + if (payload.kind === 'confirmation') { + // Only a push arriving in the requesting authenticated browser can + // complete this challenge. It is never returned to the subscriber API. + const response = await fetch('/api/notifications/confirm', { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: payload.id, challenge: payload.challenge }) + }); + if (!response.ok || (await response.json()).state !== 'active') return; + await saveBinding({ id: payload.id, generation: payload.generation, seen: [] }); + await self.registration.showNotification('Von notifications enabled', { + body: 'Direct messages can now notify you on this device.', + tag: 'von-push-confirmation', data: { url: '/von/' } + }); + return; + } + const binding = await readBinding(); + if (!binding || binding.id !== payload.id || binding.generation !== payload.generation) return; + // Recheck the live browser actor/device before displaying even generic + // text. A queued push must not outlive logout or organisation removal. + const active = await fetch('/api/notifications/validate', { + method: 'POST', credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ id: payload.id, generation: payload.generation }) + }); + if (!active.ok) { + if (active.status === 401 || active.status === 403) await disable(); + return; + } + if ((await active.json()).active !== true) return; + const receipt = /^[a-f0-9]{64}$/.test(payload.receipt || '') ? payload.receipt : null; + if (payload.kind !== 'test' && (payload.kind !== 'message' || !receipt)) return; + if (receipt && binding.seen.includes(receipt)) return; + await self.registration.showNotification('Von', { + body: payload.kind === 'test' ? 'This is your test notification.' : 'You have a new message. Open Von to read it.', + icon: '/static/pwa-icon-192.png', + tag: receipt ? `von-${receipt}` : 'von-push-test', renotify: false, + data: { url: receipt ? `/von/?notification=${receipt}` : '/von/' } + }); + if (receipt) { + binding.seen = [...binding.seen.slice(-127), receipt]; + await saveBinding(binding); + } + })()); +}); + +self.addEventListener('notificationclick', event => { + event.notification.close(); + // Only the canonical opaque receipt route is allowed, never a payload URL. + const raw = event.notification.data?.url || ''; + const url = /^\/von\/\?notification=[a-f0-9]{64}$/.test(raw) ? raw : '/von/'; + event.waitUntil(self.clients.openWindow(new URL(url, self.location.origin).href)); +}); + +self.addEventListener('pushsubscriptionchange', event => { + // No background re-enrolment with an expired login or changed actor. The + // next foreground visit shows the missing subscription and offers Enable. + event.waitUntil(disable()); +}); diff --git a/src/frontend/web/von_interface/templates/settings_tab.html b/src/frontend/web/von_interface/templates/settings_tab.html index 157eb63c..1b373bb9 100644 --- a/src/frontend/web/von_interface/templates/settings_tab.html +++ b/src/frontend/web/von_interface/templates/settings_tab.html @@ -40,6 +40,19 @@

Set up Von for this browser

+
+

Mobile notifications

+

Receive private alerts for direct Von messages, including coding-agent reports. + Enable for the current organisation context on this device. Other devices have their own opt-in.

+

Checking notification support…

+ + + + +

On iPhone or iPad, open Von from the Home Screen. System Focus and battery settings + can delay alerts. Logging out disables this device; already displayed alerts may remain in system history.

+
+

Signed-in Identity

@@ -1548,6 +1561,14 @@

Server Control

if (response.ok) { console.log('Logout successful'); + try { + const notifications = await import({{ url_for('static', filename='js/pushNotifications.js') | tojson }}); + await notifications.clearLocalNotifications(); + } catch (_) { + // Server revocation already succeeded; the worker also + // rechecks the active account before any queued alert. + } + const logoutResult = await response.json(); if (logoutResult.redirect_url === '/cdn-cgi/access/logout') { clearIdentityScopeMirrors(); @@ -1827,6 +1848,7 @@

Server Control

window.initializeSettingsAuthentication = initializeSettingsAuthentication; + diff --git a/src/frontend/web/von_interface/templates/von_interface.html b/src/frontend/web/von_interface/templates/von_interface.html index 364603f9..dfdd2d38 100644 --- a/src/frontend/web/von_interface/templates/von_interface.html +++ b/src/frontend/web/von_interface/templates/von_interface.html @@ -5,6 +5,9 @@ Von + + + diff --git a/tests/backend/test_web_push.py b/tests/backend/test_web_push.py new file mode 100644 index 00000000..1db35006 --- /dev/null +++ b/tests/backend/test_web_push.py @@ -0,0 +1,467 @@ +"""Bounded notification delivery and the new actor/device authority boundary.""" + +import base64 +from datetime import timedelta + +import mongomock +import pytest +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +from flask import Flask + +from src.backend.services import web_push_service as push +from src.backend.server.routes import web_push_routes as routes + + +@pytest.fixture +def store(monkeypatch): + db = mongomock.MongoClient(tz_aware=True).db + monkeypatch.setattr(push, "get_db", lambda: db) + monkeypatch.setattr(push, "get_concepts_collection", lambda: db.concepts) + monkeypatch.setattr( + push, "scope_allowed", lambda actor, org: org in (None, "#V#sail") + ) + monkeypatch.setenv(push.KEY_ENV, Fernet.generate_key().decode()) + from src.backend.security import access_control + + monkeypatch.setattr( + access_control, "apply_concept_query_filter", lambda query: query + ) + sent = [] + monkeypatch.setattr( + push, + "send", + lambda subscription, payload, **kwargs: sent.append( + (subscription, payload, kwargs) + ) + or "accepted", + ) + return db, sent + + +@pytest.fixture +def subscription(): + key = ( + ec.generate_private_key(ec.SECP256R1()) + .public_key() + .public_bytes( + serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint + ) + ) + return { + "endpoint": "https://fcm.googleapis.com/fcm/send/test-device", + "keys": { + "p256dh": base64.urlsafe_b64encode(key).decode().rstrip("="), + "auth": base64.urlsafe_b64encode(b"a" * 16).decode().rstrip("="), + }, + } + + +def enrol(store, subscription, *, actor="#V#alice", device="device", org="#V#sail"): + db, sent = store + result = push.subscribe(actor, device, org, subscription) + challenge = sent[-1][1]["challenge"] + assert push.confirm(actor, device, result["subscription_id"], challenge) + return result["subscription_id"] + + +def message( + db, identifier="message-one", recipient="#V#alice", org="#V#sail", stamp=None +): + db.concepts.insert_one( + { + "concept_id": identifier, + "created_at": stamp or push.now(), + "relationships": { + "is_an_instance_of": ["#V#direct_message"], + "#V#has_sender": ["#V#codex_dgx"], + "#V#has_recipient": [recipient], + }, + "concept_data": { + "organisation_concept_id": org, + "content_fallback": "PRIVATE research report", + }, + } + ) + + +def due(db): + db[push.COLLECTION].update_many( + {}, {"$set": {"retry_at": push.now() - timedelta(seconds=1)}} + ) + + +def test_challenge_proves_device_and_actor_without_returning_secret( + store, subscription +): + db, sent = store + result = push.subscribe("#V#alice", "device", "#V#sail", subscription) + identifier = result["subscription_id"] + challenge = sent[-1][1]["challenge"] + assert challenge not in str(result) + assert subscription["endpoint"] not in str(db[push.COLLECTION].find_one()) + assert not push.confirm("#V#bob", "device", identifier, challenge) + assert not push.confirm("#V#alice", "other-device", identifier, challenge) + assert not push.confirm("#V#alice", "device", identifier, "wrong") + assert push.confirm("#V#alice", "device", identifier, challenge) + assert not push.confirm("#V#alice", "device", identifier, challenge) + + +@pytest.mark.parametrize( + "endpoint", + [ + "http://fcm.googleapis.com/x", + "https://127.0.0.1/x", + "https://localhost/x", + "https://fcm.googleapis.com.evil.test/x", + "https://push.apple.com.evil.test/x", + "https://fcm.googleapis.com:444/x", + "https://user:password@fcm.googleapis.com/x", + "https://fcm.googleapis.com/x#fragment", + "https://jmt17.google.com.evil.test/fcm/send/device", + "https://other.google.com/fcm/send/device", + "https://jmt17.google.com/log", + "https://jmt17.google.com/fcm/send/device", + "http://jmt17.google.com/fcm/send/device", + ], +) +def test_endpoint_cannot_grant_arbitrary_network_access(subscription, endpoint): + subscription["endpoint"] = endpoint + with pytest.raises(ValueError): + push.validate_subscription(subscription) + + +def test_chromium_webpush_endpoint_is_supported(subscription): + subscription["endpoint"] = "https://fcm.googleapis.com/preprod/wp/test-device" + assert push.validate_subscription(subscription) == subscription + + +def test_expired_confirmation_retires_subscription_for_fresh_opt_in( + store, subscription, monkeypatch +): + db, _ = store + monkeypatch.setattr(push, "send", lambda *args, **kwargs: "expired") + result = push.subscribe("#V#alice", "device", "#V#sail", subscription) + assert result == {"state": "disabled", "provider_result": "expired"} + assert db[push.COLLECTION].count_documents({}) == 0 + + +def test_endpoint_cannot_be_rebound_to_unrelated_actor_device_or_scope( + store, subscription +): + enrol(store, subscription) + for actor, device, org in [ + ("#V#bob", "device", "#V#sail"), + ("#V#alice", "another", "#V#sail"), + ("#V#alice", "device", None), + ]: + with pytest.raises(PermissionError): + push.subscribe(actor, device, org, subscription) + + +def test_durable_message_one_private_alert_and_late_insert_is_not_skipped( + store, subscription +): + db, sent = store + enrol(store, subscription) + early = push.now() + message(db, "later") + assert push.deliver_one() + assert sent[-1][1]["kind"] == "message" + assert "PRIVATE" not in str(sent[-1]) and "#V#alice" not in str(sent[-1]) + assert "later" not in str(sent[-1][1]) + # A create that began earlier can commit later than the newer message. + message(db, "late-commit", stamp=early) + due(db) + assert push.deliver_one() + due(db) + push.deliver_one() + assert len([item for item in sent if item[1]["kind"] == "message"]) == 2 + assert db[push.RECEIPTS].count_documents({"terminal": True}) == 2 + + +def test_other_recipients_and_orgs_are_not_delivered(store, subscription): + db, sent = store + enrol(store, subscription) + message(db, "bob-message", recipient="#V#bob") + message(db, "other-org", org="#V#other") + message(db, "personal", org=None) + push.deliver_one() + assert len(sent) == 1 # challenge only + + +def test_retry_is_bounded_and_uses_same_tag_and_receipt( + store, subscription, monkeypatch +): + db, sent = store + enrol(store, subscription) + message(db) + attempts = [] + monkeypatch.setattr( + push, + "send", + lambda sub, payload, **kw: attempts.append((payload, kw)) or "failed", + ) + for _ in range(5): + due(db) + push.deliver_one() + assert len(attempts) == 3 + assert all(attempt == attempts[0] for attempt in attempts) + assert db[push.RECEIPTS].find_one()["terminal"] + + +def test_crash_after_provider_acceptance_keeps_receipt_and_retry_bound( + store, subscription, monkeypatch +): + db, sent = store + enrol(store, subscription) + message(db) + attempts = [] + + def crash(sub, payload, **kw): + attempts.append(payload) + raise RuntimeError("simulated process interruption after send") + + monkeypatch.setattr(push, "send", crash) + for _ in range(3): + with pytest.raises(RuntimeError): + push.deliver_one() + push.deliver_one() + assert len(attempts) == 3 + assert attempts[0] == attempts[1] == attempts[2] + assert db[push.RECEIPTS].find_one()["terminal"] + + +def test_expiry_disable_and_membership_removal_stop_delivery( + store, subscription, monkeypatch +): + db, sent = store + enrol(store, subscription) + message(db) + monkeypatch.setattr(push, "send", lambda *args, **kw: "expired") + push.deliver_one() + assert db[push.COLLECTION].count_documents({}) == 0 + monkeypatch.setattr( + push, + "send", + lambda sub, payload, **kw: sent.append((sub, payload, kw)) or "accepted", + ) + enrol(store, subscription) + push.revoke_device("device") + assert not push.deliver_one() + enrol(store, subscription) + monkeypatch.setattr(push, "scope_allowed", lambda *args: False) + push.deliver_one() + assert db[push.COLLECTION].count_documents({}) == 0 + + +@pytest.fixture +def client(store, monkeypatch): + app = Flask(__name__) + app.secret_key = "fixture-key" + app.register_blueprint(routes.web_push_bp) + monkeypatch.setattr( + push, "configuration", lambda: {"configured": True, "public_key": "public"} + ) + monkeypatch.setattr(routes, "scope", lambda: "#V#sail") + with app.test_client() as client: + with client.session_transaction() as session: + session.update( + user_concept_id="#V#alice", + user_email="alice@example.test", + auth_provider="browser_test_fixture", + ) + yield client + + +def test_session_and_origin_are_required_and_supplied_actor_is_ignored( + client, store, subscription +): + assert client.get("/api/notifications/status").status_code == 200 + path = "/api/notifications/subscribe" + payload = { + "subscription": subscription, + "user_id": "#V#bob", + "organisation": "#V#evil", + } + assert client.post(path, json=payload).status_code == 403 + assert ( + client.post( + path, json=payload, headers={"Origin": "https://evil.test"} + ).status_code + == 403 + ) + assert ( + client.post( + path, json=payload, headers={"Origin": "http://localhost"} + ).status_code + == 200 + ) + row = store[0][push.COLLECTION].find_one() + assert row["actor"] == "#V#alice" and row["organisation"] == "#V#sail" + with client.session_transaction() as session: + session.clear() + assert ( + client.get( + "/api/notifications/status", headers={"X-User-Concept-ID": "#V#alice"} + ).status_code + == 401 + ) + + +def test_browser_account_change_revokes_old_subscription(client, store, subscription): + client.get("/api/notifications/status") + client.post( + "/api/notifications/subscribe", + json={"subscription": subscription}, + headers={"Origin": "http://localhost"}, + ) + with client.session_transaction() as session: + session["user_concept_id"] = "#V#bob" + result = client.get("/api/notifications/status") + assert result.json["state"] == "disabled" + assert store[0][push.COLLECTION].count_documents({}) == 0 + + +def test_test_action_is_device_bound_and_rate_limited(store, subscription): + identifier = enrol(store, subscription) + with pytest.raises(ValueError): + push.test_notification("#V#bob", "device", identifier) + with pytest.raises(ValueError): + push.test_notification("#V#alice", "other-device", identifier) + assert ( + push.test_notification("#V#alice", "device", identifier)["provider_result"] + == "accepted" + ) + with pytest.raises(ValueError): + push.test_notification("#V#alice", "device", identifier) + + +def test_navigation_rechecks_recipient_and_current_scope( + client, store, subscription, monkeypatch +): + db, _ = store + enrol(store, subscription) + message(db) + push.deliver_one() + receipt = db[push.RECEIPTS].find_one() + from src.backend.services import message_service + + monkeypatch.setattr(message_service, "get_concepts_collection", lambda: db.concepts) + url = "/api/notifications/open/" + receipt["_id"] + assert client.get(url).json["last_message_id"] == "message-one" + with client.session_transaction() as session: + session["user_concept_id"] = "#V#bob" + assert client.get(url).status_code == 404 + with client.session_transaction() as session: + session["user_concept_id"] = "#V#alice" + db.concepts.update_one({}, {"$set": {"concept_data.deleted": True}}) + assert client.get(url).status_code == 404 + + +def test_canonical_coding_report_is_delivered_with_workflow_launch_suppressed( + store, subscription, monkeypatch +): + db, sent = store + enrol(store, subscription) + from src.backend.services import message_service + + monkeypatch.setattr(message_service, "get_concepts_collection", lambda: db.concepts) + monkeypatch.setattr( + message_service.ConceptsRepository, "insert_one", db.concepts.insert_one + ) + monkeypatch.setattr(message_service, "upsert_text_for_concept", lambda **kw: None) + monkeypatch.setattr( + message_service, + "maybe_launch_direct_message_workflow", + lambda **kw: {"triggered": False, "reason": "suppressed"}, + ) + created = message_service.create_message( + sender_id="#V#codex_dgx", + recipient_ids=["#V#alice"], + content="Completed the assigned coding work", + org_id="#V#sail", + ) + assert db.concepts.find_one({"concept_id": created["concept_id"]}) + assert push.deliver_one() + assert sent[-1][1]["kind"] == "message" + assert db[push.RECEIPTS].find_one()["message_id"] == created["concept_id"] + + +def test_queued_delivery_validation_is_bound_to_enrolment_and_logout( + client, store, subscription +): + db, sent = store + client.get("/api/notifications/status") + headers = {"Origin": "http://localhost"} + result = client.post( + "/api/notifications/subscribe", + json={"subscription": subscription}, + headers=headers, + ) + payload = sent[-1][1] + assert ( + client.post( + "/api/notifications/confirm", json=payload, headers=headers + ).status_code + == 200 + ) + assert ( + client.post( + "/api/notifications/validate", json=payload, headers=headers + ).status_code + == 200 + ) + assert ( + client.post( + "/api/notifications/validate", + json={**payload, "generation": "old"}, + headers=headers, + ).status_code + == 403 + ) + assert ( + client.post("/api/notifications/disable", json={}, headers=headers).status_code + == 200 + ) + assert ( + client.post( + "/api/notifications/validate", json=payload, headers=headers + ).status_code + == 403 + ) + + +def test_logout_revokes_before_session_clear_and_reports_storage_failure( + client, store, subscription, monkeypatch +): + from src.backend.server.routes.auth_routes import auth_bp + from src.backend.services import window_session_context_service + + client.application.register_blueprint(auth_bp, url_prefix="/von") + monkeypatch.setattr( + window_session_context_service, + "delete_window_context_if_owned", + lambda *args: None, + ) + client.get("/api/notifications/status") + client.post( + "/api/notifications/subscribe", + json={"subscription": subscription}, + headers={"Origin": "http://localhost"}, + ) + original = push.revoke_device + monkeypatch.setattr( + push, + "revoke_device", + lambda device: (_ for _ in ()).throw(RuntimeError("unavailable")), + ) + assert client.post("/von/api/auth/logout").status_code == 503 + with client.session_transaction() as session: + assert session["user_concept_id"] == "#V#alice" + monkeypatch.setattr(push, "revoke_device", original) + assert client.post("/von/api/auth/logout").status_code == 200 + assert store[0][push.COLLECTION].count_documents({}) == 0 + with client.session_transaction() as session: + assert "user_concept_id" not in session diff --git a/tests/browser/webPushFixture.py b/tests/browser/webPushFixture.py new file mode 100644 index 00000000..9c9a5719 --- /dev/null +++ b/tests/browser/webPushFixture.py @@ -0,0 +1,133 @@ +"""Disposable localhost Web Push transport fixture; never uses a real database. + +Run with ``pdm run python tests/browser/webPushFixture.py``. This serves the +candidate's actual notification controls, API and service worker. Synthetic +session binding and in-memory storage do NOT prove production authentication, +MongoDB durability, installed-app behaviour, or physical-device display. +""" + +import base64 +import logging +import os +from pathlib import Path +import re +import secrets +import subprocess +import sys + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) +os.environ["VON_DB_NAME"] = "test_web_push_fixture" + +from cryptography.fernet import Fernet +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec +from flask import Flask, jsonify, render_template_string, session +import mongomock + +from src.backend.services import web_push_service as push, message_service +from src.backend.server.routes import web_push_routes +from src.backend.security import access_control + + +def create_fixture(): + source_commit = subprocess.check_output( + ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True + ).strip() + source_dirty = bool( + subprocess.check_output( + ["git", "status", "--porcelain", "--untracked-files=no"], + cwd=ROOT, + text=True, + ).strip() + ) + key = ec.generate_private_key(ec.SECP256R1()) + os.environ["VON_WEB_PUSH_PRIVATE_KEY"] = base64.urlsafe_b64encode( + key.private_bytes( + serialization.Encoding.DER, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + ).decode() + os.environ["VON_WEB_PUSH_PUBLIC_KEY"] = ( + base64.urlsafe_b64encode( + key.public_key().public_bytes( + serialization.Encoding.X962, + serialization.PublicFormat.UncompressedPoint, + ) + ) + .decode() + .rstrip("=") + ) + os.environ["VON_WEB_PUSH_SUBJECT"] = "mailto:push-fixture@example.org" + os.environ[push.KEY_ENV] = Fernet.generate_key().decode() + db = mongomock.MongoClient(tz_aware=True).db + push.get_db = lambda: db + push.get_concepts_collection = lambda: db.concepts + push.scope_allowed = lambda actor, org: org == "#V#fixture_org" + web_push_routes.scope = lambda: "#V#fixture_org" + message_service.get_concepts_collection = lambda: db.concepts + message_service.ConceptsRepository.insert_one = db.concepts.insert_one + message_service.upsert_text_for_concept = lambda **kwargs: None + message_service.maybe_launch_direct_message_workflow = lambda **kwargs: { + "triggered": False + } + access_control.apply_concept_query_filter = lambda query: query + static = ROOT / "src/frontend/web/von_interface/static" + app = Flask(__name__, static_folder=str(static), static_url_path="/static") + app.secret_key = secrets.token_hex(32) + app.register_blueprint(web_push_routes.web_push_bp) + source = ( + ROOT / "src/frontend/web/von_interface/templates/settings_tab.html" + ).read_text() + section = re.search( + r'
', source, re.S + ).group() + + @app.get("/von/") + def home(): + session.update( + user_concept_id="#V#fixture_alice", + user_email="alice@example.test", + auth_provider="browser_test_fixture", + ) + return render_template_string( + """ + + + +

Disposable transport fixture — synthetic actor and in-memory messages.

+
""" + + section + + """
+ """ + ) + + @app.get("/health") + def health(): + return jsonify( + fixture="web-push-synthetic", + source_commit=source_commit, + candidate_dirty=source_dirty, + database="in-memory", + models="disabled", + ) + + @app.post("/fixture/message") + def create_message(): + # Fixed synthetic intent; never a free-form send surface. + message = message_service.create_message( + sender_id="#V#fixture_codex", + recipient_ids=["#V#fixture_alice"], + content="Synthetic coding-agent completion report.", + org_id="#V#fixture_org", + ) + return jsonify(message_id=message["concept_id"]) + + push.start_worker() + return app + + +if __name__ == "__main__": + logging.getLogger("werkzeug").setLevel(logging.ERROR) + create_fixture().run(host="127.0.0.1", port=5077, debug=False, use_reloader=False) diff --git a/tests/browser/webPushSmoke.cjs b/tests/browser/webPushSmoke.cjs new file mode 100644 index 00000000..bf59019e --- /dev/null +++ b/tests/browser/webPushSmoke.cjs @@ -0,0 +1,64 @@ +/* Transport attempt, not proof of installed-app or OS display acceptance. + * PLAYWRIGHT_BROWSERS_PATH=/tmp/von-playwright node tests/browser/webPushSmoke.cjs + */ +const { chromium } = require('playwright'); +const fs = require('fs'); +const path = require('path'); + +(async () => { + const base = 'http://127.0.0.1:5077'; + const evidence = path.resolve('.run/web-push-smoke'); + fs.mkdirSync(evidence, { recursive: true }); + const headed = process.env.VON_PUSH_HEADED === '1'; + const browser = await chromium.launch({ headless: !headed }); + const context = await browser.newContext({ permissions: ['notifications'], viewport: { width: 390, height: 844 } }); + const page = await context.newPage(); + const report = { browser: browser.version(), os: process.platform, + installed: false, mode: `${headed ? 'headed' : 'headless'} ordinary tab; injected permission`, + push_method: 'PushManager.subscribe then encrypted pywebpush, if registration succeeds', + device_display_verified: false }; + try { + report.fixture = await (await page.request.get(base + '/health')).json(); + if (report.fixture.fixture !== 'web-push-synthetic') throw new Error('Unexpected fixture'); + await page.goto(base + '/von/'); + await page.waitForFunction(() => !/Checking/.test(document.querySelector('[data-push-status]').textContent), null, { timeout: 15000 }); + report.registration = await page.evaluate(async () => { + const reg = await navigator.serviceWorker.ready; + return { scope: reg.scope, scriptURL: reg.active.scriptURL, permission: Notification.permission }; + }); + report.initial_status = await page.locator('[data-push-status]').textContent(); + if (await page.locator('[data-push-enable]').isEnabled()) { + await page.locator('[data-push-enable]').click(); + await page.waitForFunction(() => { + const text = document.querySelector('[data-push-status]').textContent; + return /Registration failed|push service|Waiting for|enabled|failed|denied|AbortError/i.test(text); + }, null, { timeout: 25000 }).catch(() => {}); + } else { + // Diagnose the actual browser transport without changing product + // permission handling or injecting a synthetic Push event. + report.transport_probe = await page.evaluate(async () => { + const permission = Notification.permission; + const permissionQuery = (await navigator.permissions.query({ name: 'notifications' })).state; + const config = await (await fetch('/api/notifications/status')).json(); + const reg = await navigator.serviceWorker.ready; + try { + const key = Uint8Array.from(atob(config.public_key.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)); + await Promise.race([ + reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key }), + new Promise((_, reject) => setTimeout(() => reject(new Error('Transport probe exceeded 20 seconds')), 20000)) + ]); + return { permission, permissionQuery, subscribed: true }; + } catch (error) { + return { permission, permissionQuery, error: error.name + ': ' + error.message }; + } + }); + } + report.observed_status = await page.locator('[data-push-status]').textContent(); + report.subscribed = await page.evaluate(async () => !!(await (await navigator.serviceWorker.ready).pushManager.getSubscription())); + await page.screenshot({ path: path.join(evidence, 'controls.png'), fullPage: true }); + fs.writeFileSync(path.join(evidence, 'receipt.json'), JSON.stringify(report, null, 2) + '\n'); + console.log(JSON.stringify(report, null, 2)); + } finally { + await context.close(); await browser.close(); + } +})().catch(error => { console.error(error.message); process.exitCode = 1; }); diff --git a/tests/frontend/pushNotifications.test.js b/tests/frontend/pushNotifications.test.js new file mode 100644 index 00000000..9a142056 --- /dev/null +++ b/tests/frontend/pushNotifications.test.js @@ -0,0 +1,122 @@ +/** @jest-environment jsdom */ +jest.mock('../../src/frontend/web/von_interface/static/js/apiService.js', () => ({ + getJsonDetailed: jest.fn(), postJson: jest.fn() +})); +jest.mock('../../src/frontend/web/von_interface/static/js/components/conversationCatalogue.js', () => ({ selectMessageConversation: jest.fn() })); +jest.mock('../../src/frontend/web/von_interface/static/js/tabNavigation.js', () => ({ activateTab: jest.fn() })); +const base = '../../src/frontend/web/von_interface/static/js/'; +const flush = () => new Promise(resolve => setTimeout(resolve, 0)); + +beforeEach(() => { + jest.resetModules(); + document.body.innerHTML = ''; + Object.defineProperty(window, 'isSecureContext', { value: true, configurable: true }); + Object.defineProperty(navigator, 'userAgent', { value: 'Chromium Android', configurable: true }); + Object.defineProperty(navigator, 'platform', { value: 'Linux', configurable: true }); + window.matchMedia = jest.fn(() => ({ matches: false })); + window.PushManager = function () {}; + global.caches = { match: jest.fn(async () => null) }; + window.Notification = { permission: 'default', requestPermission: jest.fn(async () => 'default') }; + const reg = { active: { state: 'activated' }, pushManager: { getSubscription: jest.fn(async () => null), subscribe: jest.fn() } }; + Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: { + register: jest.fn(async () => reg), ready: Promise.resolve(reg), getRegistration: jest.fn(async () => reg) + } }); + const api = require(base + 'apiService.js'); + api.getJsonDetailed.mockResolvedValue({ data: { configured: true, state: 'disabled', public_key: 'BAAA' }, status: 200 }); +}); + +function controls() { + document.body.innerHTML = `
+

+
`; + return require(base + 'pushNotifications.js'); +} + +test('Settings outside the worker scope becomes usable without a controlling worker', async () => { + navigator.serviceWorker.ready = new Promise(() => {}); + const module = controls(); + await module.initialiseNotificationControls(); await flush(); + expect(document.querySelector('[data-push-status]').textContent).toContain('Notifications are off'); + expect(document.querySelector('[data-push-enable]').disabled).toBe(false); +}); + +test('no permission request on initialisation; a dismissed user gesture leaves notifications off', async () => { + const module = controls(); + await module.initialiseNotificationControls(); await flush(); + expect(Notification.requestPermission).not.toHaveBeenCalled(); + document.querySelector('[data-push-enable]').click(); + expect(Notification.requestPermission).toHaveBeenCalledTimes(1); + await flush(); + expect(document.querySelector('[data-push-status]').textContent).toContain('dismissed'); + expect(require(base + 'apiService.js').postJson).not.toHaveBeenCalled(); +}); + +test('denied permission offers browser settings without prompting again', async () => { + Notification.permission = 'denied'; + const module = controls(); + await module.initialiseNotificationControls(); await flush(); + expect(document.querySelector('[data-push-enable]').disabled).toBe(true); + expect(document.querySelector('[data-push-status]').textContent).toContain('blocked'); + expect(Notification.requestPermission).not.toHaveBeenCalled(); +}); + +test('expired confirmation clears the rejected browser subscription and offers fresh opt-in', async () => { + Notification.permission = 'granted'; + Notification.requestPermission.mockResolvedValue('granted'); + const reg = await navigator.serviceWorker.ready; + const sub = { toJSON: () => ({}), unsubscribe: jest.fn(async () => true) }; + reg.pushManager.getSubscription.mockResolvedValue(sub); + reg.getNotifications = jest.fn(async () => []); + reg.active.postMessage = jest.fn(); + require(base + 'apiService.js').postJson.mockResolvedValue({ state: 'disabled', provider_result: 'expired' }); + const module = controls(); + await module.initialiseNotificationControls(); await flush(); + document.querySelector('[data-push-enable]').click(); + await flush(); await flush(); + expect(sub.unsubscribe).toHaveBeenCalledTimes(1); + expect(document.querySelector('[data-push-status]').textContent).toContain('fresh one'); + expect(document.querySelector('[data-push-enable]').disabled).toBe(false); +}); + +test('ordinary iPhone tab directs installation; installed app uses feature detection', () => { + Object.defineProperty(navigator, 'userAgent', { value: 'iPhone', configurable: true }); + const module = require(base + 'pushNotifications.js'); + expect(module.notificationSupport()).toContain('Home Screen'); + window.matchMedia = () => ({ matches: true }); + expect(module.notificationSupport()).toBeNull(); + delete window.PushManager; + expect(module.notificationSupport()).toContain('does not support'); +}); + +test('disable does not claim success or discard browser subscription if server revocation fails', async () => { + const { postJson } = require(base + 'apiService.js'); + postJson.mockRejectedValue(new Error('offline')); + const module = require(base + 'pushNotifications.js'); + await expect(module.disableBrowserNotifications()).rejects.toThrow('offline'); + expect(navigator.serviceWorker.getRegistration).not.toHaveBeenCalled(); +}); + +test('lost worker binding is shown as needing reconnection, not falsely enabled', async () => { + Notification.permission = 'granted'; + const reg = await navigator.serviceWorker.ready; + reg.pushManager.getSubscription.mockResolvedValue({ toJSON: () => ({}) }); + require(base + 'apiService.js').getJsonDetailed.mockResolvedValue({ data: { + configured: true, state: 'active', subscription_id: 'id', generation: 'generation' + } }); + const module = controls(); + await module.initialiseNotificationControls(); await flush(); + expect(document.querySelector('[data-push-status]').textContent).toContain('lost its notification binding'); + expect(document.querySelector('[data-push-test]').disabled).toBe(true); + expect(document.querySelector('[data-push-disable]').disabled).toBe(false); +}); + +test('notification navigation opens the access-checked exchange from the API response', async () => { + const receipt = 'a'.repeat(64); + window.history.replaceState({}, '', `/?notification=${receipt}`); + const row = { session_id: 'messages:exact', viewer_id: '#V#alice', participant_ids: ['#V#alice', '#V#codex'] }; + require(base + 'apiService.js').getJsonDetailed.mockResolvedValue({ data: row, status: 200 }); + const module = require(base + 'pushNotifications.js'); + await module.openNotificationMessage(); + expect(require(base + 'components/conversationCatalogue.js').selectMessageConversation).toHaveBeenCalledWith(row); + window.history.replaceState({}, '', '/'); +}); diff --git a/tests/frontend/pushServiceWorker.test.js b/tests/frontend/pushServiceWorker.test.js new file mode 100644 index 00000000..2f922b0a --- /dev/null +++ b/tests/frontend/pushServiceWorker.test.js @@ -0,0 +1,79 @@ +/** @jest-environment node */ +const fs = require('fs'); +const vm = require('vm'); +const path = require('path'); + +function worker() { + const handlers = {}; + const data = new Map(); + const cache = { match: async key => data.get(key), put: async (key, value) => data.set(key, value) }; + const shown = []; + const opened = []; + const self = { + location: { origin: 'https://von.example' }, + addEventListener: (type, handler) => { handlers[type] = handler; }, + registration: { + showNotification: async (title, options) => shown.push({ title, ...options }), + getNotifications: async () => [], pushManager: { getSubscription: async () => null } + }, + clients: { openWindow: async url => opened.push(url) } + }; + const fetch = jest.fn(async url => ({ ok: true, json: async () => + url.endsWith('/confirm') ? { state: 'active' } : { active: true } })); + // A fresh Response is returned for every cache read, like real CacheStorage. + cache.match = async key => data.get(key)?.clone(); + const context = vm.createContext({ self, fetch, URL, Response, + caches: { open: async () => cache, delete: async () => data.clear() } }); + vm.runInContext(fs.readFileSync(path.join(__dirname, + '../../src/frontend/web/von_interface/static/service-worker.js'), 'utf8'), context); + async function event(type, props) { + let pending; + handlers[type]({ ...props, waitUntil: value => { pending = value; } }); + await pending; + } + const push = payload => event('push', { data: { json: () => ({ generation: 'c'.repeat(32), ...payload }) } }); + return { push, event, shown, opened, fetch }; +} + +const id = 'a'.repeat(64), receipt = 'b'.repeat(64); + +test('real worker handler uses private text, deduplicates receipt and confines click navigation', async () => { + const w = worker(); + await w.push({ kind: 'confirmation', id, challenge: 'opaque' }); + await w.push({ kind: 'message', id, receipt, title: 'PRIVATE', url: 'https://evil.test' }); + await w.push({ kind: 'message', id, receipt }); + expect(w.shown).toHaveLength(2); + expect(w.shown[1].body).toBe('You have a new message. Open Von to read it.'); + await w.event('notificationclick', { notification: { ...w.shown[1], close() {} } }); + expect(w.opened).toEqual([`https://von.example/von/?notification=${receipt}`]); + await w.event('notificationclick', { notification: { data: { url: 'https://evil.test' }, close() {} } }); + expect(w.opened[1]).toBe('https://von.example/von/'); +}); + +test('revoked account/device and login redirects cannot display queued alerts', async () => { + const w = worker(); + await w.push({ kind: 'confirmation', id, challenge: 'opaque' }); + w.fetch.mockResolvedValue({ ok: false, status: 403 }); + await w.push({ kind: 'message', id, receipt }); + expect(w.shown).toHaveLength(1); + w.fetch.mockResolvedValue({ ok: true, json: async () => ({}) }); + await w.push({ kind: 'confirmation', id, challenge: 'opaque' }); + await w.push({ kind: 'message', id, receipt }); + expect(w.shown).toHaveLength(1); +}); + +test('disable clears the binding and stops subsequently queued alerts', async () => { + const w = worker(); + await w.push({ kind: 'confirmation', id, challenge: 'opaque' }); + await w.event('message', { source: { url: 'https://von.example/von/' }, + data: { type: 'von-push-disable' }, ports: [] }); + await w.push({ kind: 'message', id, receipt }); + expect(w.shown).toHaveLength(1); +}); + +test('queued push from a previous account enrolment cannot match a reused endpoint', async () => { + const w = worker(); + await w.push({ kind: 'confirmation', id, challenge: 'opaque', generation: 'd'.repeat(32) }); + await w.push({ kind: 'message', id, receipt, generation: 'c'.repeat(32) }); + expect(w.shown).toHaveLength(1); +});