Ver Fonte

Show announcements from the Bambuddy maintainers

Fetch a signed feed.json from the public bambuddy-notifications repo on
GitHub at startup and every 6 hours. Nothing about the install is sent;
targeting (version, beta channel, install type) is decided locally.

- Ed25519 against a key built into the app; an older serial is refused so a
  withdrawn message can't come back. The feed replaces the stored list, and
  a failed or rejected fetch keeps the last good one.
- Sidebar entry above System with an unread count, a slide-over list, and a
  banner for unread important/critical messages. Read state per user.
- Admins by default; Settings > General > Updates can show them to all
  users or switch them off, which also stops the fetch.
- Plain text only; links to github.com and bambuddy.cool only.
maziggy há 1 dia atrás
pai
commit
5eb37cd41c
35 ficheiros alterados com 1961 adições e 1 exclusões
  1. 1 0
      CHANGELOG.md
  2. 60 0
      backend/app/api/routes/announcements.py
  3. 2 0
      backend/app/api/routes/settings.py
  4. 1 0
      backend/app/core/database.py
  5. 10 0
      backend/app/main.py
  6. 3 0
      backend/app/models/__init__.py
  7. 51 0
      backend/app/models/announcement.py
  8. 8 0
      backend/app/schemas/settings.py
  9. 457 0
      backend/app/services/announcements.py
  10. 134 0
      backend/tests/integration/test_announcements_api.py
  11. 307 0
      backend/tests/unit/test_announcements_service.py
  12. 159 0
      frontend/src/__tests__/components/Announcements.test.tsx
  13. 45 0
      frontend/src/__tests__/hooks/useAnnouncements.test.ts
  14. 5 0
      frontend/src/__tests__/mocks/handlers.ts
  15. 29 0
      frontend/src/api/client.ts
  16. 63 0
      frontend/src/components/AnnouncementBanner.tsx
  17. 129 0
      frontend/src/components/AnnouncementsPanel.tsx
  18. 59 1
      frontend/src/components/Layout.tsx
  19. 85 0
      frontend/src/hooks/useAnnouncements.ts
  20. 20 0
      frontend/src/i18n/locales/de.ts
  21. 20 0
      frontend/src/i18n/locales/en.ts
  22. 20 0
      frontend/src/i18n/locales/es.ts
  23. 20 0
      frontend/src/i18n/locales/fr.ts
  24. 20 0
      frontend/src/i18n/locales/it.ts
  25. 20 0
      frontend/src/i18n/locales/ja.ts
  26. 20 0
      frontend/src/i18n/locales/ko.ts
  27. 20 0
      frontend/src/i18n/locales/nl.ts
  28. 20 0
      frontend/src/i18n/locales/pt-BR.ts
  29. 20 0
      frontend/src/i18n/locales/ru.ts
  30. 20 0
      frontend/src/i18n/locales/sv.ts
  31. 20 0
      frontend/src/i18n/locales/tr.ts
  32. 20 0
      frontend/src/i18n/locales/uk.ts
  33. 20 0
      frontend/src/i18n/locales/zh-CN.ts
  34. 20 0
      frontend/src/i18n/locales/zh-TW.ts
  35. 53 0
      frontend/src/pages/SettingsPage.tsx

+ 1 - 0
CHANGELOG.md

@@ -5,6 +5,7 @@ All notable changes to Bambuddy will be documented in this file.
 ## [1.2.6b1] - Unreleased
 
 ### Added
