From ddf44a2aeefa52142996b45ec94c2644a7ea957d Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Tue, 5 May 2026 19:38:58 -0400 Subject: [PATCH] feat: web push notifications (VAPID) - push_utils.py: subscription storage + send helper (auto-prunes 410 endpoints) - routers/push.py: GET /api/push/vapid-key (public), POST/DELETE /api/push/subscribe - sw.js: push event listener shows notification; notificationclick focuses/opens tab - app.js: subscribe/unsubscribe flow + "Enable notifications" toggle in settings dropdown - tools/notify.py: web_push orchestrator tool (user-level, no admin required) - VAPID keys in .env; pywebpush added to requirements.txt Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 4 +- cortex/auth_middleware.py | 3 +- cortex/config.py | 6 ++ cortex/main.py | 3 +- cortex/push_utils.py | 115 ++++++++++++++++++++++++++++++++++ cortex/requirements.txt | 3 + cortex/routers/push.py | 60 ++++++++++++++++++ cortex/static/app.js | 88 +++++++++++++++++++++++++- cortex/static/index.html | 4 ++ cortex/static/style.css | 1 + cortex/static/sw.js | 33 +++++++++- cortex/tools/__init__.py | 5 +- cortex/tools/notify.py | 28 +++++++++ documentation/TODO__Agents.md | 10 +-- 14 files changed, 350 insertions(+), 13 deletions(-) create mode 100644 cortex/push_utils.py create mode 100644 cortex/routers/push.py diff --git a/CLAUDE.md b/CLAUDE.md index 6ee8a99..7f656c4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -226,12 +226,12 @@ Cortex is running and stable. All channels are live: Active users: scott (inara, developer), holly (tina), brian (wintermute) -**39 orchestrator tools:** web_search, http_fetch, +**40 orchestrator tools:** web_search, http_fetch, file_read/list/write, shell_exec, claude_allow_dir, cortex_restart/logs/status/update, task_list/create/update/complete, cron_list/add/remove/toggle, reminders_add/list/remove/clear, scratch_read/write/append/clear, -email_send, nc_talk_send, +web_push, email_send, nc_talk_send, ae_journal_list/search/entries_list/entry_read/entry_create/entry_update/entry_disable/entry_append/entry_prepend, ae_task_list. diff --git a/cortex/auth_middleware.py b/cortex/auth_middleware.py index ec813a4..8959dec 100644 --- a/cortex/auth_middleware.py +++ b/cortex/auth_middleware.py @@ -17,7 +17,8 @@ from starlette.responses import RedirectResponse, JSONResponse from auth_utils import COOKIE_NAME, decode_token # Paths that don't require a session cookie -_PUBLIC = {"/login", "/logout", "/health", "/manifest.json", "/sw.js", "/favicon.ico"} +_PUBLIC = {"/login", "/logout", "/health", "/manifest.json", "/sw.js", "/favicon.ico", + "/api/push/vapid-key"} # Path prefixes that are always public (setup flow + webhooks + Google OAuth) _PUBLIC_PREFIXES = ("/setup/", "/channels/", "/webhook/", "/auth/google") diff --git a/cortex/config.py b/cortex/config.py index fd67cc7..4b275db 100644 --- a/cortex/config.py +++ b/cortex/config.py @@ -89,6 +89,12 @@ class Settings(BaseSettings): jwt_secret: str = "change-me-in-dotenv" # override in .env: JWT_SECRET= jwt_expire_days: int = 30 + # Web Push (VAPID) — for browser push notifications + # Generate once with py_vapid; see push_utils.py for key format details + vapid_public_key: str = "" # base64url-encoded uncompressed EC point (for browser) + vapid_private_key_b64: str = "" # base64-encoded PEM private key (single-line .env storage) + vapid_contact: str = "mailto:admin@example.com" + # SMTP — for sending invite emails smtp_server: str = "" smtp_port: int = 465 diff --git a/cortex/main.py b/cortex/main.py index 7e16fe8..c010bda 100644 --- a/cortex/main.py +++ b/cortex/main.py @@ -9,7 +9,7 @@ logging.basicConfig(level=logging.INFO, format="%(levelname)s:%(name)s: %(messag from config import settings from auth_middleware import SessionAuthMiddleware from routers import chat, google_chat, nextcloud_talk, files, distill, auth, orchestrator -from routers import ui, onboarding, settings, help, auth_google, local_llm +from routers import ui, onboarding, settings, help, auth_google, local_llm, push @asynccontextmanager @@ -34,6 +34,7 @@ app.include_router(files.router) app.include_router(distill.router) app.include_router(auth.router) app.include_router(orchestrator.router) +app.include_router(push.router) # Static files — must be mounted BEFORE ui.router so /static/* is matched first. # ui.router has a wildcard /{username}/{persona} that would otherwise catch /static/style.css etc. diff --git a/cortex/push_utils.py b/cortex/push_utils.py new file mode 100644 index 0000000..b533f22 --- /dev/null +++ b/cortex/push_utils.py @@ -0,0 +1,115 @@ +""" +Web Push (VAPID) helpers. + +Subscriptions are stored per-user at: + home/{user}/push_subscriptions.json → list of {endpoint, keys:{p256dh, auth}} + +send_push(username, title, body, url) iterates all stored subscriptions for that +user and fires a push. Stale endpoints (410 Gone) are pruned automatically. +""" +import asyncio +import base64 +import json +import logging +from pathlib import Path + +from config import settings + +logger = logging.getLogger(__name__) + + +def _subs_path(username: str) -> Path: + return settings.home_root() / username / "push_subscriptions.json" + + +def load_subscriptions(username: str) -> list[dict]: + path = _subs_path(username) + if not path.exists(): + return [] + try: + return json.loads(path.read_text()) + except Exception: + return [] + + +def _save_subscriptions(username: str, subs: list[dict]) -> None: + path = _subs_path(username) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(subs, indent=2)) + + +def add_subscription(username: str, sub: dict) -> None: + """Upsert a subscription by endpoint URL.""" + subs = load_subscriptions(username) + endpoint = sub.get("endpoint", "") + subs = [s for s in subs if s.get("endpoint") != endpoint] + subs.append(sub) + _save_subscriptions(username, subs) + + +def remove_subscription(username: str, endpoint: str) -> bool: + subs = load_subscriptions(username) + new_subs = [s for s in subs if s.get("endpoint") != endpoint] + if len(new_subs) == len(subs): + return False + _save_subscriptions(username, new_subs) + return True + + +def _get_private_key_pem() -> str: + """Decode the base64-encoded PEM private key from settings.""" + raw = settings.vapid_private_key_b64.strip() + if not raw: + raise RuntimeError("VAPID_PRIVATE_KEY_B64 is not set in .env") + return base64.b64decode(raw).decode() + + +def _send_one(sub: dict, payload: dict) -> bool: + """Send a push to a single subscription. Returns False if the endpoint is stale (410).""" + from pywebpush import webpush, WebPushException + + try: + webpush( + subscription_info=sub, + data=json.dumps(payload), + vapid_private_key=_get_private_key_pem(), + vapid_claims={"sub": settings.vapid_contact}, + ) + return True + except WebPushException as e: + if e.response is not None and e.response.status_code == 410: + logger.info("push endpoint gone (410), pruning: %s", sub.get("endpoint", "")[:60]) + return False + logger.warning("push failed: %s", e) + return True # keep the sub; might be transient + + +async def send_push(username: str, title: str, body: str, url: str = "") -> dict: + """ + Send a push notification to all subscriptions for username. + Returns {"sent": n, "pruned": m}. + """ + if not settings.vapid_public_key or not settings.vapid_private_key_b64: + return {"error": "VAPID keys not configured"} + + subs = load_subscriptions(username) + if not subs: + return {"error": f"No push subscriptions for {username}"} + + payload = {"title": title, "body": body, "url": url} + keep = [] + sent = 0 + pruned = 0 + + for sub in subs: + alive = await asyncio.to_thread(_send_one, sub, payload) + if alive: + keep.append(sub) + sent += 1 + else: + pruned += 1 + + if pruned: + _save_subscriptions(username, keep) + + return {"sent": sent, "pruned": pruned} diff --git a/cortex/requirements.txt b/cortex/requirements.txt index 0350613..816a287 100644 --- a/cortex/requirements.txt +++ b/cortex/requirements.txt @@ -22,5 +22,8 @@ httpx>=0.27.0 # OpenAI-compatible client — tool calling for OpenRouter / LiteLLM / any OAI-compat host openai>=1.0.0 +# Web Push / VAPID — browser push notifications +pywebpush>=2.0.0 + # anthropic SDK not needed — using claude CLI subprocess for auth # anthropic>=0.40.0 diff --git a/cortex/routers/push.py b/cortex/routers/push.py new file mode 100644 index 0000000..c28ce07 --- /dev/null +++ b/cortex/routers/push.py @@ -0,0 +1,60 @@ +""" +Web Push endpoints. + + GET /api/push/vapid-key → public VAPID key for browser PushManager.subscribe() + POST /api/push/subscribe → save a push subscription for the logged-in user + DELETE /api/push/subscribe → remove a subscription by endpoint +""" +import jwt +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from auth_utils import COOKIE_NAME, decode_token +from config import settings +import push_utils + +router = APIRouter(prefix="/api/push") + + +def _require_user(request: Request) -> str: + token = request.cookies.get(COOKIE_NAME) + if not token: + raise HTTPException(status_code=401, detail="Not authenticated") + try: + return decode_token(token) + except jwt.InvalidTokenError: + raise HTTPException(status_code=401, detail="Invalid session") + + +@router.get("/vapid-key") +async def get_vapid_key() -> dict: + """Return the VAPID public key. Public endpoint — needed before login to subscribe.""" + key = settings.vapid_public_key + if not key: + raise HTTPException(status_code=503, detail="Push notifications not configured") + return {"public_key": key} + + +class SubscribeRequest(BaseModel): + subscription: dict # full PushSubscription JSON from browser + + +class UnsubscribeRequest(BaseModel): + endpoint: str + + +@router.post("/subscribe") +async def subscribe(req: SubscribeRequest, request: Request) -> dict: + username = _require_user(request) + sub = req.subscription + if not sub.get("endpoint"): + raise HTTPException(status_code=400, detail="subscription.endpoint is required") + push_utils.add_subscription(username, sub) + return {"ok": True} + + +@router.delete("/subscribe") +async def unsubscribe(req: UnsubscribeRequest, request: Request) -> dict: + username = _require_user(request) + found = push_utils.remove_subscription(username, req.endpoint) + return {"ok": True, "found": found} diff --git a/cortex/static/app.js b/cortex/static/app.js index d1941b7..eb162d9 100644 --- a/cortex/static/app.js +++ b/cortex/static/app.js @@ -1806,7 +1806,93 @@ if (stored) resumeSession(stored, true).catch(clear_stored_session); } - // ── Service worker registration ─────────────────────────────── + // ── Service worker + Web Push ──────────────────────────────── + const pushBtn = document.getElementById('push-btn'); + const pushBtnLabel = document.getElementById('push-btn-label'); + + function _urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const raw = atob(base64); + return Uint8Array.from([...raw].map(c => c.charCodeAt(0))); + } + + async function _getPushSubscription() { + if (!('serviceWorker' in navigator) || !('PushManager' in window)) return null; + const reg = await navigator.serviceWorker.ready; + return reg.pushManager.getSubscription(); + } + + async function _syncPushBtn() { + if (!('PushManager' in window) || !('serviceWorker' in navigator)) return; + pushBtn.style.display = ''; + const sub = await _getPushSubscription(); + if (sub) { + pushBtnLabel.textContent = 'Notifications on'; + pushBtn.classList.add('push-active'); + } else { + pushBtnLabel.textContent = 'Enable notifications'; + pushBtn.classList.remove('push-active'); + } + } + + async function _subscribePush() { + try { + const keyRes = await fetch('/api/push/vapid-key'); + if (!keyRes.ok) { showToast('Push not configured on server'); return; } + const { public_key } = await keyRes.json(); + + const perm = await Notification.requestPermission(); + if (perm !== 'granted') { showToast('Notification permission denied'); return; } + + const reg = await navigator.serviceWorker.ready; + const sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: _urlBase64ToUint8Array(public_key), + }); + + await fetch('/api/push/subscribe', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ subscription: sub.toJSON() }), + }); + + showToast('Push notifications enabled'); + await _syncPushBtn(); + } catch (e) { + showToast('Could not enable push: ' + e.message); + } + } + + async function _unsubscribePush() { + try { + const sub = await _getPushSubscription(); + if (!sub) { await _syncPushBtn(); return; } + + await fetch('/api/push/subscribe', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ endpoint: sub.endpoint }), + }); + + await sub.unsubscribe(); + showToast('Notifications disabled'); + await _syncPushBtn(); + } catch (e) { + showToast('Error: ' + e.message); + } + } + + if (pushBtn) { + pushBtn.addEventListener('click', async () => { + settings_dd_el.classList.remove('open'); + const sub = await _getPushSubscription(); + if (sub) await _unsubscribePush(); + else await _subscribePush(); + }); + _syncPushBtn(); + } + if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js').catch(() => {}); } diff --git a/cortex/static/index.html b/cortex/static/index.html index 7ea35a9..27ceca7 100644 --- a/cortex/static/index.html +++ b/cortex/static/index.html @@ -63,6 +63,10 @@ Account +