Files
Notizarr/docs/phase1/01-architecture.md
2026-09-07 09:54:30 +00:00

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 management
  • users — profile, role
  • topics — CRUD, subscription toggle, access control
  • notifications — ingestion, history, search, read/star state
  • tokens — API token CRUD, hashing, scoping
  • push — VAPID, subscribe/unsubscribe, fan-out, diagnostics
  • admin — dashboard stats, noisy-source detection, settings
  • audit — write-only log + admin read view
  • rate-limit — shared in-memory limiter used by notifications ingestion