Initial commit
This commit is contained in:
commit
38c24fc9dc
166 files changed
+22002
No files matched your search
@@ -0,0 +1,34 @@
|
||||
# Notizarr — Phase 1 Design Package
|
||||
|
||||
This is the Phase 1 deliverable: architecture, threat model, data model, API
|
||||
design, PWA/Web Push design, Docker/Unraid design, folder structure, and the
|
||||
phased implementation plan. **No application code is written in this phase.**
|
||||
Per the project's own process rules, implementation (Phase 2+) does not start
|
||||
until this package is reviewed and the open decisions below are confirmed.
|
||||
|
||||
## Documents
|
||||
|
||||
1. [01-architecture.md](01-architecture.md) — components, containers, data flow
|
||||
2. [02-threat-model.md](02-threat-model.md) — trust boundaries, STRIDE notes, mitigations
|
||||
3. [03-database-model.md](03-database-model.md) — full Postgres schema
|
||||
4. [04-api-design.md](04-api-design.md) — public app API + LAN ingestion API
|
||||
5. [05-pwa-web-push-design.md](05-pwa-web-push-design.md) — manifest, service worker, push flow, iOS notes
|
||||
6. [06-docker-unraid-design.md](06-docker-unraid-design.md) — compose design, volumes, networking
|
||||
7. [07-folder-structure.md](07-folder-structure.md) — repo layout vs. deployment layout
|
||||
8. [08-implementation-plan.md](08-implementation-plan.md) — Phase 2–10 task breakdown, mapped to MVP acceptance criteria
|
||||
|
||||
## Open decisions that need your sign-off before Phase 2
|
||||
|
||||
These are the "major implementation decisions" the process asks to pause on:
|
||||
|
||||
| # | Decision | Recommendation | Why |
|
||||
|---|---|---|---|
|
||||
| 1 | Backend language/framework | **Node.js 22 + TypeScript, Fastify** | One language across front+back end for a solo-maintained homelab app; `web-push` and `openid-client` are the most mature libraries for the two hardest requirements (Web Push, OIDC); Fastify has first-class schema validation and low overhead. |
|
||||
| 2 | ORM / migrations | **Drizzle ORM + drizzle-kit** | Typesafe, SQL-first, no native binary/engine (unlike Prisma) — simpler in a small Alpine container, plain-SQL migration files you can read and back up. |
|
||||
| 3 | Realtime "instant in UI" delivery | **Server-Sent Events (SSE)**, in-process pub/sub, no Redis | Single-instance app; SSE is one-way (all we need), works over plain HTTP through a reverse proxy, avoids the complexity of a WebSocket + no new infra. |
|
||||
| 4 | VAPID key storage | **Postgres `system_settings` table**, generated on first boot if absent | Already-durable volume; no separate key file to remember to back up — `pg_dump` covers it automatically. |
|
||||
| 5 | Attachments / image handling | **No server-side fetch or proxy.** `image_url`/`external_url` are stored as plain URLs and loaded directly by the browser (`<img src>`), never fetched by the server. | Eliminates SSRF risk entirely for this feature per the security requirements, at the cost of not supporting file uploads in v1 (can be added later as an explicit, scoped feature). |
|
||||
| 6 | Webhook mapping | MVP ships a **fixed simple mapping** (`{topic, title, message, url?}` JSON body, or ntfy-style headers), not a configurable templating engine | Spec says "goal is easy migration, not full protocol cloning"; a mapping UI is called out as a later enhancement. |
|
||||
| 7 | Topic access control | Topics are **visible to all authenticated users by default**; an admin can mark a topic `restricted` and grant per-user access | Matches "single/small household" simplicity principle while still satisfying "users can browse allowed topics." |
|
||||
|
||||
If any of these should go differently, say so and I'll adjust the design before Phase 2 starts. Otherwise, confirming this package moves us into Phase 2 (repo bootstrap + Docker Compose skeleton).
|
||||
@@ -0,0 +1,126 @@
|
||||
# 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
|
||||
@@ -0,0 +1,130 @@
|
||||
# 02 — Threat Model
|
||||
|
||||
## Trust domains
|
||||
|
||||
| Domain | Trust level | Notes |
|
||||
|---|---|---|
|
||||
| A. Public internet (browsers) | Untrusted until OIDC-authenticated | Only reaches listener A, via Pangolin |
|
||||
| B. Pangolin reverse proxy | Trusted **only** when its source IP is in `TRUSTED_PROXY_CIDRS` and `AUTH_MODE=proxy` | Otherwise treated as domain A |
|
||||
| C. LAN services (Home Assistant, Seerr, etc.) | Semi-trusted network, but the real boundary is the API token | Reaches listener B only |
|
||||
| D. notizarr app | Mediates every access | |
|
||||
| E. Postgres | Fully trusted, but only reachable from the notizarr container on the internal Docker network | Never published to host |
|
||||
|
||||
## Assets to protect
|
||||
|
||||
- Notification history (may contain home automation state, personal info)
|
||||
- API tokens (plaintext shown once, hash stored)
|
||||
- OIDC client secret
|
||||
- VAPID private key
|
||||
- Push subscription endpoints (identify a user's device to a push service)
|
||||
- Session cookies
|
||||
- Admin functions (user/topic/token management)
|
||||
|
||||
## Threats and mitigations
|
||||
|
||||
### Spoofed identity via proxy headers
|
||||
**Threat:** an attacker on the LAN or a compromised container sends
|
||||
`X-Auth-Request-Email` directly to notizarr, bypassing Pangolin.
|
||||
**Mitigation:** proxy-header auth is only trusted when the *direct TCP peer*
|
||||
address (not a forwarded-for chain) is inside `TRUSTED_PROXY_CIDRS`. If
|
||||
`AUTH_MODE=oidc`, proxy headers are never read at all. Document that Pangolin
|
||||
must connect directly to notizarr (no additional untrusted hop in between).
|
||||
|
||||
### Forged/leaked OIDC tokens
|
||||
**Threat:** token replay, audience confusion, state/nonce fixation.
|
||||
**Mitigation:** Authorization Code + PKCE (S256) via `openid-client`;
|
||||
validate `iss`, `aud`, `nonce`, `exp`, signature against the issuer's JWKS;
|
||||
state/nonce stored in a short-lived signed httpOnly cookie scoped to the auth
|
||||
flow only.
|
||||
|
||||
### Session hijacking / CSRF
|
||||
**Threat:** stolen session cookie, or a malicious site making authenticated
|
||||
requests on the user's behalf.
|
||||
**Mitigation:** `httpOnly`, `Secure`, `SameSite=Lax` session cookie. All
|
||||
mutating requests (`POST`/`PATCH`/`DELETE` on listener A) additionally
|
||||
require a custom header (`X-Requested-With: notizarr`) and are rejected if
|
||||
the `Origin` header doesn't match `PUBLIC_BASE_URL` — a cross-site form post
|
||||
can't set custom headers, so this blocks CSRF without needing a token
|
||||
handshake.
|
||||
|
||||
### API token theft / abuse
|
||||
**Threat:** a leaked LAN token is used to flood topics, read data it
|
||||
shouldn't, or pivot into admin functions.
|
||||
**Mitigation:** tokens are write-only credentials — they can **only** hit
|
||||
listener B's publish endpoints, never any read/admin endpoint on listener A,
|
||||
even if presented there. Tokens are scoped (topics, source name, source
|
||||
CIDR, expiry). Only a SHA-256 hash is stored; plaintext is shown once at
|
||||
creation/rotation. Per-token rate limiting caps blast radius. Revocation is
|
||||
immediate (checked on every request, no caching of revoked status).
|
||||
|
||||
### Ingestion listener exposed to the internet by misconfiguration
|
||||
**Threat:** admin accidentally publishes 8081 to `0.0.0.0` on a
|
||||
port-forwarded router, or Pangolin is misrouted to it.
|
||||
**Mitigation:** defense in depth beyond "the admin will firewall it":
|
||||
optional `INGESTION_ALLOWED_CIDRS` env var enforced in-app (default: RFC1918
|
||||
ranges) rejects any publish request from outside those CIDRs regardless of
|
||||
token validity. Listener B never serves the SPA or session auth, so even a
|
||||
fully exposed 8081 can't be used to browse notification history or reach
|
||||
admin functions — worst case is unauthorized token-scoped publishing, which
|
||||
is still gated by needing a valid token.
|
||||
|
||||
### SSRF via notification content
|
||||
**Threat:** a malicious/compromised publisher sets `image_url` or `url` to
|
||||
an internal address (`http://169.254.169.254/...`, `http://postgres:5432`,
|
||||
etc.), hoping the server fetches it.
|
||||
**Mitigation:** the server **never fetches these URLs**. They are stored as
|
||||
opaque strings and only ever rendered as `<a href>` / `<img src>` in the
|
||||
*browser's* context, exactly like a normal webpage. This removes SSRF as a
|
||||
viable vector for this field entirely, at the cost of not offering
|
||||
server-side attachment proxying/caching (acceptable per the "attachment
|
||||
metadata only" requirement).
|
||||
|
||||
### XSS via notification content
|
||||
**Threat:** `title`/`message` containing `<script>` or event handlers.
|
||||
**Mitigation:** the client renders title/message as plain text only (React's
|
||||
default escaping); a minimal, allowlist-based linkifier turns bare URLs into
|
||||
links but never injects raw HTML (`dangerouslySetInnerHTML` is not used
|
||||
anywhere in the notification rendering path). No notification field is ever
|
||||
interpreted as HTML/Markdown-with-HTML.
|
||||
|
||||
### Rate-limit exhaustion of the push quota
|
||||
**Threat:** a noisy/misbehaving source (the original ntfy incident: ~1
|
||||
notification/minute) exhausts Apple/Google's push quota or spams the user.
|
||||
**Mitigation:** per-token and per-IP rate limits (burst + sustained) on
|
||||
listener B; noisy-source detection surfaced on the admin dashboard;
|
||||
rejections return `429` with a clear body and are logged — never silently
|
||||
dropped. See [04-api-design.md](04-api-design.md) for exact limits.
|
||||
|
||||
### Denial of service via large payloads
|
||||
**Mitigation:** JSON body size cap (e.g. 64 KB) on ingestion endpoints;
|
||||
Fastify's built-in body-limit; oversized requests rejected with `413` before
|
||||
being parsed.
|
||||
|
||||
### Secret leakage via logs
|
||||
**Mitigation:** structured logging (pino) with a redaction list covering
|
||||
`Authorization`, `Cookie`, `token_hash`, OIDC `client_secret`, VAPID private
|
||||
key. Never log full notification bodies at `info` level in a way that ends
|
||||
up in metrics labels (metrics use topic *id*/slug cardinality only, never
|
||||
message content).
|
||||
|
||||
### Privilege escalation between roles
|
||||
**Mitigation:** `role` is only settable by an existing admin via the admin
|
||||
API; the very first user to log in when the `users` table is empty is
|
||||
auto-promoted to admin (documented "bootstrap admin" behavior) so there's no
|
||||
chicken-and-egg problem, and this path is disabled once at least one admin
|
||||
exists.
|
||||
|
||||
### Retention / data minimization
|
||||
Notifications are deleted per-topic retention policy by an interval job;
|
||||
`audit_log` is exempt from automatic retention deletion (kept for security
|
||||
review) but is not unbounded — documented in Phase 8 as configurable.
|
||||
|
||||
## Explicitly out of scope for v1
|
||||
|
||||
- Multi-tenant isolation beyond role + topic access control (this is a
|
||||
household app, not a SaaS)
|
||||
- End-to-end encryption of notification content at rest (Postgres-level
|
||||
encryption is the operator's responsibility, e.g. disk encryption on the
|
||||
Unraid array)
|
||||
- Protecting against a fully compromised Postgres container (it's inside the
|
||||
same trust boundary as the app by design)
|
||||
@@ -0,0 +1,211 @@
|
||||
# 03 — Database Model (PostgreSQL 16)
|
||||
|
||||
Conventions: `id uuid primary key default gen_random_uuid()` (needs
|
||||
`pgcrypto` or use `uuid-ossp`'s `uuid_generate_v4()` — Drizzle migration will
|
||||
enable `pgcrypto`), all timestamps `timestamptz`, stored/compared in UTC.
|
||||
Migrations managed by `drizzle-kit` (plain SQL files checked into the repo).
|
||||
|
||||
## `users`
|
||||
|
||||
| column | type | notes |
|
||||
|---|---|---|
|
||||
| id | uuid pk | |
|
||||
| email | text unique not null | |
|
||||
| display_name | text not null | |
|
||||
| avatar_url | text null | |
|
||||
| role | text not null default 'user' | check in ('admin','user') |
|
||||
| auth_subject | text not null | OIDC `sub`, or the proxy-auth identity string |
|
||||
| auth_issuer | text null | OIDC issuer URL, null for proxy mode |
|
||||
| created_at | timestamptz default now() | |
|
||||
| updated_at | timestamptz default now() | |
|
||||
| last_login_at | timestamptz null | |
|
||||
|
||||
Unique index on `(auth_issuer, auth_subject)`.
|
||||
|
||||
## `topics`
|
||||
|
||||
| column | type | notes |
|
||||
|---|---|---|
|
||||
| id | uuid pk | |
|
||||
| slug | text unique not null | URL/publish-path identifier, e.g. `homeassistant` |
|
||||
| display_name | text not null | |
|
||||
| description | text null | |
|
||||
| icon | text null | icon key or emoji |
|
||||
| accent_color | text null | hex |
|
||||
| retention_days | int null | null = use global default; 0 = forever |
|
||||
| restricted | boolean not null default false | if true, visibility gated by `topic_members` |
|
||||
| created_by | uuid fk users(id) null | |
|
||||
| created_at | timestamptz default now() | |
|
||||
| updated_at | timestamptz default now() | |
|
||||
| archived_at | timestamptz null | soft delete |
|
||||
|
||||
## `topic_members`
|
||||
|
||||
Only consulted when `topics.restricted = true`.
|
||||
|
||||
| column | type |
|
||||
|---|---|
|
||||
| topic_id | uuid fk topics(id) |
|
||||
| user_id | uuid fk users(id) |
|
||||
|
||||
Primary key `(topic_id, user_id)`.
|
||||
|
||||
## `user_topic_subscriptions`
|
||||
|
||||
Per-user push preference for a topic. Absence of a row = default
|
||||
`push_enabled = true` for any topic the user can see (avoids needing to
|
||||
backfill a row per user per topic on topic creation).
|
||||
|
||||
| column | type | notes |
|
||||
|---|---|---|
|
||||
| id | uuid pk | |
|
||||
| user_id | uuid fk users(id) | |
|
||||
| topic_id | uuid fk topics(id) | |
|
||||
| push_enabled | boolean not null default true | |
|
||||
| created_at | timestamptz default now() | |
|
||||
| updated_at | timestamptz default now() | |
|
||||
|
||||
Unique `(user_id, topic_id)`.
|
||||
|
||||
## `notifications`
|
||||
|
||||
| column | type | notes |
|
||||
|---|---|---|
|
||||
| id | uuid pk | |
|
||||
| topic_id | uuid fk topics(id) not null | |
|
||||
| source | text not null | free-form publisher-supplied service name |
|
||||
| title | text not null | |
|
||||
| message | text not null | |
|
||||
| external_url | text null | "click" URL, browser-rendered only |
|
||||
| image_url | text null | browser-rendered only, never server-fetched |
|
||||
| attachment_filename | text null | metadata only |
|
||||
| attachment_content_type | text null | metadata only |
|
||||
| attachment_size_bytes | bigint null | metadata only |
|
||||
| deduplication_key | text null | |
|
||||
| occurrence_count | int not null default 1 | incremented on deduped repeats |
|
||||
| raw_priority | text null | ntfy-compat passthrough, never read for logic |
|
||||
| sender_token_id | uuid fk api_tokens(id) null | |
|
||||
| search_vector | tsvector generated | `to_tsvector('simple', title \|\| ' ' \|\| message)`, GIN indexed |
|
||||
| created_at | timestamptz default now() | = "received_at" (server receipt time) |
|
||||
| updated_at | timestamptz default now() | bumped when occurrence_count increments |
|
||||
|
||||
Indexes:
|
||||
- `(topic_id, created_at desc)` — topic history pagination
|
||||
- `(deduplication_key, topic_id, created_at desc)` where `deduplication_key is not null` — dedupe window lookups
|
||||
- GIN `(search_vector)` — full-text search
|
||||
- `(created_at)` — retention cleanup sweep
|
||||
|
||||
## `user_notification_state`
|
||||
|
||||
Per-user read/star state (notifications are shared rows across all users who
|
||||
can see the topic).
|
||||
|
||||
| column | type | notes |
|
||||
|---|---|---|
|
||||
| id | uuid pk | |
|
||||
| user_id | uuid fk users(id) | |
|
||||
| notification_id | uuid fk notifications(id) | |
|
||||
| read | boolean not null default false | |
|
||||
| read_at | timestamptz null | |
|
||||
| starred | boolean not null default false | |
|
||||
| starred_at | timestamptz null | |
|
||||
|
||||
Unique `(user_id, notification_id)`. Indexes on `(user_id, read)`,
|
||||
`(user_id, starred)` for unread-count and starred-list queries.
|
||||
|
||||
## `push_subscriptions`
|
||||
|
||||
| column | type | notes |
|
||||
|---|---|---|
|
||||
| id | uuid pk | |
|
||||
| user_id | uuid fk users(id) | |
|
||||
| endpoint | text unique not null | Push service subscription URL |
|
||||
| p256dh | text not null | |
|
||||
| auth | text not null | |
|
||||
| user_agent | text null | |
|
||||
| device_label | text null | auto-derived from UA, user-editable |
|
||||
| created_at | timestamptz default now() | |
|
||||
| last_seen_at | timestamptz default now() | bumped on each successful push or resubscribe |
|
||||
| disabled_at | timestamptz null | set after push service returns 404/410 |
|
||||
|
||||
## `api_tokens`
|
||||
|
||||
| column | type | notes |
|
||||
|---|---|---|
|
||||
| id | uuid pk | |
|
||||
| name | text not null | e.g. "Home Assistant" |
|
||||
| token_hash | text unique not null | SHA-256 hex of the full token |
|
||||
| token_prefix | text not null | first 8 chars after `nzr_`, for display |
|
||||
| allowed_source | text null | if set, publisher's `source` must match |
|
||||
| allowed_cidrs | text[] null | null = no IP restriction beyond `INGESTION_ALLOWED_CIDRS` |
|
||||
| expires_at | timestamptz null | |
|
||||
| revoked_at | timestamptz null | |
|
||||
| created_by | uuid fk users(id) | |
|
||||
| created_at | timestamptz default now() | |
|
||||
| last_used_at | timestamptz null | |
|
||||
| usage_count | bigint not null default 0 | |
|
||||
|
||||
## `api_token_topics`
|
||||
|
||||
Join table: which topics a token may publish to (empty = all topics).
|
||||
|
||||
| column | type |
|
||||
|---|---|
|
||||
| token_id | uuid fk api_tokens(id) |
|
||||
| topic_id | uuid fk topics(id) |
|
||||
|
||||
Primary key `(token_id, topic_id)`.
|
||||
|
||||
## `push_delivery_attempts`
|
||||
|
||||
| column | type | notes |
|
||||
|---|---|---|
|
||||
| id | uuid pk | |
|
||||
| notification_id | uuid fk notifications(id) | |
|
||||
| push_subscription_id | uuid fk push_subscriptions(id) | |
|
||||
| status | text not null | enum: pending, accepted_by_push_service, failed, invalid_subscription, expired |
|
||||
| http_status | int null | |
|
||||
| error_message | text null | sanitized (no PII/secrets) |
|
||||
| retry_count | int not null default 0 | |
|
||||
| attempted_at | timestamptz default now() | |
|
||||
|
||||
Index `(notification_id)`, `(push_subscription_id, attempted_at desc)`.
|
||||
|
||||
## `audit_log`
|
||||
|
||||
| column | type | notes |
|
||||
|---|---|---|
|
||||
| id | uuid pk | |
|
||||
| actor_user_id | uuid fk users(id) null | |
|
||||
| actor_token_id | uuid fk api_tokens(id) null | |
|
||||
| action | text not null | e.g. `topic.create`, `token.revoke`, `rate_limit.rejected`, `user.role_change` |
|
||||
| target_type | text null | |
|
||||
| target_id | text null | |
|
||||
| metadata | jsonb null | |
|
||||
| ip_address | inet null | |
|
||||
| created_at | timestamptz default now() | |
|
||||
|
||||
Index `(created_at desc)`, `(action)`.
|
||||
|
||||
## `system_settings`
|
||||
|
||||
Singleton key/value store — VAPID keys, global retention default, rate-limit
|
||||
config overrides.
|
||||
|
||||
| column | type |
|
||||
|---|---|
|
||||
| key | text primary key |
|
||||
| value | jsonb not null |
|
||||
| updated_at | timestamptz default now() |
|
||||
|
||||
Known keys: `vapid_keys` (`{publicKey, privateKey, generatedAt}`),
|
||||
`retention_default_days`, `rate_limit_overrides`.
|
||||
|
||||
## Retention cleanup
|
||||
|
||||
An interval job (every 1h) deletes `notifications` rows older than the
|
||||
effective retention (`topics.retention_days`, falling back to
|
||||
`system_settings.retention_default_days`, default 90). `retention_days = 0`
|
||||
means "forever" and is skipped. Cascade deletes remove associated
|
||||
`user_notification_state` and `push_delivery_attempts` rows (`ON DELETE
|
||||
CASCADE` on the FKs).
|
||||
@@ -0,0 +1,159 @@
|
||||
# 04 — API Design
|
||||
|
||||
Two listeners, two auth methods, never overlapping. JSON everywhere except
|
||||
the ntfy-compat endpoint, which also accepts headers/query params for
|
||||
compatibility. All responses use UTC ISO-8601 timestamps.
|
||||
|
||||
## Listener A — Web API — `:8080` — session-cookie auth
|
||||
|
||||
### Auth
|
||||
|
||||
- `GET /api/v1/auth/oidc/login` → redirects to IdP (PKCE)
|
||||
- `GET /api/v1/auth/oidc/callback` → validates, creates session, redirects to `PUBLIC_BASE_URL`
|
||||
- `POST /api/v1/auth/logout`
|
||||
- `GET /api/v1/session` → `{ user: { id, email, displayName, role }, authMode }`
|
||||
|
||||
In `AUTH_MODE=proxy`, there is no login/callback — the session is derived
|
||||
per-request from trusted headers (see 02-threat-model.md), with a
|
||||
lightweight session record still created so read/star state etc. behave the
|
||||
same.
|
||||
|
||||
### Topics
|
||||
|
||||
- `GET /api/v1/topics` → list of topics visible to the current user, each with unread/total counts, latest timestamp, `pushEnabled` for this user
|
||||
- `GET /api/v1/topics/:slug`
|
||||
- `PATCH /api/v1/topics/:slug/subscription` `{ "pushEnabled": true }`
|
||||
|
||||
### Notifications
|
||||
|
||||
- `GET /api/v1/notifications?topic=&since=&before=&unread=&starred=&q=&limit=&cursor=`
|
||||
- `GET /api/v1/notifications/:id`
|
||||
- `PATCH /api/v1/notifications/:id` `{ "read"?: bool, "starred"?: bool }`
|
||||
- `GET /api/v1/search?q=&topic=&source=&from=&to=&unread=&starred=&limit=&cursor=`
|
||||
- `GET /api/v1/stream` — SSE, `text/event-stream`, events: `notification.created`, `notification.updated`
|
||||
|
||||
### Push / devices
|
||||
|
||||
- `GET /api/v1/push/vapid-public-key`
|
||||
- `POST /api/v1/push/subscribe` `{ endpoint, keys: { p256dh, auth }, userAgent? }`
|
||||
- `GET /api/v1/devices` → the current user's `push_subscriptions`
|
||||
- `DELETE /api/v1/devices/:id`
|
||||
- `GET /api/v1/push/diagnostics` → server-known state for the current user's devices (last delivery attempt status per device, VAPID configured: bool)
|
||||
|
||||
### Admin (role = admin)
|
||||
|
||||
- `GET/POST /api/v1/admin/topics`, `PATCH/DELETE /api/v1/admin/topics/:id`
|
||||
- `GET/POST /api/v1/admin/tokens`, `POST /api/v1/admin/tokens/:id/rotate`, `DELETE /api/v1/admin/tokens/:id`
|
||||
- `POST` response includes the plaintext token **once**: `{ token: "nzr_...", ...record }`
|
||||
- `GET /api/v1/admin/users`, `PATCH /api/v1/admin/users/:id` `{ role? }`
|
||||
- `GET /api/v1/admin/devices`
|
||||
- `GET /api/v1/admin/stats/dashboard` → today's count, 24h count, push attempts/failures, active devices, users, topics, top topics, noisiest sources, recent errors
|
||||
- `GET /api/v1/admin/sources` → per-source volume + noisy-source flags
|
||||
- `GET /api/v1/admin/audit-log?...`
|
||||
- `GET/PATCH /api/v1/admin/settings` → auth mode status, OIDC status, trusted proxies, public base URL, retention defaults, VAPID public key + configured status, rate-limit config
|
||||
|
||||
### Meta
|
||||
|
||||
- `GET /health`, `GET /ready`
|
||||
- `GET /docs` — OpenAPI/Swagger UI (generated from Fastify schemas)
|
||||
|
||||
## Listener B — Ingestion API — `:8081` — `Authorization: Bearer nzr_...` only
|
||||
|
||||
No cookies accepted or set. No static assets served. Rejects any request
|
||||
whose peer IP is outside `INGESTION_ALLOWED_CIDRS` (default: RFC1918) before
|
||||
even checking the token, to fail closed cheaply.
|
||||
|
||||
### Canonical publish
|
||||
|
||||
```
|
||||
POST /api/v1/notifications
|
||||
Authorization: Bearer nzr_xxxxxxxxxxxxxxxxxxxxxxxxx
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"topic": "homeassistant",
|
||||
"title": "🏠 Home",
|
||||
"message": "Office window is open",
|
||||
"source": "homeassistant",
|
||||
"url": null,
|
||||
"image_url": null,
|
||||
"deduplication_key": null
|
||||
}
|
||||
```
|
||||
|
||||
`201 Created`:
|
||||
```json
|
||||
{ "id": "b6e2...", "status": "accepted" }
|
||||
```
|
||||
|
||||
Also:
|
||||
```
|
||||
POST /api/v1/topics/{slug}
|
||||
```
|
||||
same body, `topic` field omitted (taken from the path).
|
||||
|
||||
### ntfy-compatibility
|
||||
|
||||
```
|
||||
POST /:topic
|
||||
```
|
||||
|
||||
Accepts, in order of precedence: JSON body (`ntfy`-shaped: `topic` implied
|
||||
by path, `title`, `message`, `tags`, `click`, `attach`, `filename`,
|
||||
`priority`), headers (`Title`, `Tags`, `Click`, `Attach`, `Filename`,
|
||||
`Priority`), or query params. `Priority`/`priority` is accepted and stored
|
||||
in `raw_priority` for compatibility but **never** affects push behavior —
|
||||
only the topic's push setting does. Plain-text request body (as `ntfy`
|
||||
supports) is treated as `message`.
|
||||
|
||||
### Webhook
|
||||
|
||||
```
|
||||
POST /api/v1/webhook/:source
|
||||
```
|
||||
|
||||
MVP mapping: JSON body `{ topic, title, message, url? }`. `:source` becomes
|
||||
the notification's `source`. Unrecognized shapes return `422` with a
|
||||
description of the expected shape (configurable field-mapping rules are a
|
||||
documented future enhancement, not MVP).
|
||||
|
||||
### Meta
|
||||
|
||||
- `GET /health`
|
||||
- `GET /metrics` — Prometheus exposition, LAN-only by design (see below)
|
||||
|
||||
## Rate limiting (listener B)
|
||||
|
||||
In-memory token bucket per `api_tokens.id` and per source IP, e.g.:
|
||||
- sustained: 60 requests/minute
|
||||
- burst: 20 requests/10 seconds
|
||||
|
||||
Exceeding either returns `429 Too Many Requests` with
|
||||
`{ "error": "rate_limited", "retryAfterSeconds": N }` and a matching
|
||||
`Retry-After` header. Never silently drops — always logged and, if the
|
||||
window is exceeded by a wide margin (configurable multiplier), an
|
||||
`audit_log` entry with `action: "rate_limit.rejected"` is written so it
|
||||
surfaces as a "noisy source" warning on the admin dashboard.
|
||||
|
||||
## Deduplication
|
||||
|
||||
If `deduplication_key` is present and a notification with the same
|
||||
`(topic_id, deduplication_key)` exists within the configured window
|
||||
(default 5 minutes), the existing row's `occurrence_count` is incremented
|
||||
and `updated_at` bumped instead of inserting a new row. Whether the bumped
|
||||
duplicate re-triggers a push is a per-topic behavior documented in Phase 5
|
||||
(default: suppress duplicate push, always update history) — kept simple and
|
||||
optional per the spec.
|
||||
|
||||
## Metrics (Prometheus, listener B only)
|
||||
|
||||
```
|
||||
notizarr_notifications_received_total{topic="..."}
|
||||
notizarr_notifications_by_source_total{source="..."}
|
||||
notizarr_push_attempts_total
|
||||
notizarr_push_failures_total
|
||||
notizarr_active_push_subscriptions
|
||||
notizarr_rate_limited_requests_total
|
||||
```
|
||||
|
||||
No message bodies or user-identifying values ever appear in a metric label.
|
||||
@@ -0,0 +1,129 @@
|
||||
# 05 — PWA & Web Push Design
|
||||
|
||||
## Manifest (`manifest.webmanifest`, generated via `vite-plugin-pwa`)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Notizarr",
|
||||
"short_name": "Notizarr",
|
||||
"description": "Self-hosted notification hub",
|
||||
"start_url": "/?source=pwa",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0b0d12",
|
||||
"theme_color": "#0b0d12",
|
||||
"orientation": "portrait",
|
||||
"categories": ["utilities"],
|
||||
"icons": [
|
||||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||
{ "src": "/icons/maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`start_url` carries `?source=pwa` purely so the client can distinguish a
|
||||
standalone launch from a browser tab for analytics/diagnostics — not used
|
||||
for auth or routing logic.
|
||||
|
||||
## Service worker (Workbox via `vite-plugin-pwa`, `injectManifest` strategy — we need custom push/notificationclick handlers, so `generateSW` alone isn't enough)
|
||||
|
||||
- **Precache:** app shell (JS/CSS bundles, `index.html`, icon set) for
|
||||
offline load of the UI shell.
|
||||
- **Runtime caching:** `NetworkFirst` for `GET /api/v1/*` with a short
|
||||
(e.g. 1 min) fallback cache, so a flaky connection still shows the last
|
||||
known notification list instead of a blank screen. Mutating requests are
|
||||
never cached and always network-only.
|
||||
- **`push` event:** payload is small JSON:
|
||||
```json
|
||||
{ "notificationId": "...", "topicSlug": "homeassistant", "title": "...", "body": "...", "icon": "/icons/icon-192.png" }
|
||||
```
|
||||
Handler calls `self.registration.showNotification(title, { body, icon,
|
||||
badge, data: { deepLink: "/topics/homeassistant/messages/<id>" }, tag:
|
||||
notificationId })`. `tag` collapses rapid duplicate pushes for the same
|
||||
notification into one OS notification.
|
||||
- **`notificationclick` event:** `event.notification.close()`, then either
|
||||
focus an existing client and `postMessage({ type: "navigate", path })`, or
|
||||
`clients.openWindow(deepLink)` if none is open.
|
||||
|
||||
Payload is deliberately minimal (Web Push payloads are capped around 4 KB by
|
||||
most push services) — the client fetches full notification detail
|
||||
(`GET /api/v1/notifications/:id`) once it navigates, rather than trying to
|
||||
cram the full message into the push payload. This also avoids storing
|
||||
potentially sensitive message content in the push service's transit path
|
||||
longer than necessary.
|
||||
|
||||
## VAPID
|
||||
|
||||
Generated once on first boot if `system_settings.vapid_keys` is absent
|
||||
(using the `web-push` library's `generateVAPIDKeys()`), stored in Postgres —
|
||||
durable via the existing `postgres/` volume, covered automatically by
|
||||
`pg_dump` backups. `VAPID_SUBJECT` (a `mailto:` or `https:` contact) comes
|
||||
from an env var. The public key is exposed via
|
||||
`GET /api/v1/push/vapid-public-key` for the client to use with
|
||||
`PushManager.subscribe({ applicationServerKey })`.
|
||||
|
||||
## Subscription flow
|
||||
|
||||
1. User logs in (session cookie set).
|
||||
2. Client checks `navigator.serviceWorker` support and registers the worker
|
||||
on app load (idempotent).
|
||||
3. User taps "Enable notifications" (never auto-prompted on load — iOS
|
||||
Safari requires this to be a direct user gesture, and prompting
|
||||
unprompted is bad UX anyway).
|
||||
4. `Notification.requestPermission()` → if `granted`, fetch the VAPID public
|
||||
key, call `registration.pushManager.subscribe({ userVisibleOnly: true,
|
||||
applicationServerKey })`.
|
||||
5. `POST /api/v1/push/subscribe` with the resulting `PushSubscription` JSON
|
||||
→ stored as a `push_subscriptions` row tied to the current user.
|
||||
6. User can toggle push per topic from Topic Settings (`PATCH
|
||||
/api/v1/topics/:slug/subscription`).
|
||||
7–10. As described in `01-architecture.md`'s publish flow: new notification
|
||||
→ stored always → push sent if the topic's per-user setting is on → tapping
|
||||
the OS notification opens the deep link in the installed PWA (or a browser
|
||||
tab, if not installed).
|
||||
|
||||
## iOS / Safari specifics
|
||||
|
||||
- Web Push on iOS requires **iOS 16.4+** and the PWA must be **installed to
|
||||
the Home Screen** first — Safari does not support Push for ordinary
|
||||
browser tabs. This is documented prominently in the in-app onboarding and
|
||||
in the diagnostics screen (see below), since it's the single biggest
|
||||
source of "why isn't it working" confusion.
|
||||
- `<meta name="apple-mobile-web-app-capable" content="yes">`,
|
||||
`<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">`,
|
||||
`<link rel="apple-touch-icon" ...>` for the home-screen icon.
|
||||
- `<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">`
|
||||
plus CSS `env(safe-area-inset-*)` padding on the app shell, bottom nav,
|
||||
and any fixed headers, so content clears the notch/Dynamic Island and the
|
||||
home indicator.
|
||||
- Standalone detection: `window.matchMedia('(display-mode: standalone)').matches
|
||||
|| window.navigator.standalone === true`.
|
||||
|
||||
## Diagnostics screen (`/settings/diagnostics`)
|
||||
|
||||
Client-derived:
|
||||
- PWA installed (standalone display-mode detected): yes/no
|
||||
- Service worker: registered / active / not supported
|
||||
- Notification permission: `Notification.permission` (granted/denied/default)
|
||||
- Push subscription: `pushManager.getSubscription()` present/absent
|
||||
|
||||
Server-derived (`GET /api/v1/push/diagnostics`), scoped to the current
|
||||
user's devices:
|
||||
- Push endpoint status per device (active / disabled)
|
||||
- Last successful delivery attempt (timestamp) per device
|
||||
- Last failed delivery attempt (timestamp + sanitized reason) per device
|
||||
- VAPID configuration status (configured / missing — admin-only detail also
|
||||
surfaced on `/admin/push`)
|
||||
|
||||
The screen explicitly states what Web Push **cannot** guarantee (delivery
|
||||
confirmation of on-screen display, iOS version/PWA-install prerequisites)
|
||||
rather than implying certainty it can't back up.
|
||||
|
||||
## Deep-linking contract
|
||||
|
||||
Every push notification's `deepLink` is always
|
||||
`${PUBLIC_BASE_URL}/topics/{topicSlug}/messages/{notificationId}`. The SPA
|
||||
router resolves this route regardless of entry point (cold start from a
|
||||
terminated PWA, warm start, or plain browser tab), fetching the notification
|
||||
detail and marking it read on open.
|
||||
@@ -0,0 +1,180 @@
|
||||
# 06 — Docker / Unraid Design
|
||||
|
||||
## Containers
|
||||
|
||||
| Container | Image | Role |
|
||||
|---|---|---|
|
||||
| `notizarr` | built locally from `apps/server` (multi-stage, bundles the built `apps/web` static assets into the final image) | app + web UI |
|
||||
| `notizarr-postgres` | `postgres:16-alpine` (pinned to a specific digest/patch tag at Phase 2) | database |
|
||||
|
||||
No Redis, no worker, no reverse proxy container — Pangolin is external and
|
||||
already run separately per the user's existing setup.
|
||||
|
||||
## Networking
|
||||
|
||||
- Single user-defined bridge network (`notizarr_internal`), so `notizarr`
|
||||
reaches Postgres via the service name `notizarr-postgres:5432` and
|
||||
Postgres is never bound to a host port.
|
||||
- `notizarr` publishes two host ports:
|
||||
- `8080` → web/PWA + OIDC + admin. In Unraid's port mapping, this should
|
||||
be bound to the LAN interface (or left for Pangolin to reach over the
|
||||
Docker network directly, if Pangolin also runs in Docker — recommended,
|
||||
see note below) rather than published on `0.0.0.0` if the host has any
|
||||
direct WAN exposure.
|
||||
- `8081` → LAN ingestion only. **Must** be bound to a LAN-only address in
|
||||
Unraid's port mapping (e.g. `192.168.1.10:8081:8081`, not
|
||||
`0.0.0.0:8081:8081`), and/or restricted at the router/firewall level.
|
||||
The app additionally enforces `INGESTION_ALLOWED_CIDRS` as defense in
|
||||
depth (see threat model).
|
||||
- **Recommended topology if Pangolin also runs under Docker on the same
|
||||
Unraid host:** attach `notizarr` to Pangolin's Docker network as well (or
|
||||
put both on a shared network) so Pangolin reaches `notizarr:8080` directly
|
||||
over the internal network without needing 8080 published to the host at
|
||||
all. This is the cleanest setup and is called out as the recommended
|
||||
option in the deployment README written in Phase 2, while still
|
||||
supporting a published-port fallback for other reverse-proxy layouts.
|
||||
|
||||
## Volumes
|
||||
|
||||
| Host path (under `/mnt/tank/appdata/notizarr/`) | Container path | Purpose |
|
||||
|---|---|---|
|
||||
| `postgres/` | `/var/lib/postgresql/data` | Postgres data directory (named volume backed by this bind mount) |
|
||||
| `app/` | `/data` | reserved for any future app-writable state; empty/unused in v1 since VAPID keys and settings live in Postgres |
|
||||
| `backups/` | (used by the documented backup script, not mounted into either container at runtime) | `pg_dump` output destination |
|
||||
| `logs/` | (optional bind mount if file logging is enabled) | stdout JSON logs are the default and are what `docker logs` / Unraid's log viewer shows; this folder exists for operators who want to additionally pipe logs to a file via `docker compose`'s logging driver |
|
||||
|
||||
## Healthchecks
|
||||
|
||||
```yaml
|
||||
notizarr-postgres:
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U notizarr -d notizarr"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
notizarr:
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "healthcheck.js"] # small script hitting http://127.0.0.1:8080/health
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
```
|
||||
|
||||
`notizarr` has `depends_on: notizarr-postgres: condition: service_healthy`,
|
||||
so it never starts serving traffic against a not-yet-ready database.
|
||||
|
||||
## Restart policy, non-root, pinned versions
|
||||
|
||||
- `restart: unless-stopped` on both services.
|
||||
- `notizarr`'s Dockerfile creates and runs as a non-root `appuser`
|
||||
(`USER appuser` in the final stage); the official `postgres:16-alpine`
|
||||
image already drops to the `postgres` user via its entrypoint.
|
||||
- Every image reference is pinned to an explicit tag (and ideally digest)
|
||||
— no `:latest` anywhere in the compose file.
|
||||
|
||||
## Environment variables (`.env`)
|
||||
|
||||
```env
|
||||
# Identity / URLs
|
||||
PUBLIC_BASE_URL=https://notizarr.example.com
|
||||
PORT_WEB=8080
|
||||
PORT_INGEST=8081
|
||||
|
||||
# Auth
|
||||
AUTH_MODE=oidc # oidc | proxy | disabled
|
||||
OIDC_ISSUER_URL=
|
||||
OIDC_CLIENT_ID=
|
||||
OIDC_CLIENT_SECRET=
|
||||
OIDC_REDIRECT_URL=https://notizarr.example.com/api/v1/auth/oidc/callback
|
||||
OIDC_SCOPES=openid profile email
|
||||
AUTH_PROXY_USER_HEADER=
|
||||
AUTH_PROXY_EMAIL_HEADER=
|
||||
AUTH_PROXY_GROUPS_HEADER=
|
||||
TRUSTED_PROXY_CIDRS=
|
||||
|
||||
# Database
|
||||
POSTGRES_USER=notizarr
|
||||
POSTGRES_PASSWORD=
|
||||
POSTGRES_DB=notizarr
|
||||
DATABASE_URL=postgres://notizarr:${POSTGRES_PASSWORD}@notizarr-postgres:5432/notizarr
|
||||
|
||||
# Push
|
||||
VAPID_SUBJECT=mailto:admin@example.com
|
||||
|
||||
# Ingestion safety
|
||||
INGESTION_ALLOWED_CIDRS=10.0.0.0/8,172.16.0.0/12,192.168.0.0/16
|
||||
|
||||
# Retention / rate limiting (defaults; also editable in Admin > Settings)
|
||||
RETENTION_DEFAULT_DAYS=90
|
||||
RATE_LIMIT_SUSTAINED_PER_MIN=60
|
||||
RATE_LIMIT_BURST_PER_10S=20
|
||||
|
||||
# Ops
|
||||
LOG_LEVEL=info
|
||||
DOCS_ENABLED=true
|
||||
```
|
||||
|
||||
`.env.example` (committed) documents every variable; the real `.env` on the
|
||||
Unraid host is never committed and is included in the backup procedure.
|
||||
|
||||
## Illustrative `docker-compose.yml` (final version written in Phase 2)
|
||||
|
||||
```yaml
|
||||
services:
|
||||
notizarr-postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: notizarr-postgres
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
volumes:
|
||||
- ./postgres:/var/lib/postgresql/data
|
||||
networks: [notizarr_internal]
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
notizarr:
|
||||
image: notizarr:latest # built via `docker compose build`
|
||||
container_name: notizarr
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
notizarr-postgres:
|
||||
condition: service_healthy
|
||||
env_file: .env
|
||||
environment:
|
||||
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@notizarr-postgres:5432/${POSTGRES_DB}
|
||||
ports:
|
||||
- "8080:8080"
|
||||
- "8081:8081"
|
||||
volumes:
|
||||
- ./app:/data
|
||||
networks: [notizarr_internal]
|
||||
healthcheck:
|
||||
test: ["CMD", "node", "healthcheck.js"]
|
||||
interval: 15s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 20s
|
||||
|
||||
networks:
|
||||
notizarr_internal:
|
||||
```
|
||||
|
||||
## Backup / restore
|
||||
|
||||
- **Backup:** a documented script (`docker compose exec notizarr-postgres
|
||||
pg_dump -U notizarr notizarr | gzip > backups/notizarr-$(date +%F).sql.gz`)
|
||||
— since VAPID keys and all settings live in Postgres, a Postgres backup
|
||||
alone is a complete backup. `.env` is backed up separately (it's a plain
|
||||
file, not a container volume) since it holds the OIDC client secret and DB
|
||||
password.
|
||||
- **Restore:** `docker compose down`, `gunzip -c backup.sql.gz | docker
|
||||
compose exec -T notizarr-postgres psql -U notizarr notizarr`, `docker
|
||||
compose up -d`. Full runnable instructions land in the Phase 2 README.
|
||||
@@ -0,0 +1,75 @@
|
||||
# 07 — Folder Structure
|
||||
|
||||
Two different structures matter here: the **source repository** (this
|
||||
working directory, developed on Windows, deployed to Unraid) and the
|
||||
**Unraid runtime directory** the user specified. They're linked by the
|
||||
compose file and the built Docker image, not by being the same directory.
|
||||
|
||||
## Source repository (`Y:\notizarr`, this directory)
|
||||
|
||||
```
|
||||
notizarr/
|
||||
├── apps/
|
||||
│ ├── server/ # Node.js + TypeScript backend (Fastify)
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── modules/
|
||||
│ │ │ │ ├── auth/
|
||||
│ │ │ │ ├── users/
|
||||
│ │ │ │ ├── topics/
|
||||
│ │ │ │ ├── notifications/
|
||||
│ │ │ │ ├── tokens/
|
||||
│ │ │ │ ├── push/
|
||||
│ │ │ │ ├── admin/
|
||||
│ │ │ │ ├── audit/
|
||||
│ │ │ │ └── rate-limit/
|
||||
│ │ │ ├── db/
|
||||
│ │ │ │ ├── schema.ts
|
||||
│ │ │ │ └── migrations/
|
||||
│ │ │ ├── plugins/ # fastify plugins: auth, csrf, cors, logging, metrics
|
||||
│ │ │ ├── listeners/ # web.ts (8080), ingest.ts (8081)
|
||||
│ │ │ └── index.ts
|
||||
│ │ ├── healthcheck.js
|
||||
│ │ ├── Dockerfile
|
||||
│ │ └── package.json
|
||||
│ └── web/ # React + TypeScript + Vite frontend
|
||||
│ ├── src/
|
||||
│ │ ├── routes/
|
||||
│ │ ├── components/
|
||||
│ │ ├── hooks/
|
||||
│ │ ├── lib/
|
||||
│ │ └── service-worker/ # custom push/notificationclick handlers (injectManifest)
|
||||
│ ├── public/
|
||||
│ │ └── icons/
|
||||
│ ├── vite.config.ts
|
||||
│ └── package.json
|
||||
├── packages/
|
||||
│ └── shared/ # types shared between server and web (API request/response shapes)
|
||||
├── docker/
|
||||
│ ├── docker-compose.yml # dev-oriented compose (hot reload)
|
||||
│ └── docker-compose.prod.yml # reference for the Unraid deployment
|
||||
├── docs/
|
||||
│ ├── phase1/ # this design package
|
||||
│ ├── migration-ntfy.md # written in Phase 9
|
||||
│ └── home-assistant.md # written in Phase 9
|
||||
├── .env.example
|
||||
├── package.json # npm/pnpm workspaces root
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Unraid deployment directory (`/mnt/tank/appdata/notizarr`)
|
||||
|
||||
This is runtime state + the deployment compose file, not the source tree:
|
||||
|
||||
```
|
||||
/mnt/tank/appdata/notizarr/
|
||||
├── docker-compose.yml # copy of docker/docker-compose.prod.yml (or generated by a deploy step)
|
||||
├── .env # real secrets — never committed to the repo
|
||||
├── app/ # bind mount → /data in the notizarr container (reserved, unused in v1)
|
||||
├── postgres/ # bind mount → Postgres data directory
|
||||
├── backups/ # pg_dump output lands here
|
||||
└── logs/ # optional, only used if file logging is enabled
|
||||
```
|
||||
|
||||
Deploying an update is: build/pull the `notizarr` image, `git pull` (or copy)
|
||||
the updated `docker-compose.yml` into this directory if it changed, then
|
||||
`docker compose up -d` from here.
|
||||
@@ -0,0 +1,130 @@
|
||||
# 08 — Implementation Plan (Phases 2–10)
|
||||
|
||||
Each phase ends with: run tests, run lint, build, fix all errors, verify
|
||||
`docker compose up -d` still works end-to-end for whatever exists so far.
|
||||
No phase starts until the previous one is green.
|
||||
|
||||
## Phase 2 — Bootstrap project & Docker Compose
|
||||
|
||||
- npm/pnpm workspace scaffold (`apps/server`, `apps/web`, `packages/shared`)
|
||||
- Fastify skeleton with the two listeners (8080/8081), `/health`, `/ready`
|
||||
- Vite + React + Tailwind skeleton, dark theme tokens, empty shell page
|
||||
- Drizzle configured against Postgres; `docker-compose.yml` (dev) brings up
|
||||
`notizarr` (hot-reload) + `notizarr-postgres`
|
||||
- `Dockerfile` (multi-stage: build web → build server → copy web dist into
|
||||
server's static dir → slim runtime image), non-root user, healthcheck
|
||||
script
|
||||
- **Exit criteria:** `docker compose up -d` succeeds, `notizarr-postgres` is
|
||||
healthy, `GET :8080/health` returns 200, empty React shell loads.
|
||||
|
||||
## Phase 3 — Database + backend API skeleton
|
||||
|
||||
- All tables from `03-database-model.md` as Drizzle schema + migrations
|
||||
- CRUD-level modules for `topics`, `notifications` (insert, list, get) with
|
||||
no auth yet (stubbed), so the data layer can be exercised
|
||||
- OpenAPI schema generation wired up, `/docs` serving it
|
||||
- Unit tests for schema constraints (unique slugs, FK cascades, retention
|
||||
math)
|
||||
- **Exit criteria:** migrations run clean on a fresh DB; can insert/list a
|
||||
topic and a notification via the API without auth.
|
||||
|
||||
## Phase 4 — Authentication + API tokens
|
||||
|
||||
- OIDC module (`openid-client`): login/callback/logout, PKCE, state/nonce
|
||||
validation, session cookie issuance, bootstrap-first-admin logic
|
||||
- Proxy-auth module: trusted-CIDR check, header parsing, session creation
|
||||
- `AUTH_MODE=disabled` (local dev convenience: single implicit admin user)
|
||||
- API token module: create/list/revoke/rotate, hashing, scoping
|
||||
(topics/source/CIDR/expiry), last-used tracking
|
||||
- CSRF protection (Origin check + custom header) on listener A mutations
|
||||
- Tests: each auth mode, token scope enforcement, revoked/expired token
|
||||
rejection, CSRF rejection on cross-origin mutation attempts
|
||||
- **Exit criteria:** MVP criteria #5–7 (auth works, admin can create a
|
||||
topic, admin can create a scoped token) pass manually and via tests.
|
||||
|
||||
## Phase 5 — Notification ingestion + history
|
||||
|
||||
- Listener B: canonical publish endpoint, `/api/v1/topics/:slug` alias,
|
||||
ntfy-compat endpoint, webhook endpoint
|
||||
- Rate limiter (per-token, per-IP), `INGESTION_ALLOWED_CIDRS` enforcement
|
||||
- Deduplication logic, occurrence_count increment
|
||||
- SSE stream on listener A (`notification.created`/`updated` events)
|
||||
- Read/unread + starred state, unread-count aggregation per topic
|
||||
- Retention cleanup interval job
|
||||
- Tests: ingestion auth/scoping, rate limiting (burst + sustained), dedup
|
||||
window behavior, SSE fan-out, retention deletion
|
||||
- **Exit criteria:** MVP criteria #8–10 (LAN client can publish, appears
|
||||
instantly via SSE, persisted) pass.
|
||||
|
||||
## Phase 6 — Frontend UI
|
||||
|
||||
- Navigation shells: mobile bottom nav (Inbox/Topics/Search/Starred/
|
||||
Settings), desktop sidebar + stream + optional detail panel
|
||||
- Topic list (unread/total/latest, bell state), topic settings screen
|
||||
(push ON/OFF toggle)
|
||||
- Notification list (cards per spec), notification detail
|
||||
(`/topics/:slug/messages/:id`), search + filters
|
||||
- Empty states, loading skeletons, dark-first theme, responsive breakpoints
|
||||
- Manual + automated visual smoke checks in the Browser tool (mobile and
|
||||
desktop viewport)
|
||||
- **Exit criteria:** MVP criteria #22 (polished on iPhone and desktop) —
|
||||
judged by hand in the browser tool at this stage, refined further in
|
||||
Phase 7/8.
|
||||
|
||||
## Phase 7 — PWA + Web Push
|
||||
|
||||
- `vite-plugin-pwa` with `injectManifest`, manifest.webmanifest, icon set
|
||||
(incl. maskable + apple-touch-icon), safe-area CSS
|
||||
- Service worker: precache, runtime caching, `push`/`notificationclick`
|
||||
handlers
|
||||
- VAPID generation/storage in `system_settings`, `web-push` send integration
|
||||
wired into the Phase 5 fan-out step (previously a no-op stub)
|
||||
- Subscribe/unsubscribe flow, devices screen, diagnostics screen
|
||||
- Manual iPhone test pass (install to Home Screen, enable notifications,
|
||||
trigger a real push, confirm deep link opens the right message)
|
||||
- **Exit criteria:** MVP criteria #11–18 (PWA installable, push permission,
|
||||
subscription stored, push ON sends / OFF doesn't, arrives on iPhone,
|
||||
tapping opens exact notification) pass.
|
||||
|
||||
## Phase 8 — Admin UI + rate limiting polish + diagnostics
|
||||
|
||||
- Admin dashboard widgets (`04-api-design.md`'s `/admin/stats/dashboard`)
|
||||
- Noisy-source detection thresholds + admin warnings
|
||||
- Tokens/Users/Devices/Push/Security/System admin screens
|
||||
- Audit log viewer
|
||||
- Settings screens (user + admin) per `01-architecture.md`
|
||||
- **Exit criteria:** full admin IA from the spec is navigable and
|
||||
functional; rate-limit rejections are visible end-to-end (trigger one,
|
||||
see it flagged).
|
||||
|
||||
## Phase 9 — Migration helpers + ntfy compatibility hardening
|
||||
|
||||
- `docs/migration-ntfy.md`: side-by-side ntfy → Notizarr endpoint mapping
|
||||
- `docs/home-assistant.md`: `rest_command` example replacing `ntfy.publish`
|
||||
(as given in the spec), plus one example per currently-used service
|
||||
(Seerr, Uptime Kuma, generic webhook)
|
||||
- Compatibility test matrix: header-based, JSON-based, query-param-based
|
||||
ntfy-style requests all produce equivalent stored notifications
|
||||
- **Exit criteria:** MVP criteria #8 re-verified using an ntfy-style request
|
||||
shape, not just the canonical API.
|
||||
|
||||
## Phase 10 — Testing + hardening + documentation
|
||||
|
||||
- Fill any remaining gaps in the automated test list from the original spec
|
||||
(stale push subscription cleanup, device removal, authorization matrix
|
||||
across roles, retention cleanup edge cases)
|
||||
- Security pass: CSP, HSTS, frame-ancestors, request size limits, dependency
|
||||
audit
|
||||
- Top-level `README.md`: install, configure `.env`, deploy on Unraid,
|
||||
backup/restore, troubleshooting (linking the diagnostics screen)
|
||||
- Full MVP acceptance checklist run end-to-end, including container
|
||||
restart persistence checks (#19–21)
|
||||
- **Exit criteria:** all 22 MVP acceptance criteria pass.
|
||||
|
||||
## Cross-cutting, done incrementally rather than as a separate phase
|
||||
|
||||
- Structured logging + secret redaction: introduced in Phase 2, extended as
|
||||
new modules land
|
||||
- Metrics: introduced alongside the ingestion endpoints in Phase 5
|
||||
- Lint/typecheck/test/build as CI-equivalent local commands: set up in
|
||||
Phase 2, run at the end of every subsequent phase
|
||||
Reference in new issue
Block a user