Files
siliconsolder 651214017a Complete Phase 10: authorization matrix, stale push cleanup, and retention-default tests
- Add authorization.integration.test.ts covering unauthenticated/regular-user/admin
  access across both admin-only and merely-authenticated routes
- Add stale push subscription cleanup test (disableSubscription excludes a device
  from fan-out without deleting its row/history)
- Add retention test for a topic with no override falling back to the resolved
  Admin-configured global default
- Update README status: Phases 2-10 complete
2026-09-07 19:02:37 +00:00

379 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Notizarr
Self-hosted notification hub — replaces ntfy for a small homelab, with a
polished web UI/PWA, Web Push, per-topic push control, and scoped API tokens
for LAN publishers. Full design rationale lives in
[docs/phase1/](docs/phase1/00-README.md).
## Why
Started on Telegram for home notifications, but got tired of being fully
dependent on a third party and routing everything through Telegram's
servers. Moved to [ntfy](https://ntfy.sh) next — better, but self-hosting
it still runs into its default 250-messages/24h rate limit unless you tune
it, and it's a general-purpose tool built for a much broader audience than
"one household's homelab." That gap is what led to building Notizarr:
something purpose-fit for this specific use case, entirely self-hosted,
with no data ever leaving the LAN except the push notification itself.
Status: **Phases 2–10 complete**. Auth, topics, notification ingestion
(canonical + ntfy-compat + webhook), rate limiting, dedup, SSE live
updates, retention cleanup, the full user-facing UI, Web Push, and the
admin UI are all built and verified against a live dev stack. Phase 10's
specific test gaps (authorization matrix across roles, stale push
subscription cleanup, retention's global-default fallback) are covered by
the automated suite (`pnpm test`), and the dependency/CSP/HSTS security
pass is done — see [Backup and restore](#backup-and-restore) and the
troubleshooting entries below for the operational side. See
[docs/phase1/08-implementation-plan.md](docs/phase1/08-implementation-plan.md)
for the phase breakdown and [MVP acceptance status](#mvp-acceptance-status)
below for where things stand against the original 22 criteria.
> **Note:** this was written in an environment without Node.js or Docker
> installed, so none of it is executed here — it's been verified
> incrementally against a real dev stack running on the target Unraid box
> instead. `AUTH_MODE=oidc` has since been tested end-to-end against a real
> Pocket ID instance over real HTTPS (via Pangolin), and Web Push has been
> confirmed delivering to an installed iPhone PWA — see "Auth modes" and
> [docs/phase1/05-pwa-web-push-design.md](docs/phase1/05-pwa-web-push-design.md).
## Auth modes
Set via `AUTH_MODE` (`.env` / `docker-compose.yml` environment): `disabled`,
`proxy`, or `oidc` — see [docs/phase1/02-threat-model.md](docs/phase1/02-threat-model.md).
`disabled` refuses to start when `NODE_ENV=production` (it grants every
request implicit admin access — dev/test only). `oidc` is confirmed working
end-to-end against a real Pocket ID instance (Authorization Code + PKCE via
`openid-client` v5). Once `AUTH_MODE=oidc` is set and the server restarted,
the actual provider details (issuer URL, client ID/secret, redirect URL,
scopes) are editable any time from **Admin → Auth** — no further env file
edits or restarts needed for those.
### Setting up OIDC with Pocket ID
Notizarr handles the OIDC login flow itself (Authorization Code + PKCE) —
it does not delegate auth to the reverse proxy via trusted headers, so
Pangolin (or whatever sits in front of it) only needs to forward HTTP, no
auth injection.
1. In Pocket ID, create a new OIDC client:
- **Name**: `Notizarr`
- **Client's start URL**: your public Notizarr URL (e.g.
`https://notify.example.com`)
- **Callback URLs**: `https://notify.example.com/api/v1/auth/oidc/callback`
— this exact path is hardcoded server-side
([apps/server/src/modules/auth/routes.ts](apps/server/src/modules/auth/routes.ts)),
it has to match exactly
- **Public Client**: **off** — Notizarr is a confidential/server-side
client and needs a client secret. Leaving this on means Pocket ID
never issues one.
- **PKCE**: **on** — Notizarr always sends a PKCE challenge regardless,
so this just means Pocket ID requires it too
- Everything else (logout callback URLs, skip consent, logo) is
optional/cosmetic
2. Save, then open the client back up and find its **Referenser** /
Credentials tab — client secrets in Pocket ID live there, not on the
creation form, and are only shown once when generated.
3. In Notizarr, go to **Admin → Auth** and fill in the Issuer URL, Client
ID, Client Secret, Redirect URL (same value as the Callback URL above),
and Scopes (`openid profile email`). Save.
4. Set `AUTH_MODE=oidc` in `.env` and restart the container — this one
setting is the only part that still needs a restart, since it changes
which routes the server exposes at boot. Everything from step 3 takes
effect immediately without one.
5. Log in once. If you'd previously been running `AUTH_MODE=disabled`, its
implicit "Local Admin" user already claimed the bootstrap-admin slot
(the very first user *ever created*), so your real login likely comes
back as a normal user — see the "First-ever login should be an admin
but isn't" entry under [Troubleshooting](#troubleshooting) to fix that.
**Pangolin**: point it at the web listener only (`notizarr:8080` on the
same Docker network, or the host's `PUBLIC_BASE_URL`) — never at `:8081`,
the LAN-only ingestion listener. See [Unraid deployment](#unraid-deployment-production)
below.
All mutating requests on the web listener (`POST`/`PUT`/`PATCH`/`DELETE`
under `/api/`) require a `X-Requested-With: notizarr` header — this is the
CSRF defense described in the threat model, not optional. The ingestion
listener (`:8081`) instead requires `Authorization: Bearer nzr_...` — create
a token via Settings → Admin → API Tokens.
## Repo layout
```
apps/server/ Node.js + TypeScript backend (Fastify), two listeners
apps/web/ React + TypeScript + Vite frontend, installable PWA
packages/shared/ Reserved for cross-package types (unused so far)
docker/ docker-compose.yml (dev) and docker-compose.prod.yml (Unraid template)
scripts/ backup.sh / restore.sh for the Postgres database
docs/phase1/ Architecture, threat model, DB/API/PWA design, implementation plan
docs/ Migration guides (ntfy, Home Assistant/Seerr/Uptime Kuma)
```
Full breakdown: [docs/phase1/07-folder-structure.md](docs/phase1/07-folder-structure.md).
## Local development
Requires Node.js 22+ and pnpm (`corepack enable` will fetch the pinned
version automatically).
```bash
pnpm install
pnpm dev
```
This runs the web app in `vite build --watch` and the server via `tsx
watch` concurrently. The server serves the built web assets itself — open
http://localhost:8080. `PORT_INGEST` (8081) is the LAN ingestion listener.
Needs a local Postgres — either run one yourself and set `DATABASE_URL`, or
use the dev Docker Compose stack below, which includes one.
## Local development via Docker
```bash
docker compose --env-file .env -f docker/docker-compose.yml up -d
```
Brings up `notizarr-postgres-dev` and `notizarr-dev` (hot-reload: the repo
is bind-mounted in). Default host ports (deliberately not 8080/8081/5432,
since those are commonly already taken on an Unraid box):
- Web UI: http://localhost:18080
- Ingestion listener: http://localhost:18081
- Postgres: `127.0.0.1:15432` (`psql -h localhost -p 15432 -U notizarr
notizarr`, password `notizarr_dev_password`)
Whenever a `package.json` dependency changes, rebuild **and** renew the
anonymous `node_modules` volumes in one go, or the container keeps running
against the old, pre-change `node_modules`:
```bash
docker compose --env-file .env -f docker/docker-compose.yml up -d --build --force-recreate -V
```
Override host ports if they also collide, either inline or via a
`docker/.env` file:
```bash
HOST_PORT_WEB=28080 HOST_PORT_INGEST=28081 HOST_PORT_PG=25432 \
docker compose --env-file .env -f docker/docker-compose.yml up -d
```
Check health:
```bash
curl http://localhost:18080/health
curl http://localhost:18080/ready # 503 until Postgres is reachable
curl http://localhost:18081/health
```
Generate the PWA icon set once (needs `sharp`, already a devDependency —
this writes real PNGs into `apps/web/public/icons/`, committed to the repo
rather than regenerated on every build):
```bash
docker compose --env-file .env -f docker/docker-compose.yml exec notizarr pnpm --filter @notizarr/web generate-icons
```
## Unraid deployment (production)
Deployment root: `/mnt/tank/appdata/notizarr`. Full design:
[docs/phase1/06-docker-unraid-design.md](docs/phase1/06-docker-unraid-design.md).
1. Build and tag the image (from a repo checkout, e.g. on the Unraid box or
any machine with Docker, then push/copy it to where Unraid can pull it —
or build directly on Unraid if the repo is checked out there):
```bash
docker build -f apps/server/Dockerfile --target runtime -t notizarr:0.1.0 .
```
2. On the Unraid host, create the deployment directory structure:
```bash
mkdir -p /mnt/tank/appdata/notizarr/{postgres,app,backups,logs}
```
3. Copy `docker/docker-compose.prod.yml` to
`/mnt/tank/appdata/notizarr/docker-compose.yml`, and `.env.example` to
`/mnt/tank/appdata/notizarr/.env` — fill in real values (DB password,
OIDC settings, `PUBLIC_BASE_URL`, and especially `BIND_ADDR_INGEST`, which
defaults to `127.0.0.1` until you explicitly set it to your LAN IP).
4. From `/mnt/tank/appdata/notizarr`:
```bash
docker compose up -d
```
Point Pangolin at `notizarr:8080` (same Docker network, preferred) or the
host's `PUBLIC_BASE_URL`. Never point it at 8081.
## Backup and restore
Everything that matters — notification history, users, topics, API tokens,
push subscriptions, the VAPID keypair, the session secret — lives in
Postgres (see [docs/phase1/06-docker-unraid-design.md](docs/phase1/06-docker-unraid-design.md#backup--restore)),
so backing up the database is a complete backup.
**Automatic (no setup needed):** the app backs itself up once a day and
keeps the last 14, no Unraid User Scripts cron entry required. Trigger one
manually or download an existing one from **Admin → Backups**.
**Manual / scripted**, e.g. for an off-box copy:
```bash
cd /mnt/tank/appdata/notizarr
./scripts/backup.sh
```
Writes a timestamped, gzipped `pg_dump` plus a copy of the compose file
(env vars, including the DB password and OIDC client secret, are inline
there rather than in a separate `.env` — keep `backups/` off shared or
public storage) into `backups/`, keeping the last 14 of each — the same
directory and naming the in-app job uses, so both coexist. Restore with:
```bash
./scripts/restore.sh backups/notizarr-db-<timestamp>.sql.gz
```
This is destructive (replaces all current data) and asks for confirmation.
## Home Assistant
Add a `rest_command` pointing at the LAN ingestion listener (`:8081`, never
the public URL):
```yaml
# configuration.yaml
rest_command:
notizarr:
url: "http://<lan-ip>:8081/api/v1/notifications"
method: POST
headers:
Authorization: !secret notizarr_token
Content-Type: "application/json"
payload: >
{
"topic": "{{ topic }}",
"title": "{{ title }}",
"message": "{{ message }}",
"source": "{{ source | default('home-assistant') }}",
"priority": "{{ priority | default('default') }}",
"image_url": "{{ image_url | default('') }}"
}
```
```yaml
# secrets.yaml — the value is the FULL header including "Bearer ", since a
# YAML !secret must be a key's entire value, not embedded in a larger string
notizarr_token: "Bearer nzr_your_token_here"
```
Then call it from any automation:
```yaml
action: rest_command.notizarr
data:
topic: homeassistant
title: "🏠 Home"
message: "Office window is open, don't forget to close it"
```
Get the token from **Admin → API Tokens → Create token**. Two extras worth
knowing:
- **`priority: min`** stores and shows the notification as usual but skips
the push — useful for automations that should log something without
buzzing anyone's phone, without needing a separate "quiet" topic.
- **Quiet hours** (Admin → System) and **per-topic snooze** (the alarm-clock
icon on any topic page) both suppress push independently of what any
individual automation sends, if you'd rather configure "don't wake me
up" once instead of per-automation.
Full field reference, dedup keys, and examples for Seerr/Uptime
Kuma/generic webhooks: [docs/home-assistant.md](docs/home-assistant.md).
## Migrating from ntfy
[docs/migration-ntfy.md](docs/migration-ntfy.md) — endpoint mapping,
before/after examples, what's intentionally not replicated. Short version:
point existing `ntfy.sh/<topic>` publishers at
`http://<lan-ip>:8081/<topic>` with an `Authorization: Bearer nzr_...`
header instead — headers, JSON body, and query-param publishing all still
work the same way.
## Troubleshooting
- **`docker compose ... exec/logs/etc` fails with `required variable
POSTGRES_PASSWORD is missing a value`.** Compose's implicit `.env`
lookup when the compose file is passed via `-f docker/docker-compose.yml`
from the repo root doesn't reliably resolve to the repo root's `.env` —
it's ambiguous/version-dependent, and got this wrong in practice. Always
pass `--env-file .env` explicitly, on every invocation against this
file, not just `up` (see the comment at the top of
`docker/docker-compose.yml`).
- **A `docker compose up -d --build` didn't pick up a dependency change.**
Anonymous `node_modules` volumes are reused by default — add `-V
--force-recreate` (see [Local development via
Docker](#local-development-via-docker)).
- **A mutating API call returns `csrf_check_failed`.** The web app must
send `X-Requested-With: notizarr` on every `POST`/`PATCH`/`DELETE`
(`apps/web/src/lib/api.ts` does this automatically for the built-in UI —
this only bites hand-rolled `curl` testing). If the error is specifically
`"origin mismatch"`, the browser's `Origin` header didn't match the
request's own `Host` header — that's a real cross-origin request being
correctly blocked, not a false positive.
- **Push notifications don't work / diagnostics show "Service worker: Not
registered".** Service workers and the Push API require a secure context
— HTTPS, or `localhost`. A plain `http://<lan-ip>:port` origin will never
register a service worker; this isn't a bug, it's the browser enforcing
the spec. Test push once Pangolin is serving Notizarr over real HTTPS.
- **iPhone won't show push notifications even over HTTPS.** Confirm iOS
16.4+, and that Notizarr was installed via Share → Add to Home Screen —
Safari does not support Web Push for ordinary browser tabs, only
installed PWAs. Check Settings → Push diagnostics in the app.
- **`AUTH_MODE=disabled` refuses to start.** This is intentional when
`NODE_ENV=production` — that mode grants every request implicit admin
access and is dev/test-only (see `apps/server/src/index.ts`). Use `oidc`
or `proxy` for anything reachable off localhost.
- **First-ever login should be an admin but isn't.** Bootstrap-admin only
promotes the very first user *ever created* in the `users` table. If a
non-admin user was created first (e.g. during testing), promote them
manually via Settings → Admin → Users using an existing admin account, or
directly in Postgres if none exists yet.
- **A third-party app's built-in "Ntfy" notification type won't send
("test failed", 404).** Sonarr/Radarr/Prowlarr-style apps typically
append each topic in their own "Topics" field to the Server URL
(`{Server URL}/{topic}`), so the topic must **not** already be in the
Server URL — just the bare host+port. Overseerr/Jellyseerr-style apps do
the opposite: they expect the topic already in the Server URL and don't
append anything, so putting a topic in a separate "Topic" field there is
redundant but harmless. When in doubt, `curl` the ingestion listener
directly with the same topic path the app would use — Notizarr always
expects exactly one path segment (`POST /:topic`), and comparing that
against what actually gets sent explains almost every case of this.
## MVP acceptance status
All 22 hard acceptance criteria are built and verified against a live dev
stack reachable over real HTTPS (Docker Compose, Postgres healthy,
auth/topics/tokens/publish/push-toggle/PWA-install all confirmed working,
history and settings survive container restarts, OIDC login against a real
Pocket ID instance, and push confirmed arriving on an installed iPhone
PWA — 17–18 were the last two blocked on real HTTPS and are now closed
out too).
## Commands
| Command | Does |
|---|---|
| `pnpm dev` | Run web + server in watch mode |
| `pnpm build` | Production build of both |
| `pnpm typecheck` | TypeScript, no emit, across all workspaces |
| `pnpm lint` | ESLint across the repo |
| `pnpm test` | Tests across all workspaces |
## Documentation
- [docs/phase1/](docs/phase1/00-README.md) — architecture, threat model,
database model, API design, PWA/Web Push design, Docker/Unraid design,
implementation plan
- [docs/migration-ntfy.md](docs/migration-ntfy.md),
[docs/home-assistant.md](docs/home-assistant.md) — migration guides