|
| 1 | +# API Contract |
| 2 | + |
| 3 | +This document is the shared contract between the server and the client (and |
| 4 | +between work packages). Any change here should be coordinated across both. |
| 5 | + |
| 6 | +All request/response bodies are JSON unless noted otherwise. |
| 7 | + |
| 8 | +## Auth model |
| 9 | + |
| 10 | +- Auth is a single shared password (`ADMIN_PASSWORD`), compared in constant |
| 11 | + time, no user accounts/database. |
| 12 | +- On successful login, the server sets a signed, httpOnly cookie named |
| 13 | + `diun_session` (`SameSite=Lax`, `Secure` when served over HTTPS, |
| 14 | + `Max-Age` = `SESSION_TTL` seconds). |
| 15 | +- Protected routes (everything except `/api/auth/login`, `/api/health`, and |
| 16 | + `/api/diun/webhook`) require a valid `diun_session` cookie. If it is |
| 17 | + missing, invalid, or expired, the server responds `401 Unauthorized` with |
| 18 | + `{ "error": "unauthorized" }`. |
| 19 | +- The Diun webhook route uses a separate auth mechanism: a static bearer |
| 20 | + token (`DIUN_WEBHOOK_TOKEN`) in the `Authorization` header. It does not |
| 21 | + use the session cookie. |
| 22 | + |
| 23 | +## Endpoints |
| 24 | + |
| 25 | +### `POST /api/auth/login` |
| 26 | + |
| 27 | +- Auth: none. |
| 28 | +- Body: `{ "password": "string" }` |
| 29 | +- Response: |
| 30 | + - `200 { "ok": true }` + `Set-Cookie: diun_session=...` on success. |
| 31 | + - `401 { "error": "invalid_password" }` on bad password. |
| 32 | + |
| 33 | +### `POST /api/auth/logout` |
| 34 | + |
| 35 | +- Auth: cookie. |
| 36 | +- Body: none. |
| 37 | +- Response: `200 { "ok": true }`, clears the `diun_session` cookie. |
| 38 | + |
| 39 | +### `GET /api/auth/me` |
| 40 | + |
| 41 | +- Auth: cookie (optional — never errors, reports status). |
| 42 | +- Response: `200 { "authenticated": boolean }` |
| 43 | + |
| 44 | +### `POST /api/diun/webhook` |
| 45 | + |
| 46 | +- Auth: token — header `Authorization: Bearer <DIUN_WEBHOOK_TOKEN>`. `401` |
| 47 | + if missing/invalid. |
| 48 | +- Body: Diun webhook payload (see below). |
| 49 | +- Response: `204 No Content` on successful ingest. `400` if the payload is |
| 50 | + malformed. |
| 51 | + |
| 52 | +### `GET /api/containers` |
| 53 | + |
| 54 | +- Auth: cookie. |
| 55 | +- Response: `200` — array of container items (shape below). |
| 56 | + |
| 57 | +### `POST /api/update/:name` |
| 58 | + |
| 59 | +- Auth: cookie. |
| 60 | +- Path param: `name` — container name. |
| 61 | +- Body: none. |
| 62 | +- Response: `200 { "streamId": "string" }` — starts a pull + recreate |
| 63 | + operation for that container; use the returned `streamId` to subscribe to |
| 64 | + progress via the SSE endpoint below. |
| 65 | +- Errors: `404` if no such container; `409` if an update is already in |
| 66 | + progress for that container. |
| 67 | + |
| 68 | +### `GET /api/update/:name/stream` |
| 69 | + |
| 70 | +- Auth: cookie. |
| 71 | +- Path param: `name` — container name (same as used to start the update). |
| 72 | +- Response: `text/event-stream` (SSE). Events: |
| 73 | + - `data: {"type":"log","line":"..."}` — zero or more, streamed as the |
| 74 | + update runs (`docker compose pull` / `up -d` output). |
| 75 | + - `data: {"type":"result","success":boolean,"message":"..."}` — exactly |
| 76 | + one, final event; the stream closes after this. |
| 77 | + |
| 78 | +### `GET /api/history` |
| 79 | + |
| 80 | +- Auth: cookie. |
| 81 | +- Query params: `container` (optional, filter by container name), `limit` |
| 82 | + (default `50`), `offset` (default `0`). |
| 83 | +- Response: `200` — array of update history rows, newest first: |
| 84 | + ```json |
| 85 | + [ |
| 86 | + { |
| 87 | + "id": 1, |
| 88 | + "container_name": "nginx", |
| 89 | + "image": "nginx:latest", |
| 90 | + "old_digest": "sha256:...", |
| 91 | + "new_digest": "sha256:...", |
| 92 | + "status": "success", |
| 93 | + "message": "Updated successfully", |
| 94 | + "created_at": "2026-06-22 12:00:00" |
| 95 | + } |
| 96 | + ] |
| 97 | + ``` |
| 98 | + |
| 99 | +### `GET /api/history/:name` |
| 100 | + |
| 101 | +- Auth: cookie. |
| 102 | +- Path param: `name` — container name. |
| 103 | +- Query params: `limit` (default `50`), `offset` (default `0`). |
| 104 | +- Response: same shape as `GET /api/history`, filtered to that container. |
| 105 | + |
| 106 | +### `GET /api/pinned` |
| 107 | + |
| 108 | +- Auth: cookie. |
| 109 | +- Response: `200` — array of pinned refs, e.g. `["nginx:latest", "redis:7"]`. |
| 110 | + |
| 111 | +### `POST /api/pin` |
| 112 | + |
| 113 | +- Auth: cookie. |
| 114 | +- Body: `{ "ref": "string" }` |
| 115 | +- Response: `200 { "ok": true }`. Idempotent. |
| 116 | + |
| 117 | +### `DELETE /api/pin/:ref` |
| 118 | + |
| 119 | +- Auth: cookie. |
| 120 | +- Path param: `ref` — the image ref to unpin (URL-encoded). |
| 121 | +- Response: `200 { "ok": true }`. Idempotent. |
| 122 | + |
| 123 | +Note: refs passed to `POST /api/pin` and `DELETE /api/pin/:ref` are |
| 124 | +normalized server-side (via the same `normalizeRef` used for Diun events) |
| 125 | +before being stored/looked up, so e.g. raw `nginx` and |
| 126 | +`docker.io/library/nginx:latest` are equivalent and `GET /api/pinned` |
| 127 | +always returns normalized refs. |
| 128 | + |
| 129 | +### `GET /api/health` |
| 130 | + |
| 131 | +- Auth: none. |
| 132 | +- Response: `200 { "ok": true }`. |
| 133 | + |
| 134 | +## `/api/containers` item shape |
| 135 | + |
| 136 | +```json |
| 137 | +{ |
| 138 | + "name": "nginx", |
| 139 | + "project": "web", |
| 140 | + "service": "nginx", |
| 141 | + "image": "nginx:latest", |
| 142 | + "currentDigest": "sha256:...", |
| 143 | + "updateAvailable": true, |
| 144 | + "availableDigest": "sha256:...", |
| 145 | + "pinned": false, |
| 146 | + "state": "running", |
| 147 | + "composeFile": "/stacks/web/docker-compose.yml", |
| 148 | + "workingDir": "/stacks/web" |
| 149 | +} |
| 150 | +``` |
| 151 | + |
| 152 | +Field notes: |
| 153 | + |
| 154 | +- `name` — Docker container name. |
| 155 | +- `project` / `service` — derived from the `com.docker.compose.project` / |
| 156 | + `com.docker.compose.service` labels. |
| 157 | +- `image` — image ref as configured (tag, not digest). |
| 158 | +- `currentDigest` — digest of the image the running container was created |
| 159 | + from. |
| 160 | +- `updateAvailable` — `true` if the most recent unresolved Diun event for |
| 161 | + this image's normalized ref reports a digest different from |
| 162 | + `currentDigest`. |
| 163 | +- `availableDigest` — the digest from that unresolved event, if any (else |
| 164 | + `null`). |
| 165 | +- `pinned` — `true` if the image ref is in the `pinned` table (update |
| 166 | + indicator is suppressed, but manual update is still allowed). |
| 167 | +- `state` — Docker container state (`running`, `exited`, etc.). |
| 168 | +- `composeFile` / `workingDir` — derived from |
| 169 | + `com.docker.compose.project.config_files` / |
| 170 | + `com.docker.compose.project.working_dir` labels; used to run `docker |
| 171 | + compose` commands for that container. |
| 172 | + |
| 173 | +## Diun webhook payload |
| 174 | + |
| 175 | +Diun's webhook notifier posts a JSON body shaped roughly like: |
| 176 | + |
| 177 | +```json |
| 178 | +{ |
| 179 | + "status": "update", |
| 180 | + "image": "nginx:latest", |
| 181 | + "digest": "sha256:abc123...", |
| 182 | + "provider": "docker", |
| 183 | + "hub_link": "https://hub.docker.com/_/nginx", |
| 184 | + "platform": "linux/amd64", |
| 185 | + "metadata": { |
| 186 | + "hostname": "docker-host-1", |
| 187 | + "container": "nginx", |
| 188 | + "...": "additional Diun metadata fields" |
| 189 | + } |
| 190 | +} |
| 191 | +``` |
| 192 | + |
| 193 | +Fields we read: |
| 194 | + |
| 195 | +- `status` — `"new"` (first time Diun sees this image) or `"update"` (a |
| 196 | + newer digest was found). Both are recorded; only `"update"` events are |
| 197 | + meaningful for the update indicator. |
| 198 | +- `image` — the image ref Diun checked, used to derive `normalized_ref` |
| 199 | + (registry/repo without tag-specific noise, used to key |
| 200 | + `update_events.normalized_ref`). |
| 201 | +- `digest` — the new digest Diun observed. |
| 202 | +- `provider` — Diun provider (`docker`, `swarm`, etc.) — stored for |
| 203 | + reference. |
| 204 | +- `hub_link` — informational link, stored for reference. |
| 205 | +- `platform` — image platform string, stored for reference. |
| 206 | +- `metadata` — passthrough object with additional Diun-provided context; |
| 207 | + stored as part of `raw_json`, not parsed individually. |
| 208 | + |
| 209 | +The full raw payload is stored as `raw_json` in `update_events` for |
| 210 | +debugging/audit, regardless of which fields are explicitly parsed. |
0 commit comments