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 wiringapp/core/components/notification_router/logic/router_service.py—NotificationRouterService, envelope handling, discovery syncapp/core/components/notification_router/logic/precedence.py— env > DB > default resolverapp/core/components/notification_router/logic/endpoint_registry.py— in-memory endpoint registryapp/core/components/notification_router/models.py—NotificationEnvelope,ResolvedStateapp/core/components/notification_router/ui/notifications_settings.py— operator settings UI
Responsibilities¶
- discover every plugin-declared
NotificationEndpointand keep a registry in sync with the live manifests - persist per-endpoint binding state (
plugin_notification_endpointstable) and prune rows for endpoints that disappeared - validate each incoming
NotificationEnvelopeagainst 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
clearsemantics bynotification_id
Events¶
Subscribes¶
db:connected— hydrate the precedence cache from the DB and run the discovery syncnotification:routed— aNotificationEnvelope; the routing pipeline runs on itui:needs_refresh— re-sync declared endpoints (e.g. after a plugin loads/unloads)
Emits¶
messaging:outbound— when an endpoint resolves to a registered external providerui: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:
- Environment variable (highest priority, locks the value in the UI)
LYNDRIX_NOTIF__<PLUGIN_ID>__<ENDPOINT_NAME>__ACTIVELYNDRIX_NOTIF__<PLUGIN_ID>__<ENDPOINT_NAME>__PROVIDER— comma-separated list of provider ids (fan-out); present-but-empty means "no external providers".LYNDRIX_NOTIF__<PLUGIN_ID>__<ENDPOINT_NAME>__PERMISSION— the visibility gate.<PLUGIN_ID>is upper-cased with.and-replaced by_(e.g.lyndrix.plugin.deployer→LYNDRIX_PLUGIN_DEPLOYER).- DB row — set from the React Notifications settings UI (provider chips + visibility select).
- Manifest default —
default_active,required_permission, and for the providers the global defaultLYNDRIX_NOTIF_DEFAULT_PROVIDER(only whenexternal_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-emittingnotification: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, plusctx.notify(...)on theModuleContext.