+- **Announcements from the Bambuddy maintainers, inside Bambuddy** — Security fixes, breaking changes, new releases and calls for testers now reach the people running Bambuddy where they already look, instead of only on GitHub and Discord. While there is one, an **Announcements** entry sits at the bottom of the sidebar above the System icon, with a count of unread messages; it opens a list, and **important** and **critical** messages also show a banner until dismissed. Read state is kept per user on the server. Bambuddy fetches one file, `feed.json` from the public `maziggy/bambuddy-notifications` repo on GitHub, at startup and every 6 hours: no Bambuddy server is contacted and nothing about the install is sent, and whether a message applies (version range, beta channel, install type) is decided locally. The file is signed with Ed25519 against a key built into Bambuddy, so a copy of the repo or anyone in between can't make it show a message, and an older file is refused so a withdrawn message can't be brought back. Messages are plain text, and links go only to github.com and bambuddy.cool. Shown to administrators; **Settings → General → Updates** can show them to every user, or turn them off entirely, in which case nothing is fetched.
 - **Camera snapshots reach more notifications and more providers, with an Attach Photo switch per provider (#3089, requested and contributed by @bbbenji in #3199)** — Plate Not Empty now carries a photo of the plate, taken before the chamber light is switched back off, and AI Failure Detection carries the frame the model flagged. Home Assistant (with a notify service set), Bark and Slack-format webhooks get photos too: they fetch the image themselves, so Bambuddy saves it under a random name and sends a link that opens only that one photo and stops working after 3 days. This needs **External URL** set in **Settings → Network**. Each provider has an **Attach Photo** toggle, on by default so nothing changes for existing setups, and **Test** sends a sample image when it is on. The print emails sent to the user who queued a job can show the finish photo inline when the template contains `{finish_photo_url}`. The template editor shows which events can carry a photo, and lists the variables for AI Failure Detection, Plate Not Empty, Plate Clear Required and First Layer Complete. The chamber light switched on for the plate check is now always switched back off, even when the check fails.
 - **Combine several STLs, or several copies of one, onto one plate (#2999, requested by @Markus98, contributed by @adman234 in #3162)** — The slicer sidecar slices one model at a time, so putting separate STLs on one plate needed a desktop slicer to build the 3MF first. Select one or more STLs in the File Manager and click **Combine to 3MF**, set how many copies of each you want, and Bambuddy saves a new 3MF with every object on one plate, with a preview image. Tick **Open the slicer when done** to go straight to the Slice dialog with auto-arrange already on. The source STLs are left untouched. A plate holds at most 100 objects, and the selected STLs can be at most 300 MB and 5 million triangles in total; each STL is stored once however many copies you place.
 - **The API-key printer status carries layers, the job id, HMS faults and the serial (#2919, requested by @simplytoast1)** — `GET /api/v1/webhook/printer/{id}/status`, the compact status route polled by add-ons such as notify-bambuddy for iOS Live Activities, returned only state, progress and time remaining. It now also returns `layer_num` and `total_layers`, the printer's `subtask_id` for the running job (a new value means a new print, even between two polls; `null` when Bambu gives the job no id), the live `hms_errors` in the same shape as the printer status route (so a filament runout reads differently from a manual pause), and the printer's `serial_number`. `remaining_time` stays in minutes for existing clients; the new `remaining_seconds` gives the same estimate in seconds, the unit notifications use. Nothing existing changed, and the route still needs **Read Status** and honours a key's printer limits.

+ 60 - 0
backend/app/api/routes/announcements.py

@@ -0,0 +1,60 @@
+"""Announcements from the Bambuddy maintainers (services/announcements.py).
+
+Shown to administrators, and to every signed-in user when the
+``announcements_all_users`` setting is on. With authentication off whoever opens
+Bambuddy runs it, so they see them. Anyone else gets an empty list rather than a
+403: the sidebar entry and the banner are hidden on "nothing to show", which is
+the same answer for "nothing published" and "not for you".
+"""
+
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.auth import is_auth_enabled, require_auth_if_enabled
+from backend.app.core.database import get_db
+from backend.app.models.settings import Settings
+from backend.app.models.user import User
+from backend.app.services import announcements as service
+
+router = APIRouter(prefix="/announcements", tags=["announcements"])
+
+
+async def _may_see(db: AsyncSession, user: User | None) -> bool:
+    if not await service.is_enabled(db):
+        return False
+    if not await is_auth_enabled(db):
+        return True
+    # Authenticated by API key: a script, not a person with an inbox.
+    if user is None:
+        return False
+    if user.is_admin:
+        return True
+    all_users = (
+        await db.execute(select(Settings.value).where(Settings.key == service.ALL_USERS_KEY))
+    ).scalar_one_or_none()
+    return (all_users or "").lower() == "true"
+
+
+@router.get("")
+async def list_announcements(
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = Depends(require_auth_if_enabled),
+) -> list[dict]:
+    """Live announcements for this user, newest first, with their read state."""
+    if not await _may_see(db, current_user):
+        return []
+    return await service.list_for(db, current_user.id if current_user else None)
+
+
+@router.post("/{public_id}/read", status_code=status.HTTP_204_NO_CONTENT)
+async def mark_announcement_read(
+    public_id: str,
+    db: AsyncSession = Depends(get_db),
+    current_user: User | None = Depends(require_auth_if_enabled),
+) -> None:
+    if not await _may_see(db, current_user):
+        raise HTTPException(status.HTTP_404_NOT_FOUND, "Announcement not found")
+    if not await service.mark_read(db, public_id, current_user.id if current_user else None):
+        raise HTTPException(status.HTTP_404_NOT_FOUND, "Announcement not found")
+    await db.commit()

+ 2 - 0
backend/app/api/routes/settings.py

@@ -186,6 +186,8 @@ _BOOL_SETTING_KEYS = frozenset(
         "check_updates",
         "check_printer_firmware",
         "include_beta_updates",
+        "announcements_enabled",
+        "announcements_all_users",
         "virtual_printer_enabled",
         "ftp_retry_enabled",
         "mqtt_enabled",

+ 1 - 0
backend/app/core/database.py

@@ -288,6 +288,7 @@ async def init_db():
         active_print_spoolman,
         ams_history,
         ams_label,
+        announcement,
         api_key,
         archive,
         auth_ephemeral,

+ 10 - 0
backend/app/main.py

@@ -20,6 +20,7 @@ from sqlalchemy import delete, or_, select, text
 
 from backend.app.api.routes import (
     ams_history,
+    announcements,
     api_keys,
     archive_purge,
     archives,
@@ -9978,6 +9979,11 @@ async def lifespan(app: FastAPI):
     # L-2: Start periodic auth cleanup (stale TOTP + expired revoked JTIs)
     start_auth_cleanup()
 
+    # Maintainer announcements: a signed feed fetched from GitHub every few hours.
+    from backend.app.services import announcements as announcements_service
+
+    announcements_service.start()
+
     from backend.app.services.printer_media import start_printer_download_cleanup
 
     start_printer_download_cleanup()
@@ -10032,6 +10038,9 @@ async def lifespan(app: FastAPI):
         logging.warning("Failed to shut down camera broadcasters: %s", e)
     stop_expected_prints_cleanup()
     stop_auth_cleanup()
+    from backend.app.services import announcements as announcements_service
+
+    announcements_service.stop()
     from backend.app.services.printer_media import stop_printer_download_cleanup
 
     await stop_printer_download_cleanup()
@@ -10586,6 +10595,7 @@ app.include_router(spoolman.router, prefix=app_settings.api_prefix)
 app.include_router(spoolman_inventory.router, prefix=app_settings.api_prefix)
 app.include_router(updates.router, prefix=app_settings.api_prefix)
 app.include_router(sponsor_prompt.router, prefix=app_settings.api_prefix)
+app.include_router(announcements.router, prefix=app_settings.api_prefix)
 app.include_router(maintenance.router, prefix=app_settings.api_prefix)
 app.include_router(camera.router, prefix=app_settings.api_prefix)
 app.include_router(camwall.router, prefix=app_settings.api_prefix)

+ 3 - 0
backend/app/models/__init__.py

@@ -1,5 +1,6 @@
 from backend.app.models.ams_history import AMSSensorHistory
 from backend.app.models.ams_label import AmsLabel
+from backend.app.models.announcement import Announcement, AnnouncementRead
 from backend.app.models.api_key import APIKey
 from backend.app.models.archive import PrintArchive
 from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent
@@ -97,6 +98,8 @@ __all__ = [
     "ColorCatalogEntry",
     "SpoolBuddyDevice",
     "SponsorToastState",
+    "Announcement",
+    "AnnouncementRead",
     "UserEmailPreference",
     "UserOTPCode",
     "UserTOTP",

+ 51 - 0
backend/app/models/announcement.py

@@ -0,0 +1,51 @@
+"""Announcements from the Bambuddy maintainers, and who has read them.
+
+Fetched from a signed feed on GitHub (services/announcements.py). Only the ones
+that target this install are stored, and the feed is authoritative: a message
+withdrawn upstream is deleted here on the next fetch, together with its read
+markers.
+
+``AnnouncementRead.user_id`` is nullable: with authentication off there are no
+users, and the install's read state is a single NULL-keyed row per message.
+"""
+
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from backend.app.core.database import Base
+
+
+class Announcement(Base):
+    __tablename__ = "announcements"
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    # The feed's id for the message. Stable across edits upstream, so a corrected
+    # typo does not come back as unread.
+    public_id: Mapped[str] = mapped_column(String(64), unique=True, index=True)
+    level: Mapped[str] = mapped_column(String(16))  # info | important | critical
+    # JSON {"en": {"title", "body", "link_label"?}, ...}. Text for SQLite/Postgres
+    # uniformity; the service serialises with json.dumps.
+    texts: Mapped[str] = mapped_column(Text)
+    link_url: Mapped[str | None] = mapped_column(String(500), nullable=True)
+    # Naive UTC, like every other timestamp in Bambuddy.
+    published_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    expires_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
+
+
+class AnnouncementRead(Base):
+    __tablename__ = "announcement_reads"
+    __table_args__ = (UniqueConstraint("announcement_id", "user_id", name="uq_announcement_reads_user"),)
+
+    id: Mapped[int] = mapped_column(primary_key=True)
+    announcement_id: Mapped[int] = mapped_column(
+        Integer, ForeignKey("announcements.id", ondelete="CASCADE"), index=True
+    )
+    user_id: Mapped[int | None] = mapped_column(
+        Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=True, index=True
+    )
+    read_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())

+ 8 - 0
backend/app/schemas/settings.py

@@ -96,6 +96,12 @@ class AppSettings(BaseModel):
     check_updates: bool = Field(default=True, description="Automatically check for updates on startup")
     check_printer_firmware: bool = Field(default=True, description="Check for printer firmware updates from Bambu Lab")
     include_beta_updates: bool = Field(default=False, description="Include beta/prerelease versions in update checks")
+    announcements_enabled: bool = Field(
+        default=True, description="Fetch announcements from the Bambuddy maintainers (a signed file on GitHub)"
+    )
+    announcements_all_users: bool = Field(
+        default=False, description="Show announcements to every signed-in user, not only administrators"
+    )
 
     # Language
     language: str = Field(default="en", description="UI language (en, de, fr, ja, it, pt-BR)")
@@ -696,6 +702,8 @@ class AppSettingsUpdate(BaseModel):
     check_updates: bool | None = None
     check_printer_firmware: bool | None = None
     include_beta_updates: bool | None = None
+    announcements_enabled: bool | None = None
+    announcements_all_users: bool | None = None
     local_login_enabled: bool | None = None
     language: str | None = None
     notification_language: str | None = None

+ 457 - 0
backend/app/services/announcements.py

@@ -0,0 +1,457 @@
+"""Announcements from the Bambuddy maintainers: fetch, verify, keep what applies.
+
+Bambuddy fetches one file, ``feed.json``, from the public
+``maziggy/bambuddy-notifications`` repo on raw.githubusercontent.com -- the host
+the update check already talks to. No Bambuddy server is contacted and nothing
+about this install is sent: whether a message applies here (version range,
+channel, install type) is decided below, locally.
+
+The file is written by the maintainers' registrar and signed with Ed25519:
+
+    {"format": 1, "key_id": "...", "signature": "<base64>", "payload": {...}}
+
+The signature covers ``canonical(payload)``. A file that does not verify against
+a key in ``TRUSTED_KEYS`` is ignored, so neither a copy of the repo nor anyone in
+the middle can make Bambuddy show a message. The payload's ``serial`` only goes
+up; a feed older than one already accepted is refused, so an old signed file
+cannot be re-served to bring back a withdrawn message.
+
+The feed is the full current list. On every accepted fetch the stored set is
+replaced: a message withdrawn upstream disappears here with its read markers.
+Any failure keeps the last good list.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import base64
+import json
+import logging
+import random
+import re
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from urllib.parse import urlsplit
+
+import httpx
+from cryptography.exceptions import InvalidSignature
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
+from sqlalchemy import delete, select
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.config import APP_VERSION
+from backend.app.models.announcement import Announcement, AnnouncementRead
+from backend.app.models.settings import Settings
+
+logger = logging.getLogger(__name__)
+
+FEED_URL = "https://raw.githubusercontent.com/maziggy/bambuddy-notifications/main/feed.json"
+FEED_FORMAT = 1
+
+# key_id (first 16 hex of sha256 of the raw public key) -> base64 raw public key.
+# A list so the key can be rotated: ship the new key next to the old one first.
+TRUSTED_KEYS: dict[str, str] = {
+    "d70b3bf207fdfd59": "uQqUAYrInmXQ1nIwOIY/95L7tD5o/HZMmZfhAlULJ/w=",
+}
+
+# Where a message may link to. The registrar refuses anything else too; this is
+# the side that counts.
+LINK_HOSTS = ("github.com", "bambuddy.cool")
+
+LEVELS = ("info", "important", "critical")
+MAX_FEED_BYTES = 512 * 1024
+MAX_TITLE = 120
+MAX_BODY = 2000
+MAX_LINK_LABEL = 40
+
+FETCH_INTERVAL_SECONDS = 6 * 3600
+FETCH_JITTER_SECONDS = 30 * 60
+# Let startup settle before the first fetch.
+FIRST_FETCH_DELAY_SECONDS = 60
+
+ENABLED_KEY = "announcements_enabled"
+ALL_USERS_KEY = "announcements_all_users"
+# Internal state, never part of the settings API.
+SERIAL_KEY = "announcements_feed_serial"
+LAST_FETCH_KEY = "announcements_last_fetch"
+
+
+class FeedRejected(Exception):
+    """The fetched file is not a feed this install accepts. Nothing changes."""
+
+
+# ---- verification -------------------------------------------------------------------
+
+
+def canonical(payload: dict) -> bytes:
+    return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
+
+
+def verify_feed(content: bytes, trusted_keys: dict[str, str] | None = None) -> dict:
+    """The payload of a correctly signed feed, or FeedRejected."""
+    keys = TRUSTED_KEYS if trusted_keys is None else trusted_keys
+    try:
+        envelope = json.loads(content)
+    except (ValueError, UnicodeDecodeError) as exc:
+        raise FeedRejected("not JSON") from exc
+    if not isinstance(envelope, dict) or envelope.get("format") != FEED_FORMAT:
+        raise FeedRejected("unknown feed format")
+    public = keys.get(str(envelope.get("key_id")))
+    if public is None:
+        raise FeedRejected(f"signed with an unknown key ({envelope.get('key_id')!r})")
+    payload = envelope.get("payload")
+    if not isinstance(payload, dict):
+        raise FeedRejected("no payload")
+    try:
+        signature = base64.b64decode(str(envelope.get("signature")), validate=True)
+        Ed25519PublicKey.from_public_bytes(base64.b64decode(public)).verify(signature, canonical(payload))
+    except (ValueError, InvalidSignature) as exc:
+        raise FeedRejected("signature does not verify") from exc
+    if payload.get("format") != FEED_FORMAT:
+        raise FeedRejected("unknown payload format")
+    serial = payload.get("serial")
+    if not isinstance(serial, int) or isinstance(serial, bool) or serial < 1:
+        raise FeedRejected("no serial")
+    if not isinstance(payload.get("announcements"), list):
+        raise FeedRejected("no announcement list")
+    return payload
+
+
+# ---- what this install is ------------------------------------------------------------
+
+
+@dataclass(frozen=True)
+class InstallFacts:
+    version: str
+    channel: str  # stable | beta
+    install_type: str  # docker | native | ha_addon | windows
+
+
+def _version_key(version: str) -> tuple:
+    """Sortable form that puts a release above its own betas (1.2.6 > 1.2.6b3)."""
+    from backend.app.api.routes.updates import parse_version
+
+    parsed = parse_version(version)
+    major, minor, patch, micro = (parsed + (0, 0, 0, 0))[:4]
+    is_prerelease = parsed[4] if len(parsed) > 4 else 0
+    prerelease_num = parsed[5] if len(parsed) > 5 else 0
+    return (major, minor, patch, micro, 1 - is_prerelease, prerelease_num)
+
+
+def _install_type() -> str:
+    from backend.app.api.routes import updates
+
+    if updates._is_windows_installer_install():
+        return "windows"
+    if updates._is_ha_addon():
+        return "ha_addon"
+    if updates._is_docker_environment():
+        return "docker"
+    return "native"
+
+
+async def install_facts(db: AsyncSession) -> InstallFacts:
+    beta_setting = (
+        await db.execute(select(Settings.value).where(Settings.key == "include_beta_updates"))
+    ).scalar_one_or_none()
+    # Beta testers are the installs that asked for betas, and the ones running one.
+    prerelease = bool(re.search(r"[a-zA-Z]", APP_VERSION.lstrip("v")))
+    beta = prerelease or (beta_setting or "").lower() == "true"
+    return InstallFacts(version=APP_VERSION, channel="beta" if beta else "stable", install_type=_install_type())
+
+
+def targets(entry: dict, facts: InstallFacts) -> bool:
+    target = entry.get("target") or {}
+    if not isinstance(target, dict):
+        return False
+    try:
+        here = _version_key(facts.version)
+        low, high = target.get("min_version"), target.get("max_version")
+        if low and here < _version_key(str(low)):
+            return False
+        if high and here > _version_key(str(high)):
+            return False
+    except (TypeError, ValueError):
+        return False
+    channels = target.get("channels") or []
+    if channels and facts.channel not in channels:
+        return False
+    install_types = target.get("install_types") or []
+    return not (install_types and facts.install_type not in install_types)
+
+
+# ---- one entry, defensively ---------------------------------------------------------
+
+
+def link_allowed(url: str) -> bool:
+    try:
+        parts = urlsplit(url)
+        port = parts.port
+    except ValueError:
+        return False
+    host = (parts.hostname or "").lower()
+    if parts.scheme != "https" or not host or parts.username or parts.password:
+        return False
+    if port not in (None, 443):
+        return False
+    return any(host == allowed or host.endswith("." + allowed) for allowed in LINK_HOSTS)
+
+
+def _parse_time(value: object) -> datetime | None:
+    if not isinstance(value, str) or not value:
+        return None
+    try:
+        parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
+    except ValueError:
+        return None
+    if parsed.tzinfo is not None:
+        parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
+    return parsed
+
+
+def _clean_texts(texts: object) -> dict[str, dict[str, str]] | None:
+    """Plain strings, cut to length. None when there is no usable English text."""
+    if not isinstance(texts, dict):
+        return None
+    cleaned: dict[str, dict[str, str]] = {}
+    for lang, text in texts.items():
+        if not isinstance(lang, str) or len(lang) > 10 or not isinstance(text, dict):
+            continue
+        title, body = text.get("title"), text.get("body")
+        if not isinstance(title, str) or not isinstance(body, str) or not title.strip() or not body.strip():
+            continue
+        entry = {"title": title.strip()[:MAX_TITLE], "body": body.strip()[:MAX_BODY]}
+        label = text.get("link_label")
+        if isinstance(label, str) and label.strip():
+            entry["link_label"] = label.strip()[:MAX_LINK_LABEL]
+        cleaned[lang] = entry
+    return cleaned if "en" in cleaned else None
+
+
+@dataclass
+class Entry:
+    public_id: str
+    level: str
+    texts: dict[str, dict[str, str]]
+    link_url: str | None
+    published_at: datetime | None
+    expires_at: datetime | None
+
+
+def parse_entry(raw: object) -> Entry | None:
+    """One feed entry, or None if it can't be shown safely. Never raises."""
+    if not isinstance(raw, dict):
+        return None
+    public_id = raw.get("id")
+    if not isinstance(public_id, str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", public_id):
+        return None
+    level = raw.get("level") if raw.get("level") in LEVELS else "info"
+    texts = _clean_texts(raw.get("texts"))
+    if texts is None:
+        return None
+    link = raw.get("link_url")
+    link = link if isinstance(link, str) and link_allowed(link) else None
+    return Entry(
+        public_id=public_id,
+        level=level,
+        texts=texts,
+        link_url=link,
+        published_at=_parse_time(raw.get("published_at")),
+        expires_at=_parse_time(raw.get("expires_at")),
+    )
+
+
+# ---- store ---------------------------------------------------------------------------
+
+
+async def _get(db: AsyncSession, key: str) -> str | None:
+    return (await db.execute(select(Settings.value).where(Settings.key == key))).scalar_one_or_none()
+
+
+async def _set(db: AsyncSession, key: str, value: str) -> None:
+    from backend.app.core.db_dialect import upsert_setting
+
+    await upsert_setting(db, Settings, key, value)
+
+
+async def is_enabled(db: AsyncSession) -> bool:
+    return (await _get(db, ENABLED_KEY) or "true").lower() != "false"
+
+
+async def apply_payload(db: AsyncSession, payload: dict, facts: InstallFacts) -> int:
+    """Replace the stored announcements with the ones in ``payload`` that apply here.
+
+    Refuses (FeedRejected) a serial below the highest already accepted. The same
+    serial again is the same feed fetched twice, and is fine. Returns how many are
+    stored. The caller commits.
+    """
+    serial = payload["serial"]
+    seen = int(await _get(db, SERIAL_KEY) or 0)
+    if serial < seen:
+        raise FeedRejected(f"serial {serial} is older than {seen}, already accepted")
+
+    wanted: dict[str, Entry] = {}
+    for raw in payload["announcements"]:
+        entry = parse_entry(raw)
+        if entry is not None and targets(raw, facts):
+            wanted[entry.public_id] = entry
+
+    existing = {a.public_id: a for a in (await db.execute(select(Announcement))).scalars().all()}
+    gone = [a.id for pid, a in existing.items() if pid not in wanted]
+    if gone:
+        # Read markers first: SQLite enforces no foreign keys unless asked to, so
+        # ON DELETE CASCADE alone would leave them behind.
+        await db.execute(delete(AnnouncementRead).where(AnnouncementRead.announcement_id.in_(gone)))
+        await db.execute(delete(Announcement).where(Announcement.id.in_(gone)))
+    for pid, entry in wanted.items():
+        row = existing.get(pid) or Announcement(public_id=pid)
+        row.level = entry.level
+        row.texts = json.dumps(entry.texts, ensure_ascii=False)
+        row.link_url = entry.link_url
+        row.published_at = entry.published_at
+        row.expires_at = entry.expires_at
+        if pid not in existing:
+            db.add(row)
+    await _set(db, SERIAL_KEY, str(max(serial, seen)))
+    return len(wanted)
+
+
+async def _download() -> bytes:
+    # No version and no install identity in the request: the generic agent says
+    # what is asking, nothing about which install.
+    headers = {"User-Agent": "Bambuddy-Announcements", "Cache-Control": "no-cache"}
+    async with (
+        httpx.AsyncClient(timeout=15, follow_redirects=False) as client,
+        client.stream("GET", FEED_URL, headers=headers) as response,
+    ):
+        if response.status_code != 200:
+            raise FeedRejected(f"HTTP {response.status_code}")
+        chunks: list[bytes] = []
+        size = 0
+        async for chunk in response.aiter_bytes():
+            size += len(chunk)
+            if size > MAX_FEED_BYTES:
+                raise FeedRejected("larger than the feed is allowed to be")
+            chunks.append(chunk)
+        return b"".join(chunks)
+
+
+async def refresh(db: AsyncSession) -> int | None:
+    """Fetch, verify and store. Returns how many apply here, or None if nothing changed.
+
+    Never raises for a network problem or a bad feed -- those are logged and the
+    last good list stays. A switched-off install fetches nothing.
+    """
+    if not await is_enabled(db):
+        return None
+    try:
+        content = await _download()
+        payload = verify_feed(content)
+        count = await apply_payload(db, payload, await install_facts(db))
+    except FeedRejected as exc:
+        await db.rollback()
+        logger.warning("Announcements feed ignored: %s", exc)
+        return None
+    except httpx.HTTPError as exc:
+        await db.rollback()
+        # Offline and air-gapped installs land here every time; not worth a warning.
+        logger.info("Announcements feed not fetched: %s", type(exc).__name__)
+        return None
+    await _set(db, LAST_FETCH_KEY, datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"))
+    await db.commit()
+    logger.info("Announcements feed #%s: %d for this install", payload["serial"], count)
+    return count
+
+
+# ---- read state ----------------------------------------------------------------------
+
+
+def _visible_filter(now: datetime):
+    return (Announcement.expires_at.is_(None)) | (Announcement.expires_at > now)
+
+
+async def list_for(db: AsyncSession, user_id: int | None) -> list[dict]:
+    """Live announcements, newest first, each with whether this user has read it."""
+    now = datetime.now(timezone.utc).replace(tzinfo=None)
+    rows = (
+        (
+            await db.execute(
+                select(Announcement)
+                .where(_visible_filter(now))
+                .order_by(Announcement.published_at.desc(), Announcement.id.desc())
+            )
+        )
+        .scalars()
+        .all()
+    )
+    reader = AnnouncementRead.user_id.is_(None) if user_id is None else AnnouncementRead.user_id == user_id
+    read_ids = set((await db.execute(select(AnnouncementRead.announcement_id).where(reader))).scalars().all())
+    result = []
+    for a in rows:
+        try:
+            texts = json.loads(a.texts)
+        except (TypeError, ValueError):
+            continue
+        result.append(
+            {
+                "id": a.public_id,
+                "level": a.level,
+                "texts": texts,
+                "link_url": a.link_url,
+                "published_at": a.published_at.isoformat() + "Z" if a.published_at else None,
+                "expires_at": a.expires_at.isoformat() + "Z" if a.expires_at else None,
+                "read": a.id in read_ids,
+            }
+        )
+    return result
+
+
+async def mark_read(db: AsyncSession, public_id: str, user_id: int | None) -> bool:
+    """Record that this user read it. False if there is no such announcement."""
+    announcement = (
+        await db.execute(select(Announcement).where(Announcement.public_id == public_id))
+    ).scalar_one_or_none()
+    if announcement is None:
+        return False
+    reader = AnnouncementRead.user_id.is_(None) if user_id is None else AnnouncementRead.user_id == user_id
+    # Checked rather than left to the unique constraint: NULL user_ids never
+    # collide in one, so the auth-off row would be duplicated on every click.
+    already = (
+        await db.execute(select(AnnouncementRead.id).where(AnnouncementRead.announcement_id == announcement.id, reader))
+    ).first()
+    if already is None:
+        db.add(AnnouncementRead(announcement_id=announcement.id, user_id=user_id))
+    return True
+
+
+# ---- background loop -----------------------------------------------------------------
+
+_task: asyncio.Task | None = None
+
+
+async def _loop() -> None:
+    from backend.app.core.database import async_session
+
+    await asyncio.sleep(FIRST_FETCH_DELAY_SECONDS)
+    while True:
+        try:
+            async with async_session() as db:
+                await refresh(db)
+        except asyncio.CancelledError:
+            raise
+        except Exception:
+            logger.exception("Announcements refresh failed")
+        await asyncio.sleep(FETCH_INTERVAL_SECONDS + random.uniform(0, FETCH_JITTER_SECONDS))
+
+
+def start() -> None:
+    global _task
+    if _task is None:
+        _task = asyncio.create_task(_loop())
+
+
+def stop() -> None:
+    global _task
+    if _task is not None:
+        _task.cancel()
+        _task = None

+ 134 - 0
backend/tests/integration/test_announcements_api.py

@@ -0,0 +1,134 @@
+"""Who sees announcements, and per-user read state.
+
+Administrators see them; other signed-in users only when
+``announcements_all_users`` is on. With authentication off, the person running
+Bambuddy sees them. Anyone else gets an empty list, which hides the sidebar entry
+and the banner the same way "nothing published" does.
+"""
+
+import json
+import secrets
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.models.announcement import Announcement
+from backend.app.models.settings import Settings
+
+URL = "/api/v1/announcements"
+_FIXTURE_PW = "Aa1!" + secrets.token_urlsafe(12)  # pragma: allowlist secret
+
+
+async def _seed(db, *public_ids: str, level: str = "important") -> None:
+    for pid in public_ids:
+        db.add(
+            Announcement(
+                public_id=pid,
+                level=level,
+                texts=json.dumps({"en": {"title": f"T {pid}", "body": "B"}, "de": {"title": "D", "body": "B"}}),
+            )
+        )
+    await db.commit()
+
+
+async def _setting(db, key: str, value: str) -> None:
+    db.add(Settings(key=key, value=value))
+    await db.commit()
+
+
+async def _admin(client: AsyncClient, username: str) -> dict:
+    await client.post(
+        "/api/v1/auth/setup",
+        json={"auth_enabled": True, "admin_username": username, "admin_password": _FIXTURE_PW},
+    )
+    login = await client.post("/api/v1/auth/login", json={"username": username, "password": _FIXTURE_PW})
+    assert login.status_code == 200, login.text
+    return {"Authorization": f"Bearer {login.json()['access_token']}"}
+
+
+async def _user(client: AsyncClient, admin: dict, username: str) -> dict:
+    grp = await client.post(
+        "/api/v1/groups/", headers=admin, json={"name": f"ann_{username}", "permissions": ["printers:read"]}
+    )
+    assert grp.status_code == 201, grp.text
+    created = await client.post(
+        "/api/v1/users/",
+        headers=admin,
+        json={"username": username, "password": _FIXTURE_PW, "role": "user", "group_ids": [grp.json()["id"]]},
+    )
+    assert created.status_code == 201, created.text
+    login = await client.post("/api/v1/auth/login", json={"username": username, "password": _FIXTURE_PW})
+    return {"Authorization": f"Bearer {login.json()['access_token']}"}
+
+
+@pytest.mark.integration
+class TestAuthOff:
+    @pytest.mark.asyncio
+    async def test_listed_with_every_language_and_unread(self, async_client: AsyncClient, db_session):
+        await _seed(db_session, "a1")
+        r = await async_client.get(URL)
+        assert r.status_code == 200
+        [item] = r.json()
+        assert item["id"] == "a1" and item["level"] == "important" and item["read"] is False
+        assert set(item["texts"]) == {"en", "de"}
+
+    @pytest.mark.asyncio
+    async def test_read_sticks(self, async_client: AsyncClient, db_session):
+        await _seed(db_session, "a1")
+        assert (await async_client.post(f"{URL}/a1/read")).status_code == 204
+        assert (await async_client.get(URL)).json()[0]["read"] is True
+
+    @pytest.mark.asyncio
+    async def test_unknown_id_is_404(self, async_client: AsyncClient):
+        assert (await async_client.post(f"{URL}/nope/read")).status_code == 404
+
+    @pytest.mark.asyncio
+    async def test_switched_off_shows_nothing(self, async_client: AsyncClient, db_session):
+        await _seed(db_session, "a1")
+        await _setting(db_session, "announcements_enabled", "false")
+        assert (await async_client.get(URL)).json() == []
+        assert (await async_client.post(f"{URL}/a1/read")).status_code == 404
+
+
+@pytest.mark.integration
+class TestAuthOn:
+    @pytest.mark.asyncio
+    async def test_signed_out_is_401(self, async_client: AsyncClient, db_session):
+        await _admin(async_client, "annadmin0")
+        assert (await async_client.get(URL)).status_code == 401
+
+    @pytest.mark.asyncio
+    async def test_admin_sees_them_a_user_does_not(self, async_client: AsyncClient, db_session):
+        await _seed(db_session, "a1")
+        admin = await _admin(async_client, "annadmin1")
+        user = await _user(async_client, admin, "annuser1")
+        assert [i["id"] for i in (await async_client.get(URL, headers=admin)).json()] == ["a1"]
+        assert (await async_client.get(URL, headers=user)).json() == []
+        assert (await async_client.post(f"{URL}/a1/read", headers=user)).status_code == 404
+
+    @pytest.mark.asyncio
+    async def test_all_users_setting_shows_them_with_separate_read_state(self, async_client: AsyncClient, db_session):
+        await _seed(db_session, "a1")
+        admin = await _admin(async_client, "annadmin2")
+        user = await _user(async_client, admin, "annuser2")
+        await _setting(db_session, "announcements_all_users", "true")
+
+        assert (await async_client.post(f"{URL}/a1/read", headers=user)).status_code == 204
+        assert (await async_client.get(URL, headers=user)).json()[0]["read"] is True
+        assert (await async_client.get(URL, headers=admin)).json()[0]["read"] is False
+
+
+@pytest.mark.integration
+class TestSettings:
+    @pytest.mark.asyncio
+    async def test_both_switches_round_trip(self, async_client: AsyncClient):
+        defaults = (await async_client.get("/api/v1/settings/")).json()
+        assert defaults["announcements_enabled"] is True
+        assert defaults["announcements_all_users"] is False
+        r = await async_client.put(
+            "/api/v1/settings/", json={"announcements_enabled": False, "announcements_all_users": True}
+        )
+        assert r.status_code == 200, r.text
+        after = (await async_client.get("/api/v1/settings/")).json()
+        assert after["announcements_enabled"] is False
+        assert after["announcements_all_users"] is True

+ 307 - 0
backend/tests/unit/test_announcements_service.py

@@ -0,0 +1,307 @@
+"""Announcements: verifying the signed feed, and keeping only what applies here.
+
+The feed is signed by the maintainers' registrar. These tests sign their own
+feeds with a throwaway key passed in as the trusted one, and check the rules an
+install applies: a bad signature, an unknown key or an older serial changes
+nothing; a message that does not target this install is not stored; a withdrawn
+one disappears with its read markers.
+"""
+
+import base64
+import hashlib
+import json
+from datetime import datetime, timedelta, timezone
+from unittest.mock import AsyncMock, patch
+
+import pytest
+from cryptography.hazmat.primitives import serialization
+from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
+from sqlalchemy import select
+
+from backend.app.models.announcement import Announcement, AnnouncementRead
+from backend.app.models.settings import Settings
+from backend.app.services import announcements as svc
+from backend.app.services.announcements import FeedRejected, InstallFacts
+
+KEY = Ed25519PrivateKey.generate()
+_PUB = KEY.public_key().public_bytes(serialization.Encoding.Raw, serialization.PublicFormat.Raw)
+KEY_ID = hashlib.sha256(_PUB).hexdigest()[:16]
+TRUSTED = {KEY_ID: base64.b64encode(_PUB).decode()}
+
+STABLE_DOCKER = InstallFacts(version="1.2.6", channel="stable", install_type="docker")
+
+
+def entry(public_id="a1", level="info", target=None, **extra):
+    raw = {
+        "id": public_id,
+        "level": level,
+        "published_at": "2026-10-01T12:00:00Z",
+        "expires_at": None,
+        "texts": {"en": {"title": f"Title {public_id}", "body": "Body"}},
+        "link_url": None,
+        "target": target or {"min_version": None, "max_version": None, "channels": [], "install_types": []},
+    }
+    raw.update(extra)
+    return raw
+
+
+def payload(*entries, serial=1):
+    return {"format": 1, "serial": serial, "published_at": "2026-10-01T12:00:00Z", "announcements": list(entries)}
+
+
+def signed(p: dict, key: Ed25519PrivateKey = KEY, key_id: str = KEY_ID) -> bytes:
+    """The file exactly as the registrar writes it: pretty, payload as an object."""
+    sig = key.sign(svc.canonical(p))
+    envelope = {"format": 1, "key_id": key_id, "signature": base64.b64encode(sig).decode(), "payload": p}
+    return json.dumps(envelope, indent=2, ensure_ascii=False).encode()
+
+
+class TestVerify:
+    def test_a_correctly_signed_feed_verifies(self):
+        p = payload(entry(texts={"en": {"title": "Grüße — 你好", "body": "x"}}))
+        assert svc.verify_feed(signed(p), TRUSTED) == p
+
+    def test_tampered_text_is_refused(self):
+        content = signed(payload(entry()))
+        with pytest.raises(FeedRejected, match="signature"):
+            svc.verify_feed(content.replace(b"Title a1", b"Title b1"), TRUSTED)
+
+    def test_another_key_is_refused(self):
+        stranger = Ed25519PrivateKey.generate()
+        with pytest.raises(FeedRejected, match="signature"):
+            svc.verify_feed(signed(payload(entry()), key=stranger), TRUSTED)
+
+    def test_unknown_key_id_is_refused(self):
+        with pytest.raises(FeedRejected, match="unknown key"):
+            svc.verify_feed(signed(payload(entry()), key_id="0000000000000000"), TRUSTED)
+
+    @pytest.mark.parametrize("content", [b"", b"not json", b"[]", b'{"format": 2}'])
+    def test_garbage_is_refused(self, content):
+        with pytest.raises(FeedRejected):
+            svc.verify_feed(content, TRUSTED)
+
+    @pytest.mark.parametrize("serial", [None, 0, "3", True])
+    def test_a_payload_without_a_usable_serial_is_refused(self, serial):
+        p = payload(entry())
+        p["serial"] = serial
+        with pytest.raises(FeedRejected, match="serial"):
+            svc.verify_feed(signed(p), TRUSTED)
+
+    def test_the_built_in_key_is_well_formed(self):
+        for key_id, public in svc.TRUSTED_KEYS.items():
+            raw = base64.b64decode(public)
+            assert len(raw) == 32
+            assert hashlib.sha256(raw).hexdigest()[:16] == key_id
+
+
+class TestEntries:
+    @pytest.mark.parametrize("public_id", [None, "", "a b", "x" * 65, 7, "../x"])
+    def test_unusable_ids_are_dropped(self, public_id):
+        assert svc.parse_entry(entry(public_id=public_id)) is None
+
+    def test_no_english_text_is_dropped(self):
+        assert svc.parse_entry(entry(texts={"de": {"title": "Hallo", "body": "x"}})) is None
+
+    def test_texts_are_cut_to_length_and_bad_languages_skipped(self):
+        e = svc.parse_entry(
+            entry(
+                texts={
+                    "en": {"title": "t" * 500, "body": "b" * 5000, "link_label": "l" * 100},
+                    "de": {"title": "", "body": "x"},
+                    "fr": "not a dict",
+                }
+            )
+        )
+        assert set(e.texts) == {"en"}
+        assert len(e.texts["en"]["title"]) == svc.MAX_TITLE
+        assert len(e.texts["en"]["body"]) == svc.MAX_BODY
+        assert len(e.texts["en"]["link_label"]) == svc.MAX_LINK_LABEL
+
+    def test_unknown_level_is_info(self):
+        assert svc.parse_entry(entry(level="apocalyptic")).level == "info"
+
+    @pytest.mark.parametrize(
+        "url",
+        [
+            "http://bambuddy.cool/",
+            "https://evil.example/",
+            "https://bambuddy.cool.evil.example/",
+            "https://user@github.com/",
+            "https://github.com:8443/",
+            "javascript:alert(1)",
+        ],
+    )
+    def test_links_off_the_allowlist_are_dropped_not_the_message(self, url):
+        e = svc.parse_entry(entry(link_url=url))
+        assert e is not None and e.link_url is None
+
+    def test_allowed_link_is_kept(self):
+        assert svc.parse_entry(entry(link_url="https://wiki.bambuddy.cool/x/")).link_url == (
+            "https://wiki.bambuddy.cool/x/"
+        )
+
+    def test_times_are_naive_utc(self):
+        e = svc.parse_entry(entry(expires_at="2026-11-01T02:00:00+02:00"))
+        assert e.expires_at == datetime(2026, 11, 1, 0, 0)
+
+
+class TestTargeting:
+    @pytest.mark.parametrize(
+        "version, low, high, shown",
+        [
+            ("1.2.6", None, None, True),
+            ("1.2.6", "1.2.6", None, True),
+            ("1.2.6", "1.2.7", None, False),
+            ("1.2.6", None, "1.2.5", False),
+            ("1.2.6b1", "1.2.6", None, False),  # a beta is older than its release
+            ("1.2.6b1", None, "1.2.6", True),
+            ("1.2.6b3", "1.2.6b2", "1.2.6b3", True),
+        ],
+    )
+    def test_version_range(self, version, low, high, shown):
+        facts = InstallFacts(version=version, channel="stable", install_type="docker")
+        target = {"min_version": low, "max_version": high}
+        assert svc.targets({"target": target}, facts) is shown
+
+    def test_channel(self):
+        beta_only = {"target": {"channels": ["beta"]}}
+        assert not svc.targets(beta_only, STABLE_DOCKER)
+        assert svc.targets(beta_only, InstallFacts("1.2.6", "beta", "docker"))
+
+    def test_install_type(self):
+        windows_only = {"target": {"install_types": ["windows"]}}
+        assert not svc.targets(windows_only, STABLE_DOCKER)
+        assert svc.targets(windows_only, InstallFacts("1.2.6", "stable", "windows"))
+
+    def test_a_malformed_target_shows_nothing(self):
+        assert not svc.targets({"target": "everyone"}, STABLE_DOCKER)
+
+
+async def _stored(db) -> list[str]:
+    return sorted((await db.execute(select(Announcement.public_id))).scalars().all())
+
+
+async def _serial(db) -> str | None:
+    return (await db.execute(select(Settings.value).where(Settings.key == svc.SERIAL_KEY))).scalar_one_or_none()
+
+
+class TestApply:
+    @pytest.mark.asyncio
+    async def test_stores_only_what_targets_this_install(self, db_session):
+        p = payload(
+            entry("everyone"),
+            entry("beta", target={"channels": ["beta"]}),
+            entry("windows", target={"install_types": ["windows"]}),
+            entry("broken", texts={}),
+        )
+        assert await svc.apply_payload(db_session, p, STABLE_DOCKER) == 1
+        await db_session.commit()
+        assert await _stored(db_session) == ["everyone"]
+        assert await _serial(db_session) == "1"
+
+    @pytest.mark.asyncio
+    async def test_withdrawn_upstream_goes_with_its_read_markers(self, db_session):
+        await svc.apply_payload(db_session, payload(entry("keep"), entry("drop")), STABLE_DOCKER)
+        await db_session.commit()
+        assert await svc.mark_read(db_session, "drop", None)
+        await db_session.commit()
+
+        await svc.apply_payload(db_session, payload(entry("keep"), serial=2), STABLE_DOCKER)
+        await db_session.commit()
+        assert await _stored(db_session) == ["keep"]
+        assert (await db_session.execute(select(AnnouncementRead))).scalars().all() == []
+
+    @pytest.mark.asyncio
+    async def test_an_edit_keeps_the_read_state(self, db_session):
+        await svc.apply_payload(db_session, payload(entry("a1")), STABLE_DOCKER)
+        await db_session.commit()
+        await svc.mark_read(db_session, "a1", None)
+        await db_session.commit()
+        edited = entry("a1", texts={"en": {"title": "Fixed typo", "body": "Body"}})
+        await svc.apply_payload(db_session, payload(edited, serial=2), STABLE_DOCKER)
+        await db_session.commit()
+        [item] = await svc.list_for(db_session, None)
+        assert item["texts"]["en"]["title"] == "Fixed typo"
+        assert item["read"] is True
+
+    @pytest.mark.asyncio
+    async def test_an_older_serial_is_refused_and_changes_nothing(self, db_session):
+        await svc.apply_payload(db_session, payload(entry("new"), serial=5), STABLE_DOCKER)
+        await db_session.commit()
+        with pytest.raises(FeedRejected, match="older"):
+            await svc.apply_payload(db_session, payload(entry("old"), serial=4), STABLE_DOCKER)
+        await db_session.rollback()
+        assert await _stored(db_session) == ["new"]
+        assert await _serial(db_session) == "5"
+
+    @pytest.mark.asyncio
+    async def test_the_same_serial_again_is_fine(self, db_session):
+        await svc.apply_payload(db_session, payload(entry("a1"), serial=3), STABLE_DOCKER)
+        await svc.apply_payload(db_session, payload(entry("a1"), serial=3), STABLE_DOCKER)
+        await db_session.commit()
+        assert await _stored(db_session) == ["a1"]
+
+
+class TestReadState:
+    @pytest.mark.asyncio
+    async def test_expired_ones_are_not_listed(self, db_session):
+        past = (datetime.now(timezone.utc) - timedelta(minutes=1)).strftime("%Y-%m-%dT%H:%M:%SZ")
+        future = (datetime.now(timezone.utc) + timedelta(days=1)).strftime("%Y-%m-%dT%H:%M:%SZ")
+        p = payload(entry("gone", expires_at=past), entry("live", expires_at=future), entry("forever"))
+        await svc.apply_payload(db_session, p, STABLE_DOCKER)
+        await db_session.commit()
+        assert sorted(i["id"] for i in await svc.list_for(db_session, None)) == ["forever", "live"]
+
+    @pytest.mark.asyncio
+    async def test_reading_twice_with_auth_off_records_once(self, db_session):
+        await svc.apply_payload(db_session, payload(entry("a1")), STABLE_DOCKER)
+        await db_session.commit()
+        assert await svc.mark_read(db_session, "a1", None)
+        await db_session.commit()
+        assert await svc.mark_read(db_session, "a1", None)
+        await db_session.commit()
+        assert len((await db_session.execute(select(AnnouncementRead))).scalars().all()) == 1
+
+    @pytest.mark.asyncio
+    async def test_unknown_id_is_not_marked(self, db_session):
+        assert not await svc.mark_read(db_session, "nope", None)
+
+
+class TestRefresh:
+    @pytest.mark.asyncio
+    async def test_switched_off_fetches_nothing(self, db_session):
+        db_session.add(Settings(key=svc.ENABLED_KEY, value="false"))
+        await db_session.commit()
+        with patch.object(svc, "_download", AsyncMock()) as download:
+            assert await svc.refresh(db_session) is None
+        download.assert_not_called()
+
+    @pytest.mark.asyncio
+    async def test_a_good_feed_is_stored(self, db_session):
+        with (
+            patch.object(svc, "_download", AsyncMock(return_value=signed(payload(entry("a1"))))),
+            patch.object(svc, "TRUSTED_KEYS", TRUSTED),
+            patch.object(svc, "install_facts", AsyncMock(return_value=STABLE_DOCKER)),
+        ):
+            assert await svc.refresh(db_session) == 1
+        assert await _stored(db_session) == ["a1"]
+
+    @pytest.mark.asyncio
+    async def test_a_bad_feed_keeps_the_last_good_list(self, db_session):
+        with (
+            patch.object(svc, "TRUSTED_KEYS", TRUSTED),
+            patch.object(svc, "install_facts", AsyncMock(return_value=STABLE_DOCKER)),
+        ):
+            with patch.object(svc, "_download", AsyncMock(return_value=signed(payload(entry("a1"))))):
+                await svc.refresh(db_session)
+            forged = signed(payload(serial=9), key=Ed25519PrivateKey.generate())
+            with patch.object(svc, "_download", AsyncMock(return_value=forged)):
+                assert await svc.refresh(db_session) is None
+        assert await _stored(db_session) == ["a1"]
+
+    @pytest.mark.asyncio
+    async def test_offline_is_quiet_and_harmless(self, db_session):
+        import httpx
+
+        with patch.object(svc, "_download", AsyncMock(side_effect=httpx.ConnectError("offline"))):
+            assert await svc.refresh(db_session) is None

+ 159 - 0
frontend/src/__tests__/components/Announcements.test.tsx

@@ -0,0 +1,159 @@
+/**
+ * Announcements from the Bambuddy maintainers: the sidebar entry above System,
+ * the slide-over list, and the banner for unread important/critical messages.
+ */
+
+import { describe, it, expect, beforeEach, vi } from 'vitest';
+import { screen, waitFor, fireEvent, within } from '@testing-library/react';
+import { http, HttpResponse } from 'msw';
+import { render } from '../utils';
+import { server } from '../mocks/server';
+import { AnnouncementsPanel } from '../../components/AnnouncementsPanel';
+import { AnnouncementBanner } from '../../components/AnnouncementBanner';
+import { Layout } from '../../components/Layout';
+import type { Announcement } from '../../api/client';
+
+function announcement(overrides: Partial<Announcement> = {}): Announcement {
+  return {
+    id: 'a1',
+    level: 'info',
+    texts: { en: { title: 'Hello installs', body: 'Line one\nLine two' } },
+    link_url: null,
+    published_at: '2026-10-01T12:00:00Z',
+    expires_at: null,
+    read: false,
+    ...overrides,
+  };
+}
+
+describe('AnnouncementsPanel', () => {
+  it('marks what was unread as read on opening, and keeps a New chip on it', () => {
+    const markRead = vi.fn();
+    render(
+      <AnnouncementsPanel
+        open
+        onClose={() => {}}
+        markRead={markRead}
+        announcements={[announcement(), announcement({ id: 'a2', read: true, texts: { en: { title: 'Old news', body: 'x' } } })]}
+      />
+    );
+    expect(markRead).toHaveBeenCalledTimes(1);
+    expect(markRead).toHaveBeenCalledWith('a1');
+    const items = screen.getAllByRole('listitem');
+    expect(within(items[0]).getByText('New')).toBeInTheDocument();
+    expect(within(items[1]).queryByText('New')).not.toBeInTheDocument();
+  });
+
+  it('renders the body as plain text, never as HTML', () => {
+    render(
+      <AnnouncementsPanel
+        open
+        onClose={() => {}}
+        markRead={() => {}}
+        announcements={[announcement({ texts: { en: { title: 'T', body: '<img src=x onerror=alert(1)>' } } })]}
+      />
+    );
+    expect(screen.getByText('<img src=x onerror=alert(1)>')).toBeInTheDocument();
+    expect(document.querySelector('img[src="x"]')).toBeNull();
+  });
+
+  it('links only to allowed hosts', () => {
+    render(
+      <AnnouncementsPanel
+        open
+        onClose={() => {}}
+        markRead={() => {}}
+        announcements={[
+          announcement({ id: 'ok', link_url: 'https://wiki.bambuddy.cool/x/', texts: { en: { title: 'A', body: 'b', link_label: 'Details' } } }),
+          announcement({ id: 'bad', link_url: 'https://evil.example/x', texts: { en: { title: 'B', body: 'b', link_label: 'Phish' } } }),
+        ]}
+      />
+    );
+    const link = screen.getByRole('link', { name: /Details/ });
+    expect(link).toHaveAttribute('href', 'https://wiki.bambuddy.cool/x/');
+    expect(link).toHaveAttribute('rel', 'noopener noreferrer');
+    expect(screen.queryByRole('link', { name: /Phish/ })).not.toBeInTheDocument();
+  });
+
+  it('closes on Escape', () => {
+    const onClose = vi.fn();
+    render(<AnnouncementsPanel open onClose={onClose} markRead={() => {}} announcements={[announcement()]} />);
+    fireEvent.keyDown(window, { key: 'Escape' });
+    expect(onClose).toHaveBeenCalled();
+  });
+});
+
+describe('AnnouncementBanner', () => {
+  it('shows the first item, offers the rest, and Got it marks it read', () => {
+    const markRead = vi.fn();
+    const onOpen = vi.fn();
+    render(
+      <AnnouncementBanner
+        items={[
+          announcement({ id: 'crit', level: 'critical', texts: { en: { title: 'Update now', body: 'b' } } }),
+          announcement({ id: 'imp', level: 'important' }),
+        ]}
+        onOpen={onOpen}
+        markRead={markRead}
+      />
+    );
+    expect(screen.getByRole('alert')).toHaveTextContent('Update now');
+    fireEvent.click(screen.getByRole('button', { name: 'Read more (+1)' }));
+    expect(onOpen).toHaveBeenCalled();
+    fireEvent.click(screen.getByRole('button', { name: 'Got it' }));
+    expect(markRead).toHaveBeenCalledWith('crit');
+  });
+
+  it('renders nothing without items', () => {
+    render(<AnnouncementBanner items={[]} onOpen={() => {}} markRead={() => {}} />);
+    expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+    expect(screen.queryByRole('status')).not.toBeInTheDocument();
+  });
+});
+
+describe('Layout with announcements', () => {
+  beforeEach(() => {
+    server.use(
+      http.get('/api/v1/auth/status', () => HttpResponse.json({ auth_enabled: false, requires_setup: false })),
+      http.get('/api/v1/settings/ui-flags', () =>
+        HttpResponse.json({ check_updates: false, billing_enabled: false, user_notifications_enabled: true, currency: 'EUR' })
+      )
+    );
+  });
+
+  it('shows no entry and no banner when there is nothing', async () => {
+    render(<Layout />);
+    await waitFor(() => expect(screen.getAllByTitle(/System/).length).toBeGreaterThan(0));
+    expect(screen.queryByText('Announcements')).not.toBeInTheDocument();
+  });
+
+  it('shows the entry with an unread count, the banner for important, and opens the list', async () => {
+    let reads: string[] = [];
+    server.use(
+      http.get('/api/v1/announcements', () =>
+        HttpResponse.json([
+          announcement({ id: 'imp', level: 'important', texts: { en: { title: 'Breaking change in 2.0', body: 'b' } } }),
+          announcement({ id: 'inf', level: 'info', texts: { en: { title: 'Testers wanted', body: 'b' } } }),
+        ])
+      ),
+      http.post('/api/v1/announcements/:id/read', ({ params }) => {
+        reads = [...reads, String(params.id)];
+        return new HttpResponse(null, { status: 204 });
+      })
+    );
+    render(<Layout />);
+
+    const entry = await screen.findByRole('button', { name: /Announcements/ });
+    expect(within(entry).getByText('2')).toBeInTheDocument();
+    // Only the important one earns a banner.
+    expect(screen.getByRole('status')).toHaveTextContent('Breaking change in 2.0');
+    expect(screen.queryByText('Testers wanted')).not.toBeInTheDocument();
+
+    fireEvent.click(entry);
+    const dialog = await screen.findByRole('dialog');
+    expect(within(dialog).getByText('Testers wanted')).toBeInTheDocument();
+    await waitFor(() => expect(reads.sort()).toEqual(['imp', 'inf']));
+    // Read now: the banner is gone.
+    await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
+  });
+});

+ 45 - 0
frontend/src/__tests__/hooks/useAnnouncements.test.ts

@@ -0,0 +1,45 @@
+import { describe, it, expect } from 'vitest';
+import { announcementText, isAllowedAnnouncementLink } from '../../hooks/useAnnouncements';
+import type { Announcement } from '../../api/client';
+
+const a = (texts: Announcement['texts']): Announcement => ({
+  id: 'x', level: 'info', texts, link_url: null, published_at: null, expires_at: null, read: false,
+});
+
+describe('announcementText', () => {
+  const texts = {
+    en: { title: 'Hello', body: 'b' },
+    de: { title: 'Hallo', body: 'b' },
+    'pt-BR': { title: 'Olá', body: 'b' },
+    'zh-CN': { title: '你好', body: 'b' },
+  };
+
+  it.each([
+    ['de', 'Hallo'],
+    ['pt-BR', 'Olá'],
+    ['pt-br', 'Olá'],
+    ['pt', 'Olá'],
+    ['zh-TW', '你好'],
+    ['fr', 'Hello'],
+    ['en-US', 'Hello'],
+  ])('%s -> %s', (lang, title) => {
+    expect(announcementText(a(texts), lang).title).toBe(title);
+  });
+});
+
+describe('isAllowedAnnouncementLink', () => {
+  it.each([
+    ['https://wiki.bambuddy.cool/x', true],
+    ['https://bambuddy.cool', true],
+    ['https://github.com/maziggy/bambuddy', true],
+    ['http://github.com/', false],
+    ['https://github.com.evil.example/', false],
+    ['https://user:pw@github.com/', false],
+    ['https://github.com:8443/', false],
+    ['javascript:alert(1)', false],
+    ['not a url', false],
+    [null, false],
+  ])('%s -> %s', (url, allowed) => {
+    expect(isAllowedAnnouncementLink(url)).toBe(allowed);
+  });
+});

+ 5 - 0
frontend/src/__tests__/mocks/handlers.ts

@@ -383,6 +383,11 @@ export const handlers = [
   // Version / Health
   // ========================================================================
 
+  // Announcements from the maintainers: none by default.
+  http.get('/api/v1/announcements', () => {
+    return HttpResponse.json([]);
+  }),
+
   http.get('/api/v1/version', () => {
     return HttpResponse.json({
       version: '0.1.5',

+ 29 - 0
frontend/src/api/client.ts

@@ -1383,6 +1383,9 @@ export interface AppSettings {
   check_updates: boolean;
   check_printer_firmware: boolean;
   include_beta_updates: boolean;
+  // Announcements from the Bambuddy maintainers (a signed file on GitHub).
+  announcements_enabled?: boolean;
+  announcements_all_users?: boolean;
   // #1589: false hides the local username/password form on the login page;
   // BAMBUDDY_LOCAL_LOGIN=true on the server flips the reported value back to
   // true so the env-var recovery path is visible to the SPA.
@@ -9196,6 +9199,32 @@ export const bugReportApi = {
     }),
 };
 
+export type AnnouncementLevel = 'info' | 'important' | 'critical';
+
+export interface AnnouncementText {
+  title: string;
+  body: string;
+  link_label?: string;
+}
+
+// One message from the Bambuddy maintainers. `texts` holds every language the
+// message was written in; English is always there.
+export interface Announcement {
+  id: string;
+  level: AnnouncementLevel;
+  texts: Record<string, AnnouncementText>;
+  link_url: string | null;
+  published_at: string | null;
+  expires_at: string | null;
+  read: boolean;
+}
+
+export const announcementsApi = {
+  list: () => request<Announcement[]>('/announcements'),
+  markRead: (id: string) =>
+    request<void>(`/announcements/${encodeURIComponent(id)}/read`, { method: 'POST' }),
+};
+
 export interface SponsorPromptCheckResponse {
   show: boolean;
   milestone?: string;

+ 63 - 0
frontend/src/components/AnnouncementBanner.tsx

@@ -0,0 +1,63 @@
+import { useTranslation } from 'react-i18next';
+import { AlertTriangle, Megaphone } from 'lucide-react';
+import type { Announcement } from '../api/client';
+import { announcementText } from '../hooks/useAnnouncements';
+
+interface AnnouncementBannerProps {
+  // Unread important/critical announcements, most severe first.
+  items: Announcement[];
+  onOpen: () => void;
+  markRead: (id: string) => void;
+}
+
+/**
+ * The strip above the page for an unread important or critical announcement.
+ *
+ * One at a time, the most severe; "+N more" opens the panel. "Got it" marks it
+ * read on the server, so it stays gone on every browser this user signs in on.
+ * Info-level messages never get a banner -- only the sidebar dot.
+ */
+export function AnnouncementBanner({ items, onOpen, markRead }: AnnouncementBannerProps) {
+  const { t, i18n } = useTranslation();
+  const first = items[0];
+  if (!first) return null;
+  const text = announcementText(first, i18n.language);
+  const critical = first.level === 'critical';
+  const Icon = critical ? AlertTriangle : Megaphone;
+
+  return (
+    <div
+      role={critical ? 'alert' : 'status'}
+      className={`px-4 py-2 flex items-center gap-3 border-b text-sm ${
+        critical
+          ? 'bg-red-100 dark:bg-red-500/20 border-red-300 dark:border-red-500/30'
+          : 'bg-amber-100 dark:bg-amber-500/20 border-amber-300 dark:border-amber-500/30'
+      }`}
+    >
+      <Icon className={`w-4 h-4 flex-shrink-0 ${critical ? 'text-red-500' : 'text-amber-500'}`} />
+      <div className="flex items-center gap-x-2 gap-y-1 flex-wrap min-w-0">
+        <span className={`font-medium ${critical ? 'text-red-800 dark:text-red-200' : 'text-amber-800 dark:text-amber-200'}`}>
+          {text.title}
+        </span>
+        <button
+          onClick={onOpen}
+          className={`font-medium underline ${
+            critical
+              ? 'text-red-700 dark:text-red-400 hover:text-red-900 dark:hover:text-red-300'
+              : 'text-amber-700 dark:text-amber-400 hover:text-amber-900 dark:hover:text-amber-300'
+          }`}
+        >
+          {items.length > 1
+            ? t('announcements.readMoreCount', { count: items.length - 1 })
+            : t('announcements.readMore')}
+        </button>
+      </div>
+      <button
+        onClick={() => markRead(first.id)}
+        className="ml-auto flex-shrink-0 px-2 py-1 rounded font-medium text-bambu-gray-light hover:text-white hover:bg-bambu-dark-tertiary transition-colors"
+      >
+        {t('announcements.gotIt')}
+      </button>
+    </div>
+  );
+}

+ 129 - 0
frontend/src/components/AnnouncementsPanel.tsx

@@ -0,0 +1,129 @@
+import { useEffect, useRef, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { ExternalLink, Megaphone, X } from 'lucide-react';
+import type { Announcement, AnnouncementLevel } from '../api/client';
+import { announcementText, isAllowedAnnouncementLink } from '../hooks/useAnnouncements';
+import { formatDateOnly } from '../utils/date';
+
+const LEVEL_CHIP: Record<AnnouncementLevel, string> = {
+  info: 'bg-sky-100 text-sky-800 dark:bg-sky-500/20 dark:text-sky-300',
+  important: 'bg-amber-100 text-amber-800 dark:bg-amber-500/20 dark:text-amber-300',
+  critical: 'bg-red-100 text-red-800 dark:bg-red-500/20 dark:text-red-300',
+};
+
+interface AnnouncementsPanelProps {
+  open: boolean;
+  onClose: () => void;
+  announcements: Announcement[];
+  markRead: (id: string) => void;
+}
+
+/**
+ * Slide-over list of announcements from the Bambuddy maintainers.
+ *
+ * Opening it marks everything in it read -- the dot and the banner go -- but the
+ * ones that were unread keep a "New" chip until it closes, so the reader can
+ * still tell what they came for.
+ */
+export function AnnouncementsPanel({ open, onClose, announcements, markRead }: AnnouncementsPanelProps) {
+  const { t, i18n } = useTranslation();
+  const [newIds, setNewIds] = useState<Set<string>>(() => new Set());
+  const closeRef = useRef<HTMLButtonElement>(null);
+  // The list at the moment of opening; later refetches don't re-mark anything.
+  const latest = useRef({ announcements, markRead });
+  latest.current = { announcements, markRead };
+
+  useEffect(() => {
+    if (!open) return;
+    const unreadIds = latest.current.announcements.filter((a) => !a.read).map((a) => a.id);
+    setNewIds(new Set(unreadIds));
+    unreadIds.forEach((id) => latest.current.markRead(id));
+    closeRef.current?.focus();
+  }, [open]);
+
+  useEffect(() => {
+    if (!open) return;
+    const onKey = (e: KeyboardEvent) => {
+      if (e.key === 'Escape') onClose();
+    };
+    window.addEventListener('keydown', onKey);
+    return () => window.removeEventListener('keydown', onKey);
+  }, [open, onClose]);
+
+  if (!open) return null;
+
+  return (
+    <>
+      <div className="fixed inset-0 bg-black/60 z-[60]" onClick={onClose} aria-hidden="true" />
+      <aside
+        role="dialog"
+        aria-modal="true"
+        aria-labelledby="announcements-title"
+        className="fixed inset-y-0 right-0 z-[61] w-full max-w-md bg-bambu-dark-secondary border-l border-bambu-dark-tertiary flex flex-col shadow-2xl"
+      >
+        <header className="flex items-center gap-3 px-5 py-4 border-b border-bambu-dark-tertiary">
+          <Megaphone className="w-5 h-5 text-bambu-green" />
+          <h2 id="announcements-title" className="text-lg font-semibold text-white">
+            {t('announcements.title')}
+          </h2>
+          <button
+            ref={closeRef}
+            onClick={onClose}
+            className="ml-auto p-2 -mr-2 rounded-lg text-bambu-gray-light hover:text-white hover:bg-bambu-dark-tertiary transition-colors"
+            aria-label={t('common.close')}
+            title={t('common.close')}
+          >
+            <X className="w-5 h-5" />
+          </button>
+        </header>
+
+        <div className="flex-1 overflow-y-auto">
+          {announcements.length === 0 ? (
+            <p className="p-6 text-center text-bambu-gray">{t('announcements.empty')}</p>
+          ) : (
+            <ul className="divide-y divide-bambu-dark-tertiary">
+              {announcements.map((a) => {
+                const text = announcementText(a, i18n.language);
+                const link = isAllowedAnnouncementLink(a.link_url) ? a.link_url : null;
+                return (
+                  <li key={a.id} className="px-5 py-4 space-y-2">
+                    <div className="flex items-center gap-2 flex-wrap text-xs">
+                      <span className={`px-2 py-0.5 rounded-full font-semibold ${LEVEL_CHIP[a.level]}`}>
+                        {t(`announcements.level.${a.level}`)}
+                      </span>
+                      {newIds.has(a.id) && (
+                        <span className="px-2 py-0.5 rounded-full font-semibold bg-bambu-green/20 text-bambu-green">
+                          {t('announcements.new')}
+                        </span>
+                      )}
+                      {a.published_at && (
+                        <span className="ml-auto text-bambu-gray">{formatDateOnly(a.published_at)}</span>
+                      )}
+                    </div>
+                    <h3 className="text-white font-semibold text-balance">{text.title}</h3>
+                    <p className="text-sm text-bambu-gray-light whitespace-pre-line break-words">{text.body}</p>
+                    {link && (
+                      <a
+                        href={link}
+                        target="_blank"
+                        rel="noopener noreferrer"
+                        className="inline-flex items-center gap-1 text-sm font-medium text-bambu-green hover:underline"
+                      >
+                        {text.link_label || t('announcements.readMore')}
+                        <ExternalLink className="w-3.5 h-3.5" />
+                      </a>
+                    )}
+                  </li>
+                );
+              })}
+            </ul>
+          )}
+        </div>
+
+        <footer className="px-5 py-3 border-t border-bambu-dark-tertiary text-xs text-bambu-gray">
+          {t('announcements.source')}
+        </footer>
+      </aside>
+    </>
+  );
+}

+ 59 - 1
frontend/src/components/Layout.tsx

@@ -1,6 +1,6 @@
 import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
 import { NavLink, Outlet, useNavigate, useLocation } from 'react-router-dom';
-import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, Bell, Receipt, type LucideIcon } from 'lucide-react';
+import { Printer, Archive, ListOrdered, BarChart3, Cloud, Settings, Sun, Moon, Monitor, ChevronLeft, ChevronRight, Keyboard, Github, ArrowUpCircle, Wrench, FolderKanban, FolderOpen, X, Menu, Info, Plug, Bug, LogOut, Key, Loader2, Disc3, ShieldAlert, Globe, Bell, Receipt, Megaphone, type LucideIcon } from 'lucide-react';
 import { useTranslation } from 'react-i18next';
 import { useTheme } from '../contexts/ThemeContext';
 import { KeyboardShortcutsModal } from './KeyboardShortcutsModal';
@@ -21,6 +21,9 @@ import { Card, CardHeader, CardContent } from './Card';
 import { parseUTCDate } from '../utils/date';
 import { Button } from './Button';
 import { BugReportBubble } from './BugReportBubble';
+import { AnnouncementsPanel } from './AnnouncementsPanel';
+import { AnnouncementBanner } from './AnnouncementBanner';
+import { useAnnouncements } from '../hooks/useAnnouncements';
 import {
   getHiddenSidebarSystemItemIds,
   getSidebarOrder,
@@ -149,6 +152,17 @@ export function Layout() {
     staleTime: 5 * 60 * 1000, // 5 minutes
   });
 
+  // Announcements from the Bambuddy maintainers: the sidebar entry above System,
+  // the slide-over list, and the banner for unread important/critical ones.
+  const { announcements, unread: unreadAnnouncements, bannerItems, markRead: markAnnouncementRead } =
+    useAnnouncements();
+  const [announcementsOpen, setAnnouncementsOpen] = useState(false);
+  const openAnnouncements = useCallback(() => {
+    setMobileDrawerOpen(false);
+    setAnnouncementsOpen(true);
+  }, []);
+  const closeAnnouncements = useCallback(() => setAnnouncementsOpen(false), []);
+
   // Sponsor-prompt toast — fires once per session post-auth if a milestone is eligible.
   useSponsorPrompt(uiFlags?.currency ?? 'USD');
 
@@ -733,6 +747,26 @@ export function Layout() {
         <div className="flex-shrink-0 p-2 border-t border-bambu-dark-tertiary">
           {isSidebarCompact || sidebarExpanded ? (
             <div className="flex flex-col gap-2 px-2">
+              {/* Announcements: a row of its own above the icons, so it isn't one
+                  more glyph in a row people have stopped reading. Only while
+                  there is something to show. */}
+              {announcements.length > 0 && (
+                <button
+                  onClick={openAnnouncements}
+                  className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white text-sm"
+                >
+                  <Megaphone className="w-5 h-5 flex-shrink-0" />
+                  <span>{t('announcements.title')}</span>
+                  {unreadAnnouncements.length > 0 && (
+                    <span
+                      className="ml-auto min-w-[20px] h-5 px-1.5 flex items-center justify-center text-[11px] font-bold rounded-full bg-bambu-green text-white"
+                      aria-label={t('announcements.unread', { count: unreadAnnouncements.length })}
+                    >
+                      {unreadAnnouncements.length}
+                    </span>
+                  )}
+                </button>
+              )}
               {/* Top row: icons */}
               <div className="flex items-center justify-center gap-1 flex-wrap">
                 {hasSwitchbarPlugs && (
@@ -856,6 +890,23 @@ export function Layout() {
                   )}
                 </div>
               )}
+              {announcements.length > 0 && (
+                <button
+                  onClick={openAnnouncements}
+                  className="relative p-2 rounded-lg hover:bg-bambu-dark-tertiary transition-colors text-bambu-gray-light hover:text-white"
+                  title={t('announcements.title')}
+                  aria-label={
+                    unreadAnnouncements.length > 0
+                      ? t('announcements.unread', { count: unreadAnnouncements.length })
+                      : t('announcements.title')
+                  }
+                >
+                  <Megaphone className="w-5 h-5" />
+                  {unreadAnnouncements.length > 0 && (
+                    <span className="absolute top-1 right-1 w-2.5 h-2.5 rounded-full bg-bambu-green ring-2 ring-bambu-dark-secondary" />
+                  )}
+                </button>
+              )}
               {hasPermission('system:read') ? (
                 <NavLink
                   to="/system"
@@ -979,6 +1030,7 @@ export function Layout() {
             </div>
           </div>
         )}
+        <AnnouncementBanner items={bannerItems} onOpen={openAnnouncements} markRead={markAnnouncementRead} />
         {/* Persistent update banner */}
         {showUpdateBanner && (
           <div className="bg-bambu-green/20 border-b border-bambu-green/30 px-4 py-2 flex items-center justify-between">
@@ -1008,6 +1060,12 @@ export function Layout() {
         )}
         <Outlet />
       </main>
+      <AnnouncementsPanel
+        open={announcementsOpen}
+        onClose={closeAnnouncements}
+        announcements={announcements}
+        markRead={markAnnouncementRead}
+      />
 
       <UnknownSpoolModal
         prompt={unknownSpool.prompt}

+ 85 - 0
frontend/src/hooks/useAnnouncements.ts

@@ -0,0 +1,85 @@
+import { useCallback, useMemo } from 'react';
+import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import { announcementsApi, type Announcement, type AnnouncementText } from '../api/client';
+import { useAuth } from '../contexts/AuthContext';
+
+// Hosts a message may link to. The backend already drops any other link; this
+// is the second check, so a bad link never becomes a clickable one.
+const LINK_HOSTS = ['github.com', 'bambuddy.cool'];
+
+export function isAllowedAnnouncementLink(url: string | null | undefined): url is string {
+  if (!url) return false;
+  try {
+    const parsed = new URL(url);
+    if (parsed.protocol !== 'https:' || parsed.username || parsed.password) return false;
+    if (parsed.port && parsed.port !== '443') return false;
+    const host = parsed.hostname.toLowerCase();
+    return LINK_HOSTS.some((allowed) => host === allowed || host.endsWith(`.${allowed}`));
+  } catch {
+    return false;
+  }
+}
+
+/** The message in the UI language when it was written in it, else English. */
+export function announcementText(announcement: Announcement, language: string): AnnouncementText {
+  const { texts } = announcement;
+  if (texts[language]) return texts[language];
+  const lower = language.toLowerCase();
+  const sameCase = Object.keys(texts).find((key) => key.toLowerCase() === lower);
+  if (sameCase) return texts[sameCase];
+  // "pt" finds "pt-BR", "zh-HK" finds "zh-TW" or "zh-CN" -- closer than English.
+  const base = lower.split('-')[0];
+  const sameBase = Object.keys(texts).find((key) => key.toLowerCase().split('-')[0] === base);
+  return sameBase ? texts[sameBase] : texts.en;
+}
+
+const SEVERITY: Record<Announcement['level'], number> = { critical: 2, important: 1, info: 0 };
+
+/**
+ * Announcements from the Bambuddy maintainers, for whoever may see them.
+ *
+ * The backend answers with an empty list for anyone who may not (and when the
+ * install has them switched off), so an empty list is the one signal for
+ * "show nothing" -- no separate permission check here.
+ */
+export function useAnnouncements() {
+  const { authEnabled, user, loading } = useAuth();
+  const queryClient = useQueryClient();
+
+  const { data } = useQuery({
+    queryKey: ['announcements'],
+    queryFn: announcementsApi.list,
+    enabled: !loading && (!authEnabled || !!user),
+    staleTime: 5 * 60 * 1000,
+    // A local read of what the backend fetched; the backend itself only goes
+    // to GitHub every few hours.
+    refetchInterval: 10 * 60 * 1000,
+  });
+  const announcements = useMemo(() => data ?? [], [data]);
+
+  const markReadMutation = useMutation({
+    mutationFn: announcementsApi.markRead,
+    onMutate: (id: string) => {
+      queryClient.setQueryData<Announcement[]>(['announcements'], (old) =>
+        old?.map((a) => (a.id === id ? { ...a, read: true } : a))
+      );
+    },
+    onError: () => queryClient.invalidateQueries({ queryKey: ['announcements'] }),
+  });
+  const { mutate } = markReadMutation;
+  const markRead = useCallback((id: string) => mutate(id), [mutate]);
+
+  const unread = useMemo(() => announcements.filter((a) => !a.read), [announcements]);
+
+  // What earns a banner: important or critical, not yet read. Most severe first,
+  // then newest (the list already comes newest first).
+  const bannerItems = useMemo(
+    () =>
+      unread
+        .filter((a) => a.level !== 'info')
+        .sort((a, b) => SEVERITY[b.level] - SEVERITY[a.level]),
+    [unread]
+  );
+
+  return { announcements, unread, bannerItems, markRead };
+}

+ 20 - 0
frontend/src/i18n/locales/de.ts

@@ -33,6 +33,21 @@ export default {
   },
 
   // Common
+  announcements: {
+    title: 'Ankündigungen',
+    unread: 'Ungelesene Ankündigungen: {{count}}',
+    empty: 'Derzeit keine Ankündigungen.',
+    new: 'Neu',
+    readMore: 'Mehr erfahren',
+    readMoreCount: 'Mehr erfahren (+{{count}})',
+    gotIt: 'Verstanden',
+    source: 'Von den Bambuddy-Entwicklern, abgerufen von GitHub. Du kannst sie in den Einstellungen abschalten.',
+    level: {
+      info: 'Hinweis',
+      important: 'Wichtig',
+      critical: 'Kritisch',
+    },
+  },
   common: {
     plusNMore: '+{{count}} weitere',
     save: 'Speichern',
@@ -2204,6 +2219,11 @@ export default {
     checkPrinterFirmware: 'Drucker-Firmware prüfen',
     includeBetaUpdates: 'Beta-Versionen einschließen',
     includeBetaUpdatesDesc: 'Über Beta- und Vorabversionen bei der Updateprüfung benachrichtigen',
+    announcementsEnabled: 'Ankündigungen empfangen',
+    announcementsEnabledDesc: 'Nachrichten der Bambuddy-Entwickler, etwa zu Sicherheitskorrekturen und inkompatiblen Änderungen. Bambuddy lädt alle paar Stunden eine signierte Datei von GitHub; über diese Installation wird nichts gesendet.',
+    announcementsLearnMore: 'Mehr dazu',
+    announcementsAllUsers: 'Allen Benutzern anzeigen',
+    announcementsAllUsersDesc: 'Aus: Nur Administratoren sehen sie. Ohne Wirkung, solange die Authentifizierung aus ist.',
     localLogin: {
       disable: 'Lokale Benutzername-/Passwort-Anmeldung deaktivieren',
       disableHint: 'Wenn aktiviert, ist nur die Anmeldung über SSO möglich. LDAP ist davon nicht betroffen. Setzen Sie BAMBUDDY_LOCAL_LOGIN=true auf dem Server, um einen Wiederherstellungsweg offen zu halten.',

+ 20 - 0
frontend/src/i18n/locales/en.ts

@@ -33,6 +33,21 @@ export default {
   },
 
   // Common
+  announcements: {
+    title: 'Announcements',
+    unread: 'Unread announcements: {{count}}',
+    empty: 'No announcements right now.',
+    new: 'New',
+    readMore: 'Read more',
+    readMoreCount: 'Read more (+{{count}})',
+    gotIt: 'Got it',
+    source: 'From the Bambuddy maintainers, fetched from GitHub. You can turn them off in Settings.',
+    level: {
+      info: 'Info',
+      important: 'Important',
+      critical: 'Critical',
+    },
+  },
   common: {
     plusNMore: '+{{count}} more',
     save: 'Save',
@@ -2224,6 +2239,11 @@ export default {
     checkPrinterFirmware: 'Check printer firmware',
     includeBetaUpdates: 'Include beta versions',
     includeBetaUpdatesDesc: 'Notify about beta and prerelease versions when checking for updates',
+    announcementsEnabled: 'Receive announcements',
+    announcementsEnabledDesc: 'Messages from the Bambuddy maintainers, such as security fixes and breaking changes. Bambuddy fetches a signed file from GitHub every few hours; nothing about this install is sent.',
+    announcementsLearnMore: 'Learn more',
+    announcementsAllUsers: 'Show to all users',
+    announcementsAllUsersDesc: 'Off: only administrators see them. Has no effect while authentication is off.',
     localLogin: {
       disable: 'Disable local username/password login',
       disableHint: 'When enabled, only SSO providers can sign in. LDAP is unaffected. Set BAMBUDDY_LOCAL_LOGIN=true on the server to keep a recovery path.',

+ 20 - 0
frontend/src/i18n/locales/es.ts

@@ -33,6 +33,21 @@ export default {
   },
 
   // Common
+  announcements: {
+    title: 'Anuncios',
+    unread: 'Anuncios sin leer: {{count}}',
+    empty: 'No hay anuncios por ahora.',
+    new: 'Nuevo',
+    readMore: 'Leer más',
+    readMoreCount: 'Leer más (+{{count}})',
+    gotIt: 'Entendido',
+    source: 'De los desarrolladores de Bambuddy, obtenidos de GitHub. Puedes desactivarlos en Ajustes.',
+    level: {
+      info: 'Aviso',
+      important: 'Importante',
+      critical: 'Crítico',
+    },
+  },
   common: {
     plusNMore: '+{{count}} más',
     save: 'Guardar',
@@ -2207,6 +2222,11 @@ export default {
     checkPrinterFirmware: 'Comprobar el firmware de la impresora',
     includeBetaUpdates: 'Incluir versiones beta',
     includeBetaUpdatesDesc: 'Notificar sobre versiones beta y preliminares al buscar actualizaciones',
+    announcementsEnabled: 'Recibir anuncios',
+    announcementsEnabledDesc: 'Mensajes de los desarrolladores de Bambuddy, como correcciones de seguridad y cambios incompatibles. Bambuddy descarga un archivo firmado de GitHub cada pocas horas; no se envía nada sobre esta instalación.',
+    announcementsLearnMore: 'Más información',
+    announcementsAllUsers: 'Mostrar a todos los usuarios',
+    announcementsAllUsersDesc: 'Desactivado: solo los administradores los ven. No tiene efecto mientras la autenticación esté desactivada.',
     localLogin: {
       disable: 'Deshabilitar el inicio de sesión local con usuario/contraseña',
       disableHint: 'Cuando se habilita, solo los proveedores SSO pueden iniciar sesión. LDAP no se ve afectado. Defina BAMBUDDY_LOCAL_LOGIN=true en el servidor para mantener una vía de recuperación.',

+ 20 - 0
frontend/src/i18n/locales/fr.ts

@@ -33,6 +33,21 @@ export default {
   },
 
   // Common
+  announcements: {
+    title: 'Annonces',
+    unread: 'Annonces non lues : {{count}}',
+    empty: 'Aucune annonce pour le moment.',
+    new: 'Nouveau',
+    readMore: 'En savoir plus',
+    readMoreCount: 'En savoir plus (+{{count}})',
+    gotIt: 'Compris',
+    source: 'Des développeurs de Bambuddy, récupérées depuis GitHub. Vous pouvez les désactiver dans les paramètres.',
+    level: {
+      info: 'Information',
+      important: 'Importante',
+      critical: 'Critique',
+    },
+  },
   common: {
     plusNMore: '+{{count}} autres',
     save: 'Enregistrer',
@@ -2159,6 +2174,11 @@ export default {
     checkPrinterFirmware: 'Vérifier le firmware imprimante',
     includeBetaUpdates: 'Inclure les versions bêta',
     includeBetaUpdatesDesc: 'Notifier des versions bêta et préliminaires lors de la vérification des mises à jour',
+    announcementsEnabled: 'Recevoir les annonces',
+    announcementsEnabledDesc: "Messages des développeurs de Bambuddy, comme les correctifs de sécurité et les changements incompatibles. Bambuddy télécharge un fichier signé depuis GitHub toutes les quelques heures ; rien sur cette installation n'est envoyé.",
+    announcementsLearnMore: 'En savoir plus',
+    announcementsAllUsers: 'Afficher à tous les utilisateurs',
+    announcementsAllUsersDesc: "Désactivé : seuls les administrateurs les voient. Sans effet tant que l'authentification est désactivée.",
     localLogin: {
       disable: 'Désactiver la connexion locale par nom d\'utilisateur/mot de passe',
       disableHint: 'Quand activée, seuls les fournisseurs SSO peuvent se connecter. LDAP n\'est pas affecté. Définissez BAMBUDDY_LOCAL_LOGIN=true sur le serveur pour conserver une voie de récupération.',

+ 20 - 0
frontend/src/i18n/locales/it.ts

@@ -33,6 +33,21 @@ export default {
   },
 
   // Common
+  announcements: {
+    title: 'Annunci',
+    unread: 'Annunci non letti: {{count}}',
+    empty: 'Nessun annuncio al momento.',
+    new: 'Nuovo',
+    readMore: 'Scopri di più',
+    readMoreCount: 'Scopri di più (+{{count}})',
+    gotIt: 'Ho capito',
+    source: 'Dagli sviluppatori di Bambuddy, scaricati da GitHub. Puoi disattivarli nelle impostazioni.',
+    level: {
+      info: 'Informazione',
+      important: 'Importante',
+      critical: 'Critico',
+    },
+  },
   common: {
     plusNMore: '+{{count}} altri',
     save: 'Salva',
@@ -2159,6 +2174,11 @@ export default {
     checkPrinterFirmware: 'Controlla firmware stampante',
     includeBetaUpdates: 'Includi versioni beta',
     includeBetaUpdatesDesc: 'Notifica versioni beta e prerelease durante il controllo aggiornamenti',
+    announcementsEnabled: 'Ricevi annunci',
+    announcementsEnabledDesc: 'Messaggi degli sviluppatori di Bambuddy, come correzioni di sicurezza e modifiche incompatibili. Bambuddy scarica un file firmato da GitHub ogni poche ore; nulla su questa installazione viene inviato.',
+    announcementsLearnMore: 'Ulteriori informazioni',
+    announcementsAllUsers: 'Mostra a tutti gli utenti',
+    announcementsAllUsersDesc: "Disattivato: li vedono solo gli amministratori. Nessun effetto finché l'autenticazione è disattivata.",
     localLogin: {
       disable: 'Disabilita l\'accesso locale con nome utente/password',
       disableHint: 'Quando attivato, solo i provider SSO possono accedere. LDAP non è interessato. Imposta BAMBUDDY_LOCAL_LOGIN=true sul server per mantenere un percorso di ripristino.',

+ 20 - 0
frontend/src/i18n/locales/ja.ts

@@ -33,6 +33,21 @@ export default {
   },
 
   // Common
+  announcements: {
+    title: 'お知らせ',
+    unread: '未読のお知らせ: {{count}}',
+    empty: '現在お知らせはありません。',
+    new: '新着',
+    readMore: '詳細を見る',
+    readMoreCount: '詳細を見る (+{{count}})',
+    gotIt: '了解',
+    source: 'Bambuddy 開発者からのお知らせです(GitHub から取得)。設定でオフにできます。',
+    level: {
+      info: '情報',
+      important: '重要',
+      critical: '緊急',
+    },
+  },
   common: {
     plusNMore: '他{{count}}件',
     save: '保存',
@@ -2203,6 +2218,11 @@ export default {
     checkPrinterFirmware: 'プリンターファームウェアの確認',
     includeBetaUpdates: 'ベータ版を含める',
     includeBetaUpdatesDesc: 'アップデート確認時にベータ版およびプレリリース版を通知する',
+    announcementsEnabled: 'お知らせを受け取る',
+    announcementsEnabledDesc: 'セキュリティ修正や互換性のない変更など、Bambuddy 開発者からのメッセージです。Bambuddy は数時間ごとに GitHub から署名付きファイルを取得します。このインストールに関する情報は一切送信されません。',
+    announcementsLearnMore: '詳細',
+    announcementsAllUsers: 'すべてのユーザーに表示',
+    announcementsAllUsersDesc: 'オフ: 管理者のみに表示されます。認証がオフの間は効果がありません。',
     localLogin: {
       disable: 'ローカルのユーザー名/パスワードログインを無効化',
       disableHint: '有効にすると、SSOプロバイダーのみでサインインできます。LDAPには影響しません。復旧用のパスを残すには、サーバーで BAMBUDDY_LOCAL_LOGIN=true を設定してください。',

+ 20 - 0
frontend/src/i18n/locales/ko.ts

@@ -30,6 +30,21 @@ export default {
     installApp: '앱 설치',
     installAppSuccess: 'Bambuddy가 설치되었습니다'
   },
+  announcements: {
+    title: '공지사항',
+    unread: '읽지 않은 공지사항: {{count}}',
+    empty: '현재 공지사항이 없습니다.',
+    new: '새 소식',
+    readMore: '자세히 보기',
+    readMoreCount: '자세히 보기 (+{{count}})',
+    gotIt: '확인',
+    source: 'Bambuddy 개발자의 공지로, GitHub에서 가져옵니다. 설정에서 끌 수 있습니다.',
+    level: {
+      info: '정보',
+      important: '중요',
+      critical: '긴급',
+    },
+  },
   common: {
     plusNMore: '외 {{count}}개',
     save: '저장',
@@ -2101,6 +2116,11 @@ export default {
     checkPrinterFirmware: '프린터 펌웨어 확인',
     includeBetaUpdates: '베타 버전 포함',
     includeBetaUpdatesDesc: '업데이트 확인 시 베타 및 사전 릴리스 버전에 대해 알림',
+    announcementsEnabled: '공지사항 받기',
+    announcementsEnabledDesc: '보안 수정이나 호환되지 않는 변경 사항 등 Bambuddy 개발자의 메시지입니다. Bambuddy는 몇 시간마다 GitHub에서 서명된 파일을 가져오며, 이 설치에 관한 정보는 전송하지 않습니다.',
+    announcementsLearnMore: '자세히 알아보기',
+    announcementsAllUsers: '모든 사용자에게 표시',
+    announcementsAllUsersDesc: '꺼짐: 관리자만 볼 수 있습니다. 인증이 꺼져 있는 동안에는 효과가 없습니다.',
     localLogin: {
       disable: '로컬 사용자명/비밀번호 로그인 비활성화',
       disableHint: '활성화하면 SSO 공급자로만 로그인할 수 있습니다. LDAP는 영향을 받지 않습니다. 서버에서 BAMBUDDY_LOCAL_LOGIN=true 를 설정하면 복구 경로가 유지됩니다.'

+ 20 - 0
frontend/src/i18n/locales/nl.ts

@@ -33,6 +33,21 @@ export default {
   },
 
   // Common
+  announcements: {
+    title: 'Aankondigingen',
+    unread: 'Ongelezen aankondigingen: {{count}}',
+    empty: 'Op dit moment geen aankondigingen.',
+    new: 'Nieuw',
+    readMore: 'Meer lezen',
+    readMoreCount: 'Meer lezen (+{{count}})',
+    gotIt: 'Begrepen',
+    source: 'Van de Bambuddy-ontwikkelaars, opgehaald van GitHub. Je kunt ze uitzetten in de instellingen.',
+    level: {
+      info: 'Mededeling',
+      important: 'Belangrijk',
+      critical: 'Kritiek',
+    },
+  },
   common: {
     plusNMore: '+{{count}} meer',
     save: 'Opslaan',
@@ -2224,6 +2239,11 @@ export default {
     checkPrinterFirmware: 'Printerfirmware controleren',
     includeBetaUpdates: 'Bètaversies opnemen',
     includeBetaUpdatesDesc: 'Melden over bèta- en prereleaseversies bij controle op updates',
+    announcementsEnabled: 'Aankondigingen ontvangen',
+    announcementsEnabledDesc: 'Berichten van de Bambuddy-ontwikkelaars, zoals beveiligingsfixes en incompatibele wijzigingen. Bambuddy haalt om de paar uur een ondertekend bestand op van GitHub; er wordt niets over deze installatie verstuurd.',
+    announcementsLearnMore: 'Meer informatie',
+    announcementsAllUsers: 'Tonen aan alle gebruikers',
+    announcementsAllUsersDesc: 'Uit: alleen beheerders zien ze. Heeft geen effect zolang authenticatie uit staat.',
     localLogin: {
       disable: 'Lokale login met gebruikersnaam/wachtwoord uitschakelen',
       disableHint: 'Wanneer ingeschakeld kunnen alleen SSO-providers inloggen. LDAP blijft onaangetast. Stel BAMBUDDY_LOCAL_LOGIN=true in op de server om een herstelmogelijkheid te behouden.',

+ 20 - 0
frontend/src/i18n/locales/pt-BR.ts

@@ -33,6 +33,21 @@ export default {
   },
 
   // Common
+  announcements: {
+    title: 'Anúncios',
+    unread: 'Anúncios não lidos: {{count}}',
+    empty: 'Nenhum anúncio no momento.',
+    new: 'Novo',
+    readMore: 'Saiba mais',
+    readMoreCount: 'Saiba mais (+{{count}})',
+    gotIt: 'Entendi',
+    source: 'Dos desenvolvedores do Bambuddy, obtidos do GitHub. Você pode desativá-los nas configurações.',
+    level: {
+      info: 'Aviso',
+      important: 'Importante',
+      critical: 'Crítico',
+    },
+  },
   common: {
     plusNMore: '+{{count}} outros',
     save: 'Salvar',
@@ -2159,6 +2174,11 @@ export default {
     checkPrinterFirmware: 'Verificar firmware da impressora',
     includeBetaUpdates: 'Incluir versões beta',
     includeBetaUpdatesDesc: 'Notificar sobre versões beta e pré-lançamento ao verificar atualizações',
+    announcementsEnabled: 'Receber anúncios',
+    announcementsEnabledDesc: 'Mensagens dos desenvolvedores do Bambuddy, como correções de segurança e mudanças incompatíveis. O Bambuddy baixa um arquivo assinado do GitHub a cada poucas horas; nada sobre esta instalação é enviado.',
+    announcementsLearnMore: 'Saiba mais',
+    announcementsAllUsers: 'Mostrar para todos os usuários',
+    announcementsAllUsersDesc: 'Desativado: apenas administradores os veem. Sem efeito enquanto a autenticação estiver desativada.',
     localLogin: {
       disable: 'Desativar login local com usuário/senha',
       disableHint: 'Quando ativado, somente provedores SSO podem fazer login. O LDAP não é afetado. Defina BAMBUDDY_LOCAL_LOGIN=true no servidor para manter um caminho de recuperação.',

+ 20 - 0
frontend/src/i18n/locales/ru.ts

@@ -30,6 +30,21 @@ export default {
     installApp: "Установить приложение",
     installAppSuccess: "Bambuddy установлен",
   },
+  announcements: {
+    title: 'Объявления',
+    unread: 'Непрочитанные объявления: {{count}}',
+    empty: 'Сейчас объявлений нет.',
+    new: 'Новое',
+    readMore: 'Подробнее',
+    readMoreCount: 'Подробнее (+{{count}})',
+    gotIt: 'Понятно',
+    source: 'От разработчиков Bambuddy, загружаются с GitHub. Их можно отключить в настройках.',
+    level: {
+      info: 'Информация',
+      important: 'Важно',
+      critical: 'Критично',
+    },
+  },
   common: {
     plusNMore: 'ещё {{count}}',
     save: "Сохранить",
@@ -2098,6 +2113,11 @@ export default {
     checkPrinterFirmware: "Проверять прошивку принтера",
     includeBetaUpdates: "Включать бета-версии",
     includeBetaUpdatesDesc: "Уведомлять о бета- и предварительных версиях при проверке обновлений",
+    announcementsEnabled: 'Получать объявления',
+    announcementsEnabledDesc: 'Сообщения от разработчиков Bambuddy, например об исправлениях безопасности и несовместимых изменениях. Bambuddy раз в несколько часов загружает подписанный файл с GitHub; никакие данные об этой установке не отправляются.',
+    announcementsLearnMore: 'Подробнее',
+    announcementsAllUsers: 'Показывать всем пользователям',
+    announcementsAllUsersDesc: 'Выкл.: видят только администраторы. Не действует, пока аутентификация отключена.',
     localLogin: {
       disable: "Отключить локальный вход по имени пользователя и паролю",
       disableHint: "Если включено, вход возможен только через провайдеров SSO. На LDAP это не влияет. Чтобы сохранить аварийный способ входа, задайте на сервере BAMBUDDY_LOCAL_LOGIN=true.",

+ 20 - 0
frontend/src/i18n/locales/sv.ts

@@ -33,6 +33,21 @@ export default {
   },
 
   // Common
+  announcements: {
+    title: 'Meddelanden',
+    unread: 'Olästa meddelanden: {{count}}',
+    empty: 'Inga meddelanden just nu.',
+    new: 'Ny',
+    readMore: 'Läs mer',
+    readMoreCount: 'Läs mer (+{{count}})',
+    gotIt: 'Uppfattat',
+    source: 'Från Bambuddys utvecklare, hämtade från GitHub. Du kan stänga av dem i inställningarna.',
+    level: {
+      info: 'Information',
+      important: 'Viktigt',
+      critical: 'Kritiskt',
+    },
+  },
   common: {
     plusNMore: '+{{count}} till',
     save: 'Spara',
@@ -2223,6 +2238,11 @@ export default {
     checkPrinterFirmware: 'Kontrollera skrivarens firmware',
     includeBetaUpdates: 'Inkludera betaversioner',
     includeBetaUpdatesDesc: 'Notis om betaversioner och förhandsversioner vid sökning av uppdateringar',
+    announcementsEnabled: 'Ta emot meddelanden',
+    announcementsEnabledDesc: 'Meddelanden från Bambuddys utvecklare, till exempel säkerhetsrättningar och inkompatibla ändringar. Bambuddy hämtar en signerad fil från GitHub med några timmars mellanrum; ingenting om den här installationen skickas.',
+    announcementsLearnMore: 'Läs mer',
+    announcementsAllUsers: 'Visa för alla användare',
+    announcementsAllUsersDesc: 'Av: endast administratörer ser dem. Ingen effekt så länge autentisering är avstängd.',
     localLogin: {
       disable: 'Inaktivera lokal användarnamn/lösenordsinloggning',
       disableHint: 'När aktiverat kan endast SSO-leverantörer logga in. LDAP påverkas inte. Sätt BAMBUDDY_LOCAL_LOGIN=true på servern för att behålla en återställningsväg.',

+ 20 - 0
frontend/src/i18n/locales/tr.ts

@@ -33,6 +33,21 @@ export default {
   },
 
   // Ortak
+  announcements: {
+    title: 'Duyurular',
+    unread: 'Okunmamış duyurular: {{count}}',
+    empty: 'Şu anda duyuru yok.',
+    new: 'Yeni',
+    readMore: 'Devamını oku',
+    readMoreCount: 'Devamını oku (+{{count}})',
+    gotIt: 'Anladım',
+    source: "Bambuddy geliştiricilerinden, GitHub'dan alınır. Ayarlardan kapatabilirsiniz.",
+    level: {
+      info: 'Bilgi',
+      important: 'Önemli',
+      critical: 'Kritik',
+    },
+  },
   common: {
     plusNMore: '+{{count}} tane daha',
     save: 'Kaydet',
@@ -2207,6 +2222,11 @@ export default {
     checkPrinterFirmware: 'Yazıcı firmware\'ini kontrol et',
     includeBetaUpdates: 'Beta sürümleri dahil et',
     includeBetaUpdatesDesc: 'Güncellemeleri kontrol ederken beta ve önyayım sürümleri hakkında bildir',
+    announcementsEnabled: 'Duyuruları al',
+    announcementsEnabledDesc: "Güvenlik düzeltmeleri ve uyumsuz değişiklikler gibi Bambuddy geliştiricilerinden gelen mesajlar. Bambuddy birkaç saatte bir GitHub'dan imzalı bir dosya indirir; bu kurulum hakkında hiçbir şey gönderilmez.",
+    announcementsLearnMore: 'Daha fazla bilgi',
+    announcementsAllUsers: 'Tüm kullanıcılara göster',
+    announcementsAllUsersDesc: 'Kapalı: yalnızca yöneticiler görür. Kimlik doğrulama kapalıyken etkisi yoktur.',
     localLogin: {
       disable: 'Yerel kullanıcı adı/şifre ile oturum açmayı devre dışı bırak',
       disableHint: 'Etkinleştirildiğinde yalnızca SSO sağlayıcıları ile oturum açılabilir. LDAP etkilenmez. Bir kurtarma yolu açık tutmak için sunucuda BAMBUDDY_LOCAL_LOGIN=true ayarlayın.',

+ 20 - 0
frontend/src/i18n/locales/uk.ts

@@ -33,6 +33,21 @@ export default {
   },
 
   // Common
+  announcements: {
+    title: 'Оголошення',
+    unread: 'Непрочитані оголошення: {{count}}',
+    empty: 'Наразі оголошень немає.',
+    new: 'Нове',
+    readMore: 'Докладніше',
+    readMoreCount: 'Докладніше (+{{count}})',
+    gotIt: 'Зрозуміло',
+    source: 'Від розробників Bambuddy, завантажуються з GitHub. Їх можна вимкнути в налаштуваннях.',
+    level: {
+      info: 'Інформація',
+      important: 'Важливо',
+      critical: 'Критично',
+    },
+  },
   common: {
     plusNMore: 'ще {{count}}',
     save: "Зберегти",
@@ -2222,6 +2237,11 @@ export default {
     checkPrinterFirmware: "Перевірити прошивку принтера",
     includeBetaUpdates: "Включати бета-версії",
     includeBetaUpdatesDesc: "Повідомляти про бета-версії та попередні версії під час перевірки оновлень",
+    announcementsEnabled: 'Отримувати оголошення',
+    announcementsEnabledDesc: 'Повідомлення від розробників Bambuddy, наприклад про виправлення безпеки та несумісні зміни. Bambuddy раз на кілька годин завантажує підписаний файл із GitHub; жодних даних про цю інсталяцію не надсилається.',
+    announcementsLearnMore: 'Докладніше',
+    announcementsAllUsers: 'Показувати всім користувачам',
+    announcementsAllUsersDesc: 'Вимк.: бачать лише адміністратори. Не діє, поки автентифікацію вимкнено.',
     localLogin: {
       disable: "Вимкнути локальний вхід за іменем користувача й паролем",
       disableHint: "Якщо ввімкнено, увійти можна буде лише через постачальників SSO. На LDAP це не впливає. Щоб зберегти резервний спосіб входу, установіть на сервері BAMBUDDY_LOCAL_LOGIN=true.",

+ 20 - 0
frontend/src/i18n/locales/zh-CN.ts

@@ -33,6 +33,21 @@ export default {
   },
 
   // Common
+  announcements: {
+    title: '公告',
+    unread: '未读公告:{{count}}',
+    empty: '暂无公告。',
+    new: '新',
+    readMore: '了解更多',
+    readMoreCount: '了解更多 (+{{count}})',
+    gotIt: '知道了',
+    source: '来自 Bambuddy 开发者,从 GitHub 获取。可在设置中关闭。',
+    level: {
+      info: '信息',
+      important: '重要',
+      critical: '紧急',
+    },
+  },
   common: {
     plusNMore: '另 {{count}} 个',
     save: '保存',
@@ -2204,6 +2219,11 @@ export default {
     checkPrinterFirmware: '检查打印机固件',
     includeBetaUpdates: '包含测试版本',
     includeBetaUpdatesDesc: '检查更新时通知测试版和预发布版本',
+    announcementsEnabled: '接收公告',
+    announcementsEnabledDesc: '来自 Bambuddy 开发者的消息,例如安全修复和不兼容的变更。Bambuddy 每隔几小时从 GitHub 获取一个已签名的文件;不会发送有关此安装的任何信息。',
+    announcementsLearnMore: '了解更多',
+    announcementsAllUsers: '向所有用户显示',
+    announcementsAllUsersDesc: '关闭:仅管理员可见。身份验证关闭时无效。',
     localLogin: {
       disable: '禁用本地用户名/密码登录',
       disableHint: '启用后,只能通过SSO提供商登录。LDAP不受影响。在服务器上设置 BAMBUDDY_LOCAL_LOGIN=true 可保留恢复通道。',

+ 20 - 0
frontend/src/i18n/locales/zh-TW.ts

@@ -33,6 +33,21 @@ export default {
   },
 
   // Common
+  announcements: {
+    title: '公告',
+    unread: '未讀公告:{{count}}',
+    empty: '目前沒有公告。',
+    new: '新',
+    readMore: '了解更多',
+    readMoreCount: '了解更多 (+{{count}})',
+    gotIt: '知道了',
+    source: '來自 Bambuddy 開發者,從 GitHub 取得。可在設定中關閉。',
+    level: {
+      info: '資訊',
+      important: '重要',
+      critical: '緊急',
+    },
+  },
   common: {
     plusNMore: '另 {{count}} 個',
     save: '儲存',
@@ -2204,6 +2219,11 @@ export default {
     checkPrinterFirmware: '檢查印表機韌體',
     includeBetaUpdates: '包含測試版本',
     includeBetaUpdatesDesc: '檢查更新時通知測試版和預發布版本',
+    announcementsEnabled: '接收公告',
+    announcementsEnabledDesc: '來自 Bambuddy 開發者的訊息,例如安全性修正和不相容的變更。Bambuddy 每隔幾小時從 GitHub 取得一個已簽署的檔案;不會傳送有關此安裝的任何資訊。',
+    announcementsLearnMore: '了解更多',
+    announcementsAllUsers: '向所有使用者顯示',
+    announcementsAllUsersDesc: '關閉:僅管理員可見。驗證關閉時無效。',
     localLogin: {
       disable: '停用本機使用者名稱/密碼登入',
       disableHint: '啟用後,只能透過SSO提供者登入。LDAP不受影響。在伺服器上設定 BAMBUDDY_LOCAL_LOGIN=true 可保留復原途徑。',

+ 53 - 0
frontend/src/pages/SettingsPage.tsx

@@ -1094,6 +1094,9 @@ export function SettingsPage() {
       // cannot read /settings. Nothing invalidated it, so a currency change
       // sat behind that query's own staleTime instead of showing up (#3123).
       queryClient.invalidateQueries({ queryKey: ['ui-flags'] });
+      // Switching announcements off (or to all users) changes who sees the
+      // sidebar entry and the banner; don't wait for the next poll.
+      queryClient.invalidateQueries({ queryKey: ['announcements'] });
       showToast(t('settings.toast.settingsSaved'), 'success');
     },
     onError: (error: Error) => {
@@ -1153,6 +1156,8 @@ export function SettingsPage() {
       baseline.check_updates !== localSettings.check_updates ||
       (baseline.check_printer_firmware ?? true) !== (localSettings.check_printer_firmware ?? true) ||
       (baseline.include_beta_updates ?? false) !== (localSettings.include_beta_updates ?? false) ||
+      (baseline.announcements_enabled ?? true) !== (localSettings.announcements_enabled ?? true) ||
+      (baseline.announcements_all_users ?? false) !== (localSettings.announcements_all_users ?? false) ||
       (baseline.local_login_enabled ?? true) !== (localSettings.local_login_enabled ?? true) ||
       baseline.notification_language !== localSettings.notification_language ||
       (baseline.bed_cooled_threshold ?? 35) !== (localSettings.bed_cooled_threshold ?? 35) ||
@@ -1269,6 +1274,8 @@ export function SettingsPage() {
         check_updates: localSettings.check_updates,
         check_printer_firmware: localSettings.check_printer_firmware,
         include_beta_updates: localSettings.include_beta_updates,
+        announcements_enabled: localSettings.announcements_enabled ?? true,
+        announcements_all_users: localSettings.announcements_all_users ?? false,
         local_login_enabled: localSettings.local_login_enabled,
         notification_language: localSettings.notification_language,
         bed_cooled_threshold: localSettings.bed_cooled_threshold,
@@ -3103,6 +3110,52 @@ export function SettingsPage() {
                   </p>
                 ) : null}
               </div>
+              <div className="border-t border-bambu-dark-tertiary pt-4">
+                <p className="text-xs font-medium text-bambu-gray uppercase tracking-wider mb-4">{t('announcements.title')}</p>
+              </div>
+              <div className="flex items-center justify-between">
+                <div>
+                  <p className="text-white">{t('settings.announcementsEnabled')}</p>
+                  <p className="text-sm text-bambu-gray">
+                    {t('settings.announcementsEnabledDesc')}{' '}
+                    <a
+                      href="https://wiki.bambuddy.cool/features/announcements/"
+                      target="_blank"
+                      rel="noopener noreferrer"
+                      className="text-bambu-green hover:underline"
+                    >
+                      {t('settings.announcementsLearnMore')}
+                    </a>
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.announcements_enabled ?? true}
+                    onChange={(e) => updateSetting('announcements_enabled', e.target.checked)}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
+              <div className={`flex items-center justify-between ${localSettings.announcements_enabled === false ? 'opacity-50' : ''}`}>
+                <div>
+                  <p className="text-white">{t('settings.announcementsAllUsers')}</p>
+                  <p className="text-sm text-bambu-gray">
+                    {t('settings.announcementsAllUsersDesc')}
+                  </p>
+                </div>
+                <label className="relative inline-flex items-center cursor-pointer">
+                  <input
+                    type="checkbox"
+                    checked={localSettings.announcements_all_users ?? false}
+                    onChange={(e) => updateSetting('announcements_all_users', e.target.checked)}
+                    disabled={localSettings.announcements_enabled === false}
+                    className="sr-only peer"
+                  />
+                  <div className="w-11 h-6 bg-bambu-dark-tertiary peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-bambu-green"></div>
+                </label>
+              </div>
             </CardContent>
           </Card>