160 lines
5.8 KiB
Markdown
160 lines
5.8 KiB
Markdown
# 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.
|