127 lines
6.9 KiB
Markdown
127 lines
6.9 KiB
Markdown
# 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](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
|