From 6e90e3fa9e991fafc3e8a53271ad8cb5ec0f05ad Mon Sep 17 00:00:00 2001 From: timmy Date: Tue, 11 Aug 2026 07:07:52 +0000 Subject: [PATCH] feat: notify mobile operators of unread updates (Closes #549) --- README.md | 8 ++ frontend/dashboard.css | 1 + frontend/dashboard.js | 20 +++- frontend/index.html | 3 + frontend/push-notifications.js | 79 +++++++++++++++ frontend/service-worker.js | 15 +++ requirements.txt | 1 + src/frontend_bundle.py | 1 + src/main.py | 130 +++++++++++++++++++++++- src/push_notifications.py | 74 ++++++++++++++ src/push_subscription_store.py | 96 ++++++++++++++++++ tests/test_frontend_bundle.py | 2 +- tests/test_push_frontend.py | 77 ++++++++++++++ tests/test_push_notifications.py | 166 +++++++++++++++++++++++++++++++ tests/test_service_worker.py | 36 +++++++ 15 files changed, 702 insertions(+), 7 deletions(-) create mode 100644 frontend/push-notifications.js create mode 100644 src/push_notifications.py create mode 100644 src/push_subscription_store.py create mode 100644 tests/test_push_frontend.py create mode 100644 tests/test_push_notifications.py diff --git a/README.md b/README.md index 4703f1a..64cd93b 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,14 @@ export STACKCHAIN_PASSKEY_MAX_CHALLENGES=10000 export STACKCHAIN_LOGIN_ATTEMPT_DB='/var/lib/stackchain-dashboard/login-attempts.sqlite3' # Trust forwarding headers only from these immediate reverse-proxy networks. export STACKCHAIN_TRUSTED_PROXY_CIDRS='127.0.0.0/8' +# Optional Web Push. Generate a VAPID key pair outside the repo and inject it. +# The feature stays disabled unless all three values are present. +export STACKCHAIN_VAPID_PUBLIC_KEY='' +export STACKCHAIN_VAPID_PRIVATE_KEY='' +export STACKCHAIN_VAPID_SUBJECT='mailto:ops@example.com' +# Optional; defaults to 30 seconds and STACKCHAIN_STATE_DIR/push-subscriptions.sqlite3. +export STACKCHAIN_PUSH_POLL_SECONDS=30 +export STACKCHAIN_PUSH_DB='/var/lib/stackchain-dashboard/push-subscriptions.sqlite3' uvicorn src.main:app --host 127.0.0.1 --port 8000 ``` diff --git a/frontend/dashboard.css b/frontend/dashboard.css index d635179..f1e88d5 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -81,6 +81,7 @@ textarea { resize: vertical; min-height: 120px; } .offline-status[hidden] { display:none; } .offline-work-controls { display:flex; gap:10px; align-items:center; flex-wrap:wrap; width:100%; padding-top:2px; } .offline-work-controls label { display:flex; gap:8px; align-items:center; min-height:44px; } +.push-update-control { min-height:44px; } .offline-work-controls input { width:20px; height:20px; } .offline-work-controls button { min-height:44px; } .offline-today-readiness { display:flex; gap:8px; align-items:center; flex-wrap:wrap; } diff --git a/frontend/dashboard.js b/frontend/dashboard.js index c94a21a..53c70b9 100644 --- a/frontend/dashboard.js +++ b/frontend/dashboard.js @@ -417,6 +417,7 @@ document, urls: { 'issue-capture': document.querySelector('meta[name="stackchain-feature-issue-capture"]')?.content || '', + 'push-notifications': document.querySelector('meta[name="stackchain-feature-push-notifications"]')?.content || '', }, }); let sharedLaunchState = null; @@ -5270,9 +5271,22 @@ if (workSession.active()) workSession.reconcile(); }); if ('serviceWorker' in navigator) { - navigator.serviceWorker.register('service-worker.js').catch(error => - console.warn('Stackchain install support unavailable', error) - ); + navigator.serviceWorker.register('service-worker.js').then(async () => { + await issueCaptureFeatures.load('push-notifications'); + return createPushNotifications({ + control:qs('#push-updates'), + status:qs('#push-update-status'), + notification:window.Notification, + serviceWorker:navigator.serviceWorker, + fetchJson:fetchReviewJson, + }).init(); + }).catch(error => { + qs('#push-update-status').textContent = 'Update notification settings unavailable.'; + console.warn('Push notifications unavailable', error); + }); + } else { + qs('#push-updates').disabled = true; + qs('#push-update-status').textContent = 'This browser does not support update notifications.'; } const isIosDevice = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); diff --git a/frontend/index.html b/frontend/index.html index c3fd1d3..848d3ec 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -97,6 +97,8 @@
+ + @@ -777,6 +779,7 @@ + diff --git a/frontend/push-notifications.js b/frontend/push-notifications.js new file mode 100644 index 0000000..6226cfc --- /dev/null +++ b/frontend/push-notifications.js @@ -0,0 +1,79 @@ +(function(root, factory) { + if (typeof module === 'object' && module.exports) module.exports = factory; + else root.createPushNotifications = factory; +})(typeof self !== 'undefined' ? self : this, function createPushNotifications({ + control, status, notification, serviceWorker, fetchJson, +}) { + let configuration = null; + + function applicationServerKey(value) { + const padded = value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - value.length % 4) % 4); + if (typeof atob === 'function') { + return Uint8Array.from(atob(padded), character => character.charCodeAt(0)); + } + return Uint8Array.from(Buffer.from(padded, 'base64')); + } + + async function disable() { + const registration = await serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + await fetchJson('api/v1/push-subscription', {method:'DELETE'}); + await subscription?.unsubscribe?.(); + control.checked = false; + status.textContent = 'New update notifications are off for this device.'; + } + + async function enable() { + const permission = await notification.requestPermission(); + if (permission !== 'granted') { + control.checked = false; + status.textContent = 'Notifications are blocked. Allow them in your browser settings to enable updates.'; + return; + } + const registration = await serviceWorker.ready; + let subscription = await registration.pushManager.getSubscription(); + if (!subscription) { + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: applicationServerKey(configuration.public_key), + }); + } + await fetchJson('api/v1/push-subscription', { + method:'PUT', + headers:{'Content-Type':'application/json'}, + body:JSON.stringify(subscription.toJSON()), + }); + control.checked = true; + status.textContent = 'New update notifications enabled for this device.'; + } + + async function change() { + control.disabled = true; + try { + if (control.checked) await enable(); + else await disable(); + } catch (error) { + control.checked = !control.checked; + status.textContent = 'Could not change update notifications. Check your connection and try again.'; + } finally { + control.disabled = false; + } + } + + async function init() { + if (!control || !notification || !serviceWorker) return; + control.addEventListener('change', change); + configuration = await fetchJson('api/v1/push-subscription'); + if (!configuration.available) { + control.disabled = true; + status.textContent = 'New update notifications are not available on this server.'; + return; + } + control.checked = Boolean(configuration.subscribed); + status.textContent = configuration.subscribed + ? 'New update notifications enabled for this device.' + : 'New update notifications are off for this device.'; + } + + return {init, change}; +}); diff --git a/frontend/service-worker.js b/frontend/service-worker.js index 4c84dee..8fb53aa 100644 --- a/frontend/service-worker.js +++ b/frontend/service-worker.js @@ -63,6 +63,7 @@ const SHELL = [ BASE + 'static/mobile-search-viewport.js', BASE + 'static/mobile-composer-viewport.js', BASE + 'static/mention-composer.js', + BASE + 'static/push-notifications.js', BASE + 'static/background-issue-sync.js', ]; @@ -268,6 +269,20 @@ self.addEventListener('message', event => { })()); }); +self.addEventListener('push', event => { + let payload; + try { payload = event.data?.json?.() || {}; } + catch (_error) { return; } + const route = String(payload.route || ''); + const tag = String(payload.tag || ''); + if (!/^#\/my-work\/update\/\d+$/.test(route) || !/^stackchain-update-\d+$/.test(tag)) return; + event.waitUntil(self.registration.showNotification('New work update', { + body: 'Tap to review it in Stackchain.', + tag, + data: {route}, + })); +}); + self.addEventListener('notificationclick', event => { event.notification.close(); const route = String(event.notification.data?.route || ''); diff --git a/requirements.txt b/requirements.txt index f6c7419..977bd13 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ httpx==0.28.1 pydantic==2.13.4 python-multipart==0.0.22 pytest==9.1.1 +pywebpush==2.1.2 rjsmin==1.2.5 uvicorn==0.41.0 webauthn==3.0.0 diff --git a/src/frontend_bundle.py b/src/frontend_bundle.py index 10276a4..5102e14 100644 --- a/src/frontend_bundle.py +++ b/src/frontend_bundle.py @@ -19,6 +19,7 @@ WORKER_RUNTIME_SOURCE = "static/background-issue-sync.js" FEATURE_SOURCES = { "issue-capture": ("static/create-issue-sheet.js",), "pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"), + "push-notifications": ("static/push-notifications.js",), } CACHE_DECLARATION = re.compile( r"const CACHE = 'stackchain-dashboard-shell-(?:v\d+|[0-9a-f]{16})';" diff --git a/src/main.py b/src/main.py index 823f001..54d76c8 100644 --- a/src/main.py +++ b/src/main.py @@ -47,6 +47,8 @@ from src.live_snapshot_store import ( ) from src.models import Issue, Milestone, PullRequest, Repo, User from src.passkey_store import PasskeyStore +from src.push_notifications import PushConfiguration, dispatch_unread_updates +from src.push_subscription_store import PushSubscriptionStore from src.request_boundary import RequestBodyLimitMiddleware, request_body_limit from src.security_event_store import SecurityEventStore, SecurityEventStoreError from src.suggestion_engine import compute @@ -76,16 +78,37 @@ async def _drain_authored_action_operations() -> None: _authored_action_operations.pop(key, None) +async def _push_poll_loop() -> None: + interval = max(5.0, float(os.getenv("STACKCHAIN_PUSH_POLL_SECONDS", "30"))) + while True: + await asyncio.sleep(interval) + try: + await dispatch_unread_updates( + _push_subscription_store, + _push_configuration(), + notifications, + ) + except asyncio.CancelledError: + raise + except Exception: + # Gitea and push endpoints are external; one failed poll must not + # stop later delivery attempts. + continue + + @asynccontextmanager async def lifespan(_app: FastAPI): - global _live_snapshot_task, _available_issue_snapshot_task + global _live_snapshot_task, _available_issue_snapshot_task, _push_poll_task gitea_proxy.start_client() + if _push_configuration().enabled: + _push_poll_task = asyncio.create_task(_push_poll_loop()) try: yield finally: live_task = _live_snapshot_task available_task = _available_issue_snapshot_task - for task in (live_task, available_task): + push_task = _push_poll_task + for task in (live_task, available_task, push_task): if task is not None and not task.done(): task.cancel() try: @@ -100,6 +123,8 @@ async def lifespan(_app: FastAPI): _live_snapshot_task = None if _available_issue_snapshot_task is available_task: _available_issue_snapshot_task = None + if _push_poll_task is push_task: + _push_poll_task = None app = FastAPI(title="Stackchain Dashboard", lifespan=lifespan) @@ -135,6 +160,7 @@ AVAILABLE_ISSUE_SNAPSHOT_RETRY_SECONDS = 5.0 AVAILABLE_ISSUE_SNAPSHOT_LEASE_SECONDS = WORK_PAGE_TIMEOUT_SECONDS + 1.0 FRONTEND_DIR = Path(__file__).resolve().parent.parent / "frontend" _live_snapshot_task: asyncio.Task | None = None +_push_poll_task: asyncio.Task | None = None _live_snapshot_value: dict | None = None _live_snapshot_created_at: float | None = None LIVE_SNAPSHOT_SECTIONS = ("context", "events", "notifications") @@ -182,6 +208,17 @@ _available_issue_snapshot_store = AvailableIssueSnapshotStore( str(_state_dir / "available-issue-snapshot.sqlite3"), ) ) +_push_subscription_store = PushSubscriptionStore( + os.getenv("STACKCHAIN_PUSH_DB", str(_state_dir / "push-subscriptions.sqlite3")) +) + + +def _push_configuration() -> PushConfiguration: + return PushConfiguration( + os.getenv("STACKCHAIN_VAPID_PUBLIC_KEY", "").strip(), + os.getenv("STACKCHAIN_VAPID_PRIVATE_KEY", "").strip(), + os.getenv("STACKCHAIN_VAPID_SUBJECT", "").strip(), + ) class ContextPayloadError(ValueError): @@ -205,6 +242,20 @@ class DashboardSignIn(BaseModel): return normalized +class PushSubscriptionPayload(BaseModel): + endpoint: str = Field(min_length=12, max_length=2_048, pattern=r"^https://") + keys: dict[str, str] + + @field_validator("keys") + @classmethod + def validate_push_keys(cls, value: dict[str, str]) -> dict[str, str]: + if set(value) != {"p256dh", "auth"} or any( + not item or len(item) > 1_024 for item in value.values() + ): + raise ValueError("Valid Web Push keys are required") + return value + + StepUpAction = Literal[ "merge_pull", "submit_pull_review", @@ -859,7 +910,7 @@ async def require_operator_session(request: Request, call_next): async def prevent_live_api_caching(request, call_next): response = await call_next(request) path = dashboard_auth.application_path(request) - if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/security-events"} or path.startswith("/api/v1/work/") or ( + if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/later", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or ( path.startswith("/api/v1/repos/") and path.endswith("/review") ) or path.startswith("/api/v1/notifications") or ( @@ -1631,6 +1682,67 @@ async def session_status(request: Request): return payload +@app.get("/api/v1/push-subscription") +async def push_status(request: Request): + configuration = _push_configuration() + device_id = await dashboard_auth.session_management_id( + request.state.dashboard_session + ) + subscribed = await asyncio.to_thread( + _push_subscription_store.is_subscribed, + device_id, + ) + return { + "available": configuration.enabled, + "subscribed": subscribed, + "public_key": configuration.public_key if configuration.enabled else "", + } + + +@app.put("/api/v1/push-subscription") +async def subscribe_push(payload: PushSubscriptionPayload, request: Request): + if not _push_configuration().enabled: + raise HTTPException(status_code=503, detail="Push notifications are not configured") + device_id = await dashboard_auth.session_management_id( + request.state.dashboard_session + ) + await asyncio.to_thread( + _push_subscription_store.upsert, + device_id, + payload.model_dump(), + ) + try: + current = await asyncio.wait_for(notifications(), NOTIFICATION_PAGE_TIMEOUT_SECONDS) + except Exception as error: + await asyncio.to_thread(_push_subscription_store.delete_session, device_id) + raise HTTPException( + status_code=503, + detail="Unread updates are temporarily unavailable", + headers={"Retry-After": "1"}, + ) from error + existing_ids = { + int(item["notification_id"]) + for item in current.get("items", []) + if isinstance(item, dict) and str(item.get("notification_id", "")).isdigit() + } + await asyncio.to_thread( + _push_subscription_store.mark_delivered, device_id, existing_ids + ) + return {"subscribed": True} + + +@app.delete("/api/v1/push-subscription") +async def unsubscribe_push(request: Request): + device_id = await dashboard_auth.session_management_id( + request.state.dashboard_session + ) + await asyncio.to_thread( + _push_subscription_store.delete_session, + device_id, + ) + return {"subscribed": False} + + @app.post("/api/v1/session/activity") async def record_session_activity(request: Request): session = request.state.dashboard_session @@ -1776,6 +1888,10 @@ async def update_later_plan(payload: LaterOperation | LaterOperationBatch): @app.delete("/api/v1/session") async def sign_out(request: Request, response: Response): session = request.state.dashboard_session + try: + push_device_id = await dashboard_auth.session_management_id(session) + except (dashboard_auth.SessionStoreError, AttributeError): + push_device_id = None journal = _security_event_store() try: operation_id = await asyncio.to_thread( @@ -1808,6 +1924,10 @@ async def sign_out(request: Request, response: Response): # successful revocation as failed merely because completion could not # be marked yet. pass + if push_device_id is not None: + await asyncio.to_thread( + _push_subscription_store.delete_session, push_device_id + ) path = dashboard_auth.cookie_path(request) response.delete_cookie( dashboard_auth.SESSION_COOKIE, @@ -1908,6 +2028,9 @@ async def revoke_active_device( ) if not revoked: raise HTTPException(status_code=404, detail="Active device not found") + await asyncio.to_thread( + _push_subscription_store.delete_session, management_id + ) try: await asyncio.to_thread(journal.finalize, operation_id) except SecurityEventStoreError: @@ -1950,6 +2073,7 @@ async def sign_out_all_devices( status_code=503, headers={"Cache-Control": "no-store"}, ) + await asyncio.to_thread(_push_subscription_store.delete_all) try: await asyncio.to_thread(journal.finalize, operation_id) except SecurityEventStoreError: diff --git a/src/push_notifications.py b/src/push_notifications.py new file mode 100644 index 0000000..08d3665 --- /dev/null +++ b/src/push_notifications.py @@ -0,0 +1,74 @@ +import asyncio +import json +from dataclasses import dataclass +from typing import Awaitable, Callable + +from src.push_subscription_store import PushSubscriptionStore + + +@dataclass(frozen=True) +class PushConfiguration: + public_key: str + private_key: str + subject: str + + @property + def enabled(self) -> bool: + return bool(self.public_key and self.private_key and self.subject) + + +async def send_web_push( + subscription: dict, payload: str, configuration: PushConfiguration +) -> None: + from pywebpush import webpush + + await asyncio.to_thread( + webpush, + subscription_info=subscription, + data=payload, + vapid_private_key=configuration.private_key, + vapid_claims={"sub": configuration.subject}, + ttl=300, + ) + + +async def dispatch_unread_updates( + store: PushSubscriptionStore, + configuration: PushConfiguration, + unread: Callable[[], Awaitable[dict]], + send: Callable[[dict, str], Awaitable[None]] | None = None, +) -> int: + if not configuration.enabled: + return 0 + page = await unread() + thread_ids = { + int(item["notification_id"]) + for item in page.get("items", []) + if isinstance(item, dict) and str(item.get("notification_id", "")).isdigit() + } + count = 0 + for delivery in store.claim_unseen(thread_ids): + for thread_id in delivery.thread_ids: + payload = json.dumps( + { + "title": "New work update", + "body": "Tap to review it in Stackchain.", + "route": f"#/my-work/update/{thread_id}", + "tag": f"stackchain-update-{thread_id}", + }, + separators=(",", ":"), + ) + try: + if send is None: + await send_web_push(delivery.subscription, payload, configuration) + else: + await send(delivery.subscription, payload) + except Exception as error: + status = getattr(getattr(error, "response", None), "status_code", None) + if status in {404, 410}: + store.delete_session(delivery.session_id) + break + raise + store.mark_delivered(delivery.session_id, (thread_id,)) + count += 1 + return count diff --git a/src/push_subscription_store.py b/src/push_subscription_store.py new file mode 100644 index 0000000..9c91afa --- /dev/null +++ b/src/push_subscription_store.py @@ -0,0 +1,96 @@ +import json +import sqlite3 +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + + +@dataclass(frozen=True) +class PushDelivery: + session_id: str + subscription: dict + thread_ids: tuple[int, ...] + + +class PushSubscriptionStore: + """Durable, device-bound Web Push subscriptions and delivery deduplication.""" + + def __init__(self, path: str | Path): + self.path = Path(path) + self.path.parent.mkdir(parents=True, exist_ok=True) + with self._connect() as connection: + connection.executescript( + """ + CREATE TABLE IF NOT EXISTS push_subscriptions ( + session_id TEXT PRIMARY KEY, + endpoint TEXT NOT NULL UNIQUE, + subscription_json TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS push_deliveries ( + session_id TEXT NOT NULL, + thread_id INTEGER NOT NULL, + PRIMARY KEY (session_id, thread_id), + FOREIGN KEY (session_id) REFERENCES push_subscriptions(session_id) + ON DELETE CASCADE + ); + """ + ) + + def _connect(self): + connection = sqlite3.connect(self.path, timeout=2) + connection.execute("PRAGMA foreign_keys = ON") + return connection + + def upsert(self, session_id: str, subscription: dict) -> None: + endpoint = subscription["endpoint"] + encoded = json.dumps(subscription, separators=(",", ":"), sort_keys=True) + with self._connect() as connection: + connection.execute("DELETE FROM push_subscriptions WHERE endpoint = ?", (endpoint,)) + connection.execute("DELETE FROM push_subscriptions WHERE session_id = ?", (session_id,)) + connection.execute( + "INSERT INTO push_subscriptions(session_id, endpoint, subscription_json) VALUES (?, ?, ?)", + (session_id, endpoint, encoded), + ) + + def delete_session(self, session_id: str) -> None: + with self._connect() as connection: + connection.execute("DELETE FROM push_subscriptions WHERE session_id = ?", (session_id,)) + + def delete_all(self) -> None: + with self._connect() as connection: + connection.execute("DELETE FROM push_subscriptions") + + def is_subscribed(self, session_id: str) -> bool: + with self._connect() as connection: + return connection.execute( + "SELECT 1 FROM push_subscriptions WHERE session_id = ?", (session_id,) + ).fetchone() is not None + + def claim_unseen(self, thread_ids: Iterable[int]) -> list[PushDelivery]: + candidates = tuple(sorted({int(value) for value in thread_ids if int(value) > 0})) + if not candidates: + return [] + with self._connect() as connection: + rows = connection.execute( + "SELECT session_id, subscription_json FROM push_subscriptions ORDER BY session_id" + ).fetchall() + deliveries = [] + for session_id, encoded in rows: + delivered = { + row[0] + for row in connection.execute( + "SELECT thread_id FROM push_deliveries WHERE session_id = ?", + (session_id,), + ) + } + unseen = tuple(value for value in candidates if value not in delivered) + if unseen: + deliveries.append(PushDelivery(session_id, json.loads(encoded), unseen)) + return deliveries + + def mark_delivered(self, session_id: str, thread_ids: Iterable[int]) -> None: + with self._connect() as connection: + connection.executemany( + "INSERT OR IGNORE INTO push_deliveries(session_id, thread_id) VALUES (?, ?)", + ((session_id, int(thread_id)) for thread_id in thread_ids), + ) diff --git a/tests/test_frontend_bundle.py b/tests/test_frontend_bundle.py index 020e44b..747a30f 100644 --- a/tests/test_frontend_bundle.py +++ b/tests/test_frontend_bundle.py @@ -45,7 +45,7 @@ def test_page_runtime_is_one_deterministic_content_addressed_bundle(tmp_path): def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path): first = build_frontend(FRONTEND) - assert set(first.feature_bundles) == {"issue-capture", "pull-workflow"} + assert set(first.feature_bundles) == {"issue-capture", "pull-workflow", "push-notifications"} capture = first.feature_bundles["issue-capture"] pull_workflow = first.feature_bundles["pull-workflow"] assert b"function createIssueCapture" not in first.runtime_bytes diff --git a/tests/test_push_frontend.py b/tests/test_push_frontend.py new file mode 100644 index 0000000..f518a9d --- /dev/null +++ b/tests/test_push_frontend.py @@ -0,0 +1,77 @@ +import json +import subprocess +from pathlib import Path + + +MODULE = Path(__file__).parents[1] / "frontend" / "push-notifications.js" + + +def run_scenario(script: str) -> dict: + harness = r""" +const createPushNotifications = require(__MODULE__); +const state = { prompts:0, requests:[], subscriptions:[], text:'' }; +const control = { + checked:false, disabled:false, + addEventListener:(_name, callback) => state.change = callback, +}; +const status = {set textContent(value) { state.text = value; }, get textContent() { return state.text; }}; +const existing = {endpoint:'https://push.example/device', toJSON() { return {endpoint:this.endpoint, keys:{p256dh:'key',auth:'auth'}}; }}; +const registration = {pushManager:{ + getSubscription: async () => state.current || null, + subscribe: async options => { state.subscriptions.push(options); state.current=existing; return existing; }, +}}; +const feature = createPushNotifications({ + control, status, + notification: {permission:'default', requestPermission:async () => { state.prompts += 1; return state.permission || 'granted'; }}, + serviceWorker: {ready:Promise.resolve(registration)}, + fetchJson: async (url, options={}) => { state.requests.push([url,options.method || 'GET',options.body || '']); return state.server || {available:true,subscribed:false,public_key:'AQID'}; }, +}); +(async () => { __SCENARIO__ })().catch(error => { console.error(error); process.exit(1); }); +""".replace("__MODULE__", json.dumps(str(MODULE))).replace("__SCENARIO__", script) + completed = subprocess.run( + ["node", "-e", harness], capture_output=True, check=True, text=True + ) + return json.loads(completed.stdout) + + +def test_initialization_reports_availability_without_prompting_for_permission(): + result = run_scenario("await feature.init(); process.stdout.write(JSON.stringify(state));") + + assert result["prompts"] == 0 + assert result["requests"] == [["api/v1/push-subscription", "GET", ""]] + assert result["text"] == "New update notifications are off for this device." + + +def test_user_gesture_subscribes_device_and_reports_enabled_state(): + result = run_scenario(""" +await feature.init(); +control.checked = true; +await state.change(); +process.stdout.write(JSON.stringify(state)); +""") + + assert result["prompts"] == 1 + assert result["subscriptions"][0]["userVisibleOnly"] is True + assert result["requests"][-1][0:2] == ["api/v1/push-subscription", "PUT"] + assert json.loads(result["requests"][-1][2])["endpoint"] == "https://push.example/device" + assert result["text"] == "New update notifications enabled for this device." + + +def test_mobile_dashboard_mounts_opt_in_and_precaches_its_controller(): + root = MODULE.parents[1] + html = (root / "frontend" / "index.html").read_text() + dashboard = (root / "frontend" / "dashboard.js").read_text() + worker = (root / "frontend" / "service-worker.js").read_text() + css = (root / "frontend" / "dashboard.css").read_text() + requirements = (root / "requirements.txt").read_text() + readme = (root / "README.md").read_text() + + assert 'id="push-updates"' in html + assert 'id="push-update-status"' in html + assert '' in html + assert "createPushNotifications({" in dashboard + assert "BASE + 'static/push-notifications.js'" in worker + assert ".push-update-control" in css and "min-height:44px" in css + assert "pywebpush==" in requirements + assert "STACKCHAIN_VAPID_PUBLIC_KEY" in readme + assert "STACKCHAIN_VAPID_PRIVATE_KEY" in readme diff --git a/tests/test_push_notifications.py b/tests/test_push_notifications.py new file mode 100644 index 0000000..56cd498 --- /dev/null +++ b/tests/test_push_notifications.py @@ -0,0 +1,166 @@ +import json +from types import SimpleNamespace + +import pytest + +from src import main +from src.push_notifications import PushConfiguration, dispatch_unread_updates +from src.push_subscription_store import PushSubscriptionStore + + +def test_subscription_store_delivers_each_unread_thread_once_per_device(tmp_path): + store = PushSubscriptionStore(tmp_path / "push.sqlite3") + subscription = { + "endpoint": "https://push.example/device-a", + "keys": {"p256dh": "public-key", "auth": "auth-secret"}, + } + + store.upsert("session-a", subscription) + + first = store.claim_unseen({41, 42}) + store.mark_delivered("session-a", first[0].thread_ids) + second = store.claim_unseen({41, 42}) + + assert [(item.session_id, item.thread_ids) for item in first] == [ + ("session-a", (41, 42)) + ] + assert second == [] + + +def test_replacing_subscription_resets_delivery_cursor_and_revocation_removes_device(tmp_path): + store = PushSubscriptionStore(tmp_path / "push.sqlite3") + original = { + "endpoint": "https://push.example/original", + "keys": {"p256dh": "old-key", "auth": "old-auth"}, + } + replacement = { + "endpoint": "https://push.example/replacement", + "keys": {"p256dh": "new-key", "auth": "new-auth"}, + } + store.upsert("session-a", original) + store.claim_unseen({9}) + + store.upsert("session-a", replacement) + delivery = store.claim_unseen({9}) + store.delete_session("session-a") + + assert delivery[0].subscription == replacement + assert delivery[0].thread_ids == (9,) + assert store.claim_unseen({10}) == [] + + +@pytest.mark.anyio +async def test_dispatch_sends_one_privacy_safe_deep_link_per_new_thread(tmp_path): + store = PushSubscriptionStore(tmp_path / "push.sqlite3") + store.upsert("session-a", { + "endpoint": "https://push.example/device-a", + "keys": {"p256dh": "public-key", "auth": "auth-secret"}, + }) + sent = [] + + async def unread(): + return {"items": [ + {"notification_id": 42, "repository": "private/repo", "title": "Secret title"}, + ]} + + async def send(subscription, payload): + sent.append((subscription, json.loads(payload))) + + config = PushConfiguration("public-vapid", "private-vapid", "mailto:ops@example.com") + assert await dispatch_unread_updates(store, config, unread, send) == 1 + assert await dispatch_unread_updates(store, config, unread, send) == 0 + assert sent[0][1] == { + "title": "New work update", + "body": "Tap to review it in Stackchain.", + "route": "#/my-work/update/42", + "tag": "stackchain-update-42", + } + assert "private/repo" not in json.dumps(sent) + assert "Secret title" not in json.dumps(sent) + + +@pytest.mark.anyio +async def test_dispatch_removes_an_expired_push_endpoint(tmp_path): + store = PushSubscriptionStore(tmp_path / "push.sqlite3") + store.upsert("session-a", { + "endpoint": "https://push.example/expired", + "keys": {"p256dh": "public-key", "auth": "auth-secret"}, + }) + + class Expired(Exception): + response = SimpleNamespace(status_code=410) + + async def unread(): + return {"items": [{"notification_id": 8}]} + + async def send(_subscription, _payload): + raise Expired() + + config = PushConfiguration("public", "private", "mailto:ops@example.com") + assert await dispatch_unread_updates(store, config, unread, send) == 0 + assert store.is_subscribed("session-a") is False + + +@pytest.mark.anyio +async def test_authenticated_device_can_subscribe_report_status_and_unsubscribe(tmp_path, monkeypatch): + store = PushSubscriptionStore(tmp_path / "push.sqlite3") + monkeypatch.setattr(main, "_push_subscription_store", store) + monkeypatch.setattr( + main, + "_push_configuration", + lambda: PushConfiguration("public-vapid", "private-vapid", "mailto:ops@example.com"), + ) + async def management_id(_session): + return "session-a" + + monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id) + + async def no_unread(): + return {"items": []} + + monkeypatch.setattr(main, "notifications", no_unread) + request = SimpleNamespace(state=SimpleNamespace( + dashboard_session=SimpleNamespace(session_id="session-a") + )) + payload = main.PushSubscriptionPayload( + endpoint="https://push.example/device-a", + keys={"p256dh": "public-key", "auth": "auth-secret"}, + ) + + assert await main.push_status(request) == { + "available": True, "subscribed": False, "public_key": "public-vapid" + } + assert await main.subscribe_push(payload, request) == {"subscribed": True} + assert (await main.push_status(request))["subscribed"] is True + assert await main.unsubscribe_push(request) == {"subscribed": False} + assert (await main.push_status(request))["subscribed"] is False + + +@pytest.mark.anyio +async def test_subscription_fails_closed_when_existing_unread_baseline_is_unavailable(tmp_path, monkeypatch): + store = PushSubscriptionStore(tmp_path / "push.sqlite3") + monkeypatch.setattr(main, "_push_subscription_store", store) + monkeypatch.setattr( + main, "_push_configuration", + lambda: PushConfiguration("public", "private", "mailto:ops@example.com"), + ) + + async def management_id(_session): + return "device-a" + + async def unavailable(): + raise RuntimeError("Gitea unavailable") + + monkeypatch.setattr(main.dashboard_auth, "session_management_id", management_id) + monkeypatch.setattr(main, "notifications", unavailable) + request = SimpleNamespace(state=SimpleNamespace(dashboard_session=object())) + payload = main.PushSubscriptionPayload( + endpoint="https://push.example/device-a", + keys={"p256dh": "public-key", "auth": "auth-secret"}, + ) + + with pytest.raises(main.HTTPException) as raised: + await main.subscribe_push(payload, request) + + assert raised.value.status_code == 503 + assert store.is_subscribed("device-a") is False diff --git a/tests/test_service_worker.py b/tests/test_service_worker.py index 0a257ed..0739362 100644 --- a/tests/test_service_worker.py +++ b/tests/test_service_worker.py @@ -112,6 +112,14 @@ async function dispatchNotificationClick(route) {{ }}); if (pending) await pending; }} +async function dispatchPush(payload) {{ + let pending; + listeners.push({{ + data: {{json: () => payload}}, + waitUntil: promise => {{ pending = promise; }}, + }}); + if (pending) await pending; +}} (async () => {{ {scenario} }})().catch(error => {{ console.error(error); process.exit(1); }}); @@ -373,6 +381,33 @@ def test_opted_in_background_sync_notifies_privately_and_receipt_tap_focuses_rou assert result["notificationClosed"] is True +def test_inbound_push_renders_generic_update_and_tap_opens_existing_workflow(): + result = run_worker_scenario( + """ + await dispatchPush({ + title:'New work update', body:'Tap to review it in Stackchain.', + tag:'stackchain-update-42', route:'#/my-work/update/42', + repository:'must-not-render', + }); + await dispatchNotificationClick('#/my-work/update/42'); + process.stdout.write(JSON.stringify(state)); +""" + ) + + assert result["notifications"] == [{ + "title": "New work update", + "options": { + "body": "Tap to review it in Stackchain.", + "tag": "stackchain-update-42", + "data": {"route": "#/my-work/update/42"}, + }, + }] + assert result["opened"] == [ + "https://forge.example/dashboard/#/my-work/update/42" + ] + assert "must-not-render" not in json.dumps(result["notifications"]) + + def test_background_mutations_obtain_session_bound_csrf_proof(): source = WORKER.read_text() @@ -470,6 +505,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell(): "/dashboard/static/mobile-search-viewport.js", "/dashboard/static/mobile-composer-viewport.js", "/dashboard/static/mention-composer.js", + "/dashboard/static/push-notifications.js", "/dashboard/static/background-issue-sync.js", }