6.9 KiB
01 — Architecture
Guiding constraint
Single-user / small-household homelab. Two containers, no queues, no microservices. Everything that looks like it needs a worker is instead done in-process, synchronously or via a lightweight interval timer, because volume is low (dozens to low-thousands of notifications/day, not per-second traffic).
Components
┌─────────────────────────────────────────────────────────────────┐
│ notizarr (single Node.js/TypeScript container, modular monolith)│
│ │
│ Listener A 0.0.0.0:8080 "web" │
│ - Serves the built React SPA (static assets + index.html) │
│ - Session-cookie authenticated JSON API (/api/v1/*) │
│ - OIDC login/callback, or proxy-header auth │
│ - SSE stream for live UI updates │
│ - Web Push subscribe/unsubscribe, diagnostics │
│ - Admin API (role=admin) │
│ - /docs (OpenAPI/Swagger UI) │
│ - /health, /ready │
│ │
│ Listener B 0.0.0.0:8081 "ingest" │
│ - Bearer-token authenticated only. No cookies, no static SPA. │
│ - POST /api/v1/notifications, /api/v1/topics/:slug │
│ - ntfy-compat: POST /:topic │
│ - Webhook: POST /api/v1/webhook/:source │
│ - /health, /metrics (Prometheus) │
│ │
│ Shared internal modules: │
│ - Notification service (insert, dedupe, fan-out) │
│ - Push service (VAPID sign + send via web-push lib) │
│ - Rate limiter (in-memory token bucket, per-token & per-IP) │
│ - Retention cleanup (interval timer, e.g. every 1h) │
│ - Audit logger │
│ │
│ On boot: run pending DB migrations, load/generate VAPID keys │
└─────────────────────────────────────────────────────────────────┘
│
│ single internal Docker network,
│ Postgres not published to host
▼
┌─────────────────────────────────────────────────────────────────┐
│ notizarr-postgres (postgres:16-alpine) │
│ Single source of truth: users, topics, notifications, │
│ subscriptions, tokens, push subscriptions, delivery history, │
│ audit log, VAPID keys, system settings. │
└─────────────────────────────────────────────────────────────────┘
Pangolin (external, not part of this compose stack) sits in front of listener A only. Listener B is reachable only from the LAN (see §06 networking).
Why one process, two listeners, instead of two containers
Splitting "public API" and "ingestion API" into two containers would mean two copies of the same DB access layer, two deployments, two healthchecks, for no real isolation benefit — a compromised process still has the same DB credentials either way. The actual security boundary that matters is network reachability (LAN-only port vs. reverse-proxied port) and auth method (bearer token vs. session), both of which are enforced per listener inside one process. This keeps the "prefer one app container + Postgres" principle intact.
Data flow: LAN publish → history → push
LAN service (Home Assistant, Seerr, ...)
│ POST http://<lan-ip>:8081/api/v1/notifications
│ Authorization: Bearer nzr_xxxxxxxx
▼
Ingestion listener
1. Resolve token → validate hash, not revoked/expired
2. Check token's allowed_topics / allowed_source / allowed_cidrs against request
3. Rate limit check (per-token bucket, per-IP bucket)
4. Validate payload (topic exists, sizes, required fields)
5. Deduplication check (deduplication_key within window) → increment
occurrence_count on existing row instead of inserting, if matched
6. Insert notification row (always — storage never depends on push)
7. Write audit log entry (only for rejects/rate-limit events, to keep the
audit log meaningful rather than noisy)
8. Publish SSE event to any connected web/PWA clients subscribed to that topic
9. For each user with a user_topic_subscriptions row where push_enabled=true
for this topic (or no row = default push_enabled=true), and who has one
or more active push_subscriptions:
- build a small push payload (ids + short title/body + deep link)
- send via web-push (VAPID), record push_delivery_attempts
- on 404/410 from the push service, mark subscription disabled_at
10. Return { id, status: "accepted" }
Data flow: browser session
Browser (PWA or desktop)
│ HTTPS → Pangolin → :8080
▼
notizarr web listener
- AUTH_MODE=oidc: Authorization Code + PKCE against Pocket ID (or any OIDC IdP)
- AUTH_MODE=proxy: trust identity headers only if request's peer IP is in
TRUSTED_PROXY_CIDRS (Pangolin already validated OIDC upstream)
- Sets httpOnly, Secure, SameSite=Lax session cookie
▼
React SPA fetches /api/v1/topics, /api/v1/notifications, opens /api/v1/stream (SSE)
- New notifications arrive over SSE while the tab is open → instant UI update
- Push notifications handle the case where the tab/app is closed
Modular monolith layout (backend)
Each module owns its DB access, routes, and types; only db/schema.ts is
shared. See 07-folder-structure.md for the concrete
tree.
auth— OIDC flow, proxy-header trust, session managementusers— profile, roletopics— CRUD, subscription toggle, access controlnotifications— ingestion, history, search, read/star statetokens— API token CRUD, hashing, scopingpush— VAPID, subscribe/unsubscribe, fan-out, diagnosticsadmin— dashboard stats, noisy-source detection, settingsaudit— write-only log + admin read viewrate-limit— shared in-memory limiter used bynotificationsingestion