diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue
index 75e4b03..0c50c89 100644
--- a/frontend/src/views/SettingsView.vue
+++ b/frontend/src/views/SettingsView.vue
@@ -428,6 +428,28 @@ const notifySecurityAlerts = ref(true);
const savingNotifications = ref(false);
const notificationsSaved = ref(false);
+// VAPID key reset (admin)
+const vapidResetting = ref(false);
+const vapidResetMsg = ref("");
+const vapidResetError = ref(false);
+
+async function resetVapidKeys() {
+ if (!confirm("This will regenerate VAPID keys and clear all push subscriptions. You will need to re-enable notifications afterwards. Continue?")) return;
+ vapidResetting.value = true;
+ vapidResetMsg.value = "";
+ vapidResetError.value = false;
+ try {
+ await apiPost("/api/push/reset-vapid", {});
+ vapidResetMsg.value = "Keys regenerated. Please re-enable notifications in this browser.";
+ pushStore.checkSubscription();
+ } catch {
+ vapidResetError.value = true;
+ vapidResetMsg.value = "Reset failed — check server logs.";
+ } finally {
+ vapidResetting.value = false;
+ }
+}
+
// CalDAV settings (per-user)
const caldav = ref({
caldav_url: "",
@@ -1882,6 +1904,22 @@ function formatUserDate(iso: string): string {
Notifications are blocked. Allow them in your browser site settings to re-enable.
+
+
+
+ If push notifications fail due to a corrupted or misformatted VAPID key, regenerate
+ the key pair here. All existing subscriptions will be cleared — you will need to
+ re-enable notifications in this browser afterwards.
+
+
+
+
+
+ {{ vapidResetMsg }}
+
+
diff --git a/src/fabledassistant/routes/briefing.py b/src/fabledassistant/routes/briefing.py
index 4c623ef..4e6da05 100644
--- a/src/fabledassistant/routes/briefing.py
+++ b/src/fabledassistant/routes/briefing.py
@@ -453,7 +453,8 @@ async def discuss_article(item_id: int):
if get_buffer(conv_id) is not None:
return jsonify({"error": "Generation already in progress"}), 409
- article_content = item.content or ""
+ from fabledassistant.services.rss import _fetch_full_article
+ article_content = await _fetch_full_article(item.url) or item.content or ""
# Store synthetic assistant message with read_article tool result
synthetic_tool_calls = [{
diff --git a/src/fabledassistant/routes/chat.py b/src/fabledassistant/routes/chat.py
index 9e83e5a..8bfa2b7 100644
--- a/src/fabledassistant/routes/chat.py
+++ b/src/fabledassistant/routes/chat.py
@@ -533,8 +533,9 @@ async def create_conversation_from_article(item_id: int):
conv_title = (item.title or "Article discussion")[:80]
conv = await create_conversation(uid, title=conv_title, conversation_type="chat")
+ from fabledassistant.services.rss import _fetch_full_article
source = feed_title or "News"
- content_body = (item.content or "").strip()
+ content_body = (await _fetch_full_article(item.url) if item.url else None) or (item.content or "").strip()
seeded_text = f"**{source}**\n\n**{item.title}**"
if content_body:
seeded_text += f"\n\n{content_body}"
diff --git a/src/fabledassistant/routes/push.py b/src/fabledassistant/routes/push.py
index c827420..6179518 100644
--- a/src/fabledassistant/routes/push.py
+++ b/src/fabledassistant/routes/push.py
@@ -3,9 +3,9 @@ import logging
from quart import Blueprint, jsonify, request
-from fabledassistant.auth import login_required, get_current_user_id
+from fabledassistant.auth import login_required, get_current_user_id, admin_required
from fabledassistant.config import Config
-from fabledassistant.services.push import delete_subscription, save_subscription, vapid_enabled
+from fabledassistant.services.push import delete_subscription, regenerate_vapid_keys, save_subscription, vapid_enabled
logger = logging.getLogger(__name__)
@@ -42,3 +42,13 @@ async def unsubscribe():
return jsonify({"error": "endpoint is required"}), 400
await delete_subscription(uid, endpoint)
return "", 204
+
+
+@push_bp.route("/reset-vapid", methods=["POST"])
+@admin_required
+async def reset_vapid():
+ """Regenerate VAPID keys and clear all push subscriptions."""
+ ok = await regenerate_vapid_keys()
+ if ok:
+ return jsonify({"publicKey": Config.VAPID_PUBLIC_KEY}), 200
+ return jsonify({"error": "Key regeneration failed"}), 500
diff --git a/src/fabledassistant/services/push.py b/src/fabledassistant/services/push.py
index 87256ad..b6c7708 100644
--- a/src/fabledassistant/services/push.py
+++ b/src/fabledassistant/services/push.py
@@ -62,11 +62,14 @@ def ensure_vapid_keys() -> None:
v = Vapid01()
v.generate_keys()
- private_pem = v.private_key.private_bytes(
- serialization.Encoding.PEM,
+ # pywebpush expects the private key as a base64url-encoded DER blob
+ # (passed to Vapid.from_string → from_der), NOT a PEM string.
+ private_der = v.private_key.private_bytes(
+ serialization.Encoding.DER,
serialization.PrivateFormat.TraditionalOpenSSL,
serialization.NoEncryption(),
- ).decode()
+ )
+ private_b64 = base64.urlsafe_b64encode(private_der).rstrip(b"=").decode()
pub_bytes = v.public_key.public_bytes(
serialization.Encoding.X962,
@@ -76,15 +79,40 @@ def ensure_vapid_keys() -> None:
# Persist so they survive container restarts.
_VAPID_KEYS_FILE.parent.mkdir(parents=True, exist_ok=True)
- _VAPID_KEYS_FILE.write_text(json.dumps({"private_key": private_pem, "public_key": public_b64}))
+ _VAPID_KEYS_FILE.write_text(json.dumps({"private_key": private_b64, "public_key": public_b64}))
- Config.VAPID_PRIVATE_KEY = private_pem
+ Config.VAPID_PRIVATE_KEY = private_b64
Config.VAPID_PUBLIC_KEY = public_b64
logger.info("Generated new VAPID keys and saved to %s", _VAPID_KEYS_FILE)
except Exception:
logger.warning("Failed to generate VAPID keys — push notifications will be unavailable", exc_info=True)
+async def regenerate_vapid_keys() -> bool:
+ """Delete existing VAPID keys, clear all push subscriptions, and generate a fresh pair.
+
+ All existing browser subscriptions are invalidated when keys rotate, so they
+ must be cleared — users will need to re-enable notifications.
+ """
+ if _VAPID_KEYS_FILE.exists():
+ _VAPID_KEYS_FILE.unlink()
+ Config.VAPID_PRIVATE_KEY = ""
+ Config.VAPID_PUBLIC_KEY = ""
+
+ # Clear all push subscriptions — they are bound to the old public key.
+ async with async_session() as session:
+ await session.execute(delete(PushSubscription))
+ await session.commit()
+
+ ensure_vapid_keys()
+ enabled = vapid_enabled()
+ if enabled:
+ logger.info("VAPID keys regenerated successfully")
+ else:
+ logger.error("VAPID key regeneration failed")
+ return enabled
+
+
async def save_subscription(user_id: int, subscription_json: dict, user_agent: str | None = None) -> PushSubscription:
"""Upsert a push subscription by endpoint."""
endpoint = subscription_json.get("endpoint", "")