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

+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.