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 <noreply@anthropic.com>
This commit is contained in:
Scott Idem
2026-05-05 19:38:58 -04:00
parent 0b96772fa6
commit ddf44a2aee
14 changed files with 350 additions and 13 deletions

View File

@@ -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.

View File

@@ -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")

View File

@@ -89,6 +89,12 @@ class Settings(BaseSettings):
jwt_secret: str = "change-me-in-dotenv" # override in .env: JWT_SECRET=<random>
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

View File

@@ -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.

115
cortex/push_utils.py Normal file
View File

@@ -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}

View File

@@ -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

60
cortex/routers/push.py Normal file
View File

@@ -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}

View File

@@ -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(() => {});
}

View File

@@ -63,6 +63,10 @@
<a href="/settings" class="hdr-dd-item">
<svg data-lucide="user" class="btn-icon"></svg> Account
</a>
<button id="push-btn" class="hdr-dd-item" style="display:none">
<svg data-lucide="bell" class="btn-icon"></svg>
<span id="push-btn-label">Enable notifications</span>
</button>
<div class="hdr-dd-divider"></div>
<form method="POST" action="/logout" style="margin:0">
<button type="submit" class="hdr-dd-item">

View File

@@ -266,6 +266,7 @@
box-sizing: border-box;
}
.hdr-dd-item:hover { background: var(--border); }
.hdr-dd-item.push-active { color: var(--accent); }
.hdr-dd-divider {
border-top: 1px solid var(--border);

View File

@@ -1,4 +1,4 @@
const CACHE = 'cortex-v1';
const CACHE = 'cortex-v2';
const PRECACHE = [
'/static/style.css',
@@ -28,6 +28,37 @@ self.addEventListener('activate', evt => {
);
});
self.addEventListener('push', evt => {
let data = { title: 'Cortex', body: '', url: '/' };
if (evt.data) {
try { data = { ...data, ...evt.data.json() }; } catch (_) {}
}
evt.waitUntil(
self.registration.showNotification(data.title, {
body: data.body,
icon: '/static/icon-192.png',
badge: '/static/icon-192.png',
data: { url: data.url },
})
);
});
self.addEventListener('notificationclick', evt => {
evt.notification.close();
const url = evt.notification.data?.url || '/';
evt.waitUntil(
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(list => {
for (const c of list) {
if (c.url.includes(self.location.origin) && 'focus' in c) {
c.navigate(url);
return c.focus();
}
}
if (clients.openWindow) return clients.openWindow(url);
})
);
});
self.addEventListener('fetch', evt => {
const url = new URL(evt.request.url);

View File

@@ -63,7 +63,7 @@ from tools.scratch import (
scratch_append as _scratch_append,
scratch_clear as _scratch_clear,
)
from tools.notify import nc_talk_send as _nc_talk_send, email_send as _email_send
from tools.notify import nc_talk_send as _nc_talk_send, email_send as _email_send, web_push as _web_push
# ── Declaration imports ───────────────────────────────────────────────────────
@@ -89,7 +89,7 @@ TOOL_CATEGORIES: dict[str, list[str]] = {
"Cron": ["cron_list", "cron_add", "cron_remove", "cron_toggle"],
"Reminders": ["reminders_add", "reminders_list", "reminders_remove", "reminders_clear"],
"Scratchpad": ["scratch_read", "scratch_write", "scratch_append", "scratch_clear"],
"Notifications": ["email_send", "nc_talk_send"],
"Notifications": ["web_push", "email_send", "nc_talk_send"],
"Aether Journals": [
"ae_journal_list", "ae_journal_search",
"ae_journal_entries_list", "ae_journal_entry_read",
@@ -142,6 +142,7 @@ _CALLABLES: dict[str, callable] = {
"scratch_clear": _scratch_clear,
"email_send": _email_send,
"nc_talk_send": _nc_talk_send,
"web_push": _web_push,
}
# ── Role-based access control ─────────────────────────────────────────────────

View File

@@ -67,6 +67,16 @@ async def email_send(to: str, subject: str, body: str) -> str:
return "Failed to send email — check SMTP configuration in .env."
async def web_push(title: str, body: str, url: str = "") -> str:
"""Send a browser push notification to the current user's registered devices."""
import push_utils
username = get_user()
result = await push_utils.send_push(username, title, body, url)
if "error" in result:
return f"Push failed: {result['error']}"
return f"Push sent to {result['sent']} device(s) for {username} (pruned {result['pruned']} stale)."
async def nc_talk_send(message: str) -> str:
"""Send a message to the user via their configured notification channel.
@@ -84,6 +94,24 @@ async def nc_talk_send(message: str) -> str:
DECLARATIONS = [
types.FunctionDeclaration(
name="web_push",
description=(
"Send a browser push notification to the current user. Works even when the "
"Cortex tab is not open. Use for completing long tasks, reminders that fire "
"in the background, or anything the user should see immediately. "
"url is optional — if set, clicking the notification opens that URL."
),
parameters=types.Schema(
type=types.Type.OBJECT,
properties={
"title": types.Schema(type=types.Type.STRING, description="Notification title (short)"),
"body": types.Schema(type=types.Type.STRING, description="Notification body text"),
"url": types.Schema(type=types.Type.STRING, description="Optional URL to open on click"),
},
required=["title", "body"],
),
),
types.FunctionDeclaration(
name="email_send",
description=(

View File

@@ -89,9 +89,9 @@ See `ARCH__Intelligence_Layer.md` for full design.
- [x] Tool: `ae_journal_entry_disable` — soft-delete via enable=false — 2026-04-28
- [x] Tool: `ae_journal_entry_append` — read→append timestamped section→write (running logs) — 2026-04-28
- [x] Tool: `ae_journal_entry_prepend` — read→prepend timestamped section→write (newest-first logs) — 2026-04-28
- [ ] Import script: walk a markdown directory, chunk by H2 section, create entries
- [ ] Target: markdown files from `~/DgrZone_Nextcloud/` and `~/OSIT_Nextcloud/`
- [ ] Tag strategy: source path, date, topic tags from frontmatter or filename
- [x] Import script: walk a markdown directory, chunk by H2 section, create entries — 2026-05-05
- [x] Target: markdown files from `~/DgrZone_Nextcloud/` and `~/OSIT_Nextcloud/` — 2026-05-05
- [x] Tag strategy: source path, topic tags from path components — 2026-05-05
---
@@ -116,8 +116,8 @@ Read before finalising either design.
### [Backend] API usage / cost tracking
Multi-user setup with real Gemini/Claude API costs. Track per-user token consumption
so Scott can see who's spending what.
- [ ] Count input + output tokens per `/chat` and `/orchestrate` call (all backends return usage)
- [ ] Append to `home/{user}/usage.json` — daily buckets, per-model breakdown
- [x] Count input + output tokens — local backend (OpenAI `usage` field) + Gemini API (`usage_metadata`) — 2026-05-05
- [x] Append to `home/{user}/usage.json` — daily buckets, per-model breakdown — 2026-05-05
- [ ] Expose via `/api/usage` endpoint; add a summary row to the Settings page
- [ ] Optional: soft spending limit with a warning toast when exceeded