Initial commit

This commit is contained in:
siliconsolder committed 2026-09-07 09:54:30 +00:00
commit 38c24fc9dc
166 files changed
+22002

No files matched your search

+34
View File
@@ -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).
+126
View File
@@ -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
+130
View File
@@ -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)
+211
View File
@@ -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).
+159
View File
@@ -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.
+129
View File
@@ -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.
+180
View File
@@ -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.
+75
View File
@@ -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.
+130
View File
@@ -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