Initial commit
This commit is contained in:
commit
38c24fc9dc
166 files changed
+22002
No files matched your search
@@ -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.
|
||||
Reference in new issue
Block a user