Skip to content

Notification Router Component

Purpose

The Notification Router owns the routing pipeline between plugin code and the two delivery channels: the in-app notification service (toasts + history) and the external Messaging Gateway. Plugins declare named notification endpoints in their manifest and emit them through ctx.notify(...); operators then decide — per endpoint — whether each one is delivered internally, externally, or both.

This decouples what a plugin wants to announce from whether and where it is actually delivered.

Main locations

  • app/core/components/notification_router/entrypoint.py — CORE component wiring
  • app/core/components/notification_router/logic/router_service.pyNotificationRouterService, envelope handling, discovery sync
  • app/core/components/notification_router/logic/precedence.py — env > DB > default resolver
  • app/core/components/notification_router/logic/endpoint_registry.py — in-memory endpoint registry
  • app/core/components/notification_router/models.pyNotificationEnvelope, ResolvedState
  • app/core/components/notification_router/ui/notifications_settings.py — operator settings UI

Responsibilities

  • discover every plugin-declared NotificationEndpoint and keep a registry in sync with the live manifests
  • persist per-endpoint binding state (plugin_notification_endpoints table) and prune rows for endpoints that disappeared
  • validate each incoming NotificationEnvelope against the registry (unknown endpoint → warning + drop)
  • resolve the effective active state, provider list and required-permission via env var > DB row > manifest default precedence
  • apply internal effects (toast / persisted history) through the notification service, honouring the endpoint's audience gate
  • fan out externally to every bound provider through the messaging gateway
  • support sticky/updatable notifications and clear semantics by notification_id

Events

Subscribes

  • db:connected — hydrate the precedence cache from the DB and run the discovery sync
  • notification:routed — a NotificationEnvelope; the routing pipeline runs on it
  • ui:needs_refresh — re-sync declared endpoints (e.g. after a plugin loads/unloads)

Emits

  • messaging:outbound — when an endpoint resolves to a registered external provider
  • ui:needs_refresh

Declaring and emitting notifications (plugins)

Declare endpoints in the manifest, then emit through the context helper:

from core.api import ModuleManifest, NotificationEndpoint

manifest = ModuleManifest(
    id="lyndrix.plugin.deployer",
    name="Deployer",
    version="0.1.0",
    type="PLUGIN",
    notification_endpoints=[
        NotificationEndpoint(
            name="deployment_succeeded",          # snake_case, [a-z][a-z0-9_]*
            description="A deployment finished successfully.",
            default_active=True,
            internal_toast=True,      # raise an in-app toast
            internal_persist=True,    # append to notification history
            external_default=False,   # route to the global default provider when no binding exists
            required_permission=None, # None = visible to all; e.g. "feature:plugins.manage" = admins only
        ),
    ],
)

def setup(ctx):
    ...

async def on_deploy_done(ctx):
    ctx.notify(
        "deployment_succeeded",
        title="Deploy complete",
        body="prod-web-01 is live",
        severity="success",
    )

ctx.notify() raises PermissionError if endpoint_name is not declared in the manifest. It builds a NotificationEnvelope and emits it on notification:routed; the router takes over from there.

Operator configuration and precedence

For each (plugin_id, endpoint_name) the active flag, the provider list and the required permission each resolve in this order:

  1. Environment variable (highest priority, locks the value in the UI)
  2. LYNDRIX_NOTIF__<PLUGIN_ID>__<ENDPOINT_NAME>__ACTIVE
  3. LYNDRIX_NOTIF__<PLUGIN_ID>__<ENDPOINT_NAME>__PROVIDERcomma-separated list of provider ids (fan-out); present-but-empty means "no external providers".
  4. LYNDRIX_NOTIF__<PLUGIN_ID>__<ENDPOINT_NAME>__PERMISSION — the visibility gate.
  5. <PLUGIN_ID> is upper-cased with . and - replaced by _ (e.g. lyndrix.plugin.deployerLYNDRIX_PLUGIN_DEPLOYER).
  6. DB row — set from the React Notifications settings UI (provider chips + visibility select).
  7. Manifest defaultdefault_active, required_permission, and for the providers the global default LYNDRIX_NOTIF_DEFAULT_PROVIDER (only when external_default=True).

ResolvedState reports the effective providers, required_permission and each source (active_source / provider_source / required_permission_source).

Multi-provider fan-out (1.0)

A binding is a list of provider ids, so one endpoint can deliver to several providers at once (e.g. the internal system feed and discord). The router builds one message per bound-and-registered provider and dispatches them concurrently; unregistered providers are skipped with a warning.

Delivery visibility & personal mutes (1.0)

required_permission gates who can see an endpoint's notifications internally (feed, id-targeted read/dismiss, SSE stream and NiceGUI toast all apply it via ApiIdentity.allows / the session's effective permissions). None = everyone; e.g. feature:plugins.manage restricts plugin-lifecycle notifications to admins. On top of that, users self-manage a personal mute list (GET /api/notifications/subscribable — filtered to what they may see — stored in notifications.muted_endpoints under /api/me/preferences).

Persistence

The plugin_notification_endpoints table (created on db:connected) stores one row per (plugin_id, endpoint_name) with is_active, provider_bindings (JSON list), required_permission_override (tri-state: NULL = manifest default, "" = explicitly unrestricted, value = required id), and the declared defaults. On every discovery sync, current manifest declarations are upserted and rows whose declaration disappeared (plugin uninstalled or endpoint renamed) are deleted in one transaction.

Integration notes

  • Internal effects are applied via notification_service._process_notification(...) directly to avoid re-emitting notification:outbound (which the gateway would also bridge — that would double-deliver).
  • External dispatch only happens when the resolved provider is actually registered in the gateway; otherwise the router logs a warning and skips external delivery.
  • Stable plugin surface from core.api: NotificationEndpoint, NotificationEnvelope, ResolvedState, plus ctx.notify(...) on the ModuleContext